From e25fcade98ac6c787f99e50214b6b52099abd80f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 2 Dec 2021 19:02:18 +0100 Subject: [PATCH 001/395] move version detection --- igniter/bootstrap_repos.py | 210 +++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 151597e505..b0f3b482ac 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -232,6 +232,216 @@ class OpenPypeVersion(semver.VersionInfo): else: return hash(str(self)) + @staticmethod + def is_version_in_dir( + dir_item: Path, version: OpenPypeVersion) -> Tuple[bool, str]: + """Test if path item is OpenPype version matching detected version. + + If item is directory that might (based on it's name) + contain OpenPype version, check if it really does contain + OpenPype and that their versions matches. + + Args: + dir_item (Path): Directory to test. + version (OpenPypeVersion): OpenPype version detected + from name. + + Returns: + Tuple: State and reason, True if it is valid OpenPype version, + False otherwise. + + """ + try: + # add one 'openpype' level as inside dir there should + # be many other repositories. + version_str = OpenPypeVersion.get_version_string_from_directory( + dir_item) # noqa: E501 + version_check = OpenPypeVersion(version=version_str) + except ValueError: + return False, f"cannot determine version from {dir_item}" + + version_main = version_check.get_main_version() + detected_main = version.get_main_version() + if version_main != detected_main: + return False, (f"dir version ({version}) and " + f"its content version ({version_check}) " + "doesn't match. Skipping.") + return True, "Versions match" + + @staticmethod + def is_version_in_zip( + zip_item: Path, version: OpenPypeVersion) -> Tuple[bool, str]: + """Test if zip path is OpenPype version matching detected version. + + Open zip file, look inside and parse version from OpenPype + inside it. If there is none, or it is different from + version specified in file name, skip it. + + Args: + zip_item (Path): Zip file to test. + version (OpenPypeVersion): Pype version detected + from name. + + Returns: + Tuple: State and reason, True if it is valid OpenPype version, + False otherwise. + + """ + # skip non-zip files + if zip_item.suffix.lower() != ".zip": + return False, "Not a zip" + + try: + with ZipFile(zip_item, "r") as zip_file: + with zip_file.open( + "openpype/version.py") as version_file: + zip_version = {} + exec(version_file.read(), zip_version) + try: + version_check = OpenPypeVersion( + version=zip_version["__version__"]) + except ValueError as e: + return False, str(e) + + version_main = version_check.get_main_version() # + # noqa: E501 + detected_main = version.get_main_version() + # noqa: E501 + + if version_main != detected_main: + return False, (f"zip version ({version}) " + f"and its content version " + f"({version_check}) " + "doesn't match. Skipping.") + except BadZipFile: + return False, f"{zip_item} is not a zip file" + except KeyError: + return False, "Zip does not contain OpenPype" + return True, "Versions match" + + @staticmethod + def get_version_string_from_directory(repo_dir: Path) -> Union[str, None]: + """Get version of OpenPype in given directory. + + Note: in frozen OpenPype installed in user data dir, this must point + one level deeper as it is: + `openpype-version-v3.0.0/openpype/version.py` + + Args: + repo_dir (Path): Path to OpenPype repo. + + Returns: + str: version string. + None: if OpenPype is not found. + + """ + # try to find version + version_file = Path(repo_dir) / "openpype" / "version.py" + if not version_file.exists(): + return None + + version = {} + with version_file.open("r") as fp: + exec(fp.read(), version) + + return version['__version__'] + + @staticmethod + def get_available_versions( + staging: bool = False, local: bool = False) -> List: + """Get ordered dict of detected OpenPype version. + + Resolution order for OpenPype is following: + + 1) First we test for ``OPENPYPE_PATH`` environment variable + 2) We try to find ``openPypePath`` in registry setting + 3) We use user data directory + + Only versions from 3) will be listed when ``local`` is set to True. + + Args: + staging (bool, optional): List staging versions if True. + local (bool, optional): List only local versions. + + """ + registry = OpenPypeSettingsRegistry() + dir_to_search = Path(user_data_dir("openpype", "pypeclub")) + user_versions = OpenPypeVersion.get_versions_from_directory( + dir_to_search) + # if we have openpype_path specified, search only there. + + if not local: + if os.getenv("OPENPYPE_PATH"): + if Path(os.getenv("OPENPYPE_PATH")).exists(): + dir_to_search = Path(os.getenv("OPENPYPE_PATH")) + else: + try: + registry_dir = Path( + str(registry.get_item("openPypePath"))) + if registry_dir.exists(): + dir_to_search = registry_dir + + except ValueError: + # nothing found in registry, we'll use data dir + pass + + openpype_versions = OpenPypeVersion.get_versions_from_directory( + dir_to_search) + openpype_versions += user_versions + + # remove duplicates and staging/production + openpype_versions = [ + v for v in openpype_versions if v.is_staging() == staging + ] + openpype_versions = sorted(list(set(openpype_versions))) + + return openpype_versions + + @staticmethod + def get_versions_from_directory(openpype_dir: Path) -> List: + """Get all detected OpenPype versions in directory. + + Args: + openpype_dir (Path): Directory to scan. + + Returns: + list of OpenPypeVersion + + Throws: + ValueError: if invalid path is specified. + + """ + if not openpype_dir.exists() and not openpype_dir.is_dir(): + raise ValueError("specified directory is invalid") + + _openpype_versions = [] + # iterate over directory in first level and find all that might + # contain OpenPype. + for item in openpype_dir.iterdir(): + + # if file, strip extension, in case of dir not. + name = item.name if item.is_dir() else item.stem + result = OpenPypeVersion.version_in_str(name) + + if result: + detected_version: OpenPypeVersion + detected_version = result + + if item.is_dir() and not OpenPypeVersion.is_version_in_dir( + item, detected_version + )[0]: + continue + + if item.is_file() and not OpenPypeVersion.is_version_in_zip( + item, detected_version + )[0]: + continue + + detected_version.path = item + _openpype_versions.append(detected_version) + + return sorted(_openpype_versions) + class BootstrapRepos: """Class for bootstrapping local OpenPype installation. From 383307d88803404c1bac74366797b5a23e2fc5e0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 3 Dec 2021 13:51:03 +0100 Subject: [PATCH 002/395] tweak tests and add get latest version --- igniter/bootstrap_repos.py | 97 +++++++++------------- start.py | 1 - tests/unit/igniter/test_bootstrap_repos.py | 7 +- 3 files changed, 41 insertions(+), 64 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index b0f3b482ac..ca64d193c7 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -401,16 +401,16 @@ class OpenPypeVersion(semver.VersionInfo): def get_versions_from_directory(openpype_dir: Path) -> List: """Get all detected OpenPype versions in directory. - Args: - openpype_dir (Path): Directory to scan. + Args: + openpype_dir (Path): Directory to scan. - Returns: - list of OpenPypeVersion + Returns: + list of OpenPypeVersion - Throws: - ValueError: if invalid path is specified. + Throws: + ValueError: if invalid path is specified. - """ + """ if not openpype_dir.exists() and not openpype_dir.is_dir(): raise ValueError("specified directory is invalid") @@ -442,6 +442,26 @@ class OpenPypeVersion(semver.VersionInfo): return sorted(_openpype_versions) + @staticmethod + def get_latest_version( + staging: bool = False, local: bool = False) -> OpenPypeVersion: + """Get latest available version. + + This is utility version to get latest version from all found. + + Args: + staging (bool, optional): List staging versions if True. + local (bool, optional): List only local versions. + + See also: + OpenPypeVersion.get_available_versions() + + """ + openpype_versions = OpenPypeVersion.get_available_versions( + staging, local) + + return openpype_versions[-1] + class BootstrapRepos: """Class for bootstrapping local OpenPype installation. @@ -944,66 +964,23 @@ class BootstrapRepos: os.environ["PYTHONPATH"] = os.pathsep.join(paths) + @staticmethod def find_openpype( - self, openpype_path: Union[Path, str] = None, staging: bool = False, include_zips: bool = False) -> Union[List[OpenPypeVersion], None]: - """Get ordered dict of detected OpenPype version. - Resolution order for OpenPype is following: - - 1) First we test for ``OPENPYPE_PATH`` environment variable - 2) We try to find ``openPypePath`` in registry setting - 3) We use user data directory - - Args: - openpype_path (Path or str, optional): Try to find OpenPype on - the given path or url. - staging (bool, optional): Filter only staging version, skip them - otherwise. - include_zips (bool, optional): If set True it will try to find - OpenPype in zip files in given directory. - - Returns: - dict of Path: Dictionary of detected OpenPype version. - Key is version, value is path to zip file. - - None: if OpenPype is not found. - - Todo: - implement git/url support as OpenPype location, so it would be - possible to enter git url, OpenPype would check it out and if it is - ok install it as normal version. - - """ - if openpype_path and not isinstance(openpype_path, Path): - raise NotImplementedError( - ("Finding OpenPype in non-filesystem locations is" - " not implemented yet.")) - - dir_to_search = self.data_dir - user_versions = self.get_openpype_versions(self.data_dir, staging) - # if we have openpype_path specified, search only there. if openpype_path: - dir_to_search = openpype_path + openpype_versions = OpenPypeVersion.get_versions_from_directory( + openpype_path) + # filter out staging + + openpype_versions = [ + v for v in openpype_versions if v.is_staging() == staging + ] + else: - if os.getenv("OPENPYPE_PATH"): - if Path(os.getenv("OPENPYPE_PATH")).exists(): - dir_to_search = Path(os.getenv("OPENPYPE_PATH")) - else: - try: - registry_dir = Path( - str(self.registry.get_item("openPypePath"))) - if registry_dir.exists(): - dir_to_search = registry_dir - - except ValueError: - # nothing found in registry, we'll use data dir - pass - - openpype_versions = self.get_openpype_versions(dir_to_search, staging) - openpype_versions += user_versions + openpype_versions = OpenPypeVersion.get_available_versions(staging) # remove zip file version if needed. if not include_zips: diff --git a/start.py b/start.py index 0f7e82071d..05b7da6308 100644 --- a/start.py +++ b/start.py @@ -966,7 +966,6 @@ def boot(): ) sys.exit(1) - if not openpype_path: _print("*** Cannot get OpenPype path from database.") diff --git a/tests/unit/igniter/test_bootstrap_repos.py b/tests/unit/igniter/test_bootstrap_repos.py index d6e861c262..65cd5a2399 100644 --- a/tests/unit/igniter/test_bootstrap_repos.py +++ b/tests/unit/igniter/test_bootstrap_repos.py @@ -140,9 +140,10 @@ def test_search_string_for_openpype_version(printer): ] for ver_string in strings: printer(f"testing {ver_string[0]} should be {ver_string[1]}") - assert OpenPypeVersion.version_in_str(ver_string[0]) == \ - ver_string[1] - + assert isinstance( + OpenPypeVersion.version_in_str(ver_string[0]), + OpenPypeVersion if ver_string[1] else type(None) + ) @pytest.mark.slow def test_install_live_repos(fix_bootstrap, printer, monkeypatch, pytestconfig): From 88218caf2869d8452b734ed4712fa224a540b0c6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 15:56:11 +0100 Subject: [PATCH 003/395] store OpenPypeVersion to sys.modules --- igniter/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/igniter/__init__.py b/igniter/__init__.py index defd45e233..bbc3dbfc88 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -6,9 +6,15 @@ import sys os.chdir(os.path.dirname(__file__)) # for override sys.path in Deadline -from .bootstrap_repos import BootstrapRepos +from .bootstrap_repos import ( + BootstrapRepos, + OpenPypeVersion +) from .version import __version__ as version +if "OpenPypeVersion" not in sys.modules: + sys.modules["OpenPypeVersion"] = OpenPypeVersion + def open_dialog(): """Show Igniter dialog.""" From 2bfdc5c3e970b1747e39b3ed16f439fe4d0e59d2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 16:05:36 +0100 Subject: [PATCH 004/395] create new enum entities used to determine openpype version --- openpype/settings/entities/__init__.py | 6 ++- openpype/settings/entities/enum_entity.py | 52 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index ccf2a5993e..e4a13b8053 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -106,7 +106,9 @@ from .enum_entity import ( ToolsEnumEntity, TaskTypeEnumEntity, DeadlineUrlEnumEntity, - AnatomyTemplatesEnumEntity + AnatomyTemplatesEnumEntity, + ProductionVersionsEnumEntity, + StagingVersionsEnumEntity ) from .list_entity import ListEntity @@ -169,6 +171,8 @@ __all__ = ( "TaskTypeEnumEntity", "DeadlineUrlEnumEntity", "AnatomyTemplatesEnumEntity", + "ProductionVersionsEnumEntity", + "StagingVersionsEnumEntity", "ListEntity", diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index ab3cebbd42..5f0cbb1261 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,4 +1,5 @@ import copy +import sys from .input_entities import InputEntity from .exceptions import EntitySchemaError from .lib import ( @@ -564,3 +565,54 @@ class AnatomyTemplatesEnumEntity(BaseEnumEntity): self.enum_items, self.valid_keys = self._get_enum_values() if self._current_value not in self.valid_keys: self._current_value = self.value_on_not_set + + +class _OpenPypeVersionEnum(BaseEnumEntity): + def _item_initialization(self): + self.multiselection = False + self.valid_value_types = (STRING_TYPE, ) + + self.value_on_not_set = "" + items = [ + {"": "Latest"} + ] + items.extend(self._get_openpype_versions()) + + self.enum_items = items + self.valid_keys = { + tuple(item.keys())[0] + for item in items + } + + def _get_openpype_versions(self): + return [] + + +class ProductionVersionsEnumEntity(_OpenPypeVersionEnum): + schema_types = ["production-versions-enum"] + + def _get_openpype_versions(self): + items = [] + if "OpenPypeVersion" in sys.modules: + OpenPypeVersion = sys.modules["OpenPypeVersion"] + versions = OpenPypeVersion.get_available_versions( + staging=False, local=False + ) + for item in versions: + items.append({item: item}) + return items + + +class StagingVersionsEnumEntity(_OpenPypeVersionEnum): + schema_types = ["staging-versions-enum"] + + def _get_openpype_versions(self): + items = [] + if "OpenPypeVersion" in sys.modules: + OpenPypeVersion = sys.modules["OpenPypeVersion"] + versions = OpenPypeVersion.get_available_versions( + staging=False, local=False + ) + for item in versions: + items.append({item: item}) + return items From 27b1be9279152b7fde4c3fbd5bb9d2d507cfc299 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 16:05:49 +0100 Subject: [PATCH 005/395] use new enities in settings --- .../settings/defaults/system_settings/general.json | 2 ++ .../schemas/system_schema/schema_general.json | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/openpype/settings/defaults/system_settings/general.json b/openpype/settings/defaults/system_settings/general.json index f54e8b2b16..a07152eaf8 100644 --- a/openpype/settings/defaults/system_settings/general.json +++ b/openpype/settings/defaults/system_settings/general.json @@ -2,6 +2,8 @@ "studio_name": "Studio name", "studio_code": "stu", "admin_password": "", + "production_version": "", + "staging_version": "", "environment": { "__environment_keys__": { "global": [] diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index 51a58a6e27..d548a22c97 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -30,6 +30,19 @@ { "type": "splitter" }, + { + "type": "production-versions-enum", + "key": "production_version", + "label": "Production version" + }, + { + "type": "staging-versions-enum", + "key": "staging_version", + "label": "Staging version" + }, + { + "type": "splitter" + }, { "key": "environment", "label": "Environment", From ab8dacd0f8cc4e7f578695a6a5159472876ea1aa Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 16:06:04 +0100 Subject: [PATCH 006/395] added "production_version" and "staging_version" to global settings --- openpype/settings/handlers.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py index c59e2bc542..51e390bb6d 100644 --- a/openpype/settings/handlers.py +++ b/openpype/settings/handlers.py @@ -168,7 +168,13 @@ class CacheValues: class MongoSettingsHandler(SettingsHandler): """Settings handler that use mongo for storing and loading of settings.""" - global_general_keys = ("openpype_path", "admin_password", "disk_mapping") + global_general_keys = ( + "openpype_path", + "admin_password", + "disk_mapping", + "production_version", + "staging_version" + ) def __init__(self): # Get mongo connection From 80d887f14e80a1c2862343f1407280c1341a44ec Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 17:44:49 +0100 Subject: [PATCH 007/395] added 3 method related to openpype path --- igniter/bootstrap_repos.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index ca64d193c7..921557b1a0 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -346,6 +346,37 @@ class OpenPypeVersion(semver.VersionInfo): return version['__version__'] + @classmethod + def get_openpype_path(cls): + """Path to openpype zip directory. + + Path can be set through environment variable 'OPENPYPE_PATH' which + is set during start of OpenPype if is not available. + """ + return os.getenv("OPENPYPE_PATH") + + @classmethod + def openpype_path_is_set(cls): + """Path to OpenPype zip directory is set.""" + if cls.get_openpype_path(): + return True + return False + + @classmethod + def openpype_path_is_accessible(cls): + """Path to OpenPype zip directory is accessible. + + Exists for this machine. + """ + # First check if is set + if not cls.openpype_path_is_set(): + return False + + # Validate existence + if Path(cls.get_openpype_path()).exists(): + return True + return False + @staticmethod def get_available_versions( staging: bool = False, local: bool = False) -> List: From 494bd26d80e41e0169344442ae2b773642e4191f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:04:18 +0100 Subject: [PATCH 008/395] created OpenPypeVersion wrapper in openpype lib --- openpype/lib/openpype_version.py | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 openpype/lib/openpype_version.py diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py new file mode 100644 index 0000000000..4d1a1a7bb8 --- /dev/null +++ b/openpype/lib/openpype_version.py @@ -0,0 +1,59 @@ +"""Lib access to OpenPypeVersion from igniter. + +Access to logic from igniter is available only for OpenPype processes. +Is meant to be able check OpenPype versions for studio. The logic is dependent +on igniter's logic of processing. +""" + +import sys + + +def get_OpenPypeVersion(): + """Access to OpenPypeVersion class stored in sys modules.""" + return sys.modules.get("OpenPypeVersion") + + +def op_version_control_available(): + """Check if current process has access to OpenPypeVersion.""" + if get_OpenPypeVersion() is None: + return False + return True + + +def get_available_versions(*args, **kwargs): + """Get list of available versions.""" + if op_version_control_available(): + return get_OpenPypeVersion().get_available_versions( + *args, **kwargs + ) + return None + + +def openpype_path_is_set(): + if op_version_control_available(): + return get_OpenPypeVersion().openpype_path_is_set() + return None + + +def openpype_path_is_accessible(): + if op_version_control_available(): + return get_OpenPypeVersion().openpype_path_is_accessible() + return None + + +def get_latest_version(): + if op_version_control_available(): + return get_OpenPypeVersion().get_latest_version() + return None + + +def get_production_version(): + if op_version_control_available(): + return get_OpenPypeVersion().get_production_version() + return None + + +def get_staging_version(): + if op_version_control_available(): + return get_OpenPypeVersion().get_staging_version() + return None From cc1cc49235f288ba95d8d213e5fe16a8c67d397f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:06:52 +0100 Subject: [PATCH 009/395] added logic to load data for version entities --- openpype/settings/entities/enum_entity.py | 75 ++++++++++++++++++----- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 5f0cbb1261..591b2dd94f 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,8 +1,15 @@ import copy import sys +from openpype.lib.openpype_version import ( + op_version_control_available, + get_available_versions, + openpype_path_is_set, + openpype_path_is_accessible +) from .input_entities import InputEntity from .exceptions import EntitySchemaError from .lib import ( + OverrideState, NOT_SET, STRING_TYPE ) @@ -573,46 +580,84 @@ class _OpenPypeVersionEnum(BaseEnumEntity): self.valid_value_types = (STRING_TYPE, ) self.value_on_not_set = "" - items = [ - {"": "Latest"} - ] - items.extend(self._get_openpype_versions()) + items = self._get_default_items() self.enum_items = items - self.valid_keys = { + self.valid_keys = self._extract_valid_keys(items) + + def _extract_valid_keys(self, items): + return { tuple(item.keys())[0] for item in items } + def _get_default_items(self): + return [ + {"": "Latest"} + ] + def _get_openpype_versions(self): return [] + def set_override_state(self, state, *args, **kwargs): + items = self._get_default_items() + versions = self._get_openpype_versions() + if versions is not None: + for version in versions: + items.append({version: version}) + + self.enum_items = items + self.valid_keys = self._extract_valid_keys(items) + + # Studio value is not available in collected versions + if ( + state is OverrideState.STUDIO + and self.had_studio_override + and self._studio_override_value not in self.valid_keys + ): + # Define if entity should keep the value in settings. + # Value is marked as not existing anymore if + # - openpype version control is available + # - path to openpype zips is set + # - path to openpype zips is accessible (existing for this machine) + keep_value = True + if ( + op_version_control_available() + and openpype_path_is_set() + and openpype_path_is_accessible() + ): + keep_value = False + + if keep_value: + self.enum_items.append( + {self._studio_override_value: self._studio_override_value} + ) + self.valid_keys.add(self._studio_override_value) + + super(_OpenPypeVersionEnum, self).set_override_state( + state, *args, **kwargs + ) + class ProductionVersionsEnumEntity(_OpenPypeVersionEnum): schema_types = ["production-versions-enum"] def _get_openpype_versions(self): - items = [] if "OpenPypeVersion" in sys.modules: OpenPypeVersion = sys.modules["OpenPypeVersion"] - versions = OpenPypeVersion.get_available_versions( + return get_available_versions( staging=False, local=False ) - for item in versions: - items.append({item: item}) - return items + return None class StagingVersionsEnumEntity(_OpenPypeVersionEnum): schema_types = ["staging-versions-enum"] def _get_openpype_versions(self): - items = [] if "OpenPypeVersion" in sys.modules: OpenPypeVersion = sys.modules["OpenPypeVersion"] - versions = OpenPypeVersion.get_available_versions( + return get_available_versions( staging=False, local=False ) - for item in versions: - items.append({item: item}) - return items + return None From e9b0347d8309705da17a57463f59a95bb8d1a042 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:26:30 +0100 Subject: [PATCH 010/395] changed static method to class method --- igniter/bootstrap_repos.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 921557b1a0..5cade8324c 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -377,9 +377,9 @@ class OpenPypeVersion(semver.VersionInfo): return True return False - @staticmethod + @classmethod def get_available_versions( - staging: bool = False, local: bool = False) -> List: + cls, staging: bool = False, local: bool = False) -> List: """Get ordered dict of detected OpenPype version. Resolution order for OpenPype is following: From 6762d6645957dc177796a067c64856fafe6096b2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:26:54 +0100 Subject: [PATCH 011/395] added functions to retrieve local and remote versions separatelly --- igniter/bootstrap_repos.py | 84 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 5cade8324c..c52175c8fd 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -377,6 +377,90 @@ class OpenPypeVersion(semver.VersionInfo): return True return False + @classmethod + def get_local_versions( + cls, production: bool = None, staging: bool = None + ) -> List: + """Get all versions available on this machine. + + Arguments give ability to specify if filtering is needed. If both + arguments are set to None all found versions are returned. + + Args: + production (bool): Return production versions. + staging (bool): Return staging versions. + """ + # Return all local versions if arguments are set to None + if production is None and staging is None: + production = True + staging = True + + # Just return empty output if both are disabled + elif not production and not staging: + return [] + + dir_to_search = Path(user_data_dir("openpype", "pypeclub")) + versions = OpenPypeVersion.get_versions_from_directory( + dir_to_search + ) + filtered_versions = [] + for version in versions: + if version.is_staging(): + if staging: + filtered_versions.append(version) + elif production: + filtered_versions.append(version) + return list(sorted(set(filtered_versions))) + + @classmethod + def get_remote_versions( + cls, production: bool = None, staging: bool = None + ) -> List: + """Get all versions available in OpenPype Path. + + Arguments give ability to specify if filtering is needed. If both + arguments are set to None all found versions are returned. + + Args: + production (bool): Return production versions. + staging (bool): Return staging versions. + """ + # Return all local versions if arguments are set to None + if production is None and staging is None: + production = True + staging = True + + # Just return empty output if both are disabled + elif not production and not staging: + return [] + + dir_to_search = None + if cls.openpype_path_is_accessible(): + dir_to_search = Path(cls.get_openpype_path()) + else: + registry = OpenPypeSettingsRegistry() + try: + registry_dir = Path(str(registry.get_item("openPypePath"))) + if registry_dir.exists(): + dir_to_search = registry_dir + + except ValueError: + # nothing found in registry, we'll use data dir + pass + + if not dir_to_search: + return [] + + versions = cls.get_versions_from_directory(dir_to_search) + filtered_versions = [] + for version in versions: + if version.is_staging(): + if staging: + filtered_versions.append(version) + elif production: + filtered_versions.append(version) + return list(sorted(set(filtered_versions))) + @classmethod def get_available_versions( cls, staging: bool = False, local: bool = False) -> List: From 73f29819873c632111a3a2b3a644afc36f04509a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:27:14 +0100 Subject: [PATCH 012/395] use new functions in get_available_versions --- igniter/bootstrap_repos.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index c52175c8fd..5bb7296d3d 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -479,29 +479,11 @@ class OpenPypeVersion(semver.VersionInfo): local (bool, optional): List only local versions. """ - registry = OpenPypeSettingsRegistry() - dir_to_search = Path(user_data_dir("openpype", "pypeclub")) - user_versions = OpenPypeVersion.get_versions_from_directory( - dir_to_search) + user_versions = cls.get_local_versions() # if we have openpype_path specified, search only there. - + openpype_versions = [] if not local: - if os.getenv("OPENPYPE_PATH"): - if Path(os.getenv("OPENPYPE_PATH")).exists(): - dir_to_search = Path(os.getenv("OPENPYPE_PATH")) - else: - try: - registry_dir = Path( - str(registry.get_item("openPypePath"))) - if registry_dir.exists(): - dir_to_search = registry_dir - - except ValueError: - # nothing found in registry, we'll use data dir - pass - - openpype_versions = OpenPypeVersion.get_versions_from_directory( - dir_to_search) + openpype_versions = cls.get_remote_versions() openpype_versions += user_versions # remove duplicates and staging/production From a14662bccee58dd50faeb887842889ae7dc63ff1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:35:21 +0100 Subject: [PATCH 013/395] fix args conditions --- igniter/bootstrap_repos.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 5bb7296d3d..94f786e869 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -395,8 +395,14 @@ class OpenPypeVersion(semver.VersionInfo): production = True staging = True + elif production is None and not staging: + production = True + + elif staging is None and not production: + staging = True + # Just return empty output if both are disabled - elif not production and not staging: + if not production and not staging: return [] dir_to_search = Path(user_data_dir("openpype", "pypeclub")) @@ -430,8 +436,14 @@ class OpenPypeVersion(semver.VersionInfo): production = True staging = True + elif production is None and not staging: + production = True + + elif staging is None and not production: + staging = True + # Just return empty output if both are disabled - elif not production and not staging: + if not production and not staging: return [] dir_to_search = None From 1567afc4b9ba205669f3e716a96efb8963c5e79c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:35:34 +0100 Subject: [PATCH 014/395] extended available functions --- openpype/lib/openpype_version.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 4d1a1a7bb8..42ee454378 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -41,19 +41,31 @@ def openpype_path_is_accessible(): return None -def get_latest_version(): +def get_local_versions(*args, **kwargs): if op_version_control_available(): - return get_OpenPypeVersion().get_latest_version() + return get_OpenPypeVersion().get_local_versions(*args, **kwargs) return None -def get_production_version(): +def get_remote_versions(*args, **kwargs): + if op_version_control_available(): + return get_OpenPypeVersion().get_remote_versions(*args, **kwargs) + return None + + +def get_latest_version(*args, **kwargs): + if op_version_control_available(): + return get_OpenPypeVersion().get_latest_version(*args, **kwargs) + return None + + +def get_current_production_version(): if op_version_control_available(): return get_OpenPypeVersion().get_production_version() return None -def get_staging_version(): +def get_current_staging_version(): if op_version_control_available(): return get_OpenPypeVersion().get_staging_version() return None From 7aec21aa800dc225fbc5d2db9f46e8defcd26f29 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 3 Dec 2021 18:35:45 +0100 Subject: [PATCH 015/395] use extended functions in entities --- openpype/settings/entities/enum_entity.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 591b2dd94f..534775a41d 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -2,7 +2,7 @@ import copy import sys from openpype.lib.openpype_version import ( op_version_control_available, - get_available_versions, + get_remote_versions, openpype_path_is_set, openpype_path_is_accessible ) @@ -645,9 +645,7 @@ class ProductionVersionsEnumEntity(_OpenPypeVersionEnum): def _get_openpype_versions(self): if "OpenPypeVersion" in sys.modules: OpenPypeVersion = sys.modules["OpenPypeVersion"] - return get_available_versions( - staging=False, local=False - ) + return get_remote_versions(production=True) return None @@ -657,7 +655,5 @@ class StagingVersionsEnumEntity(_OpenPypeVersionEnum): def _get_openpype_versions(self): if "OpenPypeVersion" in sys.modules: OpenPypeVersion = sys.modules["OpenPypeVersion"] - return get_available_versions( - staging=False, local=False - ) + return get_remote_versions(staging=True) return None From 8f0f0769800645e4966fc1e301ce3c1e97bde925 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 11:51:35 +0100 Subject: [PATCH 016/395] added functions to get expected studio version --- igniter/bootstrap_repos.py | 23 ++++++++++++++++++++++- igniter/tools.py | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 94f786e869..9446b3e8ce 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -22,7 +22,10 @@ from .user_settings import ( OpenPypeSecureRegistry, OpenPypeSettingsRegistry ) -from .tools import get_openpype_path_from_db +from .tools import ( + get_openpype_path_from_db, + get_expected_studio_version_str +) LOG_INFO = 0 @@ -571,6 +574,24 @@ class OpenPypeVersion(semver.VersionInfo): return openpype_versions[-1] + @classmethod + def get_expected_studio_version(cls, staging=False): + """Expected OpenPype version that should be used at the moment. + + If version is not defined in settings the latest found version is + used. + + Args: + staging (bool): Staging version or production version. + + Returns: + OpenPypeVersion: Version that should be used. + """ + result = get_expected_studio_version_str(staging) + if not result: + return cls.get_latest_version(staging, False) + return OpenPypeVersion(version=result) + class BootstrapRepos: """Class for bootstrapping local OpenPype installation. diff --git a/igniter/tools.py b/igniter/tools.py index 3e862f5803..5cad2b9bf8 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -182,6 +182,24 @@ def get_openpype_path_from_db(url: str) -> Union[str, None]: return None +def get_expected_studio_version_str(staging=False) -> str: + """Version that should be currently used in studio. + + Args: + staging (bool): Get current version for staging. + + Returns: + str: OpenPype version which should be used. Empty string means latest. + """ + mongo_url = os.environ.get("OPENPYPE_MONGO") + global_settings = get_openpype_global_settings(mongo_url) + if staging: + key = "staging_version" + else: + key = "production_version" + return global_settings.get(key) or "" + + def load_stylesheet() -> str: """Load css style sheet. From 12498862943b12ba7c039ba19b71d58fa7e85de5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:10:29 +0100 Subject: [PATCH 017/395] text entity may have value hints --- openpype/settings/entities/input_entities.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index a0598d405e..d45e8f9f01 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -438,6 +438,7 @@ class TextEntity(InputEntity): # GUI attributes self.multiline = self.schema_data.get("multiline", False) self.placeholder_text = self.schema_data.get("placeholder") + self.value_hints = self.schema_data.get("value_hints") or [] def _convert_to_valid_type(self, value): # Allow numbers converted to string From 376c4f977864a285b2ca2f271e1cf9f4e5571589 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:10:49 +0100 Subject: [PATCH 018/395] changed openpype version enums to text inputs --- openpype/settings/entities/__init__.py | 14 +-- openpype/settings/entities/enum_entity.py | 87 ------------------- .../settings/entities/op_version_entity.py | 49 +++++++++++ .../schemas/system_schema/schema_general.json | 4 +- 4 files changed, 59 insertions(+), 95 deletions(-) create mode 100644 openpype/settings/entities/op_version_entity.py diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index e4a13b8053..4efd358297 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -107,8 +107,6 @@ from .enum_entity import ( TaskTypeEnumEntity, DeadlineUrlEnumEntity, AnatomyTemplatesEnumEntity, - ProductionVersionsEnumEntity, - StagingVersionsEnumEntity ) from .list_entity import ListEntity @@ -124,7 +122,10 @@ from .dict_conditional import ( ) from .anatomy_entities import AnatomyEntity - +from .op_version_entity import ( + ProductionVersionsInputEntity, + StagingVersionsInputEntity +) __all__ = ( "DefaultsNotDefined", @@ -171,8 +172,6 @@ __all__ = ( "TaskTypeEnumEntity", "DeadlineUrlEnumEntity", "AnatomyTemplatesEnumEntity", - "ProductionVersionsEnumEntity", - "StagingVersionsEnumEntity", "ListEntity", @@ -185,5 +184,8 @@ __all__ = ( "DictConditionalEntity", "SyncServerProviders", - "AnatomyEntity" + "AnatomyEntity", + + "ProductionVersionsInputEntity", + "StagingVersionsInputEntity" ) diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 534775a41d..1f9b361f16 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,5 +1,4 @@ import copy -import sys from openpype.lib.openpype_version import ( op_version_control_available, get_remote_versions, @@ -9,7 +8,6 @@ from openpype.lib.openpype_version import ( from .input_entities import InputEntity from .exceptions import EntitySchemaError from .lib import ( - OverrideState, NOT_SET, STRING_TYPE ) @@ -572,88 +570,3 @@ class AnatomyTemplatesEnumEntity(BaseEnumEntity): self.enum_items, self.valid_keys = self._get_enum_values() if self._current_value not in self.valid_keys: self._current_value = self.value_on_not_set - - -class _OpenPypeVersionEnum(BaseEnumEntity): - def _item_initialization(self): - self.multiselection = False - self.valid_value_types = (STRING_TYPE, ) - - self.value_on_not_set = "" - items = self._get_default_items() - - self.enum_items = items - self.valid_keys = self._extract_valid_keys(items) - - def _extract_valid_keys(self, items): - return { - tuple(item.keys())[0] - for item in items - } - - def _get_default_items(self): - return [ - {"": "Latest"} - ] - - def _get_openpype_versions(self): - return [] - - def set_override_state(self, state, *args, **kwargs): - items = self._get_default_items() - versions = self._get_openpype_versions() - if versions is not None: - for version in versions: - items.append({version: version}) - - self.enum_items = items - self.valid_keys = self._extract_valid_keys(items) - - # Studio value is not available in collected versions - if ( - state is OverrideState.STUDIO - and self.had_studio_override - and self._studio_override_value not in self.valid_keys - ): - # Define if entity should keep the value in settings. - # Value is marked as not existing anymore if - # - openpype version control is available - # - path to openpype zips is set - # - path to openpype zips is accessible (existing for this machine) - keep_value = True - if ( - op_version_control_available() - and openpype_path_is_set() - and openpype_path_is_accessible() - ): - keep_value = False - - if keep_value: - self.enum_items.append( - {self._studio_override_value: self._studio_override_value} - ) - self.valid_keys.add(self._studio_override_value) - - super(_OpenPypeVersionEnum, self).set_override_state( - state, *args, **kwargs - ) - - -class ProductionVersionsEnumEntity(_OpenPypeVersionEnum): - schema_types = ["production-versions-enum"] - - def _get_openpype_versions(self): - if "OpenPypeVersion" in sys.modules: - OpenPypeVersion = sys.modules["OpenPypeVersion"] - return get_remote_versions(production=True) - return None - - -class StagingVersionsEnumEntity(_OpenPypeVersionEnum): - schema_types = ["staging-versions-enum"] - - def _get_openpype_versions(self): - if "OpenPypeVersion" in sys.modules: - OpenPypeVersion = sys.modules["OpenPypeVersion"] - return get_remote_versions(staging=True) - return None diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py new file mode 100644 index 0000000000..e00c6a4737 --- /dev/null +++ b/openpype/settings/entities/op_version_entity.py @@ -0,0 +1,49 @@ +from openpype.lib.openpype_version import ( + op_version_control_available, + get_remote_versions, + openpype_path_is_set, + openpype_path_is_accessible +) +from .input_entities import TextEntity +from .lib import OverrideState + + +class _OpenPypeVersionInput(TextEntity): + def _item_initialization(self): + super(_OpenPypeVersionInput, self)._item_initialization() + self.multiline = False + self.placeholder_text = "Latest" + self.value_hints = [] + + def _get_openpype_versions(self): + return [] + + def set_override_state(self, state, *args, **kwargs): + value_hints = [] + if state is OverrideState.STUDIO: + versions = self._get_openpype_versions() + if versions is not None: + for version in versions: + value_hints.append(str(version)) + + self.value_hints = value_hints + + super(_OpenPypeVersionInput, self).set_override_state( + state, *args, **kwargs + ) + + +class ProductionVersionsInputEntity(_OpenPypeVersionInput): + schema_types = ["production-versions-text"] + + def _get_openpype_versions(self): + return ["", "asd", "dsa", "3.6"] + return get_remote_versions(production=True) + + +class StagingVersionsInputEntity(_OpenPypeVersionInput): + schema_types = ["staging-versions-text"] + + def _get_openpype_versions(self): + return ["", "asd+staging", "dsa+staging", "3.6+staging"] + return get_remote_versions(staging=True) diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index d548a22c97..b848a34dda 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -31,12 +31,12 @@ "type": "splitter" }, { - "type": "production-versions-enum", + "type": "production-versions-text", "key": "production_version", "label": "Production version" }, { - "type": "staging-versions-enum", + "type": "staging-versions-text", "key": "staging_version", "label": "Staging version" }, From d856c4602b712d6619883dfb7da819eb2d01dd84 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:11:26 +0100 Subject: [PATCH 019/395] added completer implementation for value hints --- openpype/tools/settings/settings/widgets.py | 220 ++++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 7a7213fa66..bf59f605c9 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -1,4 +1,5 @@ import os +import copy from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome from avalon.mongodb import ( @@ -24,13 +25,232 @@ from .constants import ( ) +class CompleterFilter(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(CompleterFilter, self).__init__(*args, **kwargs) + + self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + self._text_filter = "" + + def set_text_filter(self, text): + if self._text_filter == text: + return + self._text_filter = text + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent_index): + if not self._text_filter: + return True + model = self.sourceModel() + index = model.index(row, self.filterKeyColumn(), parent_index) + value = index.data(QtCore.Qt.DisplayRole) + if self._text_filter in value: + if self._text_filter == value: + return False + return True + return False + + +class CompleterView(QtWidgets.QListView): + row_activated = QtCore.Signal(str) + + def __init__(self, parent): + super(CompleterView, self).__init__(parent) + + self.setWindowFlags( + QtCore.Qt.FramelessWindowHint + | QtCore.Qt.Tool + ) + delegate = QtWidgets.QStyledItemDelegate() + self.setItemDelegate(delegate) + + model = QtGui.QStandardItemModel() + filter_model = CompleterFilter() + filter_model.setSourceModel(model) + self.setModel(filter_model) + + # self.installEventFilter(parent) + + self.clicked.connect(self._on_activated) + + self._last_loaded_values = None + self._model = model + self._filter_model = filter_model + self._delegate = delegate + + def _on_activated(self, index): + if index.isValid(): + value = index.data(QtCore.Qt.DisplayRole) + self.row_activated.emit(value) + + def set_text_filter(self, text): + self._filter_model.set_text_filter(text) + self._update_geo() + + def sizeHint(self): + result = super(CompleterView, self).sizeHint() + height = 0 + for row in range(self._filter_model.rowCount()): + height += self.sizeHintForRow(row) + result.setHeight(height) + return result + + def _update_geo(self): + size_hint = self.sizeHint() + self.resize(size_hint.width(), size_hint.height()) + + def update_values(self, values): + if not values: + values = [] + + if self._last_loaded_values == values: + return + self._last_loaded_values = copy.deepcopy(values) + + root_item = self._model.invisibleRootItem() + existing_values = {} + for row in reversed(range(root_item.rowCount())): + child = root_item.child(row) + value = child.data(QtCore.Qt.DisplayRole) + if value not in values: + root_item.removeRows(child.row()) + else: + existing_values[value] = child + + for row, value in enumerate(values): + if value in existing_values: + item = existing_values[value] + if item.row() == row: + continue + else: + item = QtGui.QStandardItem(value) + item.setEditable(False) + + root_item.setChild(row, item) + + self._update_geo() + + def _get_selected_row(self): + indexes = self.selectionModel().selectedIndexes() + if not indexes: + return -1 + return indexes[0].row() + + def _select_row(self, row): + index = self._filter_model.index(row, 0) + self.setCurrentIndex(index) + + def move_up(self): + rows = self._filter_model.rowCount() + if rows == 0: + return + + selected_row = self._get_selected_row() + if selected_row < 0: + new_row = rows - 1 + else: + new_row = selected_row - 1 + if new_row < 0: + new_row = rows - 1 + + if new_row != selected_row: + self._select_row(new_row) + + def move_down(self): + rows = self._filter_model.rowCount() + if rows == 0: + return + + selected_row = self._get_selected_row() + if selected_row < 0: + new_row = 0 + else: + new_row = selected_row + 1 + if new_row >= rows: + new_row = 0 + + if new_row != selected_row: + self._select_row(new_row) + + def enter_pressed(self): + selected_row = self._get_selected_row() + if selected_row < 0: + return + index = self._filter_model.index(selected_row, 0) + self._on_activated(index) + + class SettingsLineEdit(QtWidgets.QLineEdit): focused_in = QtCore.Signal() + def __init__(self, *args, **kwargs): + super(SettingsLineEdit, self).__init__(*args, **kwargs) + + self._completer = None + + self.textChanged.connect(self._on_text_change) + + def _on_text_change(self, text): + if self._completer is not None: + self._completer.set_text_filter(text) + + def _update_completer(self): + if self._completer is None or not self._completer.isVisible(): + return + point = self.frameGeometry().bottomLeft() + new_point = self.mapToGlobal(point) + self._completer.move(new_point) + def focusInEvent(self, event): super(SettingsLineEdit, self).focusInEvent(event) self.focused_in.emit() + if self._completer is None: + return + self._completer.show() + self._update_completer() + + def focusOutEvent(self, event): + super(SettingsLineEdit, self).focusOutEvent(event) + if self._completer is not None: + self._completer.hide() + + def paintEvent(self, event): + super(SettingsLineEdit, self).paintEvent(event) + self._update_completer() + + def update_completer_values(self, values): + if not values and self._completer is None: + return + + self._create_completer() + + self._completer.update_values(values) + + def _create_completer(self): + if self._completer is None: + self._completer = CompleterView(self) + self._completer.row_activated.connect(self._completer_activated) + + def _completer_activated(self, text): + self.setText(text) + + def keyPressEvent(self, event): + if self._completer is None: + super(SettingsLineEdit, self).keyPressEvent(event) + return + + key = event.key() + if key == QtCore.Qt.Key_Up: + self._completer.move_up() + elif key == QtCore.Qt.Key_Down: + self._completer.move_down() + elif key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + self._completer.enter_pressed() + else: + super(SettingsLineEdit, self).keyPressEvent(event) + class SettingsPlainTextEdit(QtWidgets.QPlainTextEdit): focused_in = QtCore.Signal() From 4c2ccb014c3c2a1c815638b5e9f90c86fd36c5cb Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:12:17 +0100 Subject: [PATCH 020/395] text input fills value hints --- openpype/tools/settings/settings/item_widgets.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 2e00967a60..f20deeb6e4 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -2,6 +2,9 @@ import json from Qt import QtWidgets, QtCore, QtGui +from openpype.widgets.sliders import NiceSlider +from openpype.tools.settings import CHILD_OFFSET + from .widgets import ( ExpandingWidget, NumberSpinBox, @@ -22,9 +25,6 @@ from .base import ( InputWidget ) -from openpype.widgets.sliders import NiceSlider -from openpype.tools.settings import CHILD_OFFSET - class DictImmutableKeysWidget(BaseWidget): def create_ui(self): @@ -378,6 +378,11 @@ class TextWidget(InputWidget): self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) + self._refresh_completer() + + def _refresh_completer(self): + self.input_field.update_completer_values(self.entity.value_hints) + def _on_input_focus(self): self.focused_in() From d7ad673462547ef8c653e2041e71b04594a31d3f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:15:26 +0100 Subject: [PATCH 021/395] removed test data --- openpype/settings/entities/op_version_entity.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index e00c6a4737..a6954119aa 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -37,7 +37,6 @@ class ProductionVersionsInputEntity(_OpenPypeVersionInput): schema_types = ["production-versions-text"] def _get_openpype_versions(self): - return ["", "asd", "dsa", "3.6"] return get_remote_versions(production=True) @@ -45,5 +44,4 @@ class StagingVersionsInputEntity(_OpenPypeVersionInput): schema_types = ["staging-versions-text"] def _get_openpype_versions(self): - return ["", "asd+staging", "dsa+staging", "3.6+staging"] return get_remote_versions(staging=True) From fc9a50f7423ecf7e684396313a1679e2f3d07b44 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:15:54 +0100 Subject: [PATCH 022/395] added special widget for openpype version input --- openpype/settings/entities/op_version_entity.py | 10 +++++----- openpype/tools/settings/settings/categories.py | 7 +++++++ openpype/tools/settings/settings/item_widgets.py | 7 +++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index a6954119aa..2458f03852 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -8,9 +8,9 @@ from .input_entities import TextEntity from .lib import OverrideState -class _OpenPypeVersionInput(TextEntity): +class OpenPypeVersionInput(TextEntity): def _item_initialization(self): - super(_OpenPypeVersionInput, self)._item_initialization() + super(OpenPypeVersionInput, self)._item_initialization() self.multiline = False self.placeholder_text = "Latest" self.value_hints = [] @@ -28,19 +28,19 @@ class _OpenPypeVersionInput(TextEntity): self.value_hints = value_hints - super(_OpenPypeVersionInput, self).set_override_state( + super(OpenPypeVersionInput, self).set_override_state( state, *args, **kwargs ) -class ProductionVersionsInputEntity(_OpenPypeVersionInput): +class ProductionVersionsInputEntity(OpenPypeVersionInput): schema_types = ["production-versions-text"] def _get_openpype_versions(self): return get_remote_versions(production=True) -class StagingVersionsInputEntity(_OpenPypeVersionInput): +class StagingVersionsInputEntity(OpenPypeVersionInput): schema_types = ["staging-versions-text"] def _get_openpype_versions(self): diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index a6e4154b2b..af7e0bd742 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -28,6 +28,9 @@ from openpype.settings.entities import ( StudioDefaultsNotDefined, SchemaError ) +from openpype.settings.entities.op_version_entity import ( + OpenPypeVersionInput +) from openpype.settings import SaveWarningExc from .widgets import ProjectListWidget @@ -46,6 +49,7 @@ from .item_widgets import ( BoolWidget, DictImmutableKeysWidget, TextWidget, + OpenPypeVersionText, NumberWidget, RawJsonWidget, EnumeratorWidget, @@ -116,6 +120,9 @@ class SettingsCategoryWidget(QtWidgets.QWidget): elif isinstance(entity, BoolEntity): return BoolWidget(*args) + elif isinstance(entity, OpenPypeVersionInput): + return OpenPypeVersionText(*args) + elif isinstance(entity, TextEntity): return TextWidget(*args) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index f20deeb6e4..11f0dc6add 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -389,6 +389,7 @@ class TextWidget(InputWidget): def _on_entity_change(self): if self.entity.value != self.input_value(): self.set_entity_value() + self._refresh_completer() def set_entity_value(self): if self.entity.multiline: @@ -411,6 +412,12 @@ class TextWidget(InputWidget): self.entity.set(self.input_value()) +class OpenPypeVersionText(TextWidget): + def _on_entity_change(self): + super(OpenPypeVersionText, self)._on_entity_change() + self._refresh_completer() + + class NumberWidget(InputWidget): _slider_widget = None From 8b4455721ed26a5bae3a4d74efe88d03a1854a45 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:22:01 +0100 Subject: [PATCH 023/395] removed overdone refresh of completer --- openpype/tools/settings/settings/item_widgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 11f0dc6add..bca70edff5 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -389,7 +389,6 @@ class TextWidget(InputWidget): def _on_entity_change(self): if self.entity.value != self.input_value(): self.set_entity_value() - self._refresh_completer() def set_entity_value(self): if self.entity.multiline: From af12e59f826f3b27c3b6c8256650da1360fcffb2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:23:40 +0100 Subject: [PATCH 024/395] skip completer refresh for multiline entities --- openpype/tools/settings/settings/item_widgets.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index bca70edff5..3a7e126846 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -378,9 +378,12 @@ class TextWidget(InputWidget): self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) - self._refresh_completer() - def _refresh_completer(self): + # Multiline entity can't have completer + # - there is not space for this UI component + if self.entity.multiline: + return + self.input_field.update_completer_values(self.entity.value_hints) def _on_input_focus(self): From 8e853314daac94ce4ecf9966ff85d72c75347a03 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:23:53 +0100 Subject: [PATCH 025/395] added schema validation for value hints --- openpype/settings/entities/input_entities.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index d45e8f9f01..a285bf3433 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -440,6 +440,15 @@ class TextEntity(InputEntity): self.placeholder_text = self.schema_data.get("placeholder") self.value_hints = self.schema_data.get("value_hints") or [] + def schema_validations(self): + if self.multiline and self.value_hints: + reason = ( + "TextEntity entity can't use value hints" + " for multiline input (yet)." + ) + raise EntitySchemaError(self, reason) + super(TextEntity, self).schema_validations() + def _convert_to_valid_type(self, value): # Allow numbers converted to string if isinstance(value, (int, float)): From 298d2da581805e3f704c651bda6ed7e680d58bf4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 6 Dec 2021 18:57:36 +0100 Subject: [PATCH 026/395] added refresh of completer to init --- openpype/tools/settings/settings/item_widgets.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 3a7e126846..1d912a90b7 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -378,6 +378,8 @@ class TextWidget(InputWidget): self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) + self._refresh_completer() + def _refresh_completer(self): # Multiline entity can't have completer # - there is not space for this UI component From c479fdecdb13085ed4d58c5c044ccf77587c3e76 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 10:36:13 +0100 Subject: [PATCH 027/395] added label --- .../entities/schemas/system_schema/schema_general.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index b848a34dda..ed66cd7ac8 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -30,6 +30,10 @@ { "type": "splitter" }, + { + "type": "label", + "label": "Define explicit OpenPype version that should be used. Keep empty to use latest available version." + }, { "type": "production-versions-text", "key": "production_version", From 1ea180ba8d58fdb4010eefad0739e84d3f1482e9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 11:21:48 +0100 Subject: [PATCH 028/395] removed dot --- .../settings/entities/schemas/system_schema/schema_general.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index ed66cd7ac8..b4c83fc85f 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -20,7 +20,7 @@ }, { "type": "label", - "label": "This is NOT a securely stored password!. It only acts as a simple barrier to stop users from accessing studio wide settings." + "label": "This is NOT a securely stored password! It only acts as a simple barrier to stop users from accessing studio wide settings." }, { "type": "text", From 1454efab3a0528ccaa8cbcb417773fafd8f10e2d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 13:43:51 +0100 Subject: [PATCH 029/395] added style for completer view --- openpype/style/style.css | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/openpype/style/style.css b/openpype/style/style.css index 19245cdc40..0ef15a2511 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -536,6 +536,27 @@ QAbstractItemView::branch:!has-children:!has-siblings:adjoins-item { background: transparent; } +CompleterView { + border: 1px solid #555555; + background: {color:bg-inputs}; +} + +CompleterView::item:selected { + background: {color:bg-view-hover}; +} + +CompleterView::item:selected:hover { + background: {color:bg-view-hover}; +} + +CompleterView::right-arrow { + min-width: 10px; +} +CompleterView::separator { + background: {color:bg-menu-separator}; + height: 2px; + margin-right: 5px; +} /* Progress bar */ QProgressBar { From 44e00ed1d442d3d9dc3cb0a68a8976fb63da401c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 13:44:02 +0100 Subject: [PATCH 030/395] don't care about ideal height --- openpype/tools/settings/settings/widgets.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index bf59f605c9..4b88c1f93f 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -90,10 +90,9 @@ class CompleterView(QtWidgets.QListView): def sizeHint(self): result = super(CompleterView, self).sizeHint() - height = 0 - for row in range(self._filter_model.rowCount()): - height += self.sizeHintForRow(row) - result.setHeight(height) + if self._filter_model.rowCount() == 0: + result.setHeight(0) + return result def _update_geo(self): From 228d6fc914f3f1e6bcd6212d323facb9b67d4364 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 13:45:21 +0100 Subject: [PATCH 031/395] added basic information about valid version --- .../tools/settings/settings/item_widgets.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 1d912a90b7..0c66162375 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -417,9 +417,30 @@ class TextWidget(InputWidget): class OpenPypeVersionText(TextWidget): + def __init__(self, *args, **kwargs): + self._info_widget = None + super(OpenPypeVersionText, self).__init__(*args, **kwargs) + + def create_ui(self): + super(OpenPypeVersionText, self).create_ui() + info_widget = QtWidgets.QLabel("Latest", self) + self.content_layout.addWidget(info_widget, 1) + + self._info_widget = info_widget + + def _update_info_widget(self): + value = self.input_value() + if value == "": + self._info_widget.setText("Latest") + elif value in self.entity.value_hints: + self._info_widget.setText("Ok") + else: + self._info_widget.setText("Version not found from this workstation") + def _on_entity_change(self): super(OpenPypeVersionText, self)._on_entity_change() self._refresh_completer() + self._update_info_widget() class NumberWidget(InputWidget): From 87b606582a95696f4a4fc3091c4cbd624538d0b7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:10:13 +0100 Subject: [PATCH 032/395] do not create new qapplication if already exists --- igniter/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/igniter/__init__.py b/igniter/__init__.py index bbc3dbfc88..d974bd9e0d 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -28,7 +28,9 @@ def open_dialog(): if scale_attr is not None: QtWidgets.QApplication.setAttribute(scale_attr) - app = QtWidgets.QApplication(sys.argv) + app = QtWidgets.QApplication.instance() + if not app: + app = QtWidgets.QApplication(sys.argv) d = InstallDialog() d.open() @@ -49,7 +51,9 @@ def open_update_window(openpype_version): if scale_attr is not None: QtWidgets.QApplication.setAttribute(scale_attr) - app = QtWidgets.QApplication(sys.argv) + app = QtWidgets.QApplication.instance() + if not app: + app = QtWidgets.QApplication(sys.argv) d = UpdateWindow(version=openpype_version) d.open() From db467dcbfd041a7fdd60399a2535bf215e40a7c3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:13:56 +0100 Subject: [PATCH 033/395] added function to get openpype icon to tools.py --- igniter/install_dialog.py | 5 +++-- igniter/tools.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index 1fe67e3397..251adebc9f 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -12,7 +12,8 @@ from Qt.QtCore import QTimer # noqa from .install_thread import InstallThread from .tools import ( validate_mongo_connection, - get_openpype_path_from_db + get_openpype_path_from_db, + get_openpype_icon_path ) from .nice_progress_bar import NiceProgressBar @@ -187,7 +188,6 @@ class InstallDialog(QtWidgets.QDialog): current_dir = os.path.dirname(os.path.abspath(__file__)) roboto_font_path = os.path.join(current_dir, "RobotoMono-Regular.ttf") poppins_font_path = os.path.join(current_dir, "Poppins") - icon_path = os.path.join(current_dir, "openpype_icon.png") # Install roboto font QtGui.QFontDatabase.addApplicationFont(roboto_font_path) @@ -196,6 +196,7 @@ class InstallDialog(QtWidgets.QDialog): QtGui.QFontDatabase.addApplicationFont(filename) # Load logo + icon_path = get_openpype_icon_path() pixmap_openpype_logo = QtGui.QPixmap(icon_path) # Set logo as icon of window self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) diff --git a/igniter/tools.py b/igniter/tools.py index 5cad2b9bf8..72b98f1f82 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -210,3 +210,11 @@ def load_stylesheet() -> str: stylesheet_path = Path(__file__).parent.resolve() / "stylesheet.css" return stylesheet_path.read_text() + + +def get_openpype_icon_path() -> str: + """Path to OpenPype icon png file.""" + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "openpype_icon.png" + ) From e4534c5074244afa096bc290809011d0b24eb7a6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:21:05 +0100 Subject: [PATCH 034/395] added new message dialog to show a dialog --- igniter/__init__.py | 22 ++++++++++++++++++++ igniter/message_dialog.py | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 igniter/message_dialog.py diff --git a/igniter/__init__.py b/igniter/__init__.py index d974bd9e0d..6f06c1eb90 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -63,9 +63,31 @@ def open_update_window(openpype_version): return version_path +def show_message_dialog(title, message): + if os.getenv("OPENPYPE_HEADLESS_MODE"): + print("!!! Can't open dialog in headless mode. Exiting.") + sys.exit(1) + from Qt import QtWidgets, QtCore + from .message_dialog import MessageDialog + + scale_attr = getattr(QtCore.Qt, "AA_EnableHighDpiScaling", None) + if scale_attr is not None: + QtWidgets.QApplication.setAttribute(scale_attr) + + app = QtWidgets.QApplication.instance() + if not app: + app = QtWidgets.QApplication(sys.argv) + + dialog = MessageDialog(title, message) + dialog.open() + + app.exec_() + + __all__ = [ "BootstrapRepos", "open_dialog", "open_update_window", + "show_message_dialog", "version" ] diff --git a/igniter/message_dialog.py b/igniter/message_dialog.py new file mode 100644 index 0000000000..88a086df1e --- /dev/null +++ b/igniter/message_dialog.py @@ -0,0 +1,44 @@ +from Qt import QtWidgets, QtGui + +from .tools import ( + load_stylesheet, + get_openpype_icon_path +) + + +class MessageDialog(QtWidgets.QDialog): + def __init__(self, title, message): + super(MessageDialog, self).__init__() + + # Set logo as icon of window + icon_path = get_openpype_icon_path() + pixmap_openpype_logo = QtGui.QPixmap(icon_path) + self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) + + # Set title + self.setWindowTitle(title) + + # Set message + label_widget = QtWidgets.QLabel(message, self) + + ok_btn = QtWidgets.QPushButton("OK", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn, 0) + + layout = QtWidgets.QVBoxLayout(self) + # layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(label_widget, 1) + layout.addLayout(btns_layout, 0) + + ok_btn.clicked.connect(self._on_ok_clicked) + + self._label_widget = label_widget + self._ok_btn = ok_btn + + def _on_ok_clicked(self): + self.close() + + def showEvent(self, event): + super(MessageDialog, self).showEvent(event) + self.setStyleSheet(load_stylesheet()) From 500339088d51a2c66536aaffd5bd07ea7a4f6619 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:24:25 +0100 Subject: [PATCH 035/395] added new exception into tools --- igniter/tools.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/igniter/tools.py b/igniter/tools.py index 72b98f1f82..2595140582 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -16,6 +16,11 @@ from pymongo.errors import ( ) +class OpenPypeVersionNotFound(Exception): + """OpenPype version was not found in remote and local repository.""" + pass + + def should_add_certificate_path_to_mongo_url(mongo_url): """Check if should add ca certificate to mongo url. From 21b9926be40079b04d970c690d11db8ffb421329 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:30:15 +0100 Subject: [PATCH 036/395] simplified _find_frozen_openpype --- start.py | 120 +++++++++++++++++++++++-------------------------------- 1 file changed, 49 insertions(+), 71 deletions(-) diff --git a/start.py b/start.py index 05b7da6308..91221da75d 100644 --- a/start.py +++ b/start.py @@ -196,7 +196,8 @@ from igniter import BootstrapRepos # noqa: E402 from igniter.tools import ( get_openpype_global_settings, get_openpype_path_from_db, - validate_mongo_connection + validate_mongo_connection, + OpenPypeVersionNotFound ) # noqa from igniter.bootstrap_repos import OpenPypeVersion # noqa: E402 @@ -645,67 +646,60 @@ def _find_frozen_openpype(use_version: str = None, (if requested). """ - version_path = None - openpype_version = None - openpype_versions = bootstrap.find_openpype(include_zips=True, - staging=use_staging) - # get local frozen version and add it to detected version so if it is - # newer it will be used instead. - local_version_str = bootstrap.get_version( - Path(os.environ["OPENPYPE_ROOT"])) + # Collect OpenPype versions + openpype_versions = bootstrap.find_openpype( + include_zips=True, + staging=use_staging + ) + openpype_root = Path(os.environ["OPENPYPE_ROOT"]) + local_version_str = bootstrap.get_version(openpype_root) + studio_version = OpenPypeVersion.get_expected_studio_version(use_staging) if local_version_str: local_version = OpenPypeVersion( version=local_version_str, - path=Path(os.environ["OPENPYPE_ROOT"])) + path=openpype_root + ) if local_version not in openpype_versions: openpype_versions.append(local_version) - openpype_versions.sort() - # if latest is currently running, ditch whole list - # and run from current without installing it. - if local_version == openpype_versions[-1]: - os.environ["OPENPYPE_TRYOUT"] = "1" - openpype_versions = [] + + # Find OpenPype version that should be used + openpype_version = None + if use_version is not None: + # Version was specified with arguments or env OPENPYPE_VERSION + # - should crash if version is not available + _print("Finding specified version \"{}\"".format(use_version)) + for version in openpype_versions: + if version == use_version: + openpype_version = version + break + + if openpype_version is None: + raise OpenPypeVersionNotFound( + "Requested version \"{}\" was not found.".format(use_version) + ) + + elif studio_version: + _print("Finding studio version \"{}\"".format(studio_version)) + # Look for version specified by settings + for version in openpype_versions: + if version == studio_version: + openpype_version = version + break + if openpype_version is None: + raise OpenPypeVersionNotFound(( + "Requested OpenPype version \"{}\" defined by settings" + " was not found." + ).format(use_version)) + else: - _print("!!! Warning: cannot determine current running version.") + # Use latest available version + _print("Finding latest version") + openpype_versions.sort() + openpype_version = openpype_versions[-1] - if not os.getenv("OPENPYPE_TRYOUT"): - try: - # use latest one found (last in the list is latest) - openpype_version = openpype_versions[-1] - except IndexError: - # no OpenPype version found, run Igniter and ask for them. - _print('*** No OpenPype versions found.') - if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": - _print("!!! Cannot open Igniter dialog in headless mode.") - sys.exit(1) - _print("--- launching setup UI ...") - import igniter - return_code = igniter.open_dialog() - if return_code == 2: - os.environ["OPENPYPE_TRYOUT"] = "1" - if return_code == 3: - # run OpenPype after installation - - _print('>>> Finding OpenPype again ...') - openpype_versions = bootstrap.find_openpype( - staging=use_staging) - try: - openpype_version = openpype_versions[-1] - except IndexError: - _print(("!!! Something is wrong and we didn't " - "found it again.")) - sys.exit(1) - elif return_code != 2: - _print(f" . finished ({return_code})") - sys.exit(return_code) - - if not openpype_versions: - # no openpype versions found anyway, lets use then the one - # shipped with frozen OpenPype - if not os.getenv("OPENPYPE_TRYOUT"): - _print("*** Still no luck finding OpenPype.") - _print(("*** We'll try to use the one coming " - "with OpenPype installation.")) + # get local frozen version and add it to detected version so if it is + # newer it will be used instead. + if local_version == openpype_version: version_path = _bootstrap_from_code(use_version, use_staging) openpype_version = OpenPypeVersion( version=BootstrapRepos.get_version(version_path), @@ -713,22 +707,6 @@ def _find_frozen_openpype(use_version: str = None, _initialize_environment(openpype_version) return version_path - # get path of version specified in `--use-version` - local_version = bootstrap.get_version(OPENPYPE_ROOT) - if use_version and use_version != local_version: - # force the one user has selected - openpype_version = None - openpype_versions = bootstrap.find_openpype(include_zips=True, - staging=use_staging) - v: OpenPypeVersion - found = [v for v in openpype_versions if str(v) == use_version] - if found: - openpype_version = sorted(found)[-1] - if not openpype_version: - _print(f"!!! Requested version {use_version} was not found.") - list_versions(openpype_versions, local_version) - sys.exit(1) - # test if latest detected is installed (in user data dir) is_inside = False try: From 5d03abe627d890785e8657931559f8dac39d66b1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:30:51 +0100 Subject: [PATCH 037/395] removed play animation part --- start.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/start.py b/start.py index 91221da75d..5f416a47b8 100644 --- a/start.py +++ b/start.py @@ -866,16 +866,6 @@ def boot(): # ------------------------------------------------------------------------ _startup_validations() - # ------------------------------------------------------------------------ - # Play animation - # ------------------------------------------------------------------------ - - # from igniter.terminal_splash import play_animation - - # don't play for silenced commands - # if all(item not in sys.argv for item in silent_commands): - # play_animation() - # ------------------------------------------------------------------------ # Process arguments # ------------------------------------------------------------------------ From 9b369de50c2ad5a281fb0ff84fcb7988f26c224f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:38:43 +0100 Subject: [PATCH 038/395] raise OpenPypeVersionNotFound in run from code too --- start.py | 57 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/start.py b/start.py index 5f416a47b8..69774c3a6c 100644 --- a/start.py +++ b/start.py @@ -769,45 +769,50 @@ def _bootstrap_from_code(use_version, use_staging): # get current version of OpenPype local_version = bootstrap.get_local_live_version() - version_to_use = None openpype_versions = bootstrap.find_openpype( include_zips=True, staging=use_staging) if use_staging and not use_version: - try: - version_to_use = openpype_versions[-1] - except IndexError: - _print("!!! No staging versions are found.") - list_versions(openpype_versions, local_version) - sys.exit(1) + if not openpype_versions: + raise OpenPypeVersionNotFound( + "Didn't find any staging versions." + ) + + # Use last found staging version + version_to_use = openpype_versions[-1] if version_to_use.path.is_file(): version_to_use.path = bootstrap.extract_openpype( version_to_use) bootstrap.add_paths_from_directory(version_to_use.path) os.environ["OPENPYPE_VERSION"] = str(version_to_use) version_path = version_to_use.path - os.environ["OPENPYPE_REPOS_ROOT"] = (version_path / "openpype").as_posix() # noqa: E501 + os.environ["OPENPYPE_REPOS_ROOT"] = ( + version_path / "openpype" + ).as_posix() _openpype_root = version_to_use.path.as_posix() elif use_version and use_version != local_version: - v: OpenPypeVersion - found = [v for v in openpype_versions if str(v) == use_version] - if found: - version_to_use = sorted(found)[-1] + version_to_use = None + for version in openpype_versions: + if str(version) == use_version: + version_to_use = version + break - if version_to_use: - # use specified - if version_to_use.path.is_file(): - version_to_use.path = bootstrap.extract_openpype( - version_to_use) - bootstrap.add_paths_from_directory(version_to_use.path) - os.environ["OPENPYPE_VERSION"] = use_version - version_path = version_to_use.path - os.environ["OPENPYPE_REPOS_ROOT"] = (version_path / "openpype").as_posix() # noqa: E501 - _openpype_root = version_to_use.path.as_posix() - else: - _print(f"!!! Requested version {use_version} was not found.") - list_versions(openpype_versions, local_version) - sys.exit(1) + if version_to_use is None: + raise OpenPypeVersionNotFound( + "Requested version \"{}\" was not found.".format(use_version) + ) + + # use specified + if version_to_use.path.is_file(): + version_to_use.path = bootstrap.extract_openpype( + version_to_use) + bootstrap.add_paths_from_directory(version_to_use.path) + os.environ["OPENPYPE_VERSION"] = use_version + version_path = version_to_use.path + os.environ["OPENPYPE_REPOS_ROOT"] = ( + version_path / "openpype" + ).as_posix() + _openpype_root = version_to_use.path.as_posix() else: os.environ["OPENPYPE_VERSION"] = local_version version_path = Path(_openpype_root) From a98bdc7d7f2e91ce6c3855d6ca0ed95c6e84be45 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:39:24 +0100 Subject: [PATCH 039/395] catch and handle OpenPypeVersioNotFound exception --- start.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/start.py b/start.py index 69774c3a6c..853e6d13a0 100644 --- a/start.py +++ b/start.py @@ -972,6 +972,15 @@ def boot(): # find versions of OpenPype to be used with frozen code try: version_path = _find_frozen_openpype(use_version, use_staging) + except OpenPypeVersionNotFound as exc: + message = str(exc) + _print(message) + if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + list_versions(openpype_versions, local_version) + else: + igniter.show_message_dialog("Version not found", message) + sys.exit(1) + except RuntimeError as e: # no version to run _print(f"!!! {e}") @@ -984,7 +993,17 @@ def boot(): sys.exit(1) _print(f"--- version is valid") else: - version_path = _bootstrap_from_code(use_version, use_staging) + try: + version_path = _bootstrap_from_code(use_version, use_staging) + + except OpenPypeVersionNotFound as exc: + message = str(exc) + _print(message) + if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + list_versions(openpype_versions, local_version) + else: + igniter.show_message_dialog("Version not found", message) + sys.exit(1) # set this to point either to `python` from venv in case of live code # or to `openpype` or `openpype_console` in case of frozen code From b0099ea5d4271d997f80932535cc2817574fc73a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 7 Dec 2021 18:39:45 +0100 Subject: [PATCH 040/395] skipped already done imports --- start.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/start.py b/start.py index 853e6d13a0..32fa4bd062 100644 --- a/start.py +++ b/start.py @@ -521,7 +521,7 @@ def _process_arguments() -> tuple: if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": _print("!!! Cannot open Igniter dialog in headless mode.") sys.exit(1) - import igniter + return_code = igniter.open_dialog() # this is when we want to run OpenPype without installing anything. @@ -719,12 +719,12 @@ def _find_frozen_openpype(use_version: str = None, if not is_inside: # install latest version to user data dir - if os.getenv("OPENPYPE_HEADLESS_MODE", "0") != "1": - import igniter - version_path = igniter.open_update_window(openpype_version) - else: + if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": version_path = bootstrap.install_version( - openpype_version, force=True) + openpype_version, force=True + ) + else: + version_path = igniter.open_update_window(openpype_version) openpype_version.path = version_path _initialize_environment(openpype_version) From 0dd3eca360fd10693f3c3a1de6375d55151d7c41 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:25:24 +0100 Subject: [PATCH 041/395] added helper method to change style properties --- openpype/tools/settings/settings/base.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index f8378ed18c..bc6c6d10ff 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -26,6 +26,14 @@ class BaseWidget(QtWidgets.QWidget): self.label_widget = None self.create_ui() + @staticmethod + def set_style_property(obj, property_name, property_value): + if obj.property(property_name) == property_value: + return + + obj.setProperty(property_name, property_value) + obj.style().polish(obj) + def scroll_to(self, widget): self.category_widget.scroll_to(widget) From 0479d544bd3c23539c3367ac2dada2d8345c7ad0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:25:41 +0100 Subject: [PATCH 042/395] update geo on show of completer view --- openpype/tools/settings/settings/widgets.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 4b88c1f93f..bfe8339219 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -95,6 +95,10 @@ class CompleterView(QtWidgets.QListView): return result + def showEvent(self, event): + super(CompleterView, self).showEvent(event) + self._update_geo() + def _update_geo(self): size_hint = self.sizeHint() self.resize(size_hint.width(), size_hint.height()) From ccce04309bac2318a6ebf87916b0f646e3204dd1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:26:33 +0100 Subject: [PATCH 043/395] renamed exception BaseInvalidValueType to BaseInvalidValue --- openpype/settings/entities/__init__.py | 4 ++-- openpype/settings/entities/base_entity.py | 4 ++-- openpype/settings/entities/color_entity.py | 6 +++--- openpype/settings/entities/exceptions.py | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index 4efd358297..a173e2454f 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -57,7 +57,7 @@ from .exceptions import ( SchemaError, DefaultsNotDefined, StudioDefaultsNotDefined, - BaseInvalidValueType, + BaseInvalidValue, InvalidValueType, InvalidKeySymbols, SchemaMissingFileInfo, @@ -130,7 +130,7 @@ from .op_version_entity import ( __all__ = ( "DefaultsNotDefined", "StudioDefaultsNotDefined", - "BaseInvalidValueType", + "BaseInvalidValue", "InvalidValueType", "InvalidKeySymbols", "SchemaMissingFileInfo", diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 341968bd75..12754d345f 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -9,7 +9,7 @@ from .lib import ( ) from .exceptions import ( - BaseInvalidValueType, + BaseInvalidValue, InvalidValueType, SchemeGroupHierarchyBug, EntitySchemaError @@ -432,7 +432,7 @@ class BaseItemEntity(BaseEntity): try: new_value = self.convert_to_valid_type(value) - except BaseInvalidValueType: + except BaseInvalidValue: new_value = NOT_SET if new_value is not NOT_SET: diff --git a/openpype/settings/entities/color_entity.py b/openpype/settings/entities/color_entity.py index 3becf2d865..bdaab6f583 100644 --- a/openpype/settings/entities/color_entity.py +++ b/openpype/settings/entities/color_entity.py @@ -1,7 +1,7 @@ from .lib import STRING_TYPE from .input_entities import InputEntity from .exceptions import ( - BaseInvalidValueType, + BaseInvalidValue, InvalidValueType ) @@ -47,7 +47,7 @@ class ColorEntity(InputEntity): reason = "Color entity expect 4 items in list got {}".format( len(value) ) - raise BaseInvalidValueType(reason, self.path) + raise BaseInvalidValue(reason, self.path) new_value = [] for item in value: @@ -60,7 +60,7 @@ class ColorEntity(InputEntity): reason = ( "Color entity expect 4 integers in range 0-255 got {}" ).format(value) - raise BaseInvalidValueType(reason, self.path) + raise BaseInvalidValue(reason, self.path) new_value.append(item) # Make sure diff --git a/openpype/settings/entities/exceptions.py b/openpype/settings/entities/exceptions.py index f352c94f20..d1728a7b12 100644 --- a/openpype/settings/entities/exceptions.py +++ b/openpype/settings/entities/exceptions.py @@ -15,14 +15,14 @@ class StudioDefaultsNotDefined(Exception): super(StudioDefaultsNotDefined, self).__init__(msg) -class BaseInvalidValueType(Exception): +class BaseInvalidValue(Exception): def __init__(self, reason, path): msg = "Path \"{}\". {}".format(path, reason) self.msg = msg - super(BaseInvalidValueType, self).__init__(msg) + super(BaseInvalidValue, self).__init__(msg) -class InvalidValueType(BaseInvalidValueType): +class InvalidValueType(BaseInvalidValue): def __init__(self, valid_types, invalid_type, path): joined_types = ", ".join( [str(valid_type) for valid_type in valid_types] From 703368c5b0eaddc93172d6487ac1b5dcb0e5f91b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:27:02 +0100 Subject: [PATCH 044/395] added validation of version value with convert_to_valid_type --- .../settings/entities/op_version_entity.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 2458f03852..20495a0d42 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -2,10 +2,15 @@ from openpype.lib.openpype_version import ( op_version_control_available, get_remote_versions, openpype_path_is_set, - openpype_path_is_accessible + openpype_path_is_accessible, + get_OpenPypeVersion ) from .input_entities import TextEntity -from .lib import OverrideState +from .lib import ( + OverrideState, + NOT_SET +) +from .exceptions import BaseInvalidValue class OpenPypeVersionInput(TextEntity): @@ -32,6 +37,21 @@ class OpenPypeVersionInput(TextEntity): state, *args, **kwargs ) + def convert_to_valid_type(self, value): + if value and value is not NOT_SET: + OpenPypeVersion = get_OpenPypeVersion() + if OpenPypeVersion is not None: + try: + OpenPypeVersion(version=value) + except Exception: + raise BaseInvalidValue( + "Value \"{}\"is not valid version format.".format( + value + ), + self.path + ) + return super(OpenPypeVersionInput, self).convert_to_valid_type(value) + class ProductionVersionsInputEntity(OpenPypeVersionInput): schema_types = ["production-versions-text"] From 9acdf2e338ebd44c4321035b4e885e863c81deab Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:27:27 +0100 Subject: [PATCH 045/395] added style to info label --- openpype/style/data.json | 4 +++- openpype/style/style.css | 8 ++++++++ openpype/tools/settings/settings/item_widgets.py | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/openpype/style/data.json b/openpype/style/data.json index 026eaf4264..62573f015e 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -111,7 +111,9 @@ "focus-border": "#839caf", "image-btn": "#bfccd6", "image-btn-hover": "#189aea", - "image-btn-disabled": "#bfccd6" + "image-btn-disabled": "#bfccd6", + "version-exists": "#458056", + "version-not-found": "#ffc671" } } } diff --git a/openpype/style/style.css b/openpype/style/style.css index 0ef15a2511..9249db5f1e 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1158,6 +1158,14 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { border-radius: 5px; } +#OpenPypeVersionLabel[state="success"] { + color: {color:settings:version-exists}; +} + +#OpenPypeVersionLabel[state="warning"] { + color: {color:settings:version-not-found}; +} + #ShadowWidget { font-size: 36pt; } diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 0c66162375..3a7a107965 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -424,6 +424,7 @@ class OpenPypeVersionText(TextWidget): def create_ui(self): super(OpenPypeVersionText, self).create_ui() info_widget = QtWidgets.QLabel("Latest", self) + info_widget.setObjectName("OpenPypeVersionLabel") self.content_layout.addWidget(info_widget, 1) self._info_widget = info_widget From 84022f9e0def952a44a7cbea3f35b690705c2c78 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:27:53 +0100 Subject: [PATCH 046/395] added more variants of info label with colors and ivalidation --- .../tools/settings/settings/item_widgets.py | 63 +++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 3a7a107965..9f78060c87 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -4,6 +4,7 @@ from Qt import QtWidgets, QtCore, QtGui from openpype.widgets.sliders import NiceSlider from openpype.tools.settings import CHILD_OFFSET +from openpype.settings.entities.exceptions import BaseInvalidValue from .widgets import ( ExpandingWidget, @@ -423,7 +424,7 @@ class OpenPypeVersionText(TextWidget): def create_ui(self): super(OpenPypeVersionText, self).create_ui() - info_widget = QtWidgets.QLabel("Latest", self) + info_widget = QtWidgets.QLabel(self) info_widget.setObjectName("OpenPypeVersionLabel") self.content_layout.addWidget(info_widget, 1) @@ -431,17 +432,67 @@ class OpenPypeVersionText(TextWidget): def _update_info_widget(self): value = self.input_value() - if value == "": - self._info_widget.setText("Latest") + + message = "" + tooltip = "" + state = None + if self._is_invalid: + message = "Invalid version format" + + elif value == "": + message = "Use latest available version" + tooltip = "Latest version from OpenPype zip repository will used" + elif value in self.entity.value_hints: - self._info_widget.setText("Ok") + message = "Version {} will be used".format(value) + state = "success" + else: - self._info_widget.setText("Version not found from this workstation") + message = ( + "Version {} not found in listed versions".format(value) + ) + state = "warning" + if self.entity.value_hints: + tooltip = "Found versions are {}".format(", ".join( + ['"{}"'.format(hint) for hint in self.entity.value_hints] + )) + else: + tooltip = "No versions were found" + + self._info_widget.setText(message) + self._info_widget.setToolTip(tooltip) + self.set_style_property(self._info_widget, "state", state) + + def set_entity_value(self): + super(OpenPypeVersionText, self).set_entity_value() + self._invalidate() + self._update_info_widget() + + def _on_value_change_timer(self): + value = self.input_value() + self._invalidate() + if not self.is_invalid: + self.entity.set(value) + self.update_style() + else: + # Manually trigger hierachical style update + self.ignore_input_changes.set_ignore(True) + self.ignore_input_changes.set_ignore(False) + + self._update_info_widget() + + def _invalidate(self): + value = self.input_value() + try: + self.entity.convert_to_valid_type(value) + is_invalid = False + except BaseInvalidValue: + is_invalid = True + self._is_invalid = is_invalid def _on_entity_change(self): super(OpenPypeVersionText, self)._on_entity_change() self._refresh_completer() - self._update_info_widget() class NumberWidget(InputWidget): From 5181c52d12a2a7e3b2688d01ea7f05df73379d70 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:29:49 +0100 Subject: [PATCH 047/395] modified messages a little bit --- openpype/tools/settings/settings/item_widgets.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 9f78060c87..22f672da2b 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -437,27 +437,29 @@ class OpenPypeVersionText(TextWidget): tooltip = "" state = None if self._is_invalid: - message = "Invalid version format" + message = "Invalid OpenPype version format" elif value == "": message = "Use latest available version" - tooltip = "Latest version from OpenPype zip repository will used" + tooltip = ( + "Latest version from OpenPype zip repository will be used" + ) elif value in self.entity.value_hints: - message = "Version {} will be used".format(value) state = "success" + message = "Version {} will be used".format(value) else: + state = "warning" message = ( "Version {} not found in listed versions".format(value) ) - state = "warning" if self.entity.value_hints: - tooltip = "Found versions are {}".format(", ".join( + tooltip = "Listed versions: {}".format(", ".join( ['"{}"'.format(hint) for hint in self.entity.value_hints] )) else: - tooltip = "No versions were found" + tooltip = "No versions were listed" self._info_widget.setText(message) self._info_widget.setToolTip(tooltip) From 1588df3ca275165a3fb143ddcd5c9d4b4af92ea8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 17:37:03 +0100 Subject: [PATCH 048/395] added placeholder inheritance back --- openpype/tools/settings/settings/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 3e44bd3969..cc6e396db0 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -185,7 +185,7 @@ class CompleterView(QtWidgets.QListView): self._on_activated(index) -class SettingsLineEdit(QtWidgets.QLineEdit): +class SettingsLineEdit(PlaceholderLineEdit): focused_in = QtCore.Signal() def __init__(self, *args, **kwargs): From 767ef9d715c92f50493a1d20352e3df5ecaff63a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 18:31:14 +0100 Subject: [PATCH 049/395] sort versions earlier --- start.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/start.py b/start.py index 32fa4bd062..55008a9526 100644 --- a/start.py +++ b/start.py @@ -662,6 +662,8 @@ def _find_frozen_openpype(use_version: str = None, if local_version not in openpype_versions: openpype_versions.append(local_version) + openpype_versions.sort() + # Find OpenPype version that should be used openpype_version = None if use_version is not None: @@ -694,7 +696,6 @@ def _find_frozen_openpype(use_version: str = None, else: # Use latest available version _print("Finding latest version") - openpype_versions.sort() openpype_version = openpype_versions[-1] # get local frozen version and add it to detected version so if it is From ce204157273e74fd15abd9a37eafbd7722e533a7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 18:31:40 +0100 Subject: [PATCH 050/395] added option to pass "latest" to use version argument --- start.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/start.py b/start.py index 55008a9526..28c04600c4 100644 --- a/start.py +++ b/start.py @@ -667,13 +667,17 @@ def _find_frozen_openpype(use_version: str = None, # Find OpenPype version that should be used openpype_version = None if use_version is not None: - # Version was specified with arguments or env OPENPYPE_VERSION - # - should crash if version is not available - _print("Finding specified version \"{}\"".format(use_version)) - for version in openpype_versions: - if version == use_version: - openpype_version = version - break + if use_version.lower() == "latest": + openpype_version = openpype_versions[-1] + else: + use_version_obj = OpenPypeVersion(use_version) + # Version was specified with arguments or env OPENPYPE_VERSION + # - should crash if version is not available + _print("Finding specified version \"{}\"".format(use_version)) + for version in openpype_versions: + if version == use_version_obj: + openpype_version = version + break if openpype_version is None: raise OpenPypeVersionNotFound( From 52c5424f2b59987876eb0412dfbdab2c8af0cd09 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 18:31:50 +0100 Subject: [PATCH 051/395] modified messages --- start.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/start.py b/start.py index 28c04600c4..6fa624b5cd 100644 --- a/start.py +++ b/start.py @@ -681,7 +681,9 @@ def _find_frozen_openpype(use_version: str = None, if openpype_version is None: raise OpenPypeVersionNotFound( - "Requested version \"{}\" was not found.".format(use_version) + "Requested version \"{}\" was not found.".format( + use_version + ) ) elif studio_version: @@ -695,7 +697,7 @@ def _find_frozen_openpype(use_version: str = None, raise OpenPypeVersionNotFound(( "Requested OpenPype version \"{}\" defined by settings" " was not found." - ).format(use_version)) + ).format(studio_version)) else: # Use latest available version From d2edf3da743459bf3b043d5c855098b04cb66e79 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:02:37 +0100 Subject: [PATCH 052/395] don't return latest version --- igniter/bootstrap_repos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 9446b3e8ce..3db9dd0b91 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -589,7 +589,7 @@ class OpenPypeVersion(semver.VersionInfo): """ result = get_expected_studio_version_str(staging) if not result: - return cls.get_latest_version(staging, False) + return None return OpenPypeVersion(version=result) From 02b8e57a81a2b4c9782dc69a57e7b53c29598d8a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:03:16 +0100 Subject: [PATCH 053/395] return None from get_latest_version if there are any openpype versions available --- igniter/bootstrap_repos.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 3db9dd0b91..163db07f59 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -559,7 +559,8 @@ class OpenPypeVersion(semver.VersionInfo): staging: bool = False, local: bool = False) -> OpenPypeVersion: """Get latest available version. - This is utility version to get latest version from all found. + This is utility version to get latest version from all found except + build. Args: staging (bool, optional): List staging versions if True. @@ -572,6 +573,8 @@ class OpenPypeVersion(semver.VersionInfo): openpype_versions = OpenPypeVersion.get_available_versions( staging, local) + if not openpype_versions: + return None return openpype_versions[-1] @classmethod From a160e2229068007f28a320800c0d983784f11405 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:03:29 +0100 Subject: [PATCH 054/395] have ability to get build version --- igniter/bootstrap_repos.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 163db07f59..920eb5fa6b 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -554,6 +554,19 @@ class OpenPypeVersion(semver.VersionInfo): return sorted(_openpype_versions) + @staticmethod + def get_build_version(): + """Get version of OpenPype inside build.""" + openpype_root = Path(os.environ["OPENPYPE_ROOT"]) + build_version_str = BootstrapRepos.get_version(openpype_root) + build_version = None + if build_version_str: + build_version = OpenPypeVersion( + version=build_version_str, + path=openpype_root + ) + return build_version + @staticmethod def get_latest_version( staging: bool = False, local: bool = False) -> OpenPypeVersion: From 406ece7e44db78e44d365632ec6ac1ddaf8494fe Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:03:55 +0100 Subject: [PATCH 055/395] use new methods in start.py --- start.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/start.py b/start.py index 6fa624b5cd..80baff5496 100644 --- a/start.py +++ b/start.py @@ -651,16 +651,11 @@ def _find_frozen_openpype(use_version: str = None, include_zips=True, staging=use_staging ) - openpype_root = Path(os.environ["OPENPYPE_ROOT"]) - local_version_str = bootstrap.get_version(openpype_root) + local_version = OpenPypeVersion.get_build_version() + if local_version not in openpype_versions: + openpype_versions.append(local_version) + studio_version = OpenPypeVersion.get_expected_studio_version(use_staging) - if local_version_str: - local_version = OpenPypeVersion( - version=local_version_str, - path=openpype_root - ) - if local_version not in openpype_versions: - openpype_versions.append(local_version) openpype_versions.sort() @@ -668,6 +663,7 @@ def _find_frozen_openpype(use_version: str = None, openpype_version = None if use_version is not None: if use_version.lower() == "latest": + _print("Finding latest version") openpype_version = openpype_versions[-1] else: use_version_obj = OpenPypeVersion(use_version) From 535aa5b29be4112ce94f7f25a2ecf19486b35266 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 8 Dec 2021 19:04:02 +0100 Subject: [PATCH 056/395] handle invalid file type --- openpype/tools/mayalookassigner/vray_proxies.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/tools/mayalookassigner/vray_proxies.py b/openpype/tools/mayalookassigner/vray_proxies.py index d2f345e628..9252f22d8f 100644 --- a/openpype/tools/mayalookassigner/vray_proxies.py +++ b/openpype/tools/mayalookassigner/vray_proxies.py @@ -41,7 +41,12 @@ def get_alembic_paths_by_property(filename, attr, verbose=False): filename = filename.replace("\\", "/") filename = str(filename) # path must be string - archive = alembic.Abc.IArchive(filename) + try: + archive = alembic.Abc.IArchive(filename) + except RuntimeError: + # invalid alembic file - probably vrmesh + log.warning("{} is not an alembic file".format(filename)) + return {} root = archive.getTop() iterator = list(root.children) From 715e1a6d32e19fe53ecd3c2061fac6cd87549f04 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:06:16 +0100 Subject: [PATCH 057/395] cache build version is it probably won't change during process time --- igniter/bootstrap_repos.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 920eb5fa6b..5e8efd1cc4 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -63,6 +63,7 @@ class OpenPypeVersion(semver.VersionInfo): staging = False path = None _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$") # noqa: E501 + _build_version = None def __init__(self, *args, **kwargs): """Create OpenPype version. @@ -554,18 +555,20 @@ class OpenPypeVersion(semver.VersionInfo): return sorted(_openpype_versions) - @staticmethod - def get_build_version(): + @classmethod + def get_build_version(cls): """Get version of OpenPype inside build.""" - openpype_root = Path(os.environ["OPENPYPE_ROOT"]) - build_version_str = BootstrapRepos.get_version(openpype_root) - build_version = None - if build_version_str: - build_version = OpenPypeVersion( - version=build_version_str, - path=openpype_root - ) - return build_version + if cls._build_version is None: + openpype_root = Path(os.environ["OPENPYPE_ROOT"]) + build_version_str = BootstrapRepos.get_version(openpype_root) + build_version = None + if build_version_str: + build_version = OpenPypeVersion( + version=build_version_str, + path=openpype_root + ) + cls._build_version = build_version + return cls._build_version @staticmethod def get_latest_version( From 02686ff096cb3dafd51aa74d1d288c6b7c556506 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:31:29 +0100 Subject: [PATCH 058/395] added get build version --- openpype/lib/openpype_version.py | 10 ++++++++++ openpype/settings/entities/op_version_entity.py | 11 ++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 42ee454378..7d9dc6ef94 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -20,6 +20,16 @@ def op_version_control_available(): return True +def get_build_version(): + """Get OpenPype version inside build. + + This version is not returned by any other functions here. + """ + if op_version_control_available(): + return get_OpenPypeVersion().get_build_version() + return None + + def get_available_versions(*args, **kwargs): """Get list of available versions.""" if op_version_control_available(): diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 20495a0d42..cf2150f12e 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -3,7 +3,8 @@ from openpype.lib.openpype_version import ( get_remote_versions, openpype_path_is_set, openpype_path_is_accessible, - get_OpenPypeVersion + get_OpenPypeVersion, + get_build_version ) from .input_entities import TextEntity from .lib import ( @@ -57,11 +58,15 @@ class ProductionVersionsInputEntity(OpenPypeVersionInput): schema_types = ["production-versions-text"] def _get_openpype_versions(self): - return get_remote_versions(production=True) + versions = get_remote_versions(staging=False, production=True) + versions.append(get_build_version()) + return sorted(versions) class StagingVersionsInputEntity(OpenPypeVersionInput): schema_types = ["staging-versions-text"] def _get_openpype_versions(self): - return get_remote_versions(staging=True) + versions = get_remote_versions(staging=True, production=False) + versions.append(get_build_version()) + return sorted(versions) From b3c1bbd84ee31795a3425d5fd245c2d639b8b58f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:31:36 +0100 Subject: [PATCH 059/395] removed unused imports --- openpype/settings/entities/enum_entity.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 7c8721556f..fb6099e82a 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,10 +1,4 @@ import copy -from openpype.lib.openpype_version import ( - op_version_control_available, - get_remote_versions, - openpype_path_is_set, - openpype_path_is_accessible -) from .input_entities import InputEntity from .exceptions import EntitySchemaError from .lib import ( From c05ec98b6049202aacb3273ed2d5ea38b97b5e00 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 8 Dec 2021 19:32:07 +0100 Subject: [PATCH 060/395] removed current verions functions and replace then with get_expected_studio_version --- openpype/lib/openpype_version.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 7d9dc6ef94..9a92454eca 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -2,7 +2,11 @@ Access to logic from igniter is available only for OpenPype processes. Is meant to be able check OpenPype versions for studio. The logic is dependent -on igniter's logic of processing. +on igniter's inner logic of versions. + +Keep in mind that all functions except 'get_build_version' does not return +OpenPype version located in build but versions available in remote versions +repository or locally available. """ import sys @@ -40,42 +44,42 @@ def get_available_versions(*args, **kwargs): def openpype_path_is_set(): + """OpenPype repository path is set in settings.""" if op_version_control_available(): return get_OpenPypeVersion().openpype_path_is_set() return None def openpype_path_is_accessible(): + """OpenPype version repository path can be accessed.""" if op_version_control_available(): return get_OpenPypeVersion().openpype_path_is_accessible() return None def get_local_versions(*args, **kwargs): + """OpenPype versions available on this workstation.""" if op_version_control_available(): return get_OpenPypeVersion().get_local_versions(*args, **kwargs) return None def get_remote_versions(*args, **kwargs): + """OpenPype versions in repository path.""" if op_version_control_available(): return get_OpenPypeVersion().get_remote_versions(*args, **kwargs) return None def get_latest_version(*args, **kwargs): + """Get latest version from repository path.""" if op_version_control_available(): return get_OpenPypeVersion().get_latest_version(*args, **kwargs) return None -def get_current_production_version(): +def get_expected_studio_version(staging=False): + """Expected production or staging version in studio.""" if op_version_control_available(): - return get_OpenPypeVersion().get_production_version() - return None - - -def get_current_staging_version(): - if op_version_control_available(): - return get_OpenPypeVersion().get_staging_version() + return get_OpenPypeVersion().get_expected_studio_version(staging) return None From 1a60f7fa0f047132c79a9a148b4ca8d1a474977e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 11:33:32 +0100 Subject: [PATCH 061/395] keep previous find_openpype implementation --- igniter/bootstrap_repos.py | 62 ++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 5e8efd1cc4..9e58f5bad2 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -1115,21 +1115,65 @@ class BootstrapRepos: @staticmethod def find_openpype( + self, openpype_path: Union[Path, str] = None, staging: bool = False, include_zips: bool = False) -> Union[List[OpenPypeVersion], None]: + """Get ordered dict of detected OpenPype version. + Resolution order for OpenPype is following: + + 1) First we test for ``OPENPYPE_PATH`` environment variable + 2) We try to find ``openPypePath`` in registry setting + 3) We use user data directory + + Args: + openpype_path (Path or str, optional): Try to find OpenPype on + the given path or url. + staging (bool, optional): Filter only staging version, skip them + otherwise. + include_zips (bool, optional): If set True it will try to find + OpenPype in zip files in given directory. + + Returns: + dict of Path: Dictionary of detected OpenPype version. + Key is version, value is path to zip file. + + None: if OpenPype is not found. + + Todo: + implement git/url support as OpenPype location, so it would be + possible to enter git url, OpenPype would check it out and if it is + ok install it as normal version. + + """ + if openpype_path and not isinstance(openpype_path, Path): + raise NotImplementedError( + ("Finding OpenPype in non-filesystem locations is" + " not implemented yet.")) + + dir_to_search = self.data_dir + user_versions = self.get_openpype_versions(self.data_dir, staging) + # if we have openpype_path specified, search only there. if openpype_path: - openpype_versions = OpenPypeVersion.get_versions_from_directory( - openpype_path) - # filter out staging - - openpype_versions = [ - v for v in openpype_versions if v.is_staging() == staging - ] - + dir_to_search = openpype_path else: - openpype_versions = OpenPypeVersion.get_available_versions(staging) + if os.getenv("OPENPYPE_PATH"): + if Path(os.getenv("OPENPYPE_PATH")).exists(): + dir_to_search = Path(os.getenv("OPENPYPE_PATH")) + else: + try: + registry_dir = Path( + str(self.registry.get_item("openPypePath"))) + if registry_dir.exists(): + dir_to_search = registry_dir + + except ValueError: + # nothing found in registry, we'll use data dir + pass + + openpype_versions = self.get_openpype_versions(dir_to_search, staging) + openpype_versions += user_versions # remove zip file version if needed. if not include_zips: From 77730e8d2b3b2e13026e77a618271936e5a2a7b5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 11:35:03 +0100 Subject: [PATCH 062/395] added new methods to find openpype versions --- igniter/bootstrap_repos.py | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 9e58f5bad2..a258f71a20 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -1114,6 +1114,64 @@ class BootstrapRepos: os.environ["PYTHONPATH"] = os.pathsep.join(paths) @staticmethod + def find_openpype_version(version, staging): + if isinstance(version, str): + version = OpenPypeVersion(version=version) + + build_version = OpenPypeVersion.get_build_version() + if build_version == version: + return build_version + + local_versions = OpenPypeVersion.get_local_versions( + staging=staging, production=not staging + ) + zip_version = None + for local_version in local_versions: + if local_version == version: + if local_version.suffix.lower() == ".zip": + zip_version = local_version + else: + return local_version + + if zip_version is not None: + return zip_version + + remote_versions = OpenPypeVersion.get_remote_versions( + staging=staging, production=not staging + ) + for remote_version in remote_versions: + if remote_version == version: + return remote_version + return None + + @staticmethod + def find_latest_openpype_version(staging): + build_version = OpenPypeVersion.get_build_version() + local_versions = OpenPypeVersion.get_local_versions( + staging=staging + ) + remote_versions = OpenPypeVersion.get_remote_versions( + staging=staging + ) + all_versions = local_versions + remote_versions + if not staging: + all_versions.append(build_version) + + if not all_versions: + return None + + all_versions.sort() + latest_version = all_versions[-1] + if latest_version == build_version: + return latest_version + + if not latest_version.path.is_dir(): + for version in local_versions: + if version == latest_version and version.path.is_dir(): + latest_version = version + break + return latest_version + def find_openpype( self, openpype_path: Union[Path, str] = None, From d22214415c17a8037353406c90a509954dcce8ac Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 11:59:57 +0100 Subject: [PATCH 063/395] simplified get build version --- igniter/bootstrap_repos.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index a258f71a20..7a367e6f09 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -561,13 +561,11 @@ class OpenPypeVersion(semver.VersionInfo): if cls._build_version is None: openpype_root = Path(os.environ["OPENPYPE_ROOT"]) build_version_str = BootstrapRepos.get_version(openpype_root) - build_version = None if build_version_str: - build_version = OpenPypeVersion( + cls._build_version = OpenPypeVersion( version=build_version_str, path=openpype_root ) - cls._build_version = build_version return cls._build_version @staticmethod From bc301fc7893551767763545e689d6ade875da428 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:02:19 +0100 Subject: [PATCH 064/395] modified get latest versions --- igniter/bootstrap_repos.py | 56 ++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 7a367e6f09..7de4c5db4b 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -570,26 +570,58 @@ class OpenPypeVersion(semver.VersionInfo): @staticmethod def get_latest_version( - staging: bool = False, local: bool = False) -> OpenPypeVersion: + staging: bool = False, + local: bool = None, + remote: bool = None + ) -> OpenPypeVersion: """Get latest available version. - This is utility version to get latest version from all found except - build. + The version does not contain information about path and source. + + This is utility version to get latest version from all found. Build + version is not listed if staging is enabled. + + Arguments 'local' and 'remote' define if local and remote repository + versions are used. All versions are used if both are not set (or set + to 'None'). If only one of them is set to 'True' the other is disabled. + It is possible to set both to 'True' (same as both set to None) and to + 'False' in that case only build version can be used. Args: staging (bool, optional): List staging versions if True. - local (bool, optional): List only local versions. - - See also: - OpenPypeVersion.get_available_versions() - + local (bool, optional): List local versions if True. + remote (bool, optional): List remote versions if True. """ - openpype_versions = OpenPypeVersion.get_available_versions( - staging, local) + if local is None and remote is None: + local = True + remote = True - if not openpype_versions: + elif local is None and not remote: + local = True + + elif remote is None and not local: + remote = True + + build_version = OpenPypeVersion.get_build_version() + local_versions = [] + remote_versions = [] + if local: + local_versions = OpenPypeVersion.get_local_versions( + staging=staging + ) + if remote: + remote_versions = OpenPypeVersion.get_remote_versions( + staging=staging + ) + all_versions = local_versions + remote_versions + if not staging: + all_versions.append(build_version) + + if not all_versions: return None - return openpype_versions[-1] + + all_versions.sort() + return all_versions[-1] @classmethod def get_expected_studio_version(cls, staging=False): From 105e6241b46da79102d50fb84c7376155478a31a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:02:32 +0100 Subject: [PATCH 065/395] fixed zip check --- igniter/bootstrap_repos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 7de4c5db4b..2f49fee064 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -1158,7 +1158,7 @@ class BootstrapRepos: zip_version = None for local_version in local_versions: if local_version == version: - if local_version.suffix.lower() == ".zip": + if local_version.path.suffix.lower() == ".zip": zip_version = local_version else: return local_version From 3e04f294e09d98102a673ebc5182258a07866070 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:02:44 +0100 Subject: [PATCH 066/395] removed unused method --- igniter/bootstrap_repos.py | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 2f49fee064..77fcd15f1e 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -477,39 +477,6 @@ class OpenPypeVersion(semver.VersionInfo): filtered_versions.append(version) return list(sorted(set(filtered_versions))) - @classmethod - def get_available_versions( - cls, staging: bool = False, local: bool = False) -> List: - """Get ordered dict of detected OpenPype version. - - Resolution order for OpenPype is following: - - 1) First we test for ``OPENPYPE_PATH`` environment variable - 2) We try to find ``openPypePath`` in registry setting - 3) We use user data directory - - Only versions from 3) will be listed when ``local`` is set to True. - - Args: - staging (bool, optional): List staging versions if True. - local (bool, optional): List only local versions. - - """ - user_versions = cls.get_local_versions() - # if we have openpype_path specified, search only there. - openpype_versions = [] - if not local: - openpype_versions = cls.get_remote_versions() - openpype_versions += user_versions - - # remove duplicates and staging/production - openpype_versions = [ - v for v in openpype_versions if v.is_staging() == staging - ] - openpype_versions = sorted(list(set(openpype_versions))) - - return openpype_versions - @staticmethod def get_versions_from_directory(openpype_dir: Path) -> List: """Get all detected OpenPype versions in directory. From d71b8e2e40d2215a7b7c644d2debbf6549e7e171 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:03:23 +0100 Subject: [PATCH 067/395] modified use version argument handling to be able pass "latest" --- start.py | 48 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/start.py b/start.py index 80baff5496..29afb759c5 100644 --- a/start.py +++ b/start.py @@ -467,23 +467,41 @@ def _process_arguments() -> tuple: use_version = None use_staging = False commands = [] - for arg in sys.argv: - if arg == "--use-version": - _print("!!! Please use option --use-version like:") - _print(" --use-version=3.0.0") - sys.exit(1) - if arg.startswith("--use-version="): - m = re.search( - r"--use-version=(?P\d+\.\d+\.\d+(?:\S*)?)", arg) - if m and m.group('version'): - use_version = m.group('version') - _print(">>> Requested version [ {} ]".format(use_version)) - sys.argv.remove(arg) - if "+staging" in use_version: - use_staging = True - break + # OpenPype version specification through arguments + use_version_arg = "--use-version" + + for arg in sys.argv: + if arg.startswith(use_version_arg): + # Remove arg from sys argv + sys.argv.remove(arg) + # Extract string after use version arg + use_version_value = arg[len(use_version_arg):] + + if ( + not use_version_value + or not use_version_value.startswith("=") + ): + _print("!!! Please use option --use-version like:") + _print(" --use-version=3.0.0") + sys.exit(1) + + version_str = use_version_value[1:] + use_version = None + if version_str.lower() == "latest": + use_version = "latest" else: + m = re.search( + r"(?P\d+\.\d+\.\d+(?:\S*)?)", version_str + ) + if m and m.group('version'): + use_version = m.group('version') + _print(">>> Requested version [ {} ]".format(use_version)) + if "+staging" in use_version: + use_staging = True + break + + if use_version is None: _print("!!! Requested version isn't in correct format.") _print((" Use --list-versions to find out" " proper version string.")) From 27df5b5d924b18c0fcd8c0ae321a3464070e37c7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:03:50 +0100 Subject: [PATCH 068/395] use new methods in bootstrap from code --- start.py | 66 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/start.py b/start.py index 29afb759c5..674cc66a0f 100644 --- a/start.py +++ b/start.py @@ -781,6 +781,13 @@ def _bootstrap_from_code(use_version, use_staging): # run through repos and add them to `sys.path` and `PYTHONPATH` # set root _openpype_root = OPENPYPE_ROOT + # Unset use version if latest should be used + # - when executed from code then code is expected as latest + # - when executed from build then build is already marked as latest + # in '_find_frozen_openpype' + if use_version and use_version.lower() == "latest": + use_version = None + if getattr(sys, 'frozen', False): local_version = bootstrap.get_version(Path(_openpype_root)) switch_str = f" - will switch to {use_version}" if use_version else "" @@ -790,43 +797,35 @@ def _bootstrap_from_code(use_version, use_staging): # get current version of OpenPype local_version = bootstrap.get_local_live_version() - openpype_versions = bootstrap.find_openpype( - include_zips=True, staging=use_staging) - if use_staging and not use_version: - if not openpype_versions: - raise OpenPypeVersionNotFound( - "Didn't find any staging versions." + # All cases when should be used different version than build + if (use_version and use_version != local_version) or use_staging: + if use_version: + # Explicit version should be used + version_to_use = bootstrap.find_openpype_version( + use_version, use_staging ) - - # Use last found staging version - version_to_use = openpype_versions[-1] - if version_to_use.path.is_file(): - version_to_use.path = bootstrap.extract_openpype( - version_to_use) - bootstrap.add_paths_from_directory(version_to_use.path) - os.environ["OPENPYPE_VERSION"] = str(version_to_use) - version_path = version_to_use.path - os.environ["OPENPYPE_REPOS_ROOT"] = ( - version_path / "openpype" - ).as_posix() - _openpype_root = version_to_use.path.as_posix() - - elif use_version and use_version != local_version: - version_to_use = None - for version in openpype_versions: - if str(version) == use_version: - version_to_use = version - break - - if version_to_use is None: - raise OpenPypeVersionNotFound( - "Requested version \"{}\" was not found.".format(use_version) + if version_to_use is None: + raise OpenPypeVersionNotFound( + "Requested version \"{}\" was not found.".format( + use_version + ) + ) + else: + # Staging version should be used + version_to_use = bootstrap.find_latest_openpype_version( + use_staging ) + if version_to_use is None: + if use_staging: + reason = "Didn't find any staging versions." + else: + # This reason is backup for possible bug in code + reason = "Didn't find any versions." + raise OpenPypeVersionNotFound(reason) - # use specified + # Start extraction of version if needed if version_to_use.path.is_file(): - version_to_use.path = bootstrap.extract_openpype( - version_to_use) + version_to_use.path = bootstrap.extract_openpype(version_to_use) bootstrap.add_paths_from_directory(version_to_use.path) os.environ["OPENPYPE_VERSION"] = use_version version_path = version_to_use.path @@ -834,6 +833,7 @@ def _bootstrap_from_code(use_version, use_staging): version_path / "openpype" ).as_posix() _openpype_root = version_to_use.path.as_posix() + else: os.environ["OPENPYPE_VERSION"] = local_version version_path = Path(_openpype_root) From d219ff0cd18219051a00de65ccfa9c6990d94e5b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:04:13 +0100 Subject: [PATCH 069/395] use new methods in find frozen openpype --- start.py | 59 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/start.py b/start.py index 674cc66a0f..58c88df340 100644 --- a/start.py +++ b/start.py @@ -665,33 +665,25 @@ def _find_frozen_openpype(use_version: str = None, """ # Collect OpenPype versions - openpype_versions = bootstrap.find_openpype( - include_zips=True, - staging=use_staging - ) - local_version = OpenPypeVersion.get_build_version() - if local_version not in openpype_versions: - openpype_versions.append(local_version) - + build_version = OpenPypeVersion.get_build_version() + # Expected version that should be used by studio settings + # - this option is used only if version is not explictly set and if + # studio has set explicit version in settings studio_version = OpenPypeVersion.get_expected_studio_version(use_staging) - openpype_versions.sort() - - # Find OpenPype version that should be used - openpype_version = None if use_version is not None: + # Specific version is defined if use_version.lower() == "latest": - _print("Finding latest version") - openpype_version = openpype_versions[-1] + # Version says to use latest version + _print("Finding latest version defined by use version") + openpype_version = bootstrap.find_latest_openpype_version( + use_staging + ) else: - use_version_obj = OpenPypeVersion(use_version) - # Version was specified with arguments or env OPENPYPE_VERSION - # - should crash if version is not available _print("Finding specified version \"{}\"".format(use_version)) - for version in openpype_versions: - if version == use_version_obj: - openpype_version = version - break + openpype_version = bootstrap.find_openpype_version( + use_version, use_staging + ) if openpype_version is None: raise OpenPypeVersionNotFound( @@ -700,13 +692,12 @@ def _find_frozen_openpype(use_version: str = None, ) ) - elif studio_version: + elif studio_version is not None: + # Studio has defined a version to use _print("Finding studio version \"{}\"".format(studio_version)) - # Look for version specified by settings - for version in openpype_versions: - if version == studio_version: - openpype_version = version - break + openpype_version = bootstrap.find_openpype_version( + studio_version, use_staging + ) if openpype_version is None: raise OpenPypeVersionNotFound(( "Requested OpenPype version \"{}\" defined by settings" @@ -714,13 +705,21 @@ def _find_frozen_openpype(use_version: str = None, ).format(studio_version)) else: - # Use latest available version + # Default behavior to use latest version _print("Finding latest version") - openpype_version = openpype_versions[-1] + openpype_version = bootstrap.find_latest_openpype_version( + use_staging + ) + if openpype_version is None: + if use_staging: + reason = "Didn't find any staging versions." + else: + reason = "Didn't find any versions." + raise OpenPypeVersionNotFound(reason) # get local frozen version and add it to detected version so if it is # newer it will be used instead. - if local_version == openpype_version: + if build_version == openpype_version: version_path = _bootstrap_from_code(use_version, use_staging) openpype_version = OpenPypeVersion( version=BootstrapRepos.get_version(version_path), From a82bf00d7a28242aa0bc134b4220a806cd108b00 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:13:00 +0100 Subject: [PATCH 070/395] removed unused imports --- openpype/settings/entities/op_version_entity.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index cf2150f12e..97b3efa028 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -1,8 +1,5 @@ from openpype.lib.openpype_version import ( - op_version_control_available, get_remote_versions, - openpype_path_is_set, - openpype_path_is_accessible, get_OpenPypeVersion, get_build_version ) From 070078d1578cb3bb177cbb53a807bd145602d084 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:13:12 +0100 Subject: [PATCH 071/395] do not add build version to staging --- openpype/settings/entities/op_version_entity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 97b3efa028..bd481dc497 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -65,5 +65,4 @@ class StagingVersionsInputEntity(OpenPypeVersionInput): def _get_openpype_versions(self): versions = get_remote_versions(staging=True, production=False) - versions.append(get_build_version()) return sorted(versions) From 43c552dbbfd74712eeebb57e22bc1ef4b807b92e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:13:36 +0100 Subject: [PATCH 072/395] added option to pass global settings to 'get_expected_studio_version_str' --- igniter/bootstrap_repos.py | 7 +++++-- igniter/tools.py | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 77fcd15f1e..70e6a75b9d 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -591,19 +591,22 @@ class OpenPypeVersion(semver.VersionInfo): return all_versions[-1] @classmethod - def get_expected_studio_version(cls, staging=False): + def get_expected_studio_version(cls, staging=False, global_settings=None): """Expected OpenPype version that should be used at the moment. If version is not defined in settings the latest found version is used. + Using precached global settings is needed for usage inside OpenPype. + Args: staging (bool): Staging version or production version. + global_settings (dict): Optional precached global settings. Returns: OpenPypeVersion: Version that should be used. """ - result = get_expected_studio_version_str(staging) + result = get_expected_studio_version_str(staging, global_settings) if not result: return None return OpenPypeVersion(version=result) diff --git a/igniter/tools.py b/igniter/tools.py index 2595140582..735402e9a2 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -187,17 +187,21 @@ def get_openpype_path_from_db(url: str) -> Union[str, None]: return None -def get_expected_studio_version_str(staging=False) -> str: +def get_expected_studio_version_str( + staging=False, global_settings=None +) -> str: """Version that should be currently used in studio. Args: staging (bool): Get current version for staging. + global_settings (dict): Optional precached global settings. Returns: str: OpenPype version which should be used. Empty string means latest. """ mongo_url = os.environ.get("OPENPYPE_MONGO") - global_settings = get_openpype_global_settings(mongo_url) + if global_settings is None: + global_settings = get_openpype_global_settings(mongo_url) if staging: key = "staging_version" else: From ad99d2a841f4cf581f13f2ef1e9f93e95c84760e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 9 Dec 2021 12:21:12 +0100 Subject: [PATCH 073/395] added few docstrings --- igniter/__init__.py | 4 ++++ igniter/message_dialog.py | 2 +- .../settings/entities/op_version_entity.py | 19 ++++++++++++++++++- openpype/tools/settings/settings/base.py | 1 + 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/igniter/__init__.py b/igniter/__init__.py index 6f06c1eb90..02cba6a483 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -12,6 +12,9 @@ from .bootstrap_repos import ( ) from .version import __version__ as version +# Store OpenPypeVersion to 'sys.modules' +# - this makes it available in OpenPype processes without modifying +# 'sys.path' or 'PYTHONPATH' if "OpenPypeVersion" not in sys.modules: sys.modules["OpenPypeVersion"] = OpenPypeVersion @@ -64,6 +67,7 @@ def open_update_window(openpype_version): def show_message_dialog(title, message): + """Show dialog with a message and title to user.""" if os.getenv("OPENPYPE_HEADLESS_MODE"): print("!!! Can't open dialog in headless mode. Exiting.") sys.exit(1) diff --git a/igniter/message_dialog.py b/igniter/message_dialog.py index 88a086df1e..c8e875cc37 100644 --- a/igniter/message_dialog.py +++ b/igniter/message_dialog.py @@ -7,6 +7,7 @@ from .tools import ( class MessageDialog(QtWidgets.QDialog): + """Simple message dialog with title, message and OK button.""" def __init__(self, title, message): super(MessageDialog, self).__init__() @@ -27,7 +28,6 @@ class MessageDialog(QtWidgets.QDialog): btns_layout.addWidget(ok_btn, 0) layout = QtWidgets.QVBoxLayout(self) - # layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(label_widget, 1) layout.addLayout(btns_layout, 0) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index bd481dc497..62c5bf4ff9 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -12,6 +12,16 @@ from .exceptions import BaseInvalidValue class OpenPypeVersionInput(TextEntity): + """Entity to store OpenPype version to use. + + It is text input as creating of settings on different machines may + affect which versions are available so it must have option to set OpenPype + version which is not available for machine where settings entities are + loaded. + + It is possible to enter empty string. In that case is used any latest + version. Any other string must match regex of OpenPype version semantic. + """ def _item_initialization(self): super(OpenPypeVersionInput, self)._item_initialization() self.multiline = False @@ -19,9 +29,13 @@ class OpenPypeVersionInput(TextEntity): self.value_hints = [] def _get_openpype_versions(self): - return [] + """This is abstract method returning version hints for UI purposes.""" + raise NotImplementedError(( + "{} does not have implemented '_get_openpype_versions'" + ).format(self.__class__.__name__)) def set_override_state(self, state, *args, **kwargs): + """Update value hints for UI purposes.""" value_hints = [] if state is OverrideState.STUDIO: versions = self._get_openpype_versions() @@ -36,6 +50,7 @@ class OpenPypeVersionInput(TextEntity): ) def convert_to_valid_type(self, value): + """Add validation of version regex.""" if value and value is not NOT_SET: OpenPypeVersion = get_OpenPypeVersion() if OpenPypeVersion is not None: @@ -52,6 +67,7 @@ class OpenPypeVersionInput(TextEntity): class ProductionVersionsInputEntity(OpenPypeVersionInput): + """Entity meant only for global settings to define production version.""" schema_types = ["production-versions-text"] def _get_openpype_versions(self): @@ -61,6 +77,7 @@ class ProductionVersionsInputEntity(OpenPypeVersionInput): class StagingVersionsInputEntity(OpenPypeVersionInput): + """Entity meant only for global settings to define staging version.""" schema_types = ["staging-versions-text"] def _get_openpype_versions(self): diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index bc6c6d10ff..48c2b42ebd 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -28,6 +28,7 @@ class BaseWidget(QtWidgets.QWidget): @staticmethod def set_style_property(obj, property_name, property_value): + """Change QWidget property and polish it's style.""" if obj.property(property_name) == property_value: return From 6b5706ef78a3c3bde0a4efd2235bd46dfe8b9d81 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 10 Dec 2021 10:38:23 +0100 Subject: [PATCH 074/395] added method to get installed version --- igniter/bootstrap_repos.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 70e6a75b9d..b603fe74f1 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -522,6 +522,16 @@ class OpenPypeVersion(semver.VersionInfo): return sorted(_openpype_versions) + @staticmethod + def get_installed_version_str() -> str: + """Get version of local OpenPype.""" + + version = {} + path = Path(os.environ["OPENPYPE_ROOT"]) / "openpype" / "version.py" + with open(path, "r") as fp: + exec(fp.read(), version) + return version["__version__"] + @classmethod def get_build_version(cls): """Get version of OpenPype inside build.""" From 1b4e5dd4435ae7acba7be6f21f6304a99ed9e938 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 10 Dec 2021 10:40:20 +0100 Subject: [PATCH 075/395] replaced get_local_live_version with get_installed_version_str --- igniter/bootstrap_repos.py | 12 +----------- start.py | 6 +++--- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index b603fe74f1..890df453a1 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -690,16 +690,6 @@ class BootstrapRepos: return v.path return None - @staticmethod - def get_local_live_version() -> str: - """Get version of local OpenPype.""" - - version = {} - path = Path(os.environ["OPENPYPE_ROOT"]) / "openpype" / "version.py" - with open(path, "r") as fp: - exec(fp.read(), version) - return version["__version__"] - @staticmethod def get_version(repo_dir: Path) -> Union[str, None]: """Get version of OpenPype in given directory. @@ -747,7 +737,7 @@ class BootstrapRepos: # version and use it as a source. Otherwise repo_dir is user # entered location. if not repo_dir: - version = self.get_local_live_version() + version = OpenPypeVersion.get_installed_version_str() repo_dir = self.live_repo_dir else: version = self.get_version(repo_dir) diff --git a/start.py b/start.py index 58c88df340..975249b4e2 100644 --- a/start.py +++ b/start.py @@ -794,7 +794,7 @@ def _bootstrap_from_code(use_version, use_staging): assert local_version else: # get current version of OpenPype - local_version = bootstrap.get_local_live_version() + local_version = OpenPypeVersion.get_installed_version_str() # All cases when should be used different version than build if (use_version and use_version != local_version) or use_staging: @@ -930,7 +930,7 @@ def boot(): if getattr(sys, 'frozen', False): local_version = bootstrap.get_version(Path(OPENPYPE_ROOT)) else: - local_version = bootstrap.get_local_live_version() + local_version = OpenPypeVersion.get_installed_version_str() if "validate" in commands: _print(f">>> Validating version [ {use_version} ]") @@ -978,7 +978,7 @@ def boot(): if getattr(sys, 'frozen', False): local_version = bootstrap.get_version(Path(_openpype_root)) else: - local_version = bootstrap.get_local_live_version() + local_version = OpenPypeVersion.get_installed_version_str() list_versions(openpype_versions, local_version) sys.exit(1) From 53e362f3db616ee95585856780b557cdcc502a25 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 10 Dec 2021 10:40:42 +0100 Subject: [PATCH 076/395] renamed build verion to installed version --- igniter/bootstrap_repos.py | 35 +++++++++++++++++------------------ start.py | 4 ++-- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 890df453a1..db62cbbe91 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -63,7 +63,7 @@ class OpenPypeVersion(semver.VersionInfo): staging = False path = None _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$") # noqa: E501 - _build_version = None + _installed_version = None def __init__(self, *args, **kwargs): """Create OpenPype version. @@ -533,17 +533,16 @@ class OpenPypeVersion(semver.VersionInfo): return version["__version__"] @classmethod - def get_build_version(cls): + def get_installed_version(cls): """Get version of OpenPype inside build.""" - if cls._build_version is None: - openpype_root = Path(os.environ["OPENPYPE_ROOT"]) - build_version_str = BootstrapRepos.get_version(openpype_root) - if build_version_str: - cls._build_version = OpenPypeVersion( - version=build_version_str, - path=openpype_root + if cls._installed_version is None: + installed_version_str = cls.get_installed_version_str() + if installed_version_str: + cls._installed_version = OpenPypeVersion( + version=installed_version_str, + path=Path(os.environ["OPENPYPE_ROOT"]) ) - return cls._build_version + return cls._installed_version @staticmethod def get_latest_version( @@ -579,7 +578,7 @@ class OpenPypeVersion(semver.VersionInfo): elif remote is None and not local: remote = True - build_version = OpenPypeVersion.get_build_version() + installed_version = OpenPypeVersion.get_installed_version() local_versions = [] remote_versions = [] if local: @@ -592,7 +591,7 @@ class OpenPypeVersion(semver.VersionInfo): ) all_versions = local_versions + remote_versions if not staging: - all_versions.append(build_version) + all_versions.append(installed_version) if not all_versions: return None @@ -1118,9 +1117,9 @@ class BootstrapRepos: if isinstance(version, str): version = OpenPypeVersion(version=version) - build_version = OpenPypeVersion.get_build_version() - if build_version == version: - return build_version + installed_version = OpenPypeVersion.get_installed_version() + if installed_version == version: + return installed_version local_versions = OpenPypeVersion.get_local_versions( staging=staging, production=not staging @@ -1146,7 +1145,7 @@ class BootstrapRepos: @staticmethod def find_latest_openpype_version(staging): - build_version = OpenPypeVersion.get_build_version() + installed_version = OpenPypeVersion.get_installed_version() local_versions = OpenPypeVersion.get_local_versions( staging=staging ) @@ -1155,14 +1154,14 @@ class BootstrapRepos: ) all_versions = local_versions + remote_versions if not staging: - all_versions.append(build_version) + all_versions.append(installed_version) if not all_versions: return None all_versions.sort() latest_version = all_versions[-1] - if latest_version == build_version: + if latest_version == installed_version: return latest_version if not latest_version.path.is_dir(): diff --git a/start.py b/start.py index 975249b4e2..5a5039cd5c 100644 --- a/start.py +++ b/start.py @@ -665,7 +665,7 @@ def _find_frozen_openpype(use_version: str = None, """ # Collect OpenPype versions - build_version = OpenPypeVersion.get_build_version() + installed_version = OpenPypeVersion.get_installed_version() # Expected version that should be used by studio settings # - this option is used only if version is not explictly set and if # studio has set explicit version in settings @@ -719,7 +719,7 @@ def _find_frozen_openpype(use_version: str = None, # get local frozen version and add it to detected version so if it is # newer it will be used instead. - if build_version == openpype_version: + if installed_version == openpype_version: version_path = _bootstrap_from_code(use_version, use_staging) openpype_version = OpenPypeVersion( version=BootstrapRepos.get_version(version_path), From 1812a05dde7fa0c37aaa4bc25dde1197040d5a86 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 13 Dec 2021 10:11:27 +0100 Subject: [PATCH 077/395] fix get all assets --- openpype/tools/mayalookassigner/commands.py | 5 ++--- openpype/tools/mayalookassigner/widgets.py | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py index f7d26f9adb..9449d042f1 100644 --- a/openpype/tools/mayalookassigner/commands.py +++ b/openpype/tools/mayalookassigner/commands.py @@ -106,7 +106,7 @@ def create_asset_id_hash(nodes): # iterate over content of reference node if cmds.nodeType(node) == "reference": ref_hashes = create_asset_id_hash( - cmds.referenceQuery(node, nodes=True)) + cmds.referenceQuery(node, nodes=True, dp=True)) for asset_id, ref_nodes in ref_hashes.items(): node_id_hash[asset_id] += ref_nodes else: @@ -151,8 +151,7 @@ def create_items_from_nodes(nodes): for k, _ in ids.items(): pid = k.split(":")[0] if not parent_id.get(pid): - parent_id.update({pid: [vp]}) - + parent_id[pid] = [vp] print("Adding ids from alembic {}".format(path)) id_hashes.update(parent_id) diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index 625e9ef8c6..bc19913b8b 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -90,8 +90,8 @@ class AssetOutliner(QtWidgets.QWidget): return items def get_all_assets(self): - """Add all items from the current scene""" - + """Add all items from the current scene.""" + items = [] with lib.preserve_expanded_rows(self.view): with lib.preserve_selection(self.view): self.clear() @@ -237,7 +237,7 @@ class LookOutliner(QtWidgets.QWidget): """ datas = [i.data(TreeModel.ItemRole) for i in self.view.get_indices()] - items = [d for d in datas if d is not None] # filter Nones + items = [d for d in datas if d is not None] # filter Nones return items From 3f4510a9c902b7f8d299ee5f73b2ebac8a9a232d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 17 Dec 2021 13:11:21 +0100 Subject: [PATCH 078/395] renamed 'get_build_version' to 'get_installed_version' in openpype --- openpype/lib/openpype_version.py | 6 +++--- openpype/settings/entities/op_version_entity.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 9a92454eca..e3a4e1fa3e 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -4,7 +4,7 @@ Access to logic from igniter is available only for OpenPype processes. Is meant to be able check OpenPype versions for studio. The logic is dependent on igniter's inner logic of versions. -Keep in mind that all functions except 'get_build_version' does not return +Keep in mind that all functions except 'get_installed_version' does not return OpenPype version located in build but versions available in remote versions repository or locally available. """ @@ -24,13 +24,13 @@ def op_version_control_available(): return True -def get_build_version(): +def get_installed_version(): """Get OpenPype version inside build. This version is not returned by any other functions here. """ if op_version_control_available(): - return get_OpenPypeVersion().get_build_version() + return get_OpenPypeVersion().get_installed_version() return None diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 62c5bf4ff9..6f6243cfee 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -1,7 +1,7 @@ from openpype.lib.openpype_version import ( get_remote_versions, get_OpenPypeVersion, - get_build_version + get_installed_version ) from .input_entities import TextEntity from .lib import ( @@ -72,7 +72,7 @@ class ProductionVersionsInputEntity(OpenPypeVersionInput): def _get_openpype_versions(self): versions = get_remote_versions(staging=False, production=True) - versions.append(get_build_version()) + versions.append(get_installed_version()) return sorted(versions) From 5d06c85a34826c040ecc62c47622719444e053f0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 17 Dec 2021 18:38:56 +0100 Subject: [PATCH 079/395] fixing vray look assigning --- openpype/tools/mayalookassigner/app.py | 12 ++++---- openpype/tools/mayalookassigner/commands.py | 30 +++++++++++++++---- .../tools/mayalookassigner/vray_proxies.py | 10 ++++--- openpype/tools/mayalookassigner/widgets.py | 15 ++++------ 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index fb99333f87..31bb455f95 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -24,7 +24,6 @@ from .commands import ( ) from .vray_proxies import vrayproxy_assign_look - module = sys.modules[__name__] module.window = None @@ -210,7 +209,7 @@ class App(QtWidgets.QWidget): # Assign the first matching look relevant for this asset # (since assigning multiple to the same nodes makes no sense) assign_look = next((subset for subset in item["looks"] - if subset["name"] in looks), None) + if subset["name"] in looks), None) if not assign_look: self.echo("{} No matching selected " "look for {}".format(prefix, asset)) @@ -229,11 +228,14 @@ class App(QtWidgets.QWidget): if cmds.pluginInfo('vrayformaya', query=True, loaded=True): self.echo("Getting vray proxy nodes ...") - vray_proxies = set(cmds.ls(type="VRayProxy")) - nodes = list(set(item["nodes"]).difference(vray_proxies)) + vray_proxies = set(cmds.ls(type="VRayProxy", long=True)) + if vray_proxies: for vp in vray_proxies: - vrayproxy_assign_look(vp, subset_name) + if vp in nodes: + vrayproxy_assign_look(vp, subset_name) + + nodes = list(set(item["nodes"]).difference(vray_proxies)) # Assign look if nodes: diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py index f7d26f9adb..740e4fa6fe 100644 --- a/openpype/tools/mayalookassigner/commands.py +++ b/openpype/tools/mayalookassigner/commands.py @@ -8,7 +8,6 @@ from openpype.hosts.maya.api import lib from avalon import io, api - from .vray_proxies import get_alembic_ids_cache log = logging.getLogger(__name__) @@ -68,7 +67,9 @@ def get_selected_nodes(): selection = cmds.ls(selection=True, long=True) hierarchy = list_descendents(selection) - return list(set(selection + hierarchy)) + selected_nodes = list(set(selection + hierarchy)) + log.warning("selected nodes: {}".format(selected_nodes)) + return selected_nodes def get_all_asset_nodes(): @@ -79,17 +80,23 @@ def get_all_asset_nodes(): """ host = api.registered_host() + containers = host.ls() nodes = [] + log.debug("got {}".format(containers)) for container in host.ls(): # We are not interested in looks but assets! if container["loader"] == "LookLoader": + log.warning("skipping {}".format(container)) continue # Gather all information container_name = container["objectName"] + log.warning("--- listing: {}".format(container_name)) nodes += cmds.sets(container_name, query=True, nodesOnly=True) or [] + nodes = list(set(nodes)) + log.warning("returning {}".format(nodes)) return nodes @@ -102,13 +109,24 @@ def create_asset_id_hash(nodes): dict """ node_id_hash = defaultdict(list) + + # log.warning(pformat(nodes)) for node in nodes: # iterate over content of reference node if cmds.nodeType(node) == "reference": ref_hashes = create_asset_id_hash( - cmds.referenceQuery(node, nodes=True)) + list(set(cmds.referenceQuery(node, nodes=True, dp=True)))) for asset_id, ref_nodes in ref_hashes.items(): node_id_hash[asset_id] += ref_nodes + elif cmds.pluginInfo('vrayformaya', query=True, + loaded=True) and cmds.nodeType( + node) == "VRayProxy": + path = cmds.getAttr("{}.fileName".format(node)) + ids = get_alembic_ids_cache(path) + for k, _ in ids.items(): + pid = k.split(":")[0] + if not node_id_hash.get(pid): + node_id_hash[pid] = [node] else: value = lib.get_id(node) if value is None: @@ -151,12 +169,12 @@ def create_items_from_nodes(nodes): for k, _ in ids.items(): pid = k.split(":")[0] if not parent_id.get(pid): - parent_id.update({pid: [vp]}) - - print("Adding ids from alembic {}".format(path)) + parent_id[pid] = [vp] + log.warning("Adding ids from alembic {}".format(path)) id_hashes.update(parent_id) if not id_hashes: + log.warning("No id hashes") return asset_view_items for _id, id_nodes in id_hashes.items(): diff --git a/openpype/tools/mayalookassigner/vray_proxies.py b/openpype/tools/mayalookassigner/vray_proxies.py index d2f345e628..fe36894466 100644 --- a/openpype/tools/mayalookassigner/vray_proxies.py +++ b/openpype/tools/mayalookassigner/vray_proxies.py @@ -41,7 +41,11 @@ def get_alembic_paths_by_property(filename, attr, verbose=False): filename = filename.replace("\\", "/") filename = str(filename) # path must be string - archive = alembic.Abc.IArchive(filename) + try: + archive = alembic.Abc.IArchive(filename) + except RuntimeError: + # invalid file format + return {} root = archive.getTop() iterator = list(root.children) @@ -201,9 +205,7 @@ def load_look(version_id): with avalon.maya.maintained_selection(): container_node = api.load(loader, look_representation) - # Get container members - shader_nodes = cmds.sets(container_node, query=True) - return shader_nodes + return cmds.sets(container_node, query=True) def get_latest_version(asset_id, subset): diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index 625e9ef8c6..fceaf27244 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -20,7 +20,6 @@ MODELINDEX = QtCore.QModelIndex() class AssetOutliner(QtWidgets.QWidget): - refreshed = QtCore.Signal() selection_changed = QtCore.Signal() @@ -84,14 +83,13 @@ class AssetOutliner(QtWidgets.QWidget): """ selection_model = self.view.selectionModel() - items = [row.data(TreeModel.ItemRole) for row in - selection_model.selectedRows(0)] - - return items + return [row.data(TreeModel.ItemRole) + for row in selection_model.selectedRows(0)] def get_all_assets(self): """Add all items from the current scene""" + items = [] with lib.preserve_expanded_rows(self.view): with lib.preserve_selection(self.view): self.clear() @@ -118,7 +116,7 @@ class AssetOutliner(QtWidgets.QWidget): # Collect all nodes by hash (optimization) if not selection: - nodes = cmds.ls(dag=True, long=True) + nodes = cmds.ls(dag=True, long=True) else: nodes = commands.get_selected_nodes() id_nodes = commands.create_asset_id_hash(nodes) @@ -187,7 +185,6 @@ class AssetOutliner(QtWidgets.QWidget): class LookOutliner(QtWidgets.QWidget): - menu_apply_action = QtCore.Signal() def __init__(self, parent=None): @@ -237,9 +234,7 @@ class LookOutliner(QtWidgets.QWidget): """ datas = [i.data(TreeModel.ItemRole) for i in self.view.get_indices()] - items = [d for d in datas if d is not None] # filter Nones - - return items + return [d for d in datas if d is not None] def right_mouse_menu(self, pos): """Build RMB menu for look view""" From f820602caae96c61a8ab4fdeb9767982fe5765a9 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 17 Dec 2021 18:43:51 +0100 Subject: [PATCH 080/395] remove debug prints --- openpype/tools/mayalookassigner/commands.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py index 740e4fa6fe..9f6f244a35 100644 --- a/openpype/tools/mayalookassigner/commands.py +++ b/openpype/tools/mayalookassigner/commands.py @@ -67,9 +67,7 @@ def get_selected_nodes(): selection = cmds.ls(selection=True, long=True) hierarchy = list_descendents(selection) - selected_nodes = list(set(selection + hierarchy)) - log.warning("selected nodes: {}".format(selected_nodes)) - return selected_nodes + return list(set(selection + hierarchy)) def get_all_asset_nodes(): @@ -83,20 +81,16 @@ def get_all_asset_nodes(): containers = host.ls() nodes = [] - log.debug("got {}".format(containers)) for container in host.ls(): # We are not interested in looks but assets! if container["loader"] == "LookLoader": - log.warning("skipping {}".format(container)) continue # Gather all information container_name = container["objectName"] - log.warning("--- listing: {}".format(container_name)) nodes += cmds.sets(container_name, query=True, nodesOnly=True) or [] nodes = list(set(nodes)) - log.warning("returning {}".format(nodes)) return nodes @@ -109,8 +103,6 @@ def create_asset_id_hash(nodes): dict """ node_id_hash = defaultdict(list) - - # log.warning(pformat(nodes)) for node in nodes: # iterate over content of reference node if cmds.nodeType(node) == "reference": From 65fa8aa90476db7d1d803574bafd712c7d2a10d6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 20 Dec 2021 10:36:56 +0100 Subject: [PATCH 081/395] added validation of installed third party libraries before build --- setup.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index cd3ed4f82c..92cc76dc7a 100644 --- a/setup.py +++ b/setup.py @@ -3,15 +3,65 @@ import os import sys import re +import platform +import distutils.spawn from pathlib import Path from cx_Freeze import setup, Executable from sphinx.setup_command import BuildDoc -version = {} - openpype_root = Path(os.path.dirname(__file__)) + +def validate_thirdparty_binaries(): + """Check existence of thirdpart executables.""" + low_platform = platform.system().lower() + binary_vendors_dir = os.path.join( + openpype_root, + "vendor", + "bin" + ) + + error_msg = ( + "Missing binary dependency {}. Please fetch thirdparty dependencies." + ) + # Validate existence of FFmpeg + ffmpeg_dir = os.path.join(binary_vendors_dir, "ffmpeg", low_platform) + if low_platform == "windows": + ffmpeg_dir = os.path.join(ffmpeg_dir, "bin") + ffmpeg_executable = os.path.join(ffmpeg_dir, "ffmpeg") + ffmpeg_result = distutils.spawn.find_executable(ffmpeg_executable) + if ffmpeg_result is None: + raise RuntimeError(error_msg.format("FFmpeg")) + + # Validate existence of OpenImageIO (not on MacOs) + oiio_tool_path = None + if low_platform == "linux": + oiio_tool_path = os.path.join( + binary_vendors_dir, + "oiio", + low_platform, + "bin", + "oiiotool" + ) + elif low_platform == "windows": + oiio_tool_path = os.path.join( + binary_vendors_dir, + "oiio", + low_platform, + "oiiotool" + ) + oiio_result = None + if oiio_tool_path is not None: + oiio_result = distutils.spawn.find_executable(oiio_tool_path) + if oiio_result is None: + raise RuntimeError(error_msg.format("OpenImageIO")) + + +validate_thirdparty_binaries() + +version = {} + with open(openpype_root / "openpype" / "version.py") as fp: exec(fp.read(), version) From b30629866abeb5f5e86ab88dda36bdbf8f2a4cb2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 14:23:37 +0100 Subject: [PATCH 082/395] Improve lib.polyConstraint performance when Select tool is not the active tool context --- openpype/hosts/maya/api/lib.py | 57 +++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..af9a16b291 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -745,6 +745,33 @@ def namespaced(namespace, new=True): cmds.namespace(set=original) +@contextlib.contextmanager +def maintained_selection_api(): + """Maintain selection using the Maya Python API. + + Warning: This is *not* added to the undo stack. + + """ + original = om.MGlobal.getActiveSelectionList() + try: + yield + finally: + om.MGlobal.setActiveSelectionList(original) + + +@contextlib.contextmanager +def tool(context): + """Set a tool context during the context manager. + + """ + original = cmds.currentCtx() + try: + cmds.setToolTo(context) + yield + finally: + cmds.setToolTo(original) + + def polyConstraint(components, *args, **kwargs): """Return the list of *components* with the constraints applied. @@ -763,17 +790,25 @@ def polyConstraint(components, *args, **kwargs): kwargs.pop('mode', None) with no_undo(flush=False): - with maya.maintained_selection(): - # Apply constraint using mode=2 (current and next) so - # it applies to the selection made before it; because just - # a `maya.cmds.select()` call will not trigger the constraint. - with reset_polySelectConstraint(): - cmds.select(components, r=1, noExpand=True) - cmds.polySelectConstraint(*args, mode=2, **kwargs) - result = cmds.ls(selection=True) - cmds.select(clear=True) - - return result + # Reverting selection to the original selection using + # `maya.cmds.select` can be slow in rare cases where previously + # `maya.cmds.polySelectConstraint` had set constrain to "All and Next" + # and the "Random" setting was activated. To work around this we + # revert to the original selection using the Maya API. This is safe + # since we're not generating any undo change anyway. + with tool("selectSuperContext"): + # Selection can be very slow when in a manipulator mode. + # So we force the selection context which is fast. + with maintained_selection_api(): + # Apply constraint using mode=2 (current and next) so + # it applies to the selection made before it; because just + # a `maya.cmds.select()` call will not trigger the constraint. + with reset_polySelectConstraint(): + cmds.select(components, r=1, noExpand=True) + return cmds.polySelectConstraint(*args, + mode=2, + returnSelection=True, + **kwargs) @contextlib.contextmanager From 86771ae01dfbdaca373cc65f05066ec86a7a48cb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 14:34:42 +0100 Subject: [PATCH 083/395] Optimization: Improve speed slightly more (somehow this is faster in most cases) --- openpype/hosts/maya/api/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index af9a16b291..bd83b13b06 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -805,10 +805,10 @@ def polyConstraint(components, *args, **kwargs): # a `maya.cmds.select()` call will not trigger the constraint. with reset_polySelectConstraint(): cmds.select(components, r=1, noExpand=True) - return cmds.polySelectConstraint(*args, - mode=2, - returnSelection=True, - **kwargs) + cmds.polySelectConstraint(*args, mode=2, **kwargs) + result = cmds.ls(selection=True) + cmds.select(clear=True) + return result @contextlib.contextmanager From 61b7b5eeee74ab17a592678bd3c411ff073e4fe3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 16:34:19 +0100 Subject: [PATCH 084/395] Fix #2449 - Remove unique name counter --- .../hosts/houdini/plugins/load/load_alembic.py | 13 +++---------- .../hosts/houdini/plugins/load/load_camera.py | 17 ++++------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_alembic.py b/openpype/hosts/houdini/plugins/load/load_alembic.py index cd0f0f0d2d..df66d56008 100644 --- a/openpype/hosts/houdini/plugins/load/load_alembic.py +++ b/openpype/hosts/houdini/plugins/load/load_alembic.py @@ -1,6 +1,6 @@ from avalon import api -from avalon.houdini import pipeline, lib +from avalon.houdini import pipeline class AbcLoader(api.Loader): @@ -25,16 +25,9 @@ class AbcLoader(api.Loader): # Get the root node obj = hou.node("/obj") - # Create a unique name - counter = 1 + # Define node name namespace = namespace if namespace else context["asset"]["name"] - formatted = "{}_{}".format(namespace, name) if namespace else name - node_name = "{0}_{1:03d}".format(formatted, counter) - - children = lib.children_as_string(hou.node("/obj")) - while node_name in children: - counter += 1 - node_name = "{0}_{1:03d}".format(formatted, counter) + node_name = "{}_{}".format(namespace, name) if namespace else name # Create a new geo node container = obj.createNode("geo", node_name=node_name) diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 83246b7d97..8b98b7c05e 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -1,5 +1,5 @@ from avalon import api -from avalon.houdini import pipeline, lib +from avalon.houdini import pipeline ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")' @@ -97,18 +97,9 @@ class CameraLoader(api.Loader): # Get the root node obj = hou.node("/obj") - # Create a unique name - counter = 1 - asset_name = context["asset"]["name"] - - namespace = namespace or asset_name - formatted = "{}_{}".format(namespace, name) if namespace else name - node_name = "{0}_{1:03d}".format(formatted, counter) - - children = lib.children_as_string(hou.node("/obj")) - while node_name in children: - counter += 1 - node_name = "{0}_{1:03d}".format(formatted, counter) + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name # Create a archive node container = self.create_and_connect(obj, "alembicarchive", node_name) From bfc6ad0b655869cb00c154b0a2d62d68d8acd20b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 14:17:56 +0100 Subject: [PATCH 085/395] Fix #2453 Refactor missing _get_reference_node method --- openpype/hosts/maya/plugins/load/load_look.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_look.py b/openpype/hosts/maya/plugins/load/load_look.py index fca612eff4..8e14778fd2 100644 --- a/openpype/hosts/maya/plugins/load/load_look.py +++ b/openpype/hosts/maya/plugins/load/load_look.py @@ -8,6 +8,8 @@ from collections import defaultdict from openpype.widgets.message_window import ScrollMessageBox from Qt import QtWidgets +from openpype.hosts.maya.api.plugin import get_reference_node + class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """Specific loader for lookdev""" @@ -70,7 +72,7 @@ class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): # Get reference node from container members members = cmds.sets(node, query=True, nodesOnly=True) - reference_node = self._get_reference_node(members) + reference_node = get_reference_node(members, log=self.log) shader_nodes = cmds.ls(members, type='shadingEngine') orig_nodes = set(self._get_nodes_with_shader(shader_nodes)) From 1e49b7c87c4d8adad4c6b468ee9adbb8c391af4a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 14:45:51 +0100 Subject: [PATCH 086/395] Do not keep fixed geometry vertices selected/active after repair --- .../hosts/maya/plugins/publish/validate_shape_zero.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index 2c594ef5f3..acc42f073a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -4,6 +4,8 @@ import pyblish.api import openpype.api import openpype.hosts.maya.api.action +from avalon.maya import maintained_selection + class ValidateShapeZero(pyblish.api.Validator): """shape can't have any values @@ -47,8 +49,12 @@ class ValidateShapeZero(pyblish.api.Validator): @classmethod def repair(cls, instance): invalid_shapes = cls.get_invalid(instance) - for shape in invalid_shapes: - cmds.polyCollapseTweaks(shape) + if not invalid_shapes: + return + + with maintained_selection(): + for shape in invalid_shapes: + cmds.polyCollapseTweaks(shape) def process(self, instance): """Process all the nodes in the instance "objectSet""" From 73400612435880f731230a18afa603b0ad05df3b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 17:31:07 +0100 Subject: [PATCH 087/395] Fix repair taking very long time for many heavy meshes (optimization) --- .../hosts/maya/plugins/publish/validate_shape_zero.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index acc42f073a..bb601b8f50 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -3,6 +3,7 @@ from maya import cmds import pyblish.api import openpype.api import openpype.hosts.maya.api.action +from openpype.hosts.maya.api import lib from avalon.maya import maintained_selection @@ -53,8 +54,13 @@ class ValidateShapeZero(pyblish.api.Validator): return with maintained_selection(): - for shape in invalid_shapes: - cmds.polyCollapseTweaks(shape) + with lib.tool("selectSuperContext"): + for shape in invalid_shapes: + cmds.polyCollapseTweaks(shape) + # cmds.polyCollapseTweaks keeps selecting the geometry + # after each command. When running on many meshes + # after one another this tends to get really heavy + cmds.select(clear=True) def process(self, instance): """Process all the nodes in the instance "objectSet""" From c4d91fb9c0e2fb6a57600c9dc18b319903735994 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 17:34:18 +0100 Subject: [PATCH 088/395] Improve docstring and error message - Previously it said something about translate, rotate and scale. However this validator doesn't check that at all and thus the docstring was incorrect. --- .../hosts/maya/plugins/publish/validate_shape_zero.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index bb601b8f50..6b5c5d1398 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -9,11 +9,9 @@ from avalon.maya import maintained_selection class ValidateShapeZero(pyblish.api.Validator): - """shape can't have any values + """Shape components may not have any "tweak" values - To solve this issue, try freezing the shapes. So long - as the translation, rotation and scaling values are zero, - you're all good. + To solve this issue, try freezing the shapes. """ @@ -67,5 +65,5 @@ class ValidateShapeZero(pyblish.api.Validator): invalid = self.get_invalid(instance) if invalid: - raise ValueError("Nodes found with shape or vertices not freezed" - "values: {0}".format(invalid)) + raise ValueError("Shapes found with non-zero component tweaks: " + "{0}".format(invalid)) From 3a95b99e42bf3903bbd5e66e6cb2558a5e880353 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 17:56:50 +0100 Subject: [PATCH 089/395] Re-use polyConstraint from openpype.host.maya.api.lib --- .../plugins/publish/validate_mesh_ngons.py | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py index e8cc019b52..839aab0d0b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py @@ -7,21 +7,6 @@ from avalon import maya from openpype.hosts.maya.api import lib -def polyConstraint(objects, *args, **kwargs): - kwargs.pop('mode', None) - - with lib.no_undo(flush=False): - with maya.maintained_selection(): - with lib.reset_polySelectConstraint(): - cmds.select(objects, r=1, noExpand=True) - # Acting as 'polyCleanupArgList' for n-sided polygon selection - cmds.polySelectConstraint(*args, mode=3, **kwargs) - result = cmds.ls(selection=True) - cmds.select(clear=True) - - return result - - class ValidateMeshNgons(pyblish.api.Validator): """Ensure that meshes don't have ngons @@ -41,8 +26,17 @@ class ValidateMeshNgons(pyblish.api.Validator): @staticmethod def get_invalid(instance): - meshes = cmds.ls(instance, type='mesh') - return polyConstraint(meshes, type=8, size=3) + meshes = cmds.ls(instance, type='mesh', long=True) + + # Get all faces + faces = ['{0}.f[*]'.format(node) for node in meshes] + + # Filter to n-sided polygon faces (ngons) + invalid = lib.polyConstraint(faces, + t=0x0008, # type=face + size=3) # size=nsided + + return invalid def process(self, instance): """Process all the nodes in the instance "objectSet""" From 676e68f56b2bef3dcc479cdb69175d552da15116 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 3 Jan 2022 12:41:34 +0100 Subject: [PATCH 090/395] make sure anatomy does not return unicode strings --- openpype/lib/anatomy.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 66ecbd66d1..5f7285fe6c 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -1568,8 +1568,11 @@ class Roots: key_items = [self.env_prefix] for _key in keys: key_items.append(_key.upper()) + key = "_".join(key_items) - return {key: roots.value} + # Make sure key and value does not contain unicode + # - can happen in Python 2 hosts + return {str(key): str(roots.value)} output = {} for _key, _value in roots.items(): From cd0ad34595286cd06fd48c970713f4beba0d8a86 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 4 Jan 2022 16:34:10 +0100 Subject: [PATCH 091/395] modify comment Co-authored-by: Petr Kalis --- openpype/settings/entities/op_version_entity.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 6f6243cfee..55f8450edf 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -14,10 +14,8 @@ from .exceptions import BaseInvalidValue class OpenPypeVersionInput(TextEntity): """Entity to store OpenPype version to use. - It is text input as creating of settings on different machines may - affect which versions are available so it must have option to set OpenPype - version which is not available for machine where settings entities are - loaded. + Settings created on another machine may affect available versions on current user's machine. + Text input element is provided to explicitly set version not yet showing up the user's machine. It is possible to enter empty string. In that case is used any latest version. Any other string must match regex of OpenPype version semantic. From 18104b6aed36ee490992e76fe11b0b02b8d7ef1f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 16:54:48 +0100 Subject: [PATCH 092/395] fix docstring text length --- openpype/settings/entities/op_version_entity.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 55f8450edf..1576173654 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -14,8 +14,9 @@ from .exceptions import BaseInvalidValue class OpenPypeVersionInput(TextEntity): """Entity to store OpenPype version to use. - Settings created on another machine may affect available versions on current user's machine. - Text input element is provided to explicitly set version not yet showing up the user's machine. + Settings created on another machine may affect available versions + on current user's machine. Text input element is provided to explicitly + set version not yet showing up the user's machine. It is possible to enter empty string. In that case is used any latest version. Any other string must match regex of OpenPype version semantic. From 07e151981c34e8cdb5710ce8a853be08530f0cb9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 17:14:00 +0100 Subject: [PATCH 093/395] skip duplicated versions --- openpype/settings/entities/op_version_entity.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 1576173654..6184f7640a 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -38,9 +38,10 @@ class OpenPypeVersionInput(TextEntity): value_hints = [] if state is OverrideState.STUDIO: versions = self._get_openpype_versions() - if versions is not None: - for version in versions: - value_hints.append(str(version)) + for version in versions: + version_str = str(version) + if version_str not in value_hints: + value_hints.append(version_str) self.value_hints = value_hints From ef87a1f086adf20d57f9284b33253f7b04d38b75 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 17:14:43 +0100 Subject: [PATCH 094/395] confirmation dialog requires to write whole project name --- .../project_manager/project_manager/widgets.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index e4c58a8a2c..392f3f4503 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -336,18 +336,21 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self.setWindowTitle("Delete project?") - message = ( + message_label = QtWidgets.QLabel(self) + message_label.setWordWrap(True) + message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + message_label.setText(( "Project \"{}\" with all related data will be" " permanently removed from the database (This actions won't remove" " any files on disk)." - ).format(project_name) - message_label = QtWidgets.QLabel(message, self) - message_label.setWordWrap(True) + ).format(project_name)) question_label = QtWidgets.QLabel("Are you sure?", self) confirm_input = PlaceholderLineEdit(self) - confirm_input.setPlaceholderText("Type \"Delete\" to confirm...") + confirm_input.setPlaceholderText( + "Type \"{}\" to confirm...".format(project_name) + ) cancel_btn = _SameSizeBtns("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") @@ -379,6 +382,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self._confirm_btn = confirm_btn self._confirm_input = confirm_input self._result = 0 + self._project_name = project_name self.setMinimumWidth(480) self.setMaximumWidth(650) @@ -411,5 +415,5 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self._on_confirm_click() def _on_confirm_text_change(self): - enabled = self._confirm_input.text().lower() == "delete" + enabled = self._confirm_input.text() == self._project_name self._confirm_btn.setEnabled(enabled) From de1c043ec8819b7fd68cebc5152ea8ad60d3e754 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 17:14:59 +0100 Subject: [PATCH 095/395] added label "Delete project" to delete button --- openpype/tools/project_manager/project_manager/window.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index a05811e813..0298d565a5 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -78,7 +78,9 @@ class ProjectManagerWindow(QtWidgets.QWidget): ) create_folders_btn.setEnabled(False) - remove_projects_btn = QtWidgets.QPushButton(project_widget) + remove_projects_btn = QtWidgets.QPushButton( + "Delete project", project_widget + ) remove_projects_btn.setIcon(ResourceCache.get_icon("remove")) remove_projects_btn.setObjectName("IconBtn") From 737a485530bbac1960ad4fe93bb381daea4cc956 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 4 Jan 2022 17:59:58 +0100 Subject: [PATCH 096/395] flame: fix passing env var and flame version dynamic filling --- .../hosts/flame/api/scripts/wiretap_com.py | 17 +++++++++----- openpype/hosts/flame/hooks/pre_flame_setup.py | 23 +++++++++++++------ .../system_settings/applications.json | 8 ++++--- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index d8dc1884cf..5f7b2580e6 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -10,14 +10,19 @@ import xml.dom.minidom as minidom from copy import deepcopy import datetime +FLAME_V = os.getenv("OPENPYPE_FLAME_VERSION") + +if not FLAME_V: + raise KeyError("Missing key in environment `OPENPYPE_FLAME_VERSION`") + try: from libwiretapPythonClientAPI import ( WireTapClientInit) except ImportError: - flame_python_path = "/opt/Autodesk/flame_2021/python" + flame_python_path = "/opt/Autodesk/flame_{}/python".format(FLAME_V) flame_exe_path = ( - "/opt/Autodesk/flame_2021/bin/flame.app" - "/Contents/MacOS/startApp") + "/opt/Autodesk/flame_{}/bin/flame.app" + "/Contents/MacOS/startApp").format(FLAME_V) sys.path.append(flame_python_path) @@ -169,7 +174,7 @@ class WireTapCom(object): # check if volumes exists if self.volume_name not in volumes: raise AttributeError( - ("Volume '{}' does not exist '{}'").format( + ("Volume '{}' does not exist in '{}'").format( self.volume_name, volumes) ) @@ -179,7 +184,7 @@ class WireTapCom(object): "/opt/Autodesk/", "wiretap", "tools", - "2021", + FLAME_V, "wiretap_create_node", ), '-n', @@ -434,7 +439,7 @@ class WireTapCom(object): "/opt/Autodesk/", "wiretap", "tools", - "2021", + FLAME_V, "wiretap_duplicate_node", ), "-s", diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 159fb37410..e7ef856907 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -19,7 +19,10 @@ class FlamePrelaunch(PreLaunchHook): app_groups = ["flame"] # todo: replace version number with avalon launch app version - flame_python_exe = "/opt/Autodesk/python/2021/bin/python2.7" + flame_python_exe = ( + "/opt/Autodesk/python/{OPENPYPE_FLAME_VERSION}" + "/bin/python2.7" + ) wtc_script_path = os.path.join( opflame.HOST_DIR, "api", "scripts", "wiretap_com.py") @@ -30,6 +33,7 @@ class FlamePrelaunch(PreLaunchHook): self.signature = "( {} )".format(self.__class__.__name__) def execute(self): + _env = self.launch_context.env """Hook entry method.""" project_doc = self.data["project_doc"] user_name = get_openpype_username() @@ -58,9 +62,9 @@ class FlamePrelaunch(PreLaunchHook): data_to_script = { # from settings - "host_name": os.getenv("FLAME_WIRETAP_HOSTNAME") or hostname, - "volume_name": os.getenv("FLAME_WIRETAP_VOLUME"), - "group_name": os.getenv("FLAME_WIRETAP_GROUP"), + "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, + "volume_name": _env.get("FLAME_WIRETAP_VOLUME"), + "group_name": _env.get("FLAME_WIRETAP_GROUP"), "color_policy": "ACES 1.1", # from project @@ -68,9 +72,12 @@ class FlamePrelaunch(PreLaunchHook): "user_name": user_name, "project_data": project_data } + + self.log.info(pformat(dict(_env))) + self.log.info(pformat(data_to_script)) + app_arguments = self._get_launch_arguments(data_to_script) - self.log.info(pformat(dict(self.launch_context.env))) opflame.setup(self.launch_context.env) @@ -83,7 +90,9 @@ class FlamePrelaunch(PreLaunchHook): with make_temp_file(dumped_script_data) as tmp_json_path: # Prepare subprocess arguments args = [ - self.flame_python_exe, + self.flame_python_exe.format( + **self.launch_context.env + ), self.wtc_script_path, tmp_json_path ] @@ -91,7 +100,7 @@ class FlamePrelaunch(PreLaunchHook): process_kwargs = { "logger": self.log, - "env": {} + "env": self.launch_context.env } openpype.api.run_subprocess(args, **process_kwargs) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 1cbe09f576..23ea64fdc1 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -118,10 +118,10 @@ "executables": { "windows": [], "darwin": [ - "/opt/Autodesk/flame_2021/bin/flame.app/Contents/MacOS/startApp" + "/opt/Autodesk/flame_{OPENPYPE_FLAME_VERSION}/bin/flame.app/Contents/MacOS/startApp" ], "linux": [ - "/opt/Autodesk/flame_2021/bin/flame" + "/opt/Autodesk/flame_{OPENPYPE_FLAME_VERSION}/bin/flame" ] }, "arguments": { @@ -129,7 +129,9 @@ "darwin": [], "linux": [] }, - "environment": {} + "environment": { + "OPENPYPE_FLAME_VERSION": "2021" + } }, "__dynamic_keys_labels__": { "2021": "2021 (Testing Only)" From eaeb96c92f4075f2719892d732ef842eb5f36fb3 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 5 Jan 2022 03:41:51 +0000 Subject: [PATCH 097/395] [Automated] Bump version --- CHANGELOG.md | 28 +++++++++++++++++++++++++++- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc14b5f507..c46b1f37e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,34 @@ # Changelog +## [3.8.0-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) + +**🆕 New features** + +- Flame: OpenTimelineIO Export Modul [\#2398](https://github.com/pypeclub/OpenPype/pull/2398) + +**🚀 Enhancements** + +- Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) +- Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) +- Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) +- Maya: Validate Rig Controllers - fix Error: in script editor [\#2459](https://github.com/pypeclub/OpenPype/pull/2459) +- Maya: Optimize Validate Locked Normals speed for dense polymeshes [\#2457](https://github.com/pypeclub/OpenPype/pull/2457) +- Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) + +**🐛 Bug fixes** + +- General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) +- Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) + ## [3.7.0](https://github.com/pypeclub/OpenPype/tree/3.7.0) (2022-01-04) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.4...3.7.0) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.7.0-nightly.14...3.7.0) + +**Deprecated:** + +- General: Default modules hierarchy n2 [\#2368](https://github.com/pypeclub/OpenPype/pull/2368) **🚀 Enhancements** diff --git a/openpype/version.py b/openpype/version.py index 8fac77bcdf..4f10deeae1 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.7.0" +__version__ = "3.8.0-nightly.1" diff --git a/pyproject.toml b/pyproject.toml index dd1f5c90b6..20aaf62d06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.7.0" # OpenPype +version = "3.8.0-nightly.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 6b8b7f22e65aa964f0f9276672adcbabae8c392e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 10:30:31 +0100 Subject: [PATCH 098/395] Fix #2473 Update "Repair Context" label to "Repair" --- openpype/action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/action.py b/openpype/action.py index 3fc6dd1a8f..50741875e4 100644 --- a/openpype/action.py +++ b/openpype/action.py @@ -72,7 +72,7 @@ class RepairContextAction(pyblish.api.Action): is available on the plugin. """ - label = "Repair Context" + label = "Repair" on = "failed" # This action is only available on a failed plug-in def process(self, context, plugin): From 6199f6e6654ee0f672935f767e5bde22dbf2c25f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 10:36:36 +0100 Subject: [PATCH 099/395] Collect 'fps' animation data only for "review" instances --- openpype/hosts/maya/api/lib.py | 7 ++++--- openpype/hosts/maya/plugins/create/create_review.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..d1054988d1 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -280,7 +280,7 @@ def shape_from_element(element): return node -def collect_animation_data(): +def collect_animation_data(fps=False): """Get the basic animation data Returns: @@ -291,7 +291,6 @@ def collect_animation_data(): # get scene values as defaults start = cmds.playbackOptions(query=True, animationStartTime=True) end = cmds.playbackOptions(query=True, animationEndTime=True) - fps = mel.eval('currentTimeUnitToFPS()') # build attributes data = OrderedDict() @@ -299,7 +298,9 @@ def collect_animation_data(): data["frameEnd"] = end data["handles"] = 0 data["step"] = 1.0 - data["fps"] = fps + + if fps: + data["fps"] = mel.eval('currentTimeUnitToFPS()') return data diff --git a/openpype/hosts/maya/plugins/create/create_review.py b/openpype/hosts/maya/plugins/create/create_review.py index 05b05be7a5..ae636ec691 100644 --- a/openpype/hosts/maya/plugins/create/create_review.py +++ b/openpype/hosts/maya/plugins/create/create_review.py @@ -22,7 +22,7 @@ class CreateReview(plugin.Creator): # get basic animation data : start / end / handles / steps data = OrderedDict(**self.data) - animation_data = lib.collect_animation_data() + animation_data = lib.collect_animation_data(fps=True) for key, value in animation_data.items(): data[key] = value From 1c865567f5b88061aff9bda6a3cd4ef4662a6034 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 11:12:21 +0100 Subject: [PATCH 100/395] flame path no env var --- .../defaults/system_settings/applications.json | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 23ea64fdc1..3a097d2b37 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -118,10 +118,10 @@ "executables": { "windows": [], "darwin": [ - "/opt/Autodesk/flame_{OPENPYPE_FLAME_VERSION}/bin/flame.app/Contents/MacOS/startApp" + "/opt/Autodesk/flame_2021/bin/flame.app/Contents/MacOS/startApp" ], "linux": [ - "/opt/Autodesk/flame_{OPENPYPE_FLAME_VERSION}/bin/flame" + "/opt/Autodesk/flame_2021/bin/flame" ] }, "arguments": { @@ -144,7 +144,10 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": ["{NUKE_PATH}", "{OPENPYPE_STUDIO_PLUGINS}/nuke"] + "NUKE_PATH": [ + "{NUKE_PATH}", + "{OPENPYPE_STUDIO_PLUGINS}/nuke" + ] }, "variants": { "13-0": { @@ -250,7 +253,10 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": ["{NUKE_PATH}", "{OPENPYPE_STUDIO_PLUGINS}/nuke"] + "NUKE_PATH": [ + "{NUKE_PATH}", + "{OPENPYPE_STUDIO_PLUGINS}/nuke" + ] }, "variants": { "13-0": { From b3a284294949189a3737ed28fac90294a8829087 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 11:29:37 +0100 Subject: [PATCH 101/395] flame: fixing permission issue overcoming --- openpype/hosts/flame/api/utils.py | 38 +++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 201c7d2fac..aae102dd7e 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -75,10 +75,19 @@ def _sync_utility_scripts(env=None): path = os.path.join(flame_shared_dir, _itm) log.info("Removing `{path}`...".format(**locals())) - if os.path.isdir(path): - shutil.rmtree(path, onerror=None) - else: - os.remove(path) + + try: + if os.path.isdir(path): + shutil.rmtree(path, onerror=None) + else: + os.remove(path) + except PermissionError as msg: + log.warning( + "Not able to remove: `{}`, Problem with: `{}`".format( + path, + msg + ) + ) # copy scripts into Resolve's utility scripts dir for dirpath, scriptlist in scripts.items(): @@ -88,13 +97,22 @@ def _sync_utility_scripts(env=None): src = os.path.join(dirpath, _script) dst = os.path.join(flame_shared_dir, _script) log.info("Copying `{src}` to `{dst}`...".format(**locals())) - if os.path.isdir(src): - shutil.copytree( - src, dst, symlinks=False, - ignore=None, ignore_dangling_symlinks=False + + try: + if os.path.isdir(src): + shutil.copytree( + src, dst, symlinks=False, + ignore=None, ignore_dangling_symlinks=False + ) + else: + shutil.copy2(src, dst) + except PermissionError as msg: + log.warning( + "Not able to coppy to: `{}`, Problem with: `{}`".format( + dst, + msg + ) ) - else: - shutil.copy2(src, dst) def setup(env=None): From 97f6afa90fb9bf6d5f4acbb1a41b0b80c730b8a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 14:36:21 +0100 Subject: [PATCH 102/395] flame: fixing pref file handling --- openpype/hosts/flame/api/lib.py | 9 ++++++--- openpype/hosts/flame/api/utils.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 96bffab774..44043c00f2 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -114,11 +114,14 @@ class FlameAppFramework(object): self.hostname, ) - self.log.info("[{}] waking up".format(self.__class__.__name__)) - self.load_prefs() + self.log.info("[{}] waking up".format(self.__class__.__name__)) + + try: + self.load_prefs() + except RuntimeError: + self.save_prefs() # menu auto-refresh defaults - if not self.prefs_global.get("menu_auto_refresh"): self.prefs_global["menu_auto_refresh"] = { "media_panel": True, diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index aae102dd7e..8ed8613b15 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -106,7 +106,7 @@ def _sync_utility_scripts(env=None): ) else: shutil.copy2(src, dst) - except PermissionError as msg: + except (PermissionError, FileExistsError) as msg: log.warning( "Not able to coppy to: `{}`, Problem with: `{}`".format( dst, From 65053ef948fc712ae939fc2756a0dc0abfed0643 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 15:33:15 +0100 Subject: [PATCH 103/395] trigger on task changed in set context method --- openpype/tools/workfiles/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 0615ec0aca..f4a86050cb 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -1074,6 +1074,7 @@ class Window(QtWidgets.QMainWindow): if "task" in context: self.tasks_widget.select_task_name(context["task"]) + self._on_task_changed() def _on_asset_changed(self): asset_id = self.assets_widget.get_selected_asset_id() From d84d334ac6140a197c10ae88d6057d31bd8122e3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 16:15:22 +0100 Subject: [PATCH 104/395] fix style and dialog modality to parent widget --- openpype/tools/loader/widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index ea45fd4364..f5ade04ae9 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -535,7 +535,7 @@ class SubsetWidget(QtWidgets.QWidget): self.load_ended.emit() if error_info: - box = LoadErrorMessageBox(error_info) + box = LoadErrorMessageBox(error_info, self) box.show() def selected_subsets(self, _groups=False, _merged=False, _other=True): @@ -1431,7 +1431,7 @@ class RepresentationWidget(QtWidgets.QWidget): self.load_ended.emit() if errors: - box = LoadErrorMessageBox(errors) + box = LoadErrorMessageBox(errors, self) box.show() def _get_optional_labels(self, loaders, selected_side): From 9d39d05e6bed847055a02a7a5f8686dcfa056545 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:44:16 +0100 Subject: [PATCH 105/395] styl have function to return path to images in resources --- openpype/style/__init__.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index cb0595d522..ea88b342ee 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -1,8 +1,10 @@ import os import json import collections -from openpype import resources import six + +from openpype import resources + from .color_defs import parse_color @@ -12,6 +14,18 @@ _FONT_IDS = None current_dir = os.path.dirname(os.path.abspath(__file__)) +def get_style_image_path(image_name): + # All filenames are lowered + image_name = image_name.lower() + # Male sure filename has png extension + if not image_name.endswith(".png"): + image_name += ".png" + filepath = os.path.join(current_dir, "images", image_name) + if os.path.exists(filepath): + return filepath + return None + + def _get_colors_raw_data(): """Read data file with stylesheet fill values. @@ -160,6 +174,11 @@ def load_stylesheet(): return _STYLESHEET_CACHE -def app_icon_path(): +def get_app_icon_path(): """Path to OpenPype icon.""" return resources.get_openpype_icon_filepath() + + +def app_icon_path(): + # Backwards compatibility + return get_app_icon_path() From 13aa752c40fdfd1f61412eaea455930101b64045 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:47:32 +0100 Subject: [PATCH 106/395] added clickable frame to utils --- openpype/tools/utils/__init__.py | 4 ++++ openpype/tools/utils/widgets.py | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 7f15e64767..2a6d62453b 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -1,8 +1,12 @@ from .widgets import ( PlaceholderLineEdit, + BaseClickableFrame, + ClickableFrame, ) __all__ = ( "PlaceholderLineEdit", + "BaseClickableFrame", + "ClickableFrame", ) diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index 3bfa092a21..3e45600e33 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -25,6 +25,41 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): self.setPalette(filter_palette) +class BaseClickableFrame(QtWidgets.QFrame): + """Widget that catch left mouse click and can trigger a callback. + + Callback is defined by overriding `_mouse_release_callback`. + """ + def __init__(self, parent): + super(BaseClickableFrame, self).__init__(parent) + + self._mouse_pressed = False + + def _mouse_release_callback(self): + pass + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + super(BaseClickableFrame, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self._mouse_release_callback() + + super(BaseClickableFrame, self).mouseReleaseEvent(event) + + +class ClickableFrame(BaseClickableFrame): + """Extended clickable frame which triggers 'clicked' signal.""" + clicked = QtCore.Signal() + + def _mouse_release_callback(self): + self.clicked.emit() + + class ImageButton(QtWidgets.QPushButton): """PushButton with icon and size of font. From b2f09495c49fda626a30ef6c1fd21d8918b9ea45 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:47:54 +0100 Subject: [PATCH 107/395] implemented expand button showing icon and having clicked signal --- openpype/tools/utils/__init__.py | 2 + openpype/tools/utils/widgets.py | 64 +++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 2a6d62453b..294b919b5c 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -2,6 +2,7 @@ from .widgets import ( PlaceholderLineEdit, BaseClickableFrame, ClickableFrame, + ExpandBtn, ) @@ -9,4 +10,5 @@ __all__ = ( "PlaceholderLineEdit", "BaseClickableFrame", "ClickableFrame", + "ExpandBtn" ) diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index 3e45600e33..c32eae043e 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -3,7 +3,10 @@ import logging from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome, qargparse -from openpype.style import get_objected_colors +from openpype.style import ( + get_objected_colors, + get_style_image_path +) log = logging.getLogger(__name__) @@ -60,6 +63,65 @@ class ClickableFrame(BaseClickableFrame): self.clicked.emit() +class ExpandBtnLabel(QtWidgets.QLabel): + """Label showing expand icon meant for ExpandBtn.""" + def __init__(self, parent): + super(ExpandBtnLabel, self).__init__(parent) + self._source_collapsed_pix = QtGui.QPixmap( + get_style_image_path("branch_closed") + ) + self._source_expanded_pix = QtGui.QPixmap( + get_style_image_path("branch_open") + ) + + self._current_image = self._source_collapsed_pix + self._collapsed = True + + def set_collapsed(self, collapsed): + if self._collapsed == collapsed: + return + self._collapsed = collapsed + if collapsed: + self._current_image = self._source_collapsed_pix + else: + self._current_image = self._source_expanded_pix + self._set_resized_pix() + + def resizeEvent(self, event): + self._set_resized_pix() + super(ExpandBtnLabel, self).resizeEvent(event) + + def _set_resized_pix(self): + size = int(self.fontMetrics().height() / 2) + if size < 1: + size = 1 + size += size % 2 + self.setPixmap( + self._current_image.scaled( + size, + size, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + ) + + +class ExpandBtn(ClickableFrame): + def __init__(self, parent=None): + super(ExpandBtn, self).__init__(parent) + + pixmap_label = ExpandBtnLabel(self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(pixmap_label) + + self._pixmap_label = pixmap_label + + def set_collapsed(self, collapsed): + self._pixmap_label.set_collapsed(collapsed) + + class ImageButton(QtWidgets.QPushButton): """PushButton with icon and size of font. From 8ef9f44854bdd1e2b6001148eb4714d41dc9fee6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:49:11 +0100 Subject: [PATCH 108/395] implemented traceback widget showin traceback in loader --- openpype/tools/loader/widgets.py | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index f5ade04ae9..c8e371ee6f 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -18,6 +18,8 @@ from openpype.tools.utils.delegates import ( ) from openpype.tools.utils.widgets import ( OptionalMenu, + ClickableFrame, + ExpandBtn, PlaceholderLineEdit ) from openpype.tools.utils.views import ( @@ -64,6 +66,55 @@ class OverlayFrame(QtWidgets.QFrame): self.label_widget.setText(label) +class TracebackWidget(QtWidgets.QWidget): + def __init__(self, tb_text, parent): + super(TracebackWidget, self).__init__(parent) + + # Modify text to match html + # - add more replacements when needed + tb_text = ( + tb_text + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + .replace(" ", " ") + ) + + expand_btn = ExpandBtn(self) + + clickable_frame = ClickableFrame(self) + clickable_layout = QtWidgets.QHBoxLayout(clickable_frame) + clickable_layout.setContentsMargins(0, 0, 0, 0) + + expand_label = QtWidgets.QLabel("Details", clickable_frame) + clickable_layout.addWidget(expand_label, 0) + clickable_layout.addStretch(1) + + show_details_layout = QtWidgets.QHBoxLayout() + show_details_layout.addWidget(expand_btn, 0) + show_details_layout.addWidget(clickable_frame, 1) + + text_widget = QtWidgets.QLabel(self) + text_widget.setText(tb_text) + text_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + text_widget.setVisible(False) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(show_details_layout, 0) + layout.addWidget(text_widget, 1) + + clickable_frame.clicked.connect(self._on_show_details_click) + expand_btn.clicked.connect(self._on_show_details_click) + + self._expand_btn = expand_btn + self._text_widget = text_widget + + def _on_show_details_click(self): + self._text_widget.setVisible(not self._text_widget.isVisible()) + self._expand_btn.set_collapsed(not self._text_widget.isVisible()) + + class LoadErrorMessageBox(QtWidgets.QDialog): def __init__(self, messages, parent=None): super(LoadErrorMessageBox, self).__init__(parent) From 72503a16e70c57b8ada5404a88f513afaa2f9161 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:50:14 +0100 Subject: [PATCH 109/395] reorganized contetn of error dialog --- openpype/tools/loader/widgets.py | 62 ++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index c8e371ee6f..80c4bade08 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -121,13 +121,10 @@ class LoadErrorMessageBox(QtWidgets.QDialog): self.setWindowTitle("Loading failed") self.setFocusPolicy(QtCore.Qt.StrongFocus) - body_layout = QtWidgets.QVBoxLayout(self) - main_label = ( "Failed to load items" ) main_label_widget = QtWidgets.QLabel(main_label, self) - body_layout.addWidget(main_label_widget) item_name_template = ( "Subset: {}
" @@ -136,38 +133,57 @@ class LoadErrorMessageBox(QtWidgets.QDialog): ) exc_msg_template = "{}" - for exc_msg, tb, repre, subset, version in messages: + content_scroll = QtWidgets.QScrollArea(self) + content_scroll.setWidgetResizable(True) + + content_widget = QtWidgets.QWidget(content_scroll) + content_scroll.setWidget(content_widget) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + for exc_msg, tb_text, repre, subset, version in messages: line = self._create_line() - body_layout.addWidget(line) + content_layout.addWidget(line) item_name = item_name_template.format(subset, version, repre) item_name_widget = QtWidgets.QLabel( item_name.replace("\n", "
"), self ) - body_layout.addWidget(item_name_widget) + item_name_widget.setWordWrap(True) + content_layout.addWidget(item_name_widget) exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) message_label_widget = QtWidgets.QLabel(exc_msg, self) - body_layout.addWidget(message_label_widget) + message_label_widget.setWordWrap(True) + content_layout.addWidget(message_label_widget) - if tb: - tb_widget = QtWidgets.QLabel(tb.replace("\n", "
"), self) - tb_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - body_layout.addWidget(tb_widget) + if tb_text: + line = self._create_line() + tb_widget = TracebackWidget(tb_text, self) + content_layout.addWidget(line) + content_layout.addWidget(tb_widget) - footer_widget = QtWidgets.QWidget(self) - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - buttonBox = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) - buttonBox.setStandardButtons( - QtWidgets.QDialogButtonBox.StandardButton.Ok - ) - buttonBox.accepted.connect(self._on_accept) - footer_layout.addWidget(buttonBox, alignment=QtCore.Qt.AlignRight) - body_layout.addWidget(footer_widget) + content_layout.addStretch(1) - def _on_accept(self): + ok_btn = QtWidgets.QPushButton("OK", self) + + footer_layout = QtWidgets.QHBoxLayout() + footer_layout.addStretch(1) + footer_layout.addWidget(ok_btn, 0) + + bottom_line = self._create_line() + body_layout = QtWidgets.QVBoxLayout(self) + body_layout.addWidget(main_label_widget, 0) + body_layout.addWidget(content_scroll, 1) + body_layout.addWidget(bottom_line, 0) + body_layout.addLayout(footer_layout, 0) + + ok_btn.clicked.connect(self._on_ok_clicked) + + self.resize(660, 350) + + def _on_ok_clicked(self): self.close() def _create_line(self): From 631ba5b12e35f669113b55dcd8d2a45136f42088 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:50:50 +0100 Subject: [PATCH 110/395] line has different color --- openpype/tools/loader/widgets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index 80c4bade08..0e392aef86 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -188,9 +188,9 @@ class LoadErrorMessageBox(QtWidgets.QDialog): def _create_line(self): line = QtWidgets.QFrame(self) - line.setFixedHeight(2) - line.setFrameShape(QtWidgets.QFrame.HLine) - line.setFrameShadow(QtWidgets.QFrame.Sunken) + line.setObjectName("Separator") + line.setMinimumHeight(2) + line.setMaximumHeight(2) return line From 049d0d27f9152c3b690a6f5546ec02547bb89faf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 17:51:00 +0100 Subject: [PATCH 111/395] added ability to copy report --- openpype/tools/loader/widgets.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index 0e392aef86..a39ac7213a 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -142,7 +142,22 @@ class LoadErrorMessageBox(QtWidgets.QDialog): content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setContentsMargins(0, 0, 0, 0) + report_data = [] for exc_msg, tb_text, repre, subset, version in messages: + report_message = ( + "During load error happened on Subset: \"{subset}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + subset=subset, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + line = self._create_line() content_layout.addWidget(line) @@ -166,9 +181,11 @@ class LoadErrorMessageBox(QtWidgets.QDialog): content_layout.addStretch(1) + copy_report_btn = QtWidgets.QPushButton("Copy report", self) ok_btn = QtWidgets.QPushButton("OK", self) footer_layout = QtWidgets.QHBoxLayout() + footer_layout.addWidget(copy_report_btn, 0) footer_layout.addStretch(1) footer_layout.addWidget(ok_btn, 0) @@ -179,13 +196,25 @@ class LoadErrorMessageBox(QtWidgets.QDialog): body_layout.addWidget(bottom_line, 0) body_layout.addLayout(footer_layout, 0) + copy_report_btn.clicked.connect(self._on_copy_report) ok_btn.clicked.connect(self._on_ok_clicked) self.resize(660, 350) + self._report_data = report_data + def _on_ok_clicked(self): self.close() + def _on_copy_report(self): + report_text = (10 * "*").join(self._report_data) + + mime_data = QtCore.QMimeData() + mime_data.setText(report_text) + QtWidgets.QApplication.instance().clipboard().setMimeData( + mime_data + ) + def _create_line(self): line = QtWidgets.QFrame(self) line.setObjectName("Separator") From 723bfb2b3cc879c09f3ccdc2b89316e6ebfeb4c0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 18:04:06 +0100 Subject: [PATCH 112/395] flame: adding openpype marker on segment handling --- openpype/hosts/flame/api/lib.py | 152 +++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 44043c00f2..f91f593eb5 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -1,5 +1,6 @@ import sys import os +import json import pickle import contextlib from pprint import pformat @@ -8,6 +9,12 @@ from openpype.api import Logger log = Logger().get_logger(__name__) +class ctx: + # OpenPype marker workflow variables + marker_name = "OpenPypeData" + marker_duration = 0 + marker_color = (0.0, 1.0, 1.0) + publish_default = False @contextlib.contextmanager def io_preferences_file(klass, filepath, write=False): @@ -114,7 +121,7 @@ class FlameAppFramework(object): self.hostname, ) - self.log.info("[{}] waking up".format(self.__class__.__name__)) + self.log.info("[{}] waking up".format(self.__class__.__name__)) try: self.load_prefs() @@ -337,3 +344,146 @@ def get_metadata(project_name, _log=None): policy_wiretap = GetProjectColorPolicy(_log=_log) return policy_wiretap.process(project_name) + + +def get_segment_pype_tag(segment, with_marker=None): + """ + Get openpype track item tag created by creator or loader plugin. + + Attributes: + segment (flame.PySegment): flame api object + with_marker (bool)[optional]: if true it will return also marker object + + Returns: + dict: openpype tag data + + Returns(with_marker=True): + flame.PyMarker, dict + """ + for marker in segment.markers: + comment = marker.comment.get_value() + color = marker.colour.get_value() + name = marker.name.get_value() + + if name == ctx.marker_name and color == ctx.marker_color: + if not with_marker: + return json.loads(comment) + else: + return marker, json.loads(comment) + + +def set_segment_pype_tag(segment, data=None): + """ + Set openpype track item tag to input segment. + + Attributes: + segment (flame.PySegment): flame api object + + Returns: + dict: json loaded data + """ + data = data or dict() + + marker_data = get_segment_pype_tag(segment, True) + + if marker_data: + # get available openpype tag if any + marker, tag_data = marker_data + # update tag data with new data + tag_data.update(data) + # update marker with tag data + marker.comment = json.dumps(tag_data) + + return True + else: + # update tag data with new data + marker = create_pype_marker(segment) + # add tag data to marker's comment + marker.comment = json.dumps(data) + + return True + + + +def imprint(segment, data=None): + """ + Adding openpype data to Flame timeline segment. + + Also including publish attribute into tag. + + Arguments: + segment (flame.PySegment)): flame api object + data (dict): Any data which needst to be imprinted + + Examples: + data = { + 'asset': 'sq020sh0280', + 'family': 'render', + 'subset': 'subsetMain' + } + """ + data = data or {} + + if not set_segment_pype_tag(segment, data): + raise AttributeError("Not imprint data to segment") + + # add publish attribute + set_publish_attribute(segment, True) + + +def set_publish_attribute(segment, value): + """ Set Publish attribute in input Tag object + + Attribute: + segment (flame.PySegment)): flame api object + value (bool): True or False + """ + tag_data = get_segment_pype_tag(segment) + tag_data["publish"] = value + + # set data to the publish attribute + if not set_segment_pype_tag(segment, tag_data): + raise AttributeError("Not imprint data to segment") + + +def get_publish_attribute(segment): + """ Get Publish attribute from input Tag object + + Attribute: + segment (flame.PySegment)): flame api object + + Returns: + bool: True or False + """ + tag_data = get_segment_pype_tag(segment) + + if not tag_data: + set_publish_attribute(segment, ctx.publish_default) + return ctx.publish_default + + return tag_data["publish"] + + +def create_pype_marker(segment): + """ Create openpype marker on a segment. + + Attributes: + segment (flame.PySegment): flame api object + + Returns: + flame.PyMarker: flame api object + """ + # get duration of segment + duration = segment.record_duration.relative_frame + # calculate start frame of the new marker + start_frame = int(segment.record_in.relative_frame) + int(duration / 2) + # create marker + marker = segment.create_marker(start_frame) + # set marker name + marker.name = ctx.marker_name + # set duration + marker.duration = ctx.marker_duration + # set colour + marker.colour = ctx.marker_color + + return marker \ No newline at end of file From 7d72481b865fcdb78e8cd26c924f37a997533423 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 18:31:08 +0100 Subject: [PATCH 113/395] create base of error dialog in utils --- openpype/tools/loader/widgets.py | 159 ++++++--------------------- openpype/tools/utils/__init__.py | 6 +- openpype/tools/utils/error_dialog.py | 143 ++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 128 deletions(-) create mode 100644 openpype/tools/utils/error_dialog.py diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index a39ac7213a..3accaed5ab 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -11,15 +11,16 @@ from Qt import QtWidgets, QtCore, QtGui from avalon import api, pipeline from avalon.lib import HeroVersionType -from openpype.tools.utils import lib as tools_lib +from openpype.tools.utils import ( + ErrorMessageBox, + lib as tools_lib +) from openpype.tools.utils.delegates import ( VersionDelegate, PrettyTimeDelegate ) from openpype.tools.utils.widgets import ( OptionalMenu, - ClickableFrame, - ExpandBtn, PlaceholderLineEdit ) from openpype.tools.utils.views import ( @@ -66,66 +67,12 @@ class OverlayFrame(QtWidgets.QFrame): self.label_widget.setText(label) -class TracebackWidget(QtWidgets.QWidget): - def __init__(self, tb_text, parent): - super(TracebackWidget, self).__init__(parent) - - # Modify text to match html - # - add more replacements when needed - tb_text = ( - tb_text - .replace("<", "<") - .replace(">", ">") - .replace("\n", "
") - .replace(" ", " ") - ) - - expand_btn = ExpandBtn(self) - - clickable_frame = ClickableFrame(self) - clickable_layout = QtWidgets.QHBoxLayout(clickable_frame) - clickable_layout.setContentsMargins(0, 0, 0, 0) - - expand_label = QtWidgets.QLabel("Details", clickable_frame) - clickable_layout.addWidget(expand_label, 0) - clickable_layout.addStretch(1) - - show_details_layout = QtWidgets.QHBoxLayout() - show_details_layout.addWidget(expand_btn, 0) - show_details_layout.addWidget(clickable_frame, 1) - - text_widget = QtWidgets.QLabel(self) - text_widget.setText(tb_text) - text_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) - text_widget.setVisible(False) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addLayout(show_details_layout, 0) - layout.addWidget(text_widget, 1) - - clickable_frame.clicked.connect(self._on_show_details_click) - expand_btn.clicked.connect(self._on_show_details_click) - - self._expand_btn = expand_btn - self._text_widget = text_widget - - def _on_show_details_click(self): - self._text_widget.setVisible(not self._text_widget.isVisible()) - self._expand_btn.set_collapsed(not self._text_widget.isVisible()) - - -class LoadErrorMessageBox(QtWidgets.QDialog): +class LoadErrorMessageBox(ErrorMessageBox): def __init__(self, messages, parent=None): - super(LoadErrorMessageBox, self).__init__(parent) - self.setWindowTitle("Loading failed") - self.setFocusPolicy(QtCore.Qt.StrongFocus) - - main_label = ( - "Failed to load items" - ) - main_label_widget = QtWidgets.QLabel(main_label, self) + self._messages = messages + super(LoadErrorMessageBox, self).__init__("Loading failed", parent) + def _create_content(self, content_layout): item_name_template = ( "Subset: {}
" "Version: {}
" @@ -133,31 +80,7 @@ class LoadErrorMessageBox(QtWidgets.QDialog): ) exc_msg_template = "{}" - content_scroll = QtWidgets.QScrollArea(self) - content_scroll.setWidgetResizable(True) - - content_widget = QtWidgets.QWidget(content_scroll) - content_scroll.setWidget(content_widget) - - content_layout = QtWidgets.QVBoxLayout(content_widget) - content_layout.setContentsMargins(0, 0, 0, 0) - - report_data = [] - for exc_msg, tb_text, repre, subset, version in messages: - report_message = ( - "During load error happened on Subset: \"{subset}\"" - " Representation: \"{repre}\" Version: {version}" - "\n\nError message: {message}" - ).format( - subset=subset, - repre=repre, - version=version, - message=exc_msg - ) - if tb_text: - report_message += "\n\n{}".format(tb_text) - report_data.append(report_message) - + for exc_msg, tb_text, repre, subset, version in self._messages: line = self._create_line() content_layout.addWidget(line) @@ -175,52 +98,34 @@ class LoadErrorMessageBox(QtWidgets.QDialog): if tb_text: line = self._create_line() - tb_widget = TracebackWidget(tb_text, self) + tb_widget = self._create_traceback_widget(tb_text, self) content_layout.addWidget(line) content_layout.addWidget(tb_widget) - content_layout.addStretch(1) + def _get_report_data(self): + report_data = [] + for exc_msg, tb_text, repre, subset, version in self._messages: + report_message = ( + "During load error happened on Subset: \"{subset}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + subset=subset, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + return report_data - copy_report_btn = QtWidgets.QPushButton("Copy report", self) - ok_btn = QtWidgets.QPushButton("OK", self) - - footer_layout = QtWidgets.QHBoxLayout() - footer_layout.addWidget(copy_report_btn, 0) - footer_layout.addStretch(1) - footer_layout.addWidget(ok_btn, 0) - - bottom_line = self._create_line() - body_layout = QtWidgets.QVBoxLayout(self) - body_layout.addWidget(main_label_widget, 0) - body_layout.addWidget(content_scroll, 1) - body_layout.addWidget(bottom_line, 0) - body_layout.addLayout(footer_layout, 0) - - copy_report_btn.clicked.connect(self._on_copy_report) - ok_btn.clicked.connect(self._on_ok_clicked) - - self.resize(660, 350) - - self._report_data = report_data - - def _on_ok_clicked(self): - self.close() - - def _on_copy_report(self): - report_text = (10 * "*").join(self._report_data) - - mime_data = QtCore.QMimeData() - mime_data.setText(report_text) - QtWidgets.QApplication.instance().clipboard().setMimeData( - mime_data + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Failed to load items" ) - - def _create_line(self): - line = QtWidgets.QFrame(self) - line.setObjectName("Separator") - line.setMinimumHeight(2) - line.setMaximumHeight(2) - return line + return label_widget class SubsetWidget(QtWidgets.QWidget): diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 294b919b5c..4dd6bdd05f 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -5,10 +5,14 @@ from .widgets import ( ExpandBtn, ) +from .error_dialog import ErrorMessageBox + __all__ = ( "PlaceholderLineEdit", "BaseClickableFrame", "ClickableFrame", - "ExpandBtn" + "ExpandBtn", + + "ErrorMessageBox" ) diff --git a/openpype/tools/utils/error_dialog.py b/openpype/tools/utils/error_dialog.py new file mode 100644 index 0000000000..2f39ccf139 --- /dev/null +++ b/openpype/tools/utils/error_dialog.py @@ -0,0 +1,143 @@ +from Qt import QtWidgets, QtCore + +from .widgets import ClickableFrame, ExpandBtn + + +class TracebackWidget(QtWidgets.QWidget): + def __init__(self, tb_text, parent): + super(TracebackWidget, self).__init__(parent) + + # Modify text to match html + # - add more replacements when needed + tb_text = ( + tb_text + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + .replace(" ", " ") + ) + + expand_btn = ExpandBtn(self) + + clickable_frame = ClickableFrame(self) + clickable_layout = QtWidgets.QHBoxLayout(clickable_frame) + clickable_layout.setContentsMargins(0, 0, 0, 0) + + expand_label = QtWidgets.QLabel("Details", clickable_frame) + clickable_layout.addWidget(expand_label, 0) + clickable_layout.addStretch(1) + + show_details_layout = QtWidgets.QHBoxLayout() + show_details_layout.addWidget(expand_btn, 0) + show_details_layout.addWidget(clickable_frame, 1) + + text_widget = QtWidgets.QLabel(self) + text_widget.setText(tb_text) + text_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + text_widget.setVisible(False) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(show_details_layout, 0) + layout.addWidget(text_widget, 1) + + clickable_frame.clicked.connect(self._on_show_details_click) + expand_btn.clicked.connect(self._on_show_details_click) + + self._expand_btn = expand_btn + self._text_widget = text_widget + + def _on_show_details_click(self): + self._text_widget.setVisible(not self._text_widget.isVisible()) + self._expand_btn.set_collapsed(not self._text_widget.isVisible()) + + +class ErrorMessageBox(QtWidgets.QDialog): + _default_width = 660 + _default_height = 350 + + def __init__(self, title, parent): + super(ErrorMessageBox, self).__init__(parent) + self.setWindowTitle(title) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + top_widget = self._create_top_widget(self) + + content_scroll = QtWidgets.QScrollArea(self) + content_scroll.setWidgetResizable(True) + + content_widget = QtWidgets.QWidget(content_scroll) + content_scroll.setWidget(content_widget) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + self._create_content(content_layout) + + content_layout.addStretch(1) + + copy_report_btn = QtWidgets.QPushButton("Copy report", self) + ok_btn = QtWidgets.QPushButton("OK", self) + + footer_layout = QtWidgets.QHBoxLayout() + footer_layout.addWidget(copy_report_btn, 0) + footer_layout.addStretch(1) + footer_layout.addWidget(ok_btn, 0) + + bottom_line = self._create_line() + body_layout = QtWidgets.QVBoxLayout(self) + body_layout.addWidget(top_widget, 0) + body_layout.addWidget(content_scroll, 1) + body_layout.addWidget(bottom_line, 0) + body_layout.addLayout(footer_layout, 0) + + copy_report_btn.clicked.connect(self._on_copy_report) + ok_btn.clicked.connect(self._on_ok_clicked) + + self.resize(self._default_width, self._default_height) + + report_data = self._get_report_data() + if not report_data: + copy_report_btn.setVisible(False) + + self._report_data = report_data + self._content_widget = content_widget + + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Something went wrong" + ) + return label_widget + + def _create_content(self, content_layout): + raise NotImplementedError( + "Method '_fill_content_layout' is not implemented!" + ) + + def _get_report_data(self): + return [] + + def _on_ok_clicked(self): + self.close() + + def _on_copy_report(self): + report_text = (10 * "*").join(self._report_data) + + mime_data = QtCore.QMimeData() + mime_data.setText(report_text) + QtWidgets.QApplication.instance().clipboard().setMimeData( + mime_data + ) + + def _create_line(self): + line = QtWidgets.QFrame(self) + line.setObjectName("Separator") + line.setMinimumHeight(2) + line.setMaximumHeight(2) + return line + + def _create_traceback_widget(self, traceback_text, parent=None): + if parent is None: + parent = self._content_widget + return TracebackWidget(traceback_text, parent) From 7107e797f0f0dfb0b5106062bcc3060cf3424de7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 18:42:18 +0100 Subject: [PATCH 114/395] extracted function for preparation of text with html symbols --- openpype/tools/loader/widgets.py | 50 ++++++++++++++-------------- openpype/tools/utils/error_dialog.py | 23 ++++++++----- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index 3accaed5ab..ed130f765c 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -72,6 +72,31 @@ class LoadErrorMessageBox(ErrorMessageBox): self._messages = messages super(LoadErrorMessageBox, self).__init__("Loading failed", parent) + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Failed to load items" + ) + return label_widget + + def _get_report_data(self): + report_data = [] + for exc_msg, tb_text, repre, subset, version in self._messages: + report_message = ( + "During load error happened on Subset: \"{subset}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + subset=subset, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + return report_data + def _create_content(self, content_layout): item_name_template = ( "Subset: {}
" @@ -102,31 +127,6 @@ class LoadErrorMessageBox(ErrorMessageBox): content_layout.addWidget(line) content_layout.addWidget(tb_widget) - def _get_report_data(self): - report_data = [] - for exc_msg, tb_text, repre, subset, version in self._messages: - report_message = ( - "During load error happened on Subset: \"{subset}\"" - " Representation: \"{repre}\" Version: {version}" - "\n\nError message: {message}" - ).format( - subset=subset, - repre=repre, - version=version, - message=exc_msg - ) - if tb_text: - report_message += "\n\n{}".format(tb_text) - report_data.append(report_message) - return report_data - - def _create_top_widget(self, parent_widget): - label_widget = QtWidgets.QLabel(parent_widget) - label_widget.setText( - "Failed to load items" - ) - return label_widget - class SubsetWidget(QtWidgets.QWidget): """A widget that lists the published subsets for an asset""" diff --git a/openpype/tools/utils/error_dialog.py b/openpype/tools/utils/error_dialog.py index 2f39ccf139..0336f4bb08 100644 --- a/openpype/tools/utils/error_dialog.py +++ b/openpype/tools/utils/error_dialog.py @@ -3,20 +3,23 @@ from Qt import QtWidgets, QtCore from .widgets import ClickableFrame, ExpandBtn +def convert_text_for_html(text): + return ( + text + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + .replace(" ", " ") + ) + + class TracebackWidget(QtWidgets.QWidget): def __init__(self, tb_text, parent): super(TracebackWidget, self).__init__(parent) # Modify text to match html # - add more replacements when needed - tb_text = ( - tb_text - .replace("<", "<") - .replace(">", ">") - .replace("\n", "
") - .replace(" ", " ") - ) - + tb_text = convert_text_for_html(tb_text) expand_btn = ExpandBtn(self) clickable_frame = ClickableFrame(self) @@ -103,6 +106,10 @@ class ErrorMessageBox(QtWidgets.QDialog): self._report_data = report_data self._content_widget = content_widget + @staticmethod + def convert_text_for_html(text): + return convert_text_for_html(text) + def _create_top_widget(self, parent_widget): label_widget = QtWidgets.QLabel(parent_widget) label_widget.setText( From acca8849a273daefe4b58567060edc4433ca96d2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 18:43:01 +0100 Subject: [PATCH 115/395] pass parent to creator error dialog --- openpype/tools/creator/window.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/tools/creator/window.py b/openpype/tools/creator/window.py index dca1735121..22a6d5ce9c 100644 --- a/openpype/tools/creator/window.py +++ b/openpype/tools/creator/window.py @@ -445,7 +445,11 @@ class CreatorWindow(QtWidgets.QDialog): if error_info: box = CreateErrorMessageBox( - creator_plugin.family, subset_name, asset_name, *error_info + creator_plugin.family, + subset_name, + asset_name, + *error_info, + parent=self ) box.show() # Store dialog so is not garbage collected before is shown From 6cc944d46ca0cded5f6324f680fe1a4835279aef Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 18:43:15 +0100 Subject: [PATCH 116/395] use ErrorMessageBox in creator too --- openpype/tools/creator/widgets.py | 99 +++++++++++++++---------------- 1 file changed, 47 insertions(+), 52 deletions(-) diff --git a/openpype/tools/creator/widgets.py b/openpype/tools/creator/widgets.py index 89c90cc048..d2258a31c5 100644 --- a/openpype/tools/creator/widgets.py +++ b/openpype/tools/creator/widgets.py @@ -7,9 +7,10 @@ from avalon.vendor import qtawesome from openpype import style from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS +from openpype.tools.utils import ErrorMessageBox -class CreateErrorMessageBox(QtWidgets.QDialog): +class CreateErrorMessageBox(ErrorMessageBox): def __init__( self, family, @@ -17,23 +18,38 @@ class CreateErrorMessageBox(QtWidgets.QDialog): asset_name, exc_msg, formatted_traceback, - parent=None + parent ): - super(CreateErrorMessageBox, self).__init__(parent) - self.setWindowTitle("Creation failed") - self.setFocusPolicy(QtCore.Qt.StrongFocus) - self.setWindowFlags( - self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint - ) + self._family = family + self._subset_name = subset_name + self._asset_name = asset_name + self._exc_msg = exc_msg + self._formatted_traceback = formatted_traceback + super(CreateErrorMessageBox, self).__init__("Creation failed", parent) - body_layout = QtWidgets.QVBoxLayout(self) - - main_label = ( + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( "Failed to create" ) - main_label_widget = QtWidgets.QLabel(main_label, self) - body_layout.addWidget(main_label_widget) + return label_widget + def _get_report_data(self): + report_message = ( + "Failed to create Subset: \"{subset}\" Family: \"{family}\"" + " in Asset: \"{asset}\"" + "\n\nError: {message}" + ).format( + subset=self._subset_name, + family=self._family, + asset=self._asset, + message=self._exc_msg + ) + if self._formatted_traceback: + report_message += "\n\n{}".format(self._formatted_traceback) + return [report_message] + + def _create_content(self, content_layout): item_name_template = ( "Family: {}
" "Subset: {}
" @@ -42,50 +58,29 @@ class CreateErrorMessageBox(QtWidgets.QDialog): exc_msg_template = "{}" line = self._create_line() - body_layout.addWidget(line) + content_layout.addWidget(line) - item_name = item_name_template.format(family, subset_name, asset_name) - item_name_widget = QtWidgets.QLabel( - item_name.replace("\n", "
"), self - ) - body_layout.addWidget(item_name_widget) - - exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) - message_label_widget = QtWidgets.QLabel(exc_msg, self) - body_layout.addWidget(message_label_widget) - - if formatted_traceback: - tb_widget = QtWidgets.QLabel( - formatted_traceback.replace("\n", "
"), self + item_name_widget = QtWidgets.QLabel(self) + item_name_widget.setText( + self.convert_text_for_html( + item_name_template.format( + self._family, self._subset_name, self._asset_name + ) ) - tb_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - body_layout.addWidget(tb_widget) - - footer_widget = QtWidgets.QWidget(self) - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - button_box = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) - button_box.setStandardButtons( - QtWidgets.QDialogButtonBox.StandardButton.Ok ) - button_box.accepted.connect(self._on_accept) - footer_layout.addWidget(button_box, alignment=QtCore.Qt.AlignRight) - body_layout.addWidget(footer_widget) + content_layout.addWidget(item_name_widget) - def showEvent(self, event): - self.setStyleSheet(style.load_stylesheet()) - super(CreateErrorMessageBox, self).showEvent(event) + message_label_widget = QtWidgets.QLabel(self) + message_label_widget.setText( + exc_msg_template.format(self.convert_text_for_html(self._exc_msg)) + ) + content_layout.addWidget(message_label_widget) - def _on_accept(self): - self.close() - - def _create_line(self): - line = QtWidgets.QFrame(self) - line.setFixedHeight(2) - line.setFrameShape(QtWidgets.QFrame.HLine) - line.setFrameShadow(QtWidgets.QFrame.Sunken) - return line + if self._formatted_traceback: + tb_widget = self._create_traceback_widget( + self._formatted_traceback + ) + content_layout.addWidget(tb_widget) class SubsetNameValidator(QtGui.QRegExpValidator): From e410addc46159f8f0c547d761890173548866dc5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 5 Jan 2022 18:50:09 +0100 Subject: [PATCH 117/395] minor fixes of dialog --- openpype/tools/creator/widgets.py | 10 +++++----- openpype/tools/utils/error_dialog.py | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/tools/creator/widgets.py b/openpype/tools/creator/widgets.py index d2258a31c5..9dd435c1cc 100644 --- a/openpype/tools/creator/widgets.py +++ b/openpype/tools/creator/widgets.py @@ -42,7 +42,7 @@ class CreateErrorMessageBox(ErrorMessageBox): ).format( subset=self._subset_name, family=self._family, - asset=self._asset, + asset=self._asset_name, message=self._exc_msg ) if self._formatted_traceback: @@ -62,10 +62,8 @@ class CreateErrorMessageBox(ErrorMessageBox): item_name_widget = QtWidgets.QLabel(self) item_name_widget.setText( - self.convert_text_for_html( - item_name_template.format( - self._family, self._subset_name, self._asset_name - ) + item_name_template.format( + self._family, self._subset_name, self._asset_name ) ) content_layout.addWidget(item_name_widget) @@ -77,9 +75,11 @@ class CreateErrorMessageBox(ErrorMessageBox): content_layout.addWidget(message_label_widget) if self._formatted_traceback: + line_widget = self._create_line() tb_widget = self._create_traceback_widget( self._formatted_traceback ) + content_layout.addWidget(line_widget) content_layout.addWidget(tb_widget) diff --git a/openpype/tools/utils/error_dialog.py b/openpype/tools/utils/error_dialog.py index 0336f4bb08..f7b12bb69f 100644 --- a/openpype/tools/utils/error_dialog.py +++ b/openpype/tools/utils/error_dialog.py @@ -75,6 +75,9 @@ class ErrorMessageBox(QtWidgets.QDialog): content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setContentsMargins(0, 0, 0, 0) + # Store content widget before creation of content + self._content_widget = content_widget + self._create_content(content_layout) content_layout.addStretch(1) @@ -104,7 +107,6 @@ class ErrorMessageBox(QtWidgets.QDialog): copy_report_btn.setVisible(False) self._report_data = report_data - self._content_widget = content_widget @staticmethod def convert_text_for_html(text): From 9d0e3363c5bfab9741ee2be01ff3e5f56d5b59cf Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 21:44:29 +0100 Subject: [PATCH 118/395] flame: testing in publish plugin --- .../plugins/publish/collect_test_selection.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 9a80a92414..d30d6ed331 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -24,3 +24,24 @@ class CollectTestSelection(pyblish.api.ContextPlugin): otio_timeline = otio_export.create_otio_timeline(sequence) self.log.info(pformat(otio_timeline)) + + # test segment markers + for ver in sequence.versions: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + continue + + for segment in track.segments: + if str(segment.name)[1:-1] == "": + continue + if not segment.selected: + continue + + self.log.debug("Segment with OpenPypeData: {}".format( + segment.name)) + + lib.imprint(segment, { + 'asset': 'sq020sh0280', + 'family': 'render', + 'subset': 'subsetMain' + }) From 5d39784abb1228bddbb5abf0fc329c5d3d4aecfc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 22:32:07 +0100 Subject: [PATCH 119/395] flame: maintained segment selection --- openpype/hosts/flame/api/lib.py | 45 +++++++++++++++++++++++++--- openpype/hosts/flame/api/pipeline.py | 26 ---------------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index f91f593eb5..5860bb728d 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -393,15 +393,13 @@ def set_segment_pype_tag(segment, data=None): tag_data.update(data) # update marker with tag data marker.comment = json.dumps(tag_data) - - return True else: # update tag data with new data marker = create_pype_marker(segment) # add tag data to marker's comment marker.comment = json.dumps(data) - return True + return True @@ -486,4 +484,43 @@ def create_pype_marker(segment): # set colour marker.colour = ctx.marker_color - return marker \ No newline at end of file + return marker + + +@contextlib.contextmanager +def maintained_segment_selection(sequence): + """Maintain selection during context + + Example: + >>> with maintained_selection(): + ... node['selected'].setValue(True) + >>> print(node['selected'].value()) + False + """ + selected_segments = [] + for ver in sequence.versions: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + continue + for segment in track.segments: + if segment.selected != True: + continue + selected_segments.append(segment) + try: + # do the operation + yield + finally: + reset_segment_selection(sequence) + for segment in selected_segments: + segment.selected = True + + +def reset_segment_selection(sequence): + """Deselect all selected nodes + """ + for ver in sequence.versions: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + continue + for segment in track.segments: + segment.selected = False diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 26dfe7c032..00860857f1 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -97,32 +97,6 @@ def update_container(tl_segment, data=None): # TODO: update_container pass - -@contextlib.contextmanager -def maintained_selection(): - """Maintain selection during context - - Example: - >>> with maintained_selection(): - ... node['selected'].setValue(True) - >>> print(node['selected'].value()) - False - """ - # TODO: maintained_selection + remove undo steps - - try: - # do the operation - yield - finally: - pass - - -def reset_selection(): - """Deselect all selected nodes - """ - pass - - def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle node passthrough states on instance toggles.""" From 655e85f12ad5c4e98b777c1f90e285fb838046a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 22:46:37 +0100 Subject: [PATCH 120/395] flame: adding docstring to maintained segment selection --- openpype/hosts/flame/api/lib.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 5860bb728d..0ba6d81c0d 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -491,26 +491,41 @@ def create_pype_marker(segment): def maintained_segment_selection(sequence): """Maintain selection during context + Attributes: + sequence (flame.PySequence): python api object + + Yield: + list of flame.PySegment + Example: - >>> with maintained_selection(): - ... node['selected'].setValue(True) - >>> print(node['selected'].value()) - False + >>> with maintained_segment_selection(sequence) as selected_segments: + ... for segment in selected_segments: + ... segment.selected = False + >>> print(segment.selected) + True """ selected_segments = [] + # loop versions in sequence for ver in sequence.versions: + # loop track in versions for track in ver.tracks: + # ignore all empty tracks and hidden too if len(track.segments) == 0 and track.hidden: continue + # loop all segment in remaining tracks for segment in track.segments: + # ignore all segments not selected if segment.selected != True: continue + # add it to original selection selected_segments.append(segment) try: - # do the operation - yield + # do the operation on selected segments + yield selected_segments finally: + # reset all selected clips reset_segment_selection(sequence) + # select only original selection of segments for segment in selected_segments: segment.selected = True From 152810f09d8eb61f17f39535819bcc789625f113 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 10:39:43 +0100 Subject: [PATCH 121/395] flame: moving and renaming api function --- openpype/hosts/flame/__init__.py | 15 +++++++++- openpype/hosts/flame/api/lib.py | 43 ++++++---------------------- openpype/hosts/flame/api/pipeline.py | 31 ++++++++++++++++++-- 3 files changed, 50 insertions(+), 39 deletions(-) diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py index da28170679..d6019100cb 100644 --- a/openpype/hosts/flame/__init__.py +++ b/openpype/hosts/flame/__init__.py @@ -8,7 +8,6 @@ from .api.pipeline import ( ls, containerise, update_container, - maintained_selection, remove_instance, list_instances, imprint @@ -21,6 +20,13 @@ from .api.lib import ( get_current_project, get_current_sequence, create_bin, + create_segment_data_marker, + get_segment_data_marker, + set_segment_data_marker, + set_publish_attribute, + get_publish_attribute, + maintained_segment_selection, + reset_segment_selection ) from .api.menu import ( @@ -90,6 +96,13 @@ __all__ = [ "get_current_project", "get_current_sequence", "create_bin", + "create_segment_data_marker", + "get_segment_data_marker", + "set_segment_data_marker", + "set_publish_attribute", + "get_publish_attribute", + "maintained_segment_selection", + "reset_segment_selection", # menu "FlameMenuProjectConnect", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 0ba6d81c0d..03b4c1f619 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -346,7 +346,7 @@ def get_metadata(project_name, _log=None): return policy_wiretap.process(project_name) -def get_segment_pype_tag(segment, with_marker=None): +def get_segment_data_marker(segment, with_marker=None): """ Get openpype track item tag created by creator or loader plugin. @@ -372,7 +372,7 @@ def get_segment_pype_tag(segment, with_marker=None): return marker, json.loads(comment) -def set_segment_pype_tag(segment, data=None): +def set_segment_data_marker(segment, data=None): """ Set openpype track item tag to input segment. @@ -384,7 +384,7 @@ def set_segment_pype_tag(segment, data=None): """ data = data or dict() - marker_data = get_segment_pype_tag(segment, True) + marker_data = get_segment_data_marker(segment, True) if marker_data: # get available openpype tag if any @@ -395,40 +395,13 @@ def set_segment_pype_tag(segment, data=None): marker.comment = json.dumps(tag_data) else: # update tag data with new data - marker = create_pype_marker(segment) + marker = create_segment_data_marker(segment) # add tag data to marker's comment marker.comment = json.dumps(data) return True - -def imprint(segment, data=None): - """ - Adding openpype data to Flame timeline segment. - - Also including publish attribute into tag. - - Arguments: - segment (flame.PySegment)): flame api object - data (dict): Any data which needst to be imprinted - - Examples: - data = { - 'asset': 'sq020sh0280', - 'family': 'render', - 'subset': 'subsetMain' - } - """ - data = data or {} - - if not set_segment_pype_tag(segment, data): - raise AttributeError("Not imprint data to segment") - - # add publish attribute - set_publish_attribute(segment, True) - - def set_publish_attribute(segment, value): """ Set Publish attribute in input Tag object @@ -436,11 +409,11 @@ def set_publish_attribute(segment, value): segment (flame.PySegment)): flame api object value (bool): True or False """ - tag_data = get_segment_pype_tag(segment) + tag_data = get_segment_data_marker(segment) tag_data["publish"] = value # set data to the publish attribute - if not set_segment_pype_tag(segment, tag_data): + if not set_segment_data_marker(segment, tag_data): raise AttributeError("Not imprint data to segment") @@ -453,7 +426,7 @@ def get_publish_attribute(segment): Returns: bool: True or False """ - tag_data = get_segment_pype_tag(segment) + tag_data = get_segment_data_marker(segment) if not tag_data: set_publish_attribute(segment, ctx.publish_default) @@ -462,7 +435,7 @@ def get_publish_attribute(segment): return tag_data["publish"] -def create_pype_marker(segment): +def create_segment_data_marker(segment): """ Create openpype marker on a segment. Attributes: diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 00860857f1..2295589627 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -5,6 +5,10 @@ import contextlib from avalon import api as avalon from pyblish import api as pyblish from openpype.api import Logger +from .lib import ( + set_segment_data_marker, + set_publish_attribute +) AVALON_CONTAINERS = "AVALON_CONTAINERS" @@ -124,6 +128,27 @@ def list_instances(): pass -def imprint(item, data=None): - # TODO: imprint - pass +def imprint(segment, data=None): + """ + Adding openpype data to Flame timeline segment. + + Also including publish attribute into tag. + + Arguments: + segment (flame.PySegment)): flame api object + data (dict): Any data which needst to be imprinted + + Examples: + data = { + 'asset': 'sq020sh0280', + 'family': 'render', + 'subset': 'subsetMain' + } + """ + data = data or {} + + if not set_segment_data_marker(segment, data): + raise AttributeError("Not imprint data to segment") + + # add publish attribute + set_publish_attribute(segment, True) From 6f8700a5c5dd4535f8c18837949219aaadf224c9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 6 Jan 2022 12:46:05 +0100 Subject: [PATCH 122/395] OP-2282 - changes custom widget with CreatorError to standardize --- .../plugins/create/create_render.py | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index b796e9eaac..c73a1a1fc1 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -1,6 +1,7 @@ -import openpype.api -from Qt import QtWidgets from avalon import aftereffects +from avalon.api import CreatorError + +import openpype.api import logging @@ -27,14 +28,13 @@ class CreateRender(openpype.api.Creator): folders=False, footages=False) if len(items) > 1: - self._show_msg("Please select only single composition at time.") - return False + raise CreatorError("Please select only single " + "composition at time.") if not items: - self._show_msg("Nothing to create. Select composition " + - "if 'useSelection' or create at least " + - "one composition.") - return False + raise CreatorError("Nothing to create. Select composition " + + "if 'useSelection' or create at least " + + "one composition.") existing_subsets = [instance['subset'].lower() for instance in aftereffects.list_instances()] @@ -42,8 +42,7 @@ class CreateRender(openpype.api.Creator): item = items.pop() if self.name.lower() in existing_subsets: txt = "Instance with name \"{}\" already exists.".format(self.name) - self._show_msg(txt) - return False + raise CreatorError(txt) self.data["members"] = [item.id] self.data["uuid"] = item.id # for SubsetManager @@ -54,9 +53,3 @@ class CreateRender(openpype.api.Creator): stub.imprint(item, self.data) stub.set_label_color(item.id, 14) # Cyan options 0 - 16 stub.rename_item(item.id, stub.PUBLISH_ICON + self.data["subset"]) - - def _show_msg(self, txt): - msg = QtWidgets.QMessageBox() - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setText(txt) - msg.exec_() From 455a8a50b1d2ca98f33c6661c1e654886c53563a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 13:02:58 +0100 Subject: [PATCH 123/395] flame: add lib functionalities for segment operations --- openpype/hosts/flame/__init__.py | 6 +- openpype/hosts/flame/api/lib.py | 124 ++++++++++++++++++++++++++----- 2 files changed, 109 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py index d6019100cb..691b6f8119 100644 --- a/openpype/hosts/flame/__init__.py +++ b/openpype/hosts/flame/__init__.py @@ -25,8 +25,10 @@ from .api.lib import ( set_segment_data_marker, set_publish_attribute, get_publish_attribute, + get_sequence_segments, maintained_segment_selection, - reset_segment_selection + reset_segment_selection, + get_segment_attributes ) from .api.menu import ( @@ -101,8 +103,10 @@ __all__ = [ "set_segment_data_marker", "set_publish_attribute", "get_publish_attribute", + "get_sequence_segments", "maintained_segment_selection", "reset_segment_selection", + "get_segment_attributes" # menu "FlameMenuProjectConnect", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 03b4c1f619..2d30390d21 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -1,5 +1,6 @@ import sys import os +import re import json import pickle import contextlib @@ -13,8 +14,21 @@ class ctx: # OpenPype marker workflow variables marker_name = "OpenPypeData" marker_duration = 0 - marker_color = (0.0, 1.0, 1.0) + marker_color = "red" publish_default = False + color_map = { + "red": (1.0, 0.0, 0.0), + "orange": (1.0, 0.5, 0.0), + "yellow": (1.0, 1.0, 0.0), + "pink": (1.0, 0.5, 1.0), + "white": (1.0, 1.0, 1.0), + "green": (0.0, 1.0, 0.0), + "cyan": (0.0, 1.0, 1.0), + "blue": (0.0, 0.0, 1.0), + "purple": (0.5, 0.0, 0.5), + "magenta": (0.5, 0.0, 1.0), + "black": (0.0, 0.0, 0.0) +} @contextlib.contextmanager def io_preferences_file(klass, filepath, write=False): @@ -262,8 +276,8 @@ def get_media_storage(): def get_current_project(): - # TODO: get_current_project - return + import flame + return flame.project.current_project def get_current_sequence(selection): @@ -365,7 +379,7 @@ def get_segment_data_marker(segment, with_marker=None): color = marker.colour.get_value() name = marker.name.get_value() - if name == ctx.marker_name and color == ctx.marker_color: + if name == ctx.marker_name and color == ctx.color_map[ctx.marker_color]: if not with_marker: return json.loads(comment) else: @@ -455,10 +469,28 @@ def create_segment_data_marker(segment): # set duration marker.duration = ctx.marker_duration # set colour - marker.colour = ctx.marker_color + marker.colour = ctx.color_map[ctx.marker_color] # Red return marker +def get_sequence_segments(sequence, selected=False): + segments = [] + # loop versions in sequence + for ver in sequence.versions: + # loop track in versions + for track in ver.tracks: + # ignore all empty tracks and hidden too + if len(track.segments) == 0 and track.hidden: + continue + # loop all segment in remaining tracks + for segment in track.segments: + # ignore all segments not selected + if segment.selected != True and selected == True: + continue + # add it to original selection + segments.append(segment) + return segments + @contextlib.contextmanager def maintained_segment_selection(sequence): @@ -477,21 +509,7 @@ def maintained_segment_selection(sequence): >>> print(segment.selected) True """ - selected_segments = [] - # loop versions in sequence - for ver in sequence.versions: - # loop track in versions - for track in ver.tracks: - # ignore all empty tracks and hidden too - if len(track.segments) == 0 and track.hidden: - continue - # loop all segment in remaining tracks - for segment in track.segments: - # ignore all segments not selected - if segment.selected != True: - continue - # add it to original selection - selected_segments.append(segment) + selected_segments = get_sequence_segments(sequence, True) try: # do the operation on selected segments yield selected_segments @@ -512,3 +530,69 @@ def reset_segment_selection(sequence): continue for segment in track.segments: segment.selected = False + + +def _get_shot_tokens_values(clip, tokens): + old_value = None + output = {} + + if not clip.shot_name: + return output + + old_value = clip.shot_name.get_value() + + for token in tokens: + clip.shot_name.set_value(token) + _key = str(re.sub("[<>]", "", token)).replace(" ", "_") + + try: + output[_key] = int(clip.shot_name.get_value()) + except ValueError: + output[_key] = clip.shot_name.get_value() + + clip.shot_name.set_value(old_value) + + return output + + +def get_segment_attributes(segment): + if str(segment.name)[1:-1] == "": + return None + + # Add timeline segment to tree + clip_data = { + "segment_name": segment.name.get_value(), + "segment_comment": segment.comment.get_value(), + "tape_name": segment.tape_name, + "source_name": segment.source_name, + "fpath": segment.file_path, + "PySegment": segment + } + + # add all available shot tokens + shot_tokens = _get_shot_tokens_values(segment, [ + "", "", "", "", "", + "", "" + ]) + clip_data.update(shot_tokens) + + # populate shot source metadata + segment_attrs = [ + "record_duration", "record_in", "record_out", + "source_duration", "source_in", "source_out" + ] + segment_attrs_data = {} + for attr in segment_attrs: + if not hasattr(segment, attr): + continue + _value = getattr(segment, attr) + segment_attrs_data[attr] = str(_value).replace("+", ":") + + if attr in ["record_in", "record_out"]: + clip_data[attr] = _value.relative_frame + else: + clip_data[attr] = _value.frame + + clip_data["segment_timecodes"] = segment_attrs_data + + return clip_data From 8f721d3360ca3a445d9dd6ea10ec9c2aac64249f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 13:46:21 +0100 Subject: [PATCH 124/395] flame: create plugin abstractions --- openpype/hosts/flame/api/plugin.py | 623 +++++++++++++++++++++++++++++ openpype/hosts/flame/api/style.css | 26 ++ 2 files changed, 649 insertions(+) create mode 100644 openpype/hosts/flame/api/style.css diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 2a28a20a75..1a3880a19a 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -1,3 +1,626 @@ +import re +import os +from Qt import QtWidgets, QtCore +import openpype.api as openpype +import openpype.hosts.flame as opflame +from . import lib +from copy import deepcopy + +log = openpype.Logger().get_logger(__name__) + # Creator plugin functions +def load_stylesheet(): + path = os.path.join(os.path.dirname(__file__), "style.css") + if not os.path.exists(path): + log.warning("Unable to load stylesheet, file not found in resources") + return "" + + with open(path, "r") as file_stream: + stylesheet = file_stream.read() + return stylesheet + + +class CreatorWidget(QtWidgets.QDialog): + + # output items + items = dict() + + def __init__(self, name, info, ui_inputs, parent=None): + super(CreatorWidget, self).__init__(parent) + + self.setObjectName(name) + + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.CustomizeWindowHint + | QtCore.Qt.WindowTitleHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowStaysOnTopHint + ) + self.setWindowTitle(name or "Pype Creator Input") + self.resize(500, 700) + + # Where inputs and labels are set + self.content_widget = [QtWidgets.QWidget(self)] + top_layout = QtWidgets.QFormLayout(self.content_widget[0]) + top_layout.setObjectName("ContentLayout") + top_layout.addWidget(Spacer(5, self)) + + # first add widget tag line + top_layout.addWidget(QtWidgets.QLabel(info)) + + # main dynamic layout + self.scroll_area = QtWidgets.QScrollArea(self, widgetResizable=True) + self.scroll_area.setVerticalScrollBarPolicy( + QtCore.Qt.ScrollBarAsNeeded) + self.scroll_area.setVerticalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOn) + self.scroll_area.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOff) + self.scroll_area.setWidgetResizable(True) + + self.content_widget.append(self.scroll_area) + + scroll_widget = QtWidgets.QWidget(self) + in_scroll_area = QtWidgets.QVBoxLayout(scroll_widget) + self.content_layout = [in_scroll_area] + + # add preset data into input widget layout + self.items = self.populate_widgets(ui_inputs) + self.scroll_area.setWidget(scroll_widget) + + # Confirmation buttons + btns_widget = QtWidgets.QWidget(self) + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + + cancel_btn = QtWidgets.QPushButton("Cancel") + btns_layout.addWidget(cancel_btn) + + ok_btn = QtWidgets.QPushButton("Ok") + btns_layout.addWidget(ok_btn) + + # Main layout of the dialog + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(0) + + # adding content widget + for w in self.content_widget: + main_layout.addWidget(w) + + main_layout.addWidget(btns_widget) + + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + stylesheet = load_stylesheet() + self.setStyleSheet(stylesheet) + + def _on_ok_clicked(self): + self.result = self.value(self.items) + self.close() + + def _on_cancel_clicked(self): + self.result = None + self.close() + + def value(self, data, new_data=None): + new_data = new_data or dict() + for k, v in data.items(): + new_data[k] = { + "target": None, + "value": None + } + if v["type"] == "dict": + new_data[k]["target"] = v["target"] + new_data[k]["value"] = self.value(v["value"]) + if v["type"] == "section": + new_data.pop(k) + new_data = self.value(v["value"], new_data) + elif getattr(v["value"], "currentText", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].currentText() + elif getattr(v["value"], "isChecked", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].isChecked() + elif getattr(v["value"], "value", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].value() + elif getattr(v["value"], "text", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].text() + + return new_data + + def camel_case_split(self, text): + matches = re.finditer( + '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) + return " ".join([str(m.group(0)).capitalize() for m in matches]) + + def create_row(self, layout, type, text, **kwargs): + # get type attribute from qwidgets + attr = getattr(QtWidgets, type) + + # convert label text to normal capitalized text with spaces + label_text = self.camel_case_split(text) + + # assign the new text to lable widget + label = QtWidgets.QLabel(label_text) + label.setObjectName("LineLabel") + + # create attribute name text strip of spaces + attr_name = text.replace(" ", "") + + # create attribute and assign default values + setattr( + self, + attr_name, + attr(parent=self)) + + # assign the created attribute to variable + item = getattr(self, attr_name) + for func, val in kwargs.items(): + if getattr(item, func): + func_attr = getattr(item, func) + func_attr(val) + + # add to layout + layout.addRow(label, item) + + return item + + def populate_widgets(self, data, content_layout=None): + """ + Populate widget from input dict. + + Each plugin has its own set of widget rows defined in dictionary + each row values should have following keys: `type`, `target`, + `label`, `order`, `value` and optionally also `toolTip`. + + Args: + data (dict): widget rows or organized groups defined + by types `dict` or `section` + content_layout (QtWidgets.QFormLayout)[optional]: used when nesting + + Returns: + dict: redefined data dict updated with created widgets + + """ + + content_layout = content_layout or self.content_layout[-1] + # fix order of process by defined order value + ordered_keys = list(data.keys()) + for k, v in data.items(): + try: + # try removing a key from index which should + # be filled with new + ordered_keys.pop(v["order"]) + except IndexError: + pass + # add key into correct order + ordered_keys.insert(v["order"], k) + + # process ordered + for k in ordered_keys: + v = data[k] + tool_tip = v.get("toolTip", "") + if v["type"] == "dict": + # adding spacer between sections + self.content_layout.append(QtWidgets.QWidget(self)) + content_layout.addWidget(self.content_layout[-1]) + self.content_layout[-1].setObjectName("sectionHeadline") + + headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) + headline.addWidget(Spacer(20, self)) + headline.addWidget(QtWidgets.QLabel(v["label"])) + + # adding nested layout with label + self.content_layout.append(QtWidgets.QWidget(self)) + self.content_layout[-1].setObjectName("sectionContent") + + nested_content_layout = QtWidgets.QFormLayout( + self.content_layout[-1]) + nested_content_layout.setObjectName("NestedContentLayout") + content_layout.addWidget(self.content_layout[-1]) + + # add nested key as label + data[k]["value"] = self.populate_widgets( + v["value"], nested_content_layout) + + if v["type"] == "section": + # adding spacer between sections + self.content_layout.append(QtWidgets.QWidget(self)) + content_layout.addWidget(self.content_layout[-1]) + self.content_layout[-1].setObjectName("sectionHeadline") + + headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) + headline.addWidget(Spacer(20, self)) + headline.addWidget(QtWidgets.QLabel(v["label"])) + + # adding nested layout with label + self.content_layout.append(QtWidgets.QWidget(self)) + self.content_layout[-1].setObjectName("sectionContent") + + nested_content_layout = QtWidgets.QFormLayout( + self.content_layout[-1]) + nested_content_layout.setObjectName("NestedContentLayout") + content_layout.addWidget(self.content_layout[-1]) + + # add nested key as label + data[k]["value"] = self.populate_widgets( + v["value"], nested_content_layout) + + elif v["type"] == "QLineEdit": + data[k]["value"] = self.create_row( + content_layout, "QLineEdit", v["label"], + setText=v["value"], setToolTip=tool_tip) + elif v["type"] == "QComboBox": + data[k]["value"] = self.create_row( + content_layout, "QComboBox", v["label"], + addItems=v["value"], setToolTip=tool_tip) + elif v["type"] == "QCheckBox": + data[k]["value"] = self.create_row( + content_layout, "QCheckBox", v["label"], + setChecked=v["value"], setToolTip=tool_tip) + elif v["type"] == "QSpinBox": + data[k]["value"] = self.create_row( + content_layout, "QSpinBox", v["label"], + setValue=v["value"], setMinimum=0, + setMaximum=100000, setToolTip=tool_tip) + return data + + +class Spacer(QtWidgets.QWidget): + def __init__(self, height, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + + self.setFixedHeight(height) + + real_spacer = QtWidgets.QWidget(self) + real_spacer.setObjectName("Spacer") + real_spacer.setFixedHeight(height) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(real_spacer) + + self.setLayout(layout) + + +class Creator(openpype.Creator): + """Creator class wrapper + """ + clip_color = lib.ctx.color_map["purple"] + rename_index = None + + def __init__(self, *args, **kwargs): + super(Creator, self).__init__(*args, **kwargs) + self.presets = openpype.get_current_project_settings()[ + "flame"]["create"].get(self.__class__.__name__, {}) + + # adding basic current context flame objects + self.project = lib.get_current_project() + self.sequence = lib.get_current_sequence(opflame.selection) + + if (self.options or {}).get("useSelection"): + self.selected = lib.get_sequence_segments(self.sequence, True) + else: + self.selected = lib.get_sequence_segments(self.sequence) + + self.widget = CreatorWidget + + +class PublishableClip: + """ + Convert a segment to publishable instance + + Args: + segment (flame.PySegment): flame api object + kwargs (optional): additional data needed for rename=True (presets) + + Returns: + flame.PySegment: flame api object + """ + vertical_clip_match = dict() + tag_data = dict() + types = { + "shot": "shot", + "folder": "folder", + "episode": "episode", + "sequence": "sequence", + "track": "sequence", + } + + # parents search patern + parents_search_patern = r"\{([a-z]*?)\}" + + # default templates for non-ui use + rename_default = False + hierarchy_default = "{_folder_}/{_sequence_}/{_track_}" + clip_name_default = "shot_{_trackIndex_:0>3}_{_clipIndex_:0>4}" + subset_name_default = "[ track name ]" + review_track_default = "[ none ]" + subset_family_default = "plate" + count_from_default = 10 + count_steps_default = 10 + vertical_sync_default = False + driving_layer_default = "" + + def __init__(self, cls, segment, **kwargs): + # populate input cls attribute onto self.[attr] + self.__dict__.update(cls.__dict__) + + # get main parent objects + self.current_segment = segment + sequence_name = lib.get_current_sequence([segment]).name.get_value() + self.sequence_name = str(sequence_name).replace(" ", "_") + + self.clip_data = lib.get_segment_attributes(segment) + # segment (clip) main attributes + self.cs_name = self.clip_data["segment_name"] + self.cs_index = int(self.clip_data["segment"]) + + # get track name and index + self.track_index = int(self.clip_data["track"]) + track_name = self.clip_data["track_name"] + self.track_name = str(track_name).replace(" ", "_").replace( + "*", "noname{}".format(self.track_index)) + + # adding tag.family into tag + if kwargs.get("avalon"): + self.tag_data.update(kwargs["avalon"]) + + # add publish attribute to marker data + self.tag_data.update({"publish": True}) + + # adding ui inputs if any + self.ui_inputs = kwargs.get("ui_inputs", {}) + + # populate default data before we get other attributes + self._populate_segment_default_data() + + # use all populated default data to create all important attributes + self._populate_attributes() + + # create parents with correct types + self._create_parents() + + def convert(self): + + # solve segment data and add them to marker data + self._convert_to_marker_data() + + # if track name is in review track name and also if driving track name + # is not in review track name: skip tag creation + if (self.track_name in self.review_layer) and ( + self.driving_layer not in self.review_layer): + return + + # deal with clip name + new_name = self.tag_data.pop("newClipName") + + if self.rename: + # rename segment + self.current_segment.setName(new_name) + self.tag_data["asset"] = new_name + else: + self.tag_data["asset"] = self.cs_name + self.tag_data["hierarchyData"]["shot"] = self.cs_name + + if self.tag_data["heroTrack"] and self.review_layer: + self.tag_data.update({"reviewTrack": self.review_layer}) + else: + self.tag_data.update({"reviewTrack": None}) + + # create pype tag on track_item and add data + lib.imprint(self.current_segment, self.tag_data) + + return self.current_segment + + def _populate_segment_default_data(self): + """ Populate default formating data from segment. """ + + self.current_segment_default_data = { + "_folder_": "shots", + "_sequence_": self.sequence_name, + "_track_": self.track_name, + "_clip_": self.cs_name, + "_trackIndex_": self.track_index, + "_clipIndex_": self.cs_index + } + + def _populate_attributes(self): + """ Populate main object attributes. """ + # segment frame range and parent track name for vertical sync check + self.clip_in = int(self.clip_data["record_in"]) + self.clip_out = int(self.clip_data["record_out"]) + + # define ui inputs if non gui mode was used + self.shot_num = self.cs_index + log.debug( + "____ self.shot_num: {}".format(self.shot_num)) + + # ui_inputs data or default values if gui was not used + self.rename = self.ui_inputs.get( + "clipRename", {}).get("value") or self.rename_default + self.clip_name = self.ui_inputs.get( + "clipName", {}).get("value") or self.clip_name_default + self.hierarchy = self.ui_inputs.get( + "hierarchy", {}).get("value") or self.hierarchy_default + self.hierarchy_data = self.ui_inputs.get( + "hierarchyData", {}).get("value") or \ + self.current_segment_default_data.copy() + self.count_from = self.ui_inputs.get( + "countFrom", {}).get("value") or self.count_from_default + self.count_steps = self.ui_inputs.get( + "countSteps", {}).get("value") or self.count_steps_default + self.subset_name = self.ui_inputs.get( + "subsetName", {}).get("value") or self.subset_name_default + self.subset_family = self.ui_inputs.get( + "subsetFamily", {}).get("value") or self.subset_family_default + self.vertical_sync = self.ui_inputs.get( + "vSyncOn", {}).get("value") or self.vertical_sync_default + self.driving_layer = self.ui_inputs.get( + "vSyncTrack", {}).get("value") or self.driving_layer_default + self.review_track = self.ui_inputs.get( + "reviewTrack", {}).get("value") or self.review_track_default + self.audio = self.ui_inputs.get( + "audio", {}).get("value") or False + + # build subset name from layer name + if self.subset_name == "[ track name ]": + self.subset_name = self.track_name + + # create subset for publishing + self.subset = self.subset_family + self.subset_name.capitalize() + + def _replace_hash_to_expression(self, name, text): + """ Replace hash with number in correct padding. """ + _spl = text.split("#") + _len = (len(_spl) - 1) + _repl = "{{{0}:0>{1}}}".format(name, _len) + return text.replace(("#" * _len), _repl) + + + def _convert_to_marker_data(self): + """ Convert internal data to marker data. + + Populating the marker data into internal variable self.tag_data + """ + # define vertical sync attributes + hero_track = True + self.review_layer = "" + if self.vertical_sync and self.track_name not in self.driving_layer: + # if it is not then define vertical sync as None + hero_track = False + + # increasing steps by index of rename iteration + self.count_steps *= self.rename_index + + hierarchy_formating_data = {} + hierarchy_data = deepcopy(self.hierarchy_data) + _data = self.current_segment_default_data.copy() + if self.ui_inputs: + # adding tag metadata from ui + for _k, _v in self.ui_inputs.items(): + if _v["target"] == "tag": + self.tag_data[_k] = _v["value"] + + # driving layer is set as positive match + if hero_track or self.vertical_sync: + # mark review layer + if self.review_track and ( + self.review_track not in self.review_track_default): + # if review layer is defined and not the same as defalut + self.review_layer = self.review_track + # shot num calculate + if self.rename_index == 0: + self.shot_num = self.count_from + else: + self.shot_num = self.count_from + self.count_steps + + # clip name sequence number + _data.update({"shot": self.shot_num}) + + # solve # in test to pythonic expression + for _k, _v in hierarchy_data.items(): + if "#" not in _v["value"]: + continue + hierarchy_data[ + _k]["value"] = self._replace_hash_to_expression( + _k, _v["value"]) + + # fill up pythonic expresisons in hierarchy data + for k, _v in hierarchy_data.items(): + hierarchy_formating_data[k] = _v["value"].format(**_data) + else: + # if no gui mode then just pass default data + hierarchy_formating_data = hierarchy_data + + tag_hierarchy_data = self._solve_tag_hierarchy_data( + hierarchy_formating_data + ) + + tag_hierarchy_data.update({"heroTrack": True}) + if hero_track and self.vertical_sync: + self.vertical_clip_match.update({ + (self.clip_in, self.clip_out): tag_hierarchy_data + }) + + if not hero_track and self.vertical_sync: + # driving layer is set as negative match + for (_in, _out), hero_data in self.vertical_clip_match.items(): + hero_data.update({"heroTrack": False}) + if _in == self.clip_in and _out == self.clip_out: + data_subset = hero_data["subset"] + # add track index in case duplicity of names in hero data + if self.subset in data_subset: + hero_data["subset"] = self.subset + str( + self.track_index) + # in case track name and subset name is the same then add + if self.subset_name == self.track_name: + hero_data["subset"] = self.subset + # assing data to return hierarchy data to tag + tag_hierarchy_data = hero_data + + # add data to return data dict + self.tag_data.update(tag_hierarchy_data) + + def _solve_tag_hierarchy_data(self, hierarchy_formating_data): + """ Solve marker data from hierarchy data and templates. """ + # fill up clip name and hierarchy keys + hierarchy_filled = self.hierarchy.format(**hierarchy_formating_data) + clip_name_filled = self.clip_name.format(**hierarchy_formating_data) + + # remove shot from hierarchy data: is not needed anymore + hierarchy_formating_data.pop("shot") + + return { + "newClipName": clip_name_filled, + "hierarchy": hierarchy_filled, + "parents": self.parents, + "hierarchyData": hierarchy_formating_data, + "subset": self.subset, + "family": self.subset_family, + "families": [self.data["family"]] + } + + def _convert_to_entity(self, type, template): + """ Converting input key to key with type. """ + # convert to entity type + entity_type = self.types.get(type, None) + + assert entity_type, "Missing entity type for `{}`".format( + type + ) + + # first collect formating data to use for formating template + formating_data = {} + for _k, _v in self.hierarchy_data.items(): + value = _v["value"].format( + **self.current_segment_default_data) + formating_data[_k] = value + + return { + "entity_type": entity_type, + "entity_name": template.format( + **formating_data + ) + } + + def _create_parents(self): + """ Create parents and return it in list. """ + self.parents = [] + + patern = re.compile(self.parents_search_patern) + + par_split = [(patern.findall(t).pop(), t) + for t in self.hierarchy.split("/")] + + for type, template in par_split: + parent = self._convert_to_entity(type, template) + self.parents.append(parent) + + # Publishing plugin functions # Loader plugin functions diff --git a/openpype/hosts/flame/api/style.css b/openpype/hosts/flame/api/style.css new file mode 100644 index 0000000000..b64c391c6e --- /dev/null +++ b/openpype/hosts/flame/api/style.css @@ -0,0 +1,26 @@ +QWidget { + font-size: 13px; +} + +QSpinBox { + padding: 2; + max-width: 8em; +} + +QLineEdit { + padding: 2; + min-width: 15em; +} + +QVBoxLayout { + min-width: 15em; + background-color: #201f1f; +} + +QComboBox { + min-width: 8em; +} + +#sectionContent { + background-color: #2E2D2D; +} \ No newline at end of file From 25e0bffe5854cbfbcfd43d8e0001c8d4471e2ee3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 14:04:01 +0100 Subject: [PATCH 125/395] flame: adding create plugin for publishable clips --- .../flame/plugins/create/create_shot_clip.py | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 openpype/hosts/flame/plugins/create/create_shot_clip.py diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py new file mode 100644 index 0000000000..71ea9b3c86 --- /dev/null +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -0,0 +1,268 @@ +from copy import deepcopy +import openpype.hosts.flame as opflame +import openpype.hosts.flame.api.plugin as fplugin +import openpype.hosts.flame.api.lib as flib +reload(fplugin) +reload(flib) + +def _get_video_track_names(sequence): + track_names = [] + for ver in sequence.versions: + for track in ver.tracks: + track_names.append(track.name.get_value()) + +class CreateShotClip(fplugin.Creator): + """Publishable clip""" + + label = "Create Publishable Clip" + family = "clip" + icon = "film" + defaults = ["Main"] + + gui_tracks = _get_video_track_names( + flib.get_current_sequence(opflame.selection) + ) + gui_name = "Pype publish attributes creator" + gui_info = "Define sequential rename and fill hierarchy data." + gui_inputs = { + "renameHierarchy": { + "type": "section", + "label": "Shot Hierarchy And Rename Settings", + "target": "ui", + "order": 0, + "value": { + "hierarchy": { + "value": "{folder}/{sequence}", + "type": "QLineEdit", + "label": "Shot Parent Hierarchy", + "target": "tag", + "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa + "order": 0}, + "clipRename": { + "value": False, + "type": "QCheckBox", + "label": "Rename clips", + "target": "ui", + "toolTip": "Renaming selected clips on fly", # noqa + "order": 1}, + "clipName": { + "value": "{sequence}{shot}", + "type": "QLineEdit", + "label": "Clip Name Template", + "target": "ui", + "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa + "order": 2}, + "countFrom": { + "value": 10, + "type": "QSpinBox", + "label": "Count sequence from", + "target": "ui", + "toolTip": "Set when the sequence number stafrom", # noqa + "order": 3}, + "countSteps": { + "value": 10, + "type": "QSpinBox", + "label": "Stepping number", + "target": "ui", + "toolTip": "What number is adding every new step", # noqa + "order": 4}, + } + }, + "hierarchyData": { + "type": "dict", + "label": "Shot Template Keywords", + "target": "tag", + "order": 1, + "value": { + "folder": { + "value": "shots", + "type": "QLineEdit", + "label": "{folder}", + "target": "tag", + "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 0}, + "episode": { + "value": "ep01", + "type": "QLineEdit", + "label": "{episode}", + "target": "tag", + "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 1}, + "sequence": { + "value": "sq01", + "type": "QLineEdit", + "label": "{sequence}", + "target": "tag", + "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 2}, + "track": { + "value": "{_track_}", + "type": "QLineEdit", + "label": "{track}", + "target": "tag", + "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 3}, + "shot": { + "value": "sh###", + "type": "QLineEdit", + "label": "{shot}", + "target": "tag", + "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 4} + } + }, + "verticalSync": { + "type": "section", + "label": "Vertical Synchronization Of Attributes", + "target": "ui", + "order": 2, + "value": { + "vSyncOn": { + "value": True, + "type": "QCheckBox", + "label": "Enable Vertical Sync", + "target": "ui", + "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa + "order": 0}, + "vSyncTrack": { + "value": gui_tracks, # noqa + "type": "QComboBox", + "label": "Hero track", + "target": "ui", + "toolTip": "Select driving track name which should be hero for all others", # noqa + "order": 1} + } + }, + "publishSettings": { + "type": "section", + "label": "Publish Settings", + "target": "ui", + "order": 3, + "value": { + "subsetName": { + "value": ["", "main", "bg", "fg", "bg", + "animatic"], + "type": "QComboBox", + "label": "Subset Name", + "target": "ui", + "toolTip": "chose subset name patern, if is selected, name of track layer will be used", # noqa + "order": 0}, + "subsetFamily": { + "value": ["plate", "take"], + "type": "QComboBox", + "label": "Subset Family", + "target": "ui", "toolTip": "What use of this subset is for", # noqa + "order": 1}, + "reviewTrack": { + "value": ["< none >"] + gui_tracks, + "type": "QComboBox", + "label": "Use Review Track", + "target": "ui", + "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa + "order": 2}, + "audio": { + "value": False, + "type": "QCheckBox", + "label": "Include audio", + "target": "tag", + "toolTip": "Process subsets with corresponding audio", # noqa + "order": 3}, + "sourceResolution": { + "value": False, + "type": "QCheckBox", + "label": "Source resolution", + "target": "tag", + "toolTip": "Is resloution taken from timeline or source?", # noqa + "order": 4}, + } + }, + "frameRangeAttr": { + "type": "section", + "label": "Shot Attributes", + "target": "ui", + "order": 4, + "value": { + "workfileFrameStart": { + "value": 1001, + "type": "QSpinBox", + "label": "Workfiles Start Frame", + "target": "tag", + "toolTip": "Set workfile starting frame number", # noqa + "order": 0 + }, + "handleStart": { + "value": 0, + "type": "QSpinBox", + "label": "Handle Start", + "target": "tag", + "toolTip": "Handle at start of clip", # noqa + "order": 1 + }, + "handleEnd": { + "value": 0, + "type": "QSpinBox", + "label": "Handle End", + "target": "tag", + "toolTip": "Handle at end of clip", # noqa + "order": 2 + } + } + } + } + + presets = None + + def process(self): + # Creator copy of object attributes that are modified during `process` + presets = deepcopy(self.presets) + gui_inputs = deepcopy(self.gui_inputs) + + # get key pares from presets and match it on ui inputs + for k, v in gui_inputs.items(): + if v["type"] in ("dict", "section"): + # nested dictionary (only one level allowed + # for sections and dict) + for _k, _v in v["value"].items(): + if presets.get(_k): + gui_inputs[k][ + "value"][_k]["value"] = presets[_k] + if presets.get(k): + gui_inputs[k]["value"] = presets[k] + + # open widget for plugins inputs + widget = self.widget(self.gui_name, self.gui_info, gui_inputs) + widget.exec_() + + if len(self.selected) < 1: + return + + if not widget.result: + print("Operation aborted") + return + + self.rename_add = 0 + + # get ui output for track name for vertical sync + v_sync_track = widget.result["vSyncTrack"]["value"] + + # sort selected trackItems by + sorted_selected_segments = [] + unsorted_selected_segments = [] + for _segment in self.selected: + if _segment.parent.name.get_value() in v_sync_track: + sorted_selected_segments.append(_segment) + else: + unsorted_selected_segments.append(_segment) + + sorted_selected_segments.extend(unsorted_selected_segments) + + kwargs = { + "ui_inputs": widget.result, + "avalon": self.data + } + + for i, segment in enumerate(sorted_selected_segments): + self.rename_index = i + + # convert track item to timeline media pool item + fplugin.PublishableClip(self, segment, **kwargs).convert() From e2ab00c54a91ffbf3d07c37b26d8a9f06e487f58 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 14:17:32 +0100 Subject: [PATCH 126/395] flame: tuning creator --- openpype/hosts/flame/api/plugin.py | 35 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 1a3880a19a..34e626b099 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -3,7 +3,7 @@ import os from Qt import QtWidgets, QtCore import openpype.api as openpype import openpype.hosts.flame as opflame -from . import lib +from . import lib, pipeline from copy import deepcopy log = openpype.Logger().get_logger(__name__) @@ -321,8 +321,8 @@ class PublishableClip: Returns: flame.PySegment: flame api object """ - vertical_clip_match = dict() - tag_data = dict() + vertical_clip_match = {} + marker_data = {} types = { "shot": "shot", "folder": "folder", @@ -368,10 +368,10 @@ class PublishableClip: # adding tag.family into tag if kwargs.get("avalon"): - self.tag_data.update(kwargs["avalon"]) + self.marker_data.update(kwargs["avalon"]) # add publish attribute to marker data - self.tag_data.update({"publish": True}) + self.marker_data.update({"publish": True}) # adding ui inputs if any self.ui_inputs = kwargs.get("ui_inputs", {}) @@ -397,23 +397,23 @@ class PublishableClip: return # deal with clip name - new_name = self.tag_data.pop("newClipName") + new_name = self.marker_data.pop("newClipName") if self.rename: # rename segment - self.current_segment.setName(new_name) - self.tag_data["asset"] = new_name + self.current_segment.name = new_name + self.marker_data["asset"] = new_name else: - self.tag_data["asset"] = self.cs_name - self.tag_data["hierarchyData"]["shot"] = self.cs_name + self.marker_data["asset"] = self.cs_name + self.marker_data["hierarchyData"]["shot"] = self.cs_name - if self.tag_data["heroTrack"] and self.review_layer: - self.tag_data.update({"reviewTrack": self.review_layer}) + if self.marker_data["heroTrack"] and self.review_layer: + self.marker_data.update({"reviewTrack": self.review_layer}) else: - self.tag_data.update({"reviewTrack": None}) + self.marker_data.update({"reviewTrack": None}) # create pype tag on track_item and add data - lib.imprint(self.current_segment, self.tag_data) + pipeline.imprint(self.current_segment, self.marker_data) return self.current_segment @@ -481,11 +481,10 @@ class PublishableClip: _repl = "{{{0}:0>{1}}}".format(name, _len) return text.replace(("#" * _len), _repl) - def _convert_to_marker_data(self): """ Convert internal data to marker data. - Populating the marker data into internal variable self.tag_data + Populating the marker data into internal variable self.marker_data """ # define vertical sync attributes hero_track = True @@ -504,7 +503,7 @@ class PublishableClip: # adding tag metadata from ui for _k, _v in self.ui_inputs.items(): if _v["target"] == "tag": - self.tag_data[_k] = _v["value"] + self.marker_data[_k] = _v["value"] # driving layer is set as positive match if hero_track or self.vertical_sync: @@ -564,7 +563,7 @@ class PublishableClip: tag_hierarchy_data = hero_data # add data to return data dict - self.tag_data.update(tag_hierarchy_data) + self.marker_data.update(tag_hierarchy_data) def _solve_tag_hierarchy_data(self, hierarchy_formating_data): """ Solve marker data from hierarchy data and templates. """ From a2bb8f1839ca253daa962a876719e94262115f6e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 14:59:59 +0100 Subject: [PATCH 127/395] flame: beautification of code --- openpype/hosts/flame/api/pipeline.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 2295589627..99a33e4cb9 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -22,7 +22,6 @@ def install(): CREATE_PATH, INVENTORY_PATH ) - # TODO: install # Disable all families except for the ones we explicitly want to see family_states = [ @@ -36,19 +35,18 @@ def install(): avalon.data["familiesStateDefault"] = False avalon.data["familiesStateToggled"] = family_states - log.info("openpype.hosts.flame installed") pyblish.register_host("flame") pyblish.register_plugin_path(PUBLISH_PATH) - log.info("Registering Flame plug-ins..") - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) avalon.register_plugin_path(avalon.Creator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + log.info("OpenPype Flame plug-ins registred ...") # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) + log.info("OpenPype Flame host installed ...") def uninstall(): from .. import ( @@ -58,11 +56,10 @@ def uninstall(): INVENTORY_PATH ) - # TODO: uninstall pyblish.deregister_host("flame") - pyblish.deregister_plugin_path(PUBLISH_PATH) - log.info("Deregistering DaVinci Resovle plug-ins..") + log.info("Deregistering Flame plug-ins..") + pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) @@ -70,6 +67,8 @@ def uninstall(): # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) + log.info("OpenPype Flame host uninstalled ...") + def containerise(tl_segment, name, From 2f0fcaebe150b307e598b070bd996568eec1c2ac Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:00:15 +0100 Subject: [PATCH 128/395] flame: fix missing return --- openpype/hosts/flame/plugins/create/create_shot_clip.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 71ea9b3c86..79afca507f 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -11,6 +11,8 @@ def _get_video_track_names(sequence): for track in ver.tracks: track_names.append(track.name.get_value()) + return track_names + class CreateShotClip(fplugin.Creator): """Publishable clip""" From 5c11089aff32e09c17439743290b50a4097c8714 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:00:37 +0100 Subject: [PATCH 129/395] flame: testing plugin refactory --- .../plugins/publish/collect_test_selection.py | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index d30d6ed331..cd7355d1f5 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -1,9 +1,11 @@ +import os import pyblish.api import openpype.hosts.flame as opflame from openpype.hosts.flame.otio import flame_export as otio_export -from openpype.hosts.flame.api import lib +from openpype.hosts.flame.api import lib, pipeline from pprint import pformat reload(lib) # noqa +reload(pipeline) # noqa reload(otio_export) # noqa @@ -17,14 +19,30 @@ class CollectTestSelection(pyblish.api.ContextPlugin): hosts = ["flame"] def process(self, context): - self.log.info(opflame.selection) + self.log.info( + "Active Selection: {}".format(opflame.selection)) sequence = lib.get_current_sequence(opflame.selection) + self.test_imprint_data(sequence) + self.test_otio_export(sequence) + + def test_otio_export(self, sequence): + home_dir = os.path.expanduser("~") + export_path = os.path.normalize( + os.path.join( + home_dir, "otio_timeline_export.otio" + ) + ) otio_timeline = otio_export.create_otio_timeline(sequence) + otio_export.write_to_file( + otio_timeline, export_path + ) self.log.info(pformat(otio_timeline)) + self.log.info("Otio exported to: {}".format(export_path)) + def test_imprint_data(self, sequence): # test segment markers for ver in sequence.versions: for track in ver.tracks: @@ -40,8 +58,8 @@ class CollectTestSelection(pyblish.api.ContextPlugin): self.log.debug("Segment with OpenPypeData: {}".format( segment.name)) - lib.imprint(segment, { - 'asset': 'sq020sh0280', + pipeline.imprint(segment, { + 'asset': segment.name.get_value(), 'family': 'render', 'subset': 'subsetMain' }) From c67e672acaaa0ed5a459ef0e52a1cd81b6ee049d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:08:06 +0100 Subject: [PATCH 130/395] flame: improving testing plugin adding maintained seqment selection functionality --- openpype/hosts/flame/api/lib.py | 2 +- .../plugins/publish/collect_test_selection.py | 27 +++++++------------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 2d30390d21..e5642dd6f9 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -14,7 +14,7 @@ class ctx: # OpenPype marker workflow variables marker_name = "OpenPypeData" marker_duration = 0 - marker_color = "red" + marker_color = "cyan" publish_default = False color_map = { "red": (1.0, 0.0, 0.0), diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index cd7355d1f5..875ef34a07 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -43,23 +43,16 @@ class CollectTestSelection(pyblish.api.ContextPlugin): self.log.info("Otio exported to: {}".format(export_path)) def test_imprint_data(self, sequence): - # test segment markers - for ver in sequence.versions: - for track in ver.tracks: - if len(track.segments) == 0 and track.hidden: + with lib.maintained_segment_selection(sequence) as selected_segments: + for segment in selected_segments: + if str(segment.name)[1:-1] == "": continue - for segment in track.segments: - if str(segment.name)[1:-1] == "": - continue - if not segment.selected: - continue + self.log.debug("Segment with OpenPypeData: {}".format( + segment.name)) - self.log.debug("Segment with OpenPypeData: {}".format( - segment.name)) - - pipeline.imprint(segment, { - 'asset': segment.name.get_value(), - 'family': 'render', - 'subset': 'subsetMain' - }) + pipeline.imprint(segment, { + 'asset': segment.name.get_value(), + 'family': 'render', + 'subset': 'subsetMain' + }) From 12f9eb2c328259c105cfe036cee08a14016be0a5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:09:41 +0100 Subject: [PATCH 131/395] flame: fix normalize to normpath --- openpype/hosts/flame/plugins/publish/collect_test_selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 875ef34a07..29ca08d9b5 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -29,7 +29,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): def test_otio_export(self, sequence): home_dir = os.path.expanduser("~") - export_path = os.path.normalize( + export_path = os.path.normpath( os.path.join( home_dir, "otio_timeline_export.otio" ) From abc39a9e9f04d7efbda4b1548966794090c6f077 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 15:17:02 +0100 Subject: [PATCH 132/395] make sure output is always the same --- openpype/lib/python_module_tools.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index 69da4cc661..903af01676 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -59,22 +59,23 @@ def modules_from_path(folder_path): """ crashed = [] modules = [] + output = (modules, crashed) # Just skip and return empty list if path is not set if not folder_path: - return modules + return output # Do not allow relative imports if folder_path.startswith("."): log.warning(( "BUG: Relative paths are not allowed for security reasons. {}" ).format(folder_path)) - return modules + return output folder_path = os.path.normpath(folder_path) if not os.path.isdir(folder_path): log.warning("Not a directory path: {}".format(folder_path)) - return modules + return output for filename in os.listdir(folder_path): # Ignore files which start with underscore @@ -101,7 +102,7 @@ def modules_from_path(folder_path): ) continue - return modules, crashed + return output def recursive_bases_from_class(klass): From 3f5b04161987ec15f937a5c0784b7389595209b3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 15:17:18 +0100 Subject: [PATCH 133/395] update docstring --- openpype/lib/python_module_tools.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index 903af01676..f62c848e4a 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -49,13 +49,10 @@ def modules_from_path(folder_path): Arguments: path (str): Path to folder containing python scripts. - return_crasher (bool): Crashed module paths with exception info - will be returned too. Returns: - list, tuple: List of modules when `return_crashed` is False else tuple - with list of modules at first place and tuple of path and exception - info at second place. + tuple: First list contains successfully imported modules + and second list contains tuples of path and exception. """ crashed = [] modules = [] From 02c41986250d1cc05dada82e4e32afc3e238536d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:20:58 +0100 Subject: [PATCH 134/395] flame: settings for creator plugin --- .../defaults/project_settings/flame.json | 20 +++ .../schemas/projects_schema/schema_main.json | 4 + .../projects_schema/schema_project_flame.json | 124 ++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 openpype/settings/defaults/project_settings/flame.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schema_project_flame.json diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json new file mode 100644 index 0000000000..b6fbdecc95 --- /dev/null +++ b/openpype/settings/defaults/project_settings/flame.json @@ -0,0 +1,20 @@ +{ + "create": { + "CreateShotClip": { + "hierarchy": "{folder}/{sequence}", + "clipRename": true, + "clipName": "{track}{sequence}{shot}", + "countFrom": 10, + "countSteps": 10, + "folder": "shots", + "episode": "ep01", + "sequence": "sq01", + "track": "{_track_}", + "shot": "sh###", + "vSyncOn": false, + "workfileFrameStart": 1001, + "handleStart": 10, + "handleEnd": 10 + } + } +} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index c9eca5dedd..8a2ad451ee 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -110,6 +110,10 @@ "type": "schema", "name": "schema_project_celaction" }, + { + "type": "schema", + "name": "schema_project_flame" + }, { "type": "schema", "name": "schema_project_resolve" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json new file mode 100644 index 0000000000..d713c37620 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -0,0 +1,124 @@ +{ + "type": "dict", + "collapsible": true, + "key": "flame", + "label": "Flame", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Create plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CreateShotClip", + "label": "Create Shot Clip", + "is_group": true, + "children": [ + { + "type": "collapsible-wrap", + "label": "Shot Hierarchy And Rename Settings", + "collapsible": false, + "children": [ + { + "type": "text", + "key": "hierarchy", + "label": "Shot parent hierarchy" + }, + { + "type": "boolean", + "key": "clipRename", + "label": "Rename clips" + }, + { + "type": "text", + "key": "clipName", + "label": "Clip name template" + }, + { + "type": "number", + "key": "countFrom", + "label": "Count sequence from" + }, + { + "type": "number", + "key": "countSteps", + "label": "Stepping number" + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Shot Template Keywords", + "collapsible": false, + "children": [ + { + "type": "text", + "key": "folder", + "label": "{folder}" + }, + { + "type": "text", + "key": "episode", + "label": "{episode}" + }, + { + "type": "text", + "key": "sequence", + "label": "{sequence}" + }, + { + "type": "text", + "key": "track", + "label": "{track}" + }, + { + "type": "text", + "key": "shot", + "label": "{shot}" + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Vertical Synchronization Of Attributes", + "collapsible": false, + "children": [ + { + "type": "boolean", + "key": "vSyncOn", + "label": "Enable Vertical Sync" + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Shot Attributes", + "collapsible": false, + "children": [ + { + "type": "number", + "key": "workfileFrameStart", + "label": "Workfiles Start Frame" + }, + { + "type": "number", + "key": "handleStart", + "label": "Handle start (head)" + }, + { + "type": "number", + "key": "handleEnd", + "label": "Handle end (tail)" + } + ] + } + ] + } + ] + } + ] +} From 3aa0efdc4dacee60e7995a4d32bfd675c0ae0afd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:29:59 +0100 Subject: [PATCH 135/395] flame: do not alter project attributes if it exists already --- openpype/hosts/flame/api/scripts/wiretap_com.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index 5f7b2580e6..f1b5ab2236 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -89,9 +89,11 @@ class WireTapCom(object): workspace_name = kwargs.get("workspace_name") color_policy = kwargs.get("color_policy") - self._project_prep(project_name) - self._set_project_settings(project_name, project_data) - self._set_project_colorspace(project_name, color_policy) + project_exists = self._project_prep(project_name) + if not project_exists: + self._set_project_settings(project_name, project_data) + self._set_project_colorspace(project_name, color_policy) + user_name = self._user_prep(user_name) if workspace_name is None: @@ -207,6 +209,7 @@ class WireTapCom(object): print( "A new project '{}' is created.".format(project_name)) + return project_exists def _get_all_volumes(self): """Request all available volumens from WireTap From e3122b9782fb1bb1ce17e6883a8d35a2c86ce12f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 6 Jan 2022 15:36:41 +0100 Subject: [PATCH 136/395] OP-2282 - added error when no layers in Background Background import without layers failed with nondescript message. Fixed broken layer naming --- .../aftereffects/plugins/load/load_background.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/load/load_background.py b/openpype/hosts/aftereffects/plugins/load/load_background.py index 9856abe3fe..4d3d46a442 100644 --- a/openpype/hosts/aftereffects/plugins/load/load_background.py +++ b/openpype/hosts/aftereffects/plugins/load/load_background.py @@ -22,21 +22,23 @@ class BackgroundLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): items = stub.get_items(comps=True) - existing_items = [layer.name for layer in items] + existing_items = [layer.name.replace(stub.LOADED_ICON, '') + for layer in items] comp_name = get_unique_layer_name( existing_items, "{}_{}".format(context["asset"]["name"], name)) layers = get_background_layers(self.fname) + if not layers: + raise ValueError("No layers found in {}".format(self.fname)) + comp = stub.import_background(None, stub.LOADED_ICON + comp_name, layers) if not comp: - self.log.warning( - "Import background failed.") - self.log.warning("Check host app for alert error.") - return + raise ValueError("Import background failed. " + "Please contact support") self[:] = [comp] namespace = namespace or comp_name From f6b9d122442d8cd95b4b9307a7d2cad4466fdddd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:57:45 +0100 Subject: [PATCH 137/395] flame: host maintained selection --- openpype/hosts/flame/__init__.py | 4 +++- openpype/hosts/flame/api/pipeline.py | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py index 691b6f8119..da42b313aa 100644 --- a/openpype/hosts/flame/__init__.py +++ b/openpype/hosts/flame/__init__.py @@ -10,7 +10,8 @@ from .api.pipeline import ( update_container, remove_instance, list_instances, - imprint + imprint, + maintained_selection ) from .api.lib import ( @@ -87,6 +88,7 @@ __all__ = [ "remove_instance", "list_instances", "imprint", + "maintained_selection", # utils "setup", diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 99a33e4cb9..ee0e12584a 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -7,7 +7,9 @@ from pyblish import api as pyblish from openpype.api import Logger from .lib import ( set_segment_data_marker, - set_publish_attribute + set_publish_attribute, + maintained_segment_selection, + get_current_sequence ) AVALON_CONTAINERS = "AVALON_CONTAINERS" @@ -151,3 +153,17 @@ def imprint(segment, data=None): # add publish attribute set_publish_attribute(segment, True) + +@contextlib.contextmanager +def maintained_selection(): + import flame + from .. import selection + + # check if segment is selected + if isinstance(selection[0], flame.PySegment): + sequence = get_current_sequence(selection) + try: + with maintained_segment_selection(sequence): + yield + finally: + pass \ No newline at end of file From 613e9ff2fb123b4e8606143a9a0af744cc384026 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:02:30 +0100 Subject: [PATCH 138/395] renamed get_pype_execute_args to get_openpype_execute_args --- openpype/lib/__init__.py | 2 ++ openpype/lib/execute.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 34926453cb..f721e0f577 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -24,6 +24,7 @@ from .env_tools import ( from .terminal import Terminal from .execute import ( + get_openpype_execute_args, get_pype_execute_args, get_linux_launcher_args, execute, @@ -173,6 +174,7 @@ from .pype_info import ( terminal = Terminal __all__ = [ + "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", "execute", diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index f97617d906..452a8fd4c0 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -147,6 +147,11 @@ def path_to_subprocess_arg(path): def get_pype_execute_args(*args): + """Backwards compatible function for 'get_openpype_execute_args'.""" + return get_openpype_execute_args(*args) + + +def get_openpype_execute_args(*args): """Arguments to run pype command. Arguments for subprocess when need to spawn new pype process. Which may be From b3082d9e211aa2245262654d52eb0f89758db04a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 16:08:57 +0100 Subject: [PATCH 139/395] flame: track name preset fix --- openpype/hosts/flame/plugins/create/create_shot_clip.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 79afca507f..45c4557dad 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -142,12 +142,12 @@ class CreateShotClip(fplugin.Creator): "order": 3, "value": { "subsetName": { - "value": ["", "main", "bg", "fg", "bg", + "value": ["[ track name ]", "main", "bg", "fg", "bg", "animatic"], "type": "QComboBox", "label": "Subset Name", "target": "ui", - "toolTip": "chose subset name patern, if is selected, name of track layer will be used", # noqa + "toolTip": "chose subset name patern, if [ track name ] is selected, name of track layer will be used", # noqa "order": 0}, "subsetFamily": { "value": ["plate", "take"], From f48b864e2359398c960debd2ab0a9668792ca00c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 16:12:38 +0100 Subject: [PATCH 140/395] Flame: fixing wrong type --- openpype/hosts/flame/api/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 34e626b099..5e47ce3a68 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -401,8 +401,8 @@ class PublishableClip: if self.rename: # rename segment - self.current_segment.name = new_name - self.marker_data["asset"] = new_name + self.current_segment.name = str(new_name) + self.marker_data["asset"] = str(new_name) else: self.marker_data["asset"] = self.cs_name self.marker_data["hierarchyData"]["shot"] = self.cs_name From 3c1967f080504e1be3916d1deeb2241c234091e8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:13:30 +0100 Subject: [PATCH 141/395] use get_openpype_execute_args instead of get_pype_execute_args --- openpype/hooks/pre_non_python_host_launch.py | 5 ++--- openpype/hosts/tvpaint/hooks/pre_launch_args.py | 4 ++-- openpype/lib/pype_info.py | 4 ++-- .../ftrack/ftrack_server/event_server_cli.py | 6 +++--- .../default_modules/ftrack/ftrack_server/socket_thread.py | 4 ++-- openpype/modules/standalonepublish_action.py | 4 ++-- .../tools/standalonepublish/widgets/widget_components.py | 4 ++-- openpype/tools/tray/pype_tray.py | 6 +++--- 8 files changed, 18 insertions(+), 19 deletions(-) diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py index 848ed675a8..29e40d28c8 100644 --- a/openpype/hooks/pre_non_python_host_launch.py +++ b/openpype/hooks/pre_non_python_host_launch.py @@ -3,7 +3,7 @@ import subprocess from openpype.lib import ( PreLaunchHook, - get_pype_execute_args + get_openpype_execute_args ) from openpype import PACKAGE_DIR as OPENPYPE_DIR @@ -35,7 +35,7 @@ class NonPythonHostHook(PreLaunchHook): "non_python_host_launch.py" ) - new_launch_args = get_pype_execute_args( + new_launch_args = get_openpype_execute_args( "run", script_path, executable_path ) # Add workfile path if exists @@ -48,4 +48,3 @@ class NonPythonHostHook(PreLaunchHook): if remainders: self.launch_context.launch_args.extend(remainders) - diff --git a/openpype/hosts/tvpaint/hooks/pre_launch_args.py b/openpype/hosts/tvpaint/hooks/pre_launch_args.py index 62fd662d79..2a8f49d5b0 100644 --- a/openpype/hosts/tvpaint/hooks/pre_launch_args.py +++ b/openpype/hosts/tvpaint/hooks/pre_launch_args.py @@ -4,7 +4,7 @@ import shutil from openpype.hosts import tvpaint from openpype.lib import ( PreLaunchHook, - get_pype_execute_args + get_openpype_execute_args ) import avalon @@ -30,7 +30,7 @@ class TvpaintPrelaunchHook(PreLaunchHook): while self.launch_context.launch_args: remainders.append(self.launch_context.launch_args.pop(0)) - new_launch_args = get_pype_execute_args( + new_launch_args = get_openpype_execute_args( "run", self.launch_script_path(), executable_path ) diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index 33715e369d..15856bfb19 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -7,7 +7,7 @@ import socket import openpype.version from openpype.settings.lib import get_local_settings -from .execute import get_pype_execute_args +from .execute import get_openpype_execute_args from .local_settings import get_local_site_id from .python_module_tools import import_filepath @@ -71,7 +71,7 @@ def is_running_staging(): def get_pype_info(): """Information about currently used Pype process.""" - executable_args = get_pype_execute_args() + executable_args = get_openpype_execute_args() if is_running_from_build(): version_type = "build" else: diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py index 1a76905b38..90ce757242 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py @@ -14,7 +14,7 @@ import uuid import ftrack_api import pymongo from openpype.lib import ( - get_pype_execute_args, + get_openpype_execute_args, OpenPypeMongoConnection, get_openpype_version, get_build_version, @@ -136,7 +136,7 @@ def legacy_server(ftrack_url): if subproc is None: if subproc_failed_count < max_fail_count: - args = get_pype_execute_args("run", subproc_path) + args = get_openpype_execute_args("run", subproc_path) subproc = subprocess.Popen( args, stdout=subprocess.PIPE @@ -248,7 +248,7 @@ def main_loop(ftrack_url): ["Username", getpass.getuser()], ["Host Name", host_name], ["Host IP", socket.gethostbyname(host_name)], - ["OpenPype executable", get_pype_execute_args()[-1]], + ["OpenPype executable", get_openpype_execute_args()[-1]], ["OpenPype version", get_openpype_version() or "N/A"], ["OpenPype build version", get_build_version() or "N/A"] ] diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py b/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py index eb8ec4d06c..f49ca5557e 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py @@ -6,7 +6,7 @@ import threading import traceback import subprocess from openpype.api import Logger -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args class SocketThread(threading.Thread): @@ -59,7 +59,7 @@ class SocketThread(threading.Thread): env = os.environ.copy() env["OPENPYPE_PROCESS_MONGO_ID"] = str(Logger.mongo_process_id) # OpenPype executable (with path to start script if not build) - args = get_pype_execute_args( + args = get_openpype_execute_args( # Add `run` command "run", self.filepath, diff --git a/openpype/modules/standalonepublish_action.py b/openpype/modules/standalonepublish_action.py index 9321a415a9..ba53ce9b9e 100644 --- a/openpype/modules/standalonepublish_action.py +++ b/openpype/modules/standalonepublish_action.py @@ -1,7 +1,7 @@ import os import platform import subprocess -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args from openpype.modules import OpenPypeModule from openpype_interfaces import ITrayAction @@ -35,7 +35,7 @@ class StandAlonePublishAction(OpenPypeModule, ITrayAction): self.publish_paths.extend(publish_paths) def run_standalone_publisher(self): - args = get_pype_execute_args("standalonepublisher") + args = get_openpype_execute_args("standalonepublisher") kwargs = {} if platform.system().lower() == "darwin": new_args = ["open", "-na", args.pop(0), "--args"] diff --git a/openpype/tools/standalonepublish/widgets/widget_components.py b/openpype/tools/standalonepublish/widgets/widget_components.py index 2ac54af4e3..4d7f94f825 100644 --- a/openpype/tools/standalonepublish/widgets/widget_components.py +++ b/openpype/tools/standalonepublish/widgets/widget_components.py @@ -10,7 +10,7 @@ from .constants import HOST_NAME from avalon import io from openpype.api import execute, Logger from openpype.lib import ( - get_pype_execute_args, + get_openpype_execute_args, apply_project_environments_value ) @@ -193,7 +193,7 @@ def cli_publish(data, publish_paths, gui=True): project_name = os.environ["AVALON_PROJECT"] env_copy = apply_project_environments_value(project_name, envcopy) - args = get_pype_execute_args("run", PUBLISH_SCRIPT_PATH) + args = get_openpype_execute_args("run", PUBLISH_SCRIPT_PATH) result = execute(args, env=envcopy) result = {} diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 8c6a6d3266..df0238c848 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -14,7 +14,7 @@ from openpype.api import ( resources, get_system_settings ) -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args from openpype.modules import TrayModulesManager from openpype import style from openpype.settings import ( @@ -208,10 +208,10 @@ class TrayManager: First creates new process with same argument and close current tray. """ - args = get_pype_execute_args() + args = get_openpype_execute_args() # Create a copy of sys.argv additional_args = list(sys.argv) - # Check last argument from `get_pype_execute_args` + # Check last argument from `get_openpype_execute_args` # - when running from code it is the same as first from sys.argv if args[-1] == additional_args[0]: additional_args.pop(0) From 4ab54f4d735adf13f7a4db376000bc9454d2ff65 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:27:41 +0100 Subject: [PATCH 142/395] added deprecation warning --- openpype/lib/execute.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 452a8fd4c0..57cb01b4ab 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -148,6 +148,13 @@ def path_to_subprocess_arg(path): def get_pype_execute_args(*args): """Backwards compatible function for 'get_openpype_execute_args'.""" + import traceback + + log = Logger.get_logger("get_pype_execute_args") + stack = "\n".join(traceback.format_stack()) + log.warning(( + "Using deprecated function 'get_pype_execute_args'. Called from:\n{}" + ).format(stack)) return get_openpype_execute_args(*args) From 6de956dae2a876044bbbc77c7d023d53441e008d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:28:03 +0100 Subject: [PATCH 143/395] implemented functions to run openpype subprocess with cleanin environments --- openpype/lib/__init__.py | 4 ++++ openpype/lib/execute.py | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index f721e0f577..12e47a8961 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -29,6 +29,8 @@ from .execute import ( get_linux_launcher_args, execute, run_subprocess, + run_openpype_process, + clean_envs_for_openpype_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -179,6 +181,8 @@ __all__ = [ "get_linux_launcher_args", "execute", "run_subprocess", + "run_openpype_process", + "clean_envs_for_openpype_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 57cb01b4ab..16b98eefb4 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -138,6 +138,49 @@ def run_subprocess(*args, **kwargs): return full_output +def clean_envs_for_openpype_process(env=None): + """Modify environemnts that may affect OpenPype process. + + Main reason to implement this function is to pop PYTHONPATH which may be + affected by in-host environments. + """ + if env is None: + env = os.environ + return { + key: value + for key, value in env.items() + if key not in ("PYTHONPATH",) + } + + +def run_openpype_process(*args, **kwargs): + """Execute OpenPype process with passed arguments and wait. + + Wrapper for 'run_process' which prepends OpenPype executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_openpype_process' function. + + Example: + ``` + run_openpype_process(["run", ""]) + ``` + + Args: + *args (tuple): OpenPype cli arguments. + **kwargs (dict): Keyword arguments for for subprocess.Popen. + """ + args = get_openpype_execute_args(*args) + env = kwargs.pop("env", None) + # Keep env untouched if are passed and not empty + if not env: + # Skip envs that can affect OpenPype process + # - fill more if you find more + env = clean_envs_for_openpype_process(os.environ) + return run_subprocess(args, env=env, **kwargs) + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. From ac6280f959cc50f980bcc37f917bc86c43d36825 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:28:43 +0100 Subject: [PATCH 144/395] use run_openpype_process in extract burnin --- openpype/plugins/publish/extract_burnin.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index df7dc47e17..1cb8608a56 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -13,7 +13,7 @@ import pyblish import openpype import openpype.api from openpype.lib import ( - get_pype_execute_args, + run_openpype_process, get_transcode_temp_directory, convert_for_ffmpeg, @@ -168,9 +168,8 @@ class ExtractBurnin(openpype.api.Extractor): anatomy = instance.context.data["anatomy"] scriptpath = self.burnin_script_path() - # Executable args that will execute the script - # [pype executable, *pype script, "run"] - executable_args = get_pype_execute_args("run", scriptpath) + # Args that will execute the script + executable_args = ["run", scriptpath] burnins_per_repres = self._get_burnins_per_representations( instance, burnin_defs ) @@ -313,7 +312,7 @@ class ExtractBurnin(openpype.api.Extractor): if platform.system().lower() == "windows": process_kwargs["creationflags"] = CREATE_NO_WINDOW - openpype.api.run_subprocess(args, **process_kwargs) + run_openpype_process(args, **process_kwargs) # Remove the temporary json os.remove(temporary_json_filepath) From ae74e0a79569308fa6dc6e602dc0b6d0d3e3c323 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 16:37:02 +0100 Subject: [PATCH 145/395] flame: stylize creator gui --- openpype/hosts/flame/api/style.css | 51 +++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/style.css b/openpype/hosts/flame/api/style.css index b64c391c6e..21e4750513 100644 --- a/openpype/hosts/flame/api/style.css +++ b/openpype/hosts/flame/api/style.css @@ -1,5 +1,5 @@ QWidget { - font-size: 13px; + font: 14px "Discreet"; } QSpinBox { @@ -8,13 +8,46 @@ QSpinBox { } QLineEdit { - padding: 2; - min-width: 15em; + color: #9a9a9a; + background-color: #373e47; + selection-color: #262626; + selection-background-color: #b8b1a7; + font: 14px "Discreet" +} +QLineEdit:focus { + background-color: #474e58 +} + +QLineEdit:disabled { + color: #6a6a6a; + background-color: #373737 +} + +QPushButton { + color: #9a9a9a; + background-color: #424142; + border-top: 1px inset #555555; + border-bottom: 1px inset black; + font: 14px "Discreet" +} + +QPushButton:pressed { + color: #d9d9d9; + background-color: #4f4f4f; + border-top: 1px inset #666666; + font: italic +} + +QPushButton:disabled { + color: #747474; + background-color: #353535; + border-top: 1px solid #444444; + border-bottom: 1px solid #242424 } QVBoxLayout { min-width: 15em; - background-color: #201f1f; + background-color: #313131 } QComboBox { @@ -23,4 +56,14 @@ QComboBox { #sectionContent { background-color: #2E2D2D; +} + +QLabel { + color: #9a9a9a; + border-bottom: 1px inset #282828; + font: 14px "Discreet" +} + +QLabel:disabled { + color: #6a6a6a } \ No newline at end of file From ed5da3e0b04b28019e2b5c192364ced71d9a2c27 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:39:59 +0100 Subject: [PATCH 146/395] expect that get_remote_versions may return None --- openpype/settings/entities/op_version_entity.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py index 6184f7640a..782d65a446 100644 --- a/openpype/settings/entities/op_version_entity.py +++ b/openpype/settings/entities/op_version_entity.py @@ -72,6 +72,8 @@ class ProductionVersionsInputEntity(OpenPypeVersionInput): def _get_openpype_versions(self): versions = get_remote_versions(staging=False, production=True) + if versions is None: + return [] versions.append(get_installed_version()) return sorted(versions) @@ -82,4 +84,6 @@ class StagingVersionsInputEntity(OpenPypeVersionInput): def _get_openpype_versions(self): versions = get_remote_versions(staging=True, production=False) + if versions is None: + return [] return sorted(versions) From 09214e175b9674fb465517eaf5e4092701e09f6d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 16:59:02 +0100 Subject: [PATCH 147/395] Flame: adding openpype style to creator gui --- openpype/hosts/flame/api/plugin.py | 15 +------ openpype/hosts/flame/api/style.css | 69 ------------------------------ 2 files changed, 2 insertions(+), 82 deletions(-) delete mode 100644 openpype/hosts/flame/api/style.css diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 5e47ce3a68..b4fc75fe00 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -2,23 +2,13 @@ import re import os from Qt import QtWidgets, QtCore import openpype.api as openpype +from openpype import style import openpype.hosts.flame as opflame from . import lib, pipeline from copy import deepcopy log = openpype.Logger().get_logger(__name__) -# Creator plugin functions -def load_stylesheet(): - path = os.path.join(os.path.dirname(__file__), "style.css") - if not os.path.exists(path): - log.warning("Unable to load stylesheet, file not found in resources") - return "" - - with open(path, "r") as file_stream: - stylesheet = file_stream.read() - return stylesheet - class CreatorWidget(QtWidgets.QDialog): @@ -93,8 +83,7 @@ class CreatorWidget(QtWidgets.QDialog): ok_btn.clicked.connect(self._on_ok_clicked) cancel_btn.clicked.connect(self._on_cancel_clicked) - stylesheet = load_stylesheet() - self.setStyleSheet(stylesheet) + self.setStyleSheet(style.load_stylesheet()) def _on_ok_clicked(self): self.result = self.value(self.items) diff --git a/openpype/hosts/flame/api/style.css b/openpype/hosts/flame/api/style.css deleted file mode 100644 index 21e4750513..0000000000 --- a/openpype/hosts/flame/api/style.css +++ /dev/null @@ -1,69 +0,0 @@ -QWidget { - font: 14px "Discreet"; -} - -QSpinBox { - padding: 2; - max-width: 8em; -} - -QLineEdit { - color: #9a9a9a; - background-color: #373e47; - selection-color: #262626; - selection-background-color: #b8b1a7; - font: 14px "Discreet" -} -QLineEdit:focus { - background-color: #474e58 -} - -QLineEdit:disabled { - color: #6a6a6a; - background-color: #373737 -} - -QPushButton { - color: #9a9a9a; - background-color: #424142; - border-top: 1px inset #555555; - border-bottom: 1px inset black; - font: 14px "Discreet" -} - -QPushButton:pressed { - color: #d9d9d9; - background-color: #4f4f4f; - border-top: 1px inset #666666; - font: italic -} - -QPushButton:disabled { - color: #747474; - background-color: #353535; - border-top: 1px solid #444444; - border-bottom: 1px solid #242424 -} - -QVBoxLayout { - min-width: 15em; - background-color: #313131 -} - -QComboBox { - min-width: 8em; -} - -#sectionContent { - background-color: #2E2D2D; -} - -QLabel { - color: #9a9a9a; - border-bottom: 1px inset #282828; - font: 14px "Discreet" -} - -QLabel:disabled { - color: #6a6a6a -} \ No newline at end of file From 16d920028ce4fe9492be653ffdedc8b26756310d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 17:04:24 +0100 Subject: [PATCH 148/395] Flame: adding closing event to creator gui --- openpype/hosts/flame/api/plugin.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index b4fc75fe00..68bdbbe510 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -93,6 +93,10 @@ class CreatorWidget(QtWidgets.QDialog): self.result = None self.close() + def closeEvent(self, event): + self.result = None + event.accept() + def value(self, data, new_data=None): new_data = new_data or dict() for k, v in data.items(): From bbbfef4ca793765e0930f7f95af93f655303cbec Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 17:32:54 +0100 Subject: [PATCH 149/395] changed action/event paths to multiplatform and multipath inputs and changed labels --- .../defaults/system_settings/modules.json | 12 ++++++++++-- .../module_settings/schema_ftrack.json | 18 +++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index f0caa153de..b31dd6856c 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -15,8 +15,16 @@ "ftrack": { "enabled": true, "ftrack_server": "", - "ftrack_actions_path": [], - "ftrack_events_path": [], + "ftrack_actions_path": { + "windows": [], + "darwin": [], + "linux": [] + }, + "ftrack_events_path": { + "windows": [], + "darwin": [], + "linux": [] + }, "intent": { "items": { "-": "-", diff --git a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json index 5f659522c3..654ddf2938 100644 --- a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json +++ b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json @@ -21,19 +21,23 @@ }, { "type": "label", - "label": "Additional Ftrack paths" + "label": "Additional Ftrack event handlers paths" }, { - "type": "list", + "type": "path", "key": "ftrack_actions_path", - "label": "Action paths", - "object_type": "text" + "label": "User paths", + "use_label_wrap": true, + "multipath": true, + "multiplatform": true }, { - "type": "list", + "type": "path", "key": "ftrack_events_path", - "label": "Event paths", - "object_type": "text" + "label": "Server paths", + "use_label_wrap": true, + "multipath": true, + "multiplatform": true }, { "type": "separator" From 5ec147b336afe2499d428cbc81251b629d6e5ee5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 17:42:36 +0100 Subject: [PATCH 150/395] ftrack module expect new type of path settings --- .../default_modules/ftrack/ftrack_module.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index 8a7525d65b..e24869fd59 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -1,6 +1,7 @@ import os import json import collections +import platform import click @@ -42,18 +43,28 @@ class FtrackModule( self.ftrack_url = ftrack_url current_dir = os.path.dirname(os.path.abspath(__file__)) + low_platform = platform.system().lower() + server_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_server") ] - server_event_handlers_paths.extend( - ftrack_settings["ftrack_events_path"] - ) + settings_server_paths = ftrack_settings["ftrack_events_path"] + if isinstance(settings_server_paths, dict): + settings_server_paths = settings_server_paths[low_platform] + + for path in settings_server_paths: + server_event_handlers_paths.append(path) + user_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_user") ] - user_event_handlers_paths.extend( - ftrack_settings["ftrack_actions_path"] - ) + settings_action_paths = ftrack_settings["ftrack_actions_path"] + if isinstance(settings_action_paths, dict): + settings_action_paths = settings_action_paths[low_platform] + + for path in settings_action_paths: + user_event_handlers_paths.append(path) + # Prepare attribute self.server_event_handlers_paths = server_event_handlers_paths self.user_event_handlers_paths = user_event_handlers_paths From dd80f331f70ab7d51a32f4fab67871bb69e497ee Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 17:42:51 +0100 Subject: [PATCH 151/395] paths to event handler are tried to format with environments --- .../modules/default_modules/ftrack/ftrack_module.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index e24869fd59..1b41159069 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -53,6 +53,11 @@ class FtrackModule( settings_server_paths = settings_server_paths[low_platform] for path in settings_server_paths: + # Try to format path with environments + try: + path = path.format(**os.environ) + except BaseException: + pass server_event_handlers_paths.append(path) user_event_handlers_paths = [ @@ -63,6 +68,11 @@ class FtrackModule( settings_action_paths = settings_action_paths[low_platform] for path in settings_action_paths: + # Try to format path with environments + try: + path = path.format(**os.environ) + except BaseException: + pass user_event_handlers_paths.append(path) # Prepare attribute From 85d973555b01d28f1b39f58f8f2bc93d2d229606 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 17:44:41 +0100 Subject: [PATCH 152/395] try format the paths in ftrack event server (more dynamic) --- .../default_modules/ftrack/ftrack_module.py | 20 ++++--------------- .../ftrack/ftrack_server/ftrack_server.py | 6 ++++++ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index 1b41159069..38ec02749a 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -45,35 +45,23 @@ class FtrackModule( current_dir = os.path.dirname(os.path.abspath(__file__)) low_platform = platform.system().lower() + # Server event handler paths server_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_server") ] settings_server_paths = ftrack_settings["ftrack_events_path"] if isinstance(settings_server_paths, dict): settings_server_paths = settings_server_paths[low_platform] + server_event_handlers_paths.extend(settings_server_paths) - for path in settings_server_paths: - # Try to format path with environments - try: - path = path.format(**os.environ) - except BaseException: - pass - server_event_handlers_paths.append(path) - + # User event handler paths user_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_user") ] settings_action_paths = ftrack_settings["ftrack_actions_path"] if isinstance(settings_action_paths, dict): settings_action_paths = settings_action_paths[low_platform] - - for path in settings_action_paths: - # Try to format path with environments - try: - path = path.format(**os.environ) - except BaseException: - pass - user_event_handlers_paths.append(path) + user_event_handlers_paths.extend(settings_action_paths) # Prepare attribute self.server_event_handlers_paths = server_event_handlers_paths diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py b/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py index bd67fba3d6..8944591b71 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py @@ -63,6 +63,12 @@ class FtrackServer: # Iterate all paths register_functions = [] for path in paths: + # Try to format path with environments + try: + path = path.format(**os.environ) + except BaseException: + pass + # Get all modules with functions modules, crashed = modules_from_path(path) for filepath, exc_info in crashed: From 9de27a8b04b1b16dbf0b1ca32b308b0d8ef46122 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 12:04:15 +0100 Subject: [PATCH 153/395] OP-2204 - added new setting for upload review in Slack notification --- .../schemas/projects_schema/schema_project_slack.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json index 9ca4e443bd..4e82c991e7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json @@ -91,6 +91,11 @@ "key": "upload_thumbnail", "label": "Upload thumbnail" }, + { + "type": "boolean", + "key": "upload_review", + "label": "Upload review" + }, { "type": "text", "multiline": true, From 2229e19caa5c3fd90ce4b8ab1516c56bbd64d3ee Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 12:06:33 +0100 Subject: [PATCH 154/395] OP-2204 - added possibility to upload or add link to review to Slack notification --- .../plugins/publish/integrate_slack_api.py | 132 ++++++++++++------ 1 file changed, 87 insertions(+), 45 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 7b81d3c364..b9f4b9d81f 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -14,6 +14,8 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): Project settings > Slack > Publish plugins > Notification to Slack. If instance contains 'thumbnail' it uploads it. Bot must be present in the target channel. + If instance contains 'review' it could upload (if configured) or place + link with {review_link} placeholder. Message template can contain {} placeholders from anatomyData. """ order = pyblish.api.IntegratorOrder + 0.499 @@ -23,44 +25,68 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): optional = True def process(self, instance): - published_path = self._get_thumbnail_path(instance) + thumbnail_path = self._get_thumbnail_path(instance) + review_path = self._get_review_path(instance) + publish_files = set() for message_profile in instance.data["slack_channel_message_profiles"]: message = self._get_filled_message(message_profile["message"], - instance) + instance, + review_path) if not message: return + if message_profile["upload_thumbnail"] and thumbnail_path: + publish_files.add(thumbnail_path) + + if message_profile["upload_review"] and review_path: + publish_files.add(review_path) + for channel in message_profile["channels"]: if six.PY2: self._python2_call(instance.data["slack_token"], channel, message, - published_path, - message_profile["upload_thumbnail"]) + publish_files) else: self._python3_call(instance.data["slack_token"], channel, message, - published_path, - message_profile["upload_thumbnail"]) + publish_files) - def _get_filled_message(self, message_templ, instance): - """Use message_templ and data from instance to get message content.""" + def _get_filled_message(self, message_templ, instance, review_path=None): + """Use message_templ and data from instance to get message content. + + Reviews might be large, so allow only adding link to message instead of + uploading only. + """ fill_data = copy.deepcopy(instance.context.data["anatomyData"]) - fill_pairs = ( + fill_pairs = [ ("asset", instance.data.get("asset", fill_data.get("asset"))), ("subset", instance.data.get("subset", fill_data.get("subset"))), - ("task", instance.data.get("task", fill_data.get("task"))), ("username", instance.data.get("username", fill_data.get("username"))), ("app", instance.data.get("app", fill_data.get("app"))), ("family", instance.data.get("family", fill_data.get("family"))), ("version", str(instance.data.get("version", fill_data.get("version")))) - ) + ] + if review_path: + fill_pairs.append(("review_link", review_path)) + task_on_instance = instance.data.get("task") + task_on_anatomy = fill_data.get("task") + if task_on_instance: + fill_pairs.append(("task[name]", task_on_instance.get("type"))) + fill_pairs.append(("task[name]", task_on_instance.get("name"))) + fill_pairs.append(("task[short]", task_on_instance.get("short"))) + elif task_on_anatomy: + fill_pairs.append(("task[name]", task_on_anatomy.get("type"))) + fill_pairs.append(("task[name]", task_on_anatomy.get("name"))) + fill_pairs.append(("task[short]", task_on_anatomy.get("short"))) + + self.log.debug("fill_pairs ::{}".format(fill_pairs)) multiple_case_variants = prepare_template_data(fill_pairs) fill_data.update(multiple_case_variants) @@ -79,39 +105,51 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): published_path = None for repre in instance.data['representations']: if repre.get('thumbnail') or "thumbnail" in repre.get('tags', []): - repre_files = repre["files"] - if isinstance(repre_files, (tuple, list, set)): - filename = repre_files[0] - else: - filename = repre_files - - published_path = os.path.join( - repre['stagingDir'], filename - ) + if os.path.exists(repre["published_path"]): + published_path = repre["published_path"] break return published_path + def _get_review_path(self, instance): + """Returns abs url for review if present in instance repres""" + published_path = None + for repre in instance.data['representations']: + tags = repre.get('tags', []) + if (repre.get("review") + or "review" in tags + or "burnin" in tags): + if os.path.exists(repre["published_path"]): + published_path = repre["published_path"] + if "burnin" in tags: # burnin has precedence if exists + break + return published_path + def _python2_call(self, token, channel, message, - published_path, upload_thumbnail): + publish_files): from slackclient import SlackClient try: client = SlackClient(token) - if upload_thumbnail and \ - published_path and os.path.exists(published_path): - with open(published_path, 'rb') as pf: + for p_file in publish_files: + attachment_str = "\n\n Attachment links: \n" + with open(p_file, 'rb') as pf: response = client.api_call( "files.upload", channels=channel, - initial_comment=message, file=pf, - title=os.path.basename(published_path) + title=os.path.basename(p_file), ) - else: - response = client.api_call( - "chat.postMessage", - channel=channel, - text=message - ) + attachment_str += "\n<{}|{}>".format( + response["file"]["permalink"], + os.path.basename(p_file)) + + if publish_files: + message += attachment_str + + response = client.api_call( + "chat.postMessage", + channel=channel, + text=message + ) if response.get("error"): error_str = self._enrich_error(str(response.get("error")), @@ -123,23 +161,27 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): self.log.warning("Error happened: {}".format(error_str)) def _python3_call(self, token, channel, message, - published_path, upload_thumbnail): + publish_files): from slack_sdk import WebClient from slack_sdk.errors import SlackApiError try: client = WebClient(token=token) - if upload_thumbnail and \ - published_path and os.path.exists(published_path): - _ = client.files_upload( - channels=channel, - initial_comment=message, - file=published_path, - ) - else: - _ = client.chat_postMessage( - channel=channel, - text=message - ) + attachment_str = "\n\n Attachment links: \n" + for published_file in publish_files: + response = client.files_upload( + file=published_file, + filename=os.path.basename(published_file)) + attachment_str += "\n<{}|{}>".format( + response["file"]["permalink"], + os.path.basename(published_file)) + + if publish_files: + message += attachment_str + + _ = client.chat_postMessage( + channel=channel, + text=message + ) except SlackApiError as e: # You will get a SlackApiError if "ok" is False error_str = self._enrich_error(str(e.response["error"]), channel) From bdd7d159bac52d598f2eaa674813664402594e0d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 12:07:35 +0100 Subject: [PATCH 155/395] OP-2204 - added documentation to Slack section --- website/docs/module_slack.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/website/docs/module_slack.md b/website/docs/module_slack.md index f71fcc2bb7..10d5e58eac 100644 --- a/website/docs/module_slack.md +++ b/website/docs/module_slack.md @@ -61,6 +61,18 @@ Integration can upload 'thumbnail' file (if present in an instance), for that bo manually added to target channel by Slack admin! (In target channel write: ```/invite @OpenPypeNotifier``) +#### Upload review +Integration can upload 'review' file (if present in an instance), for that bot must be +manually added to target channel by Slack admin! +(In target channel write: ```/invite @OpenPypeNotifier``) + +Burnin version (usually .mp4) is preferred if present. + +Please be sure that this configuration is viable for your use case. In case of uploading large reviews to Slack, +all publishes will be slowed down and you might hit a file limit on Slack pretty soon (it is 5GB for Free version of Slack, any file cannot be bigger than 1GB). +You might try to add `{review_link}` to message content. This link might help users to find review easier on their machines. +(It won't show a playable preview though!) + #### Message Message content can use Templating (see [Available template keys](admin_settings_project_anatomy#available-template-keys)). @@ -69,8 +81,22 @@ Few keys also have Capitalized and UPPERCASE format. Values will be modified acc **Available keys:** - asset - subset -- task +- task\[name\] +- task\[type\] +- task\[short\] - username - app - family - version +- review_link + +##### Message example +``` +{Subset} was published for {ASSET} in {task[name]} task. + +Here you can find review {review_link} +``` + +#### Message retention +Currently no purging of old messages is implemented in Openpype. Admins of Slack should set their own retention of messages and files per channel. +(see https://slack.com/help/articles/203457187-Customize-message-and-file-retention-policies) From 169b896ef40303b375003e5a2b3d63701894954f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 14:26:06 +0100 Subject: [PATCH 156/395] flame: refactory api calls --- openpype/hosts/flame/__init__.py | 121 ------------------ openpype/hosts/flame/api/__init__.py | 114 +++++++++++++++++ openpype/hosts/flame/api/constants.py | 24 ++++ openpype/hosts/flame/api/lib.py | 52 ++++---- openpype/hosts/flame/api/menu.py | 10 +- openpype/hosts/flame/api/pipeline.py | 11 +- openpype/hosts/flame/api/plugin.py | 28 ++-- .../hosts/flame/api/scripts/wiretap_com.py | 4 +- .../api/utility_scripts/openpype_in_flame.py | 45 +++---- openpype/hosts/flame/api/utils.py | 2 +- openpype/hosts/flame/api/workio.py | 2 +- openpype/hosts/flame/hooks/pre_flame_setup.py | 3 +- openpype/hosts/flame/otio/flame_export.py | 2 +- .../flame/plugins/create/create_shot_clip.py | 16 +-- .../plugins/publish/collect_test_selection.py | 18 ++- 15 files changed, 238 insertions(+), 214 deletions(-) create mode 100644 openpype/hosts/flame/api/constants.py diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py index da42b313aa..02befa76e2 100644 --- a/openpype/hosts/flame/__init__.py +++ b/openpype/hosts/flame/__init__.py @@ -1,126 +1,5 @@ -from .api.utils import ( - setup -) - -from .api.pipeline import ( - install, - uninstall, - ls, - containerise, - update_container, - remove_instance, - list_instances, - imprint, - maintained_selection -) - -from .api.lib import ( - FlameAppFramework, - maintain_current_timeline, - get_project_manager, - get_current_project, - get_current_sequence, - create_bin, - create_segment_data_marker, - get_segment_data_marker, - set_segment_data_marker, - set_publish_attribute, - get_publish_attribute, - get_sequence_segments, - maintained_segment_selection, - reset_segment_selection, - get_segment_attributes -) - -from .api.menu import ( - FlameMenuProjectConnect, - FlameMenuTimeline -) - -from .api.workio import ( - open_file, - save_file, - current_file, - has_unsaved_changes, - file_extensions, - work_root -) - import os HOST_DIR = os.path.dirname( os.path.abspath(__file__) ) -API_DIR = os.path.join(HOST_DIR, "api") -PLUGINS_DIR = os.path.join(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") - -app_framework = None -apps = [] -selection = None - - -__all__ = [ - "HOST_DIR", - "API_DIR", - "PLUGINS_DIR", - "PUBLISH_PATH", - "LOAD_PATH", - "CREATE_PATH", - "INVENTORY_PATH", - "INVENTORY_PATH", - - "app_framework", - "apps", - "selection", - - # pipeline - "install", - "uninstall", - "ls", - "containerise", - "update_container", - "reload_pipeline", - "maintained_selection", - "remove_instance", - "list_instances", - "imprint", - "maintained_selection", - - # utils - "setup", - - # lib - "FlameAppFramework", - "maintain_current_timeline", - "get_project_manager", - "get_current_project", - "get_current_sequence", - "create_bin", - "create_segment_data_marker", - "get_segment_data_marker", - "set_segment_data_marker", - "set_publish_attribute", - "get_publish_attribute", - "get_sequence_segments", - "maintained_segment_selection", - "reset_segment_selection", - "get_segment_attributes" - - # menu - "FlameMenuProjectConnect", - "FlameMenuTimeline", - - # plugin - - # workio - "open_file", - "save_file", - "current_file", - "has_unsaved_changes", - "file_extensions", - "work_root" -] diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 50a6b3f098..c8660aafc4 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -1,3 +1,117 @@ """ OpenPype Autodesk Flame api """ +from .constants import ( + COLOR_MAP, + MARKER_NAME, + MARKER_COLOR, + MARKER_DURATION, + MARKER_PUBLISH_DEFAULT +) +from .lib import ( + CTX, + FlameAppFramework, + maintain_current_timeline, + get_project_manager, + get_current_project, + get_current_sequence, + create_bin, + create_segment_data_marker, + get_segment_data_marker, + set_segment_data_marker, + set_publish_attribute, + get_publish_attribute, + get_sequence_segments, + maintained_segment_selection, + reset_segment_selection, + get_segment_attributes +) +from .utils import ( + setup +) +from .pipeline import ( + install, + uninstall, + ls, + containerise, + update_container, + remove_instance, + list_instances, + imprint, + maintained_selection +) +from .menu import ( + FlameMenuProjectConnect, + FlameMenuTimeline +) +from .plugin import ( + Creator, + PublishableClip +) +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root +) + +__all__ = [ + # constants + "COLOR_MAP", + "MARKER_NAME", + "MARKER_COLOR", + "MARKER_DURATION", + "MARKER_PUBLISH_DEFAULT", + + # lib + "CTX", + "FlameAppFramework", + "maintain_current_timeline", + "get_project_manager", + "get_current_project", + "get_current_sequence", + "create_bin", + "create_segment_data_marker", + "get_segment_data_marker", + "set_segment_data_marker", + "set_publish_attribute", + "get_publish_attribute", + "get_sequence_segments", + "maintained_segment_selection", + "reset_segment_selection", + "get_segment_attributes", + + # pipeline + "install", + "uninstall", + "ls", + "containerise", + "update_container", + "reload_pipeline", + "maintained_selection", + "remove_instance", + "list_instances", + "imprint", + "maintained_selection", + + # utils + "setup", + + # menu + "FlameMenuProjectConnect", + "FlameMenuTimeline", + + # plugin + "Creator", + "PublishableClip", + + # workio + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root" +] diff --git a/openpype/hosts/flame/api/constants.py b/openpype/hosts/flame/api/constants.py new file mode 100644 index 0000000000..1833031e13 --- /dev/null +++ b/openpype/hosts/flame/api/constants.py @@ -0,0 +1,24 @@ + +""" +OpenPype Flame api constances +""" +# OpenPype marker workflow variables +MARKER_NAME = "OpenPypeData" +MARKER_DURATION = 0 +MARKER_COLOR = "cyan" +MARKER_PUBLISH_DEFAULT = False + +# OpenPype color definitions +COLOR_MAP = { + "red": (1.0, 0.0, 0.0), + "orange": (1.0, 0.5, 0.0), + "yellow": (1.0, 1.0, 0.0), + "pink": (1.0, 0.5, 1.0), + "white": (1.0, 1.0, 1.0), + "green": (0.0, 1.0, 0.0), + "cyan": (0.0, 1.0, 1.0), + "blue": (0.0, 0.0, 1.0), + "purple": (0.5, 0.0, 0.5), + "magenta": (0.5, 0.0, 1.0), + "black": (0.0, 0.0, 0.0) +} diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index e5642dd6f9..ccc664ce63 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -5,30 +5,24 @@ import json import pickle import contextlib from pprint import pformat - +from .constants import ( + MARKER_COLOR, + MARKER_DURATION, + MARKER_NAME, + COLOR_MAP, + MARKER_PUBLISH_DEFAULT +) from openpype.api import Logger -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) + + +class CTX: + # singleton used for passing data between api modules + app_framework = None + apps = [] + selection = None -class ctx: - # OpenPype marker workflow variables - marker_name = "OpenPypeData" - marker_duration = 0 - marker_color = "cyan" - publish_default = False - color_map = { - "red": (1.0, 0.0, 0.0), - "orange": (1.0, 0.5, 0.0), - "yellow": (1.0, 1.0, 0.0), - "pink": (1.0, 0.5, 1.0), - "white": (1.0, 1.0, 1.0), - "green": (0.0, 1.0, 0.0), - "cyan": (0.0, 1.0, 1.0), - "blue": (0.0, 0.0, 1.0), - "purple": (0.5, 0.0, 0.5), - "magenta": (0.5, 0.0, 1.0), - "black": (0.0, 0.0, 0.0) -} @contextlib.contextmanager def io_preferences_file(klass, filepath, write=False): @@ -379,7 +373,8 @@ def get_segment_data_marker(segment, with_marker=None): color = marker.colour.get_value() name = marker.name.get_value() - if name == ctx.marker_name and color == ctx.color_map[ctx.marker_color]: + if (name == MARKER_NAME) and ( + color == COLOR_MAP[MARKER_COLOR]): if not with_marker: return json.loads(comment) else: @@ -443,8 +438,8 @@ def get_publish_attribute(segment): tag_data = get_segment_data_marker(segment) if not tag_data: - set_publish_attribute(segment, ctx.publish_default) - return ctx.publish_default + set_publish_attribute(segment, MARKER_PUBLISH_DEFAULT) + return MARKER_PUBLISH_DEFAULT return tag_data["publish"] @@ -465,14 +460,15 @@ def create_segment_data_marker(segment): # create marker marker = segment.create_marker(start_frame) # set marker name - marker.name = ctx.marker_name + marker.name = MARKER_NAME # set duration - marker.duration = ctx.marker_duration + marker.duration = MARKER_DURATION # set colour - marker.colour = ctx.color_map[ctx.marker_color] # Red + marker.colour = COLOR_MAP[MARKER_COLOR] # Red return marker + def get_sequence_segments(sequence, selected=False): segments = [] # loop versions in sequence @@ -485,7 +481,7 @@ def get_sequence_segments(sequence, selected=False): # loop all segment in remaining tracks for segment in track.segments: # ignore all segments not selected - if segment.selected != True and selected == True: + if segment.selected is not True and selected is True: continue # add it to original selection segments.append(segment) diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index fef6dbfa35..642c40a7df 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -1,7 +1,7 @@ import os from Qt import QtWidgets from copy import deepcopy - +from pprint import pformat from openpype.tools.utils.host_tools import HostToolsHelper menu_group_name = 'OpenPype' @@ -26,9 +26,11 @@ default_flame_export_presets = { def callback_selection(selection, function): - import openpype.hosts.flame as opflame - opflame.selection = selection - print(opflame.selection) + import openpype.hosts.flame.api as opfapi + opfapi.CTX.selection = selection + print("Hook Selection: \n\t{}".format( + pformat({type(item): item.name for item in CTX.selection}) + )) function() diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index ee0e12584a..5333a07210 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -1,6 +1,7 @@ """ Basic avalon integration """ +import os import contextlib from avalon import api as avalon from pyblish import api as pyblish @@ -11,10 +12,18 @@ from .lib import ( maintained_segment_selection, get_current_sequence ) +from .. import HOST_DIR + +API_DIR = os.path.join(HOST_DIR, "api") +PLUGINS_DIR = os.path.join(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") AVALON_CONTAINERS = "AVALON_CONTAINERS" -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) def install(): diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 68bdbbe510..4f71f9424e 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -1,13 +1,17 @@ import re -import os from Qt import QtWidgets, QtCore import openpype.api as openpype from openpype import style -import openpype.hosts.flame as opflame -from . import lib, pipeline +from . import selection as opfapi_selection +from . import ( + lib as flib, + pipeline as fpipeline, + constants +) + from copy import deepcopy -log = openpype.Logger().get_logger(__name__) +log = openpype.Logger.get_logger(__name__) class CreatorWidget(QtWidgets.QDialog): @@ -283,7 +287,7 @@ class Spacer(QtWidgets.QWidget): class Creator(openpype.Creator): """Creator class wrapper """ - clip_color = lib.ctx.color_map["purple"] + clip_color = constants.COLOR_MAP["purple"] rename_index = None def __init__(self, *args, **kwargs): @@ -292,13 +296,13 @@ class Creator(openpype.Creator): "flame"]["create"].get(self.__class__.__name__, {}) # adding basic current context flame objects - self.project = lib.get_current_project() - self.sequence = lib.get_current_sequence(opflame.selection) + self.project = flib.get_current_project() + self.sequence = flib.get_current_sequence(opfapi_selection) if (self.options or {}).get("useSelection"): - self.selected = lib.get_sequence_segments(self.sequence, True) + self.selected = flib.get_sequence_segments(self.sequence, True) else: - self.selected = lib.get_sequence_segments(self.sequence) + self.selected = flib.get_sequence_segments(self.sequence) self.widget = CreatorWidget @@ -345,10 +349,10 @@ class PublishableClip: # get main parent objects self.current_segment = segment - sequence_name = lib.get_current_sequence([segment]).name.get_value() + sequence_name = flib.get_current_sequence([segment]).name.get_value() self.sequence_name = str(sequence_name).replace(" ", "_") - self.clip_data = lib.get_segment_attributes(segment) + self.clip_data = flib.get_segment_attributes(segment) # segment (clip) main attributes self.cs_name = self.clip_data["segment_name"] self.cs_index = int(self.clip_data["segment"]) @@ -406,7 +410,7 @@ class PublishableClip: self.marker_data.update({"reviewTrack": None}) # create pype tag on track_item and add data - pipeline.imprint(self.current_segment, self.marker_data) + fpipeline.imprint(self.current_segment, self.marker_data) return self.current_segment diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index f1b5ab2236..0cda25804b 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -16,7 +16,7 @@ if not FLAME_V: raise KeyError("Missing key in environment `OPENPYPE_FLAME_VERSION`") try: - from libwiretapPythonClientAPI import ( + from libwiretapPythonClientAPI import ( # noqa WireTapClientInit) except ImportError: flame_python_path = "/opt/Autodesk/flame_{}/python".format(FLAME_V) @@ -26,7 +26,7 @@ except ImportError: sys.path.append(flame_python_path) - from libwiretapPythonClientAPI import ( + from libwiretapPythonClientAPI import ( # noqa WireTapClientInit, WireTapClientUninit, WireTapNodeHandle, diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py index c5fa881f3c..6e7cebd997 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py @@ -5,17 +5,14 @@ from pprint import pformat import atexit import openpype import avalon -import openpype.hosts.flame as opflame - -flh = sys.modules[__name__] -flh._project = None +import openpype.hosts.flame.api as opfapi def openpype_install(): """Registering OpenPype in context """ openpype.install() - avalon.api.install(opflame) + avalon.api.install(opfapi) print("Avalon registred hosts: {}".format( avalon.api.registered_host())) @@ -48,19 +45,19 @@ sys.excepthook = exeption_handler def cleanup(): """Cleaning up Flame framework context """ - if opflame.apps: + if opfapi.CTX.apps: print('`{}` cleaning up apps:\n {}\n'.format( - __file__, pformat(opflame.apps))) - while len(opflame.apps): - app = opflame.apps.pop() + __file__, pformat(opfapi.CTX.apps))) + while len(opfapi.CTX.apps): + app = opfapi.CTX.apps.pop() print('`{}` removing : {}'.format(__file__, app.name)) del app - opflame.apps = [] + opfapi.CTX.apps = [] - if opflame.app_framework: - print('PYTHON\t: %s cleaning up' % opflame.app_framework.bundle_name) - opflame.app_framework.save_prefs() - opflame.app_framework = None + if opfapi.CTX.app_framework: + print('PYTHON\t: %s cleaning up' % opfapi.CTX.app_framework.bundle_name) + opfapi.CTX.app_framework.save_prefs() + opfapi.CTX.app_framework = None atexit.register(cleanup) @@ -69,9 +66,9 @@ atexit.register(cleanup) def load_apps(): """Load available apps into Flame framework """ - opflame.apps.append(opflame.FlameMenuProjectConnect(opflame.app_framework)) - opflame.apps.append(opflame.FlameMenuTimeline(opflame.app_framework)) - opflame.app_framework.log.info("Apps are loaded") + opfapi.CTX.apps.append(opfapi.FlameMenuProjectConnect(opfapi.CTX.app_framework)) + opfapi.CTX.apps.append(opfapi.FlameMenuTimeline(opfapi.CTX.app_framework)) + opfapi.CTX.app_framework.log.info("Apps are loaded") def project_changed_dict(info): @@ -89,10 +86,10 @@ def app_initialized(parent=None): Args: parent (obj, optional): Parent object. Defaults to None. """ - opflame.app_framework = opflame.FlameAppFramework() + opfapi.CTX.app_framework = opfapi.FlameAppFramework() print("{} initializing".format( - opflame.app_framework.bundle_name)) + opfapi.CTX.app_framework.bundle_name)) load_apps() @@ -131,15 +128,15 @@ def _build_app_menu(app_name): # first find the relative appname app = None - for _app in opflame.apps: + for _app in opfapi.CTX.apps: if _app.__class__.__name__ == app_name: app = _app if app: menu.append(app.build_menu()) - if opflame.app_framework: - menu_auto_refresh = opflame.app_framework.prefs_global.get( + if opfapi.CTX.app_framework: + menu_auto_refresh = opfapi.CTX.app_framework.prefs_global.get( 'menu_auto_refresh', {}) if menu_auto_refresh.get('timeline_menu', True): try: @@ -163,8 +160,8 @@ def project_saved(project_name, save_time, is_auto_save): save_time (str): time when it was saved is_auto_save (bool): autosave is on or off """ - if opflame.app_framework: - opflame.app_framework.save_prefs() + if opfapi.CTX.app_framework: + opfapi.CTX.app_framework.save_prefs() def get_main_menu_custom_ui_actions(): diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 8ed8613b15..b9899900f5 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -5,7 +5,7 @@ Flame utils for syncing scripts import os import shutil from openpype.api import Logger -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) def _sync_utility_scripts(env=None): diff --git a/openpype/hosts/flame/api/workio.py b/openpype/hosts/flame/api/workio.py index d2e2408798..0c96c0752a 100644 --- a/openpype/hosts/flame/api/workio.py +++ b/openpype/hosts/flame/api/workio.py @@ -8,7 +8,7 @@ from openpype.api import Logger # ) -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) exported_projet_ext = ".otoc" diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index e7ef856907..5e0ead9414 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -6,6 +6,7 @@ import socket from openpype.lib import ( PreLaunchHook, get_openpype_username) from openpype.hosts import flame as opflame +import openpype.hosts.flame.api as opfapi import openpype from pprint import pformat @@ -79,7 +80,7 @@ class FlamePrelaunch(PreLaunchHook): app_arguments = self._get_launch_arguments(data_to_script) - opflame.setup(self.launch_context.env) + opfapi.setup(self.launch_context.env) self.launch_context.launch_args.extend(app_arguments) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index aea1f387e8..bea30b58bd 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -11,7 +11,7 @@ from . import utils import flame from pprint import pformat -reload(utils) # noqa +reload(utils) # type: ignore log = logging.getLogger(__name__) diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 45c4557dad..70b2908bec 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -1,9 +1,8 @@ from copy import deepcopy -import openpype.hosts.flame as opflame -import openpype.hosts.flame.api.plugin as fplugin -import openpype.hosts.flame.api.lib as flib -reload(fplugin) -reload(flib) +import openpype.hosts.flame.api as opfapi + +reload(opfapi) # noqa + def _get_video_track_names(sequence): track_names = [] @@ -13,7 +12,8 @@ def _get_video_track_names(sequence): return track_names -class CreateShotClip(fplugin.Creator): + +class CreateShotClip(opfapi.Creator): """Publishable clip""" label = "Create Publishable Clip" @@ -22,7 +22,7 @@ class CreateShotClip(fplugin.Creator): defaults = ["Main"] gui_tracks = _get_video_track_names( - flib.get_current_sequence(opflame.selection) + opfapi.get_current_sequence(opfapi.CTX.selection) ) gui_name = "Pype publish attributes creator" gui_info = "Define sequential rename and fill hierarchy data." @@ -267,4 +267,4 @@ class CreateShotClip(fplugin.Creator): self.rename_index = i # convert track item to timeline media pool item - fplugin.PublishableClip(self, segment, **kwargs).convert() + opfapi.PublishableClip(self, segment, **kwargs).convert() diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 29ca08d9b5..97de4e8dde 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -1,12 +1,10 @@ import os import pyblish.api -import openpype.hosts.flame as opflame +import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export as otio_export -from openpype.hosts.flame.api import lib, pipeline from pprint import pformat -reload(lib) # noqa -reload(pipeline) # noqa -reload(otio_export) # noqa +reload(opfapi) # type: ignore +reload(otio_export) # type: ignore @pyblish.api.log @@ -20,9 +18,9 @@ class CollectTestSelection(pyblish.api.ContextPlugin): def process(self, context): self.log.info( - "Active Selection: {}".format(opflame.selection)) + "Active Selection: {}".format(opfapi.CTX.selection)) - sequence = lib.get_current_sequence(opflame.selection) + sequence = opfapi.get_current_sequence(opfapi.CTX.selection) self.test_imprint_data(sequence) self.test_otio_export(sequence) @@ -43,15 +41,15 @@ class CollectTestSelection(pyblish.api.ContextPlugin): self.log.info("Otio exported to: {}".format(export_path)) def test_imprint_data(self, sequence): - with lib.maintained_segment_selection(sequence) as selected_segments: - for segment in selected_segments: + with opfapi.maintained_segment_selection(sequence) as sel_segments: + for segment in sel_segments: if str(segment.name)[1:-1] == "": continue self.log.debug("Segment with OpenPypeData: {}".format( segment.name)) - pipeline.imprint(segment, { + opfapi.imprint(segment, { 'asset': segment.name.get_value(), 'family': 'render', 'subset': 'subsetMain' From b12b27e505619a115d641eb09dba365539f5b7a9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 14:40:00 +0100 Subject: [PATCH 157/395] OP-2204 - added customization of bot appearance --- openpype/modules/slack/manifest.yml | 1 + .../slack/plugins/publish/integrate_slack_api.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/openpype/modules/slack/manifest.yml b/openpype/modules/slack/manifest.yml index 37d4669903..bd920ac266 100644 --- a/openpype/modules/slack/manifest.yml +++ b/openpype/modules/slack/manifest.yml @@ -15,6 +15,7 @@ oauth_config: scopes: bot: - chat:write + - chat:write.customize - chat:write.public - files:write settings: diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index b9f4b9d81f..dd2d4ca048 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -24,6 +24,10 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): optional = True + # internal, not configurable + bot_user_name = "OpenpypeNotifier" + icon_url = "https://openpype.io/img/favicon/favicon.ico" + def process(self, instance): thumbnail_path = self._get_thumbnail_path(instance) review_path = self._get_review_path(instance) @@ -148,7 +152,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): response = client.api_call( "chat.postMessage", channel=channel, - text=message + text=message, + username=self.bot_user_name, + icon_url=self.icon_url ) if response.get("error"): @@ -180,7 +186,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): _ = client.chat_postMessage( channel=channel, - text=message + text=message, + username=self.bot_user_name, + icon_url=self.icon_url ) except SlackApiError as e: # You will get a SlackApiError if "ok" is False From 8121a532db97e45f8414a457678d7e250f703acc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 14:40:32 +0100 Subject: [PATCH 158/395] hound_ suggestions --- openpype/hosts/flame/api/menu.py | 2 +- openpype/hosts/flame/api/pipeline.py | 3 ++- .../hosts/flame/api/utility_scripts/openpype_in_flame.py | 7 +++++-- openpype/hosts/flame/hooks/pre_flame_setup.py | 1 - openpype/hosts/flame/otio/flame_export.py | 2 +- .../hosts/flame/plugins/publish/collect_test_selection.py | 6 +++--- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index 642c40a7df..c4a18496d3 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -29,7 +29,7 @@ def callback_selection(selection, function): import openpype.hosts.flame.api as opfapi opfapi.CTX.selection = selection print("Hook Selection: \n\t{}".format( - pformat({type(item): item.name for item in CTX.selection}) + pformat({type(item): item.name for item in opfapi.CTX.selection}) )) function() diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 5333a07210..f454c33209 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -163,6 +163,7 @@ def imprint(segment, data=None): # add publish attribute set_publish_attribute(segment, True) + @contextlib.contextmanager def maintained_selection(): import flame @@ -175,4 +176,4 @@ def maintained_selection(): with maintained_segment_selection(sequence): yield finally: - pass \ No newline at end of file + pass diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py index 6e7cebd997..c385fbb8cb 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py @@ -55,7 +55,9 @@ def cleanup(): opfapi.CTX.apps = [] if opfapi.CTX.app_framework: - print('PYTHON\t: %s cleaning up' % opfapi.CTX.app_framework.bundle_name) + print('openpype\t: {} cleaning up'.format( + opfapi.CTX.app_framework.bundle_name) + ) opfapi.CTX.app_framework.save_prefs() opfapi.CTX.app_framework = None @@ -66,7 +68,8 @@ atexit.register(cleanup) def load_apps(): """Load available apps into Flame framework """ - opfapi.CTX.apps.append(opfapi.FlameMenuProjectConnect(opfapi.CTX.app_framework)) + opfapi.CTX.apps.append( + opfapi.FlameMenuProjectConnect(opfapi.CTX.app_framework)) opfapi.CTX.apps.append(opfapi.FlameMenuTimeline(opfapi.CTX.app_framework)) opfapi.CTX.app_framework.log.info("Apps are loaded") diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 5e0ead9414..512433b718 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -79,7 +79,6 @@ class FlamePrelaunch(PreLaunchHook): app_arguments = self._get_launch_arguments(data_to_script) - opfapi.setup(self.launch_context.env) self.launch_context.launch_args.extend(app_arguments) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index bea30b58bd..aea1f387e8 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -11,7 +11,7 @@ from . import utils import flame from pprint import pformat -reload(utils) # type: ignore +reload(utils) # noqa log = logging.getLogger(__name__) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 97de4e8dde..0431bd1fe3 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -3,8 +3,8 @@ import pyblish.api import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export as otio_export from pprint import pformat -reload(opfapi) # type: ignore -reload(otio_export) # type: ignore +reload(opfapi) # noqa +reload(otio_export) # noqa @pyblish.api.log @@ -35,7 +35,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): otio_timeline = otio_export.create_otio_timeline(sequence) otio_export.write_to_file( otio_timeline, export_path - ) + ) self.log.info(pformat(otio_timeline)) self.log.info("Otio exported to: {}".format(export_path)) From 6ca2ae704bbc76e61dbc439cff23dee8cb25f85d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 14:50:17 +0100 Subject: [PATCH 159/395] Update openpype/modules/slack/plugins/publish/integrate_slack_api.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../plugins/publish/integrate_slack_api.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index b9f4b9d81f..e094c268da 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -75,16 +75,13 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): if review_path: fill_pairs.append(("review_link", review_path)) - task_on_instance = instance.data.get("task") - task_on_anatomy = fill_data.get("task") - if task_on_instance: - fill_pairs.append(("task[name]", task_on_instance.get("type"))) - fill_pairs.append(("task[name]", task_on_instance.get("name"))) - fill_pairs.append(("task[short]", task_on_instance.get("short"))) - elif task_on_anatomy: - fill_pairs.append(("task[name]", task_on_anatomy.get("type"))) - fill_pairs.append(("task[name]", task_on_anatomy.get("name"))) - fill_pairs.append(("task[short]", task_on_anatomy.get("short"))) + task_data = instance.data.get("task") + if not task_data: + task_data = fill_data.get("task") + for key, value in task_data.items(): + fill_key = "task[{}]".format(key) + fill_pairs.append((fill_key , value)) + fill_pairs.append(("task", task_data["name"])) self.log.debug("fill_pairs ::{}".format(fill_pairs)) multiple_case_variants = prepare_template_data(fill_pairs) From 69b9c06f6a79236ea1c9ceeaee83ef79db5a29fd Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 14:57:32 +0100 Subject: [PATCH 160/395] OP-2204 - renamed placeholder to review_filepath --- .../slack/plugins/publish/integrate_slack_api.py | 4 ++-- website/docs/module_slack.md | 15 +++------------ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index e094c268da..cdc90a7a28 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -15,7 +15,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): If instance contains 'thumbnail' it uploads it. Bot must be present in the target channel. If instance contains 'review' it could upload (if configured) or place - link with {review_link} placeholder. + link with {review_filepath} placeholder. Message template can contain {} placeholders from anatomyData. """ order = pyblish.api.IntegratorOrder + 0.499 @@ -73,7 +73,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): fill_data.get("version")))) ] if review_path: - fill_pairs.append(("review_link", review_path)) + fill_pairs.append(("review_filepath", review_path)) task_data = instance.data.get("task") if not task_data: diff --git a/website/docs/module_slack.md b/website/docs/module_slack.md index 10d5e58eac..d74ff3a290 100644 --- a/website/docs/module_slack.md +++ b/website/docs/module_slack.md @@ -78,23 +78,14 @@ Message content can use Templating (see [Available template keys](admin_settings Few keys also have Capitalized and UPPERCASE format. Values will be modified accordingly ({Asset} >> "Asset", {FAMILY} >> "RENDER"). -**Available keys:** -- asset -- subset -- task\[name\] -- task\[type\] -- task\[short\] -- username -- app -- family -- version -- review_link +**Additional implemented keys:** +- review_filepath ##### Message example ``` {Subset} was published for {ASSET} in {task[name]} task. -Here you can find review {review_link} +Here you can find review {review_filepath} ``` #### Message retention From 75b828022103a7385a5c25116d1bcb54967fa833 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 15:07:13 +0100 Subject: [PATCH 161/395] flame: review comment https://github.com/pypeclub/OpenPype/pull/2495#discussion_r779708538 --- .../hosts/flame/api/scripts/wiretap_com.py | 33 +++++-------------- openpype/hosts/flame/hooks/pre_flame_setup.py | 20 ++++++++--- .../system_settings/applications.json | 3 +- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index 0cda25804b..4e54dfd913 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -9,31 +9,14 @@ import json import xml.dom.minidom as minidom from copy import deepcopy import datetime - -FLAME_V = os.getenv("OPENPYPE_FLAME_VERSION") - -if not FLAME_V: - raise KeyError("Missing key in environment `OPENPYPE_FLAME_VERSION`") - -try: - from libwiretapPythonClientAPI import ( # noqa - WireTapClientInit) -except ImportError: - flame_python_path = "/opt/Autodesk/flame_{}/python".format(FLAME_V) - flame_exe_path = ( - "/opt/Autodesk/flame_{}/bin/flame.app" - "/Contents/MacOS/startApp").format(FLAME_V) - - sys.path.append(flame_python_path) - - from libwiretapPythonClientAPI import ( # noqa - WireTapClientInit, - WireTapClientUninit, - WireTapNodeHandle, - WireTapServerHandle, - WireTapInt, - WireTapStr - ) +from libwiretapPythonClientAPI import ( # noqa + WireTapClientInit, + WireTapClientUninit, + WireTapNodeHandle, + WireTapServerHandle, + WireTapInt, + WireTapStr +) class WireTapCom(object): diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 512433b718..fc6b65c958 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -20,10 +20,8 @@ class FlamePrelaunch(PreLaunchHook): app_groups = ["flame"] # todo: replace version number with avalon launch app version - flame_python_exe = ( - "/opt/Autodesk/python/{OPENPYPE_FLAME_VERSION}" - "/bin/python2.7" - ) + flame_python_exe = os.getenv("OPENPYPE_FLAME_PYTHON_EXEC") + flame_pythonpath = os.getenv("OPENPYPE_FLAME_PYTHONPATH") wtc_script_path = os.path.join( opflame.HOST_DIR, "api", "scripts", "wiretap_com.py") @@ -60,7 +58,6 @@ class FlamePrelaunch(PreLaunchHook): "FieldDominance": "PROGRESSIVE" } - data_to_script = { # from settings "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, @@ -77,12 +74,25 @@ class FlamePrelaunch(PreLaunchHook): self.log.info(pformat(dict(_env))) self.log.info(pformat(data_to_script)) + # add to python path from settings + self._add_pythonpath() + app_arguments = self._get_launch_arguments(data_to_script) opfapi.setup(self.launch_context.env) self.launch_context.launch_args.extend(app_arguments) + def _add_pythonpath(self): + pythonpath = self.launch_context.env.get("PYTHONPATH") + + # separate it explicity by `;` that is what we use in settings + new_pythonpath = self.flame_pythonpath.split(";") + new_pythonpath += pythonpath.split(os.pathsep) + + self.launch_context.env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) + + def _get_launch_arguments(self, script_data): # Dump data to string dumped_script_data = json.dumps(script_data) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 3a097d2b37..7fe0432fdf 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -130,7 +130,8 @@ "linux": [] }, "environment": { - "OPENPYPE_FLAME_VERSION": "2021" + "OPENPYPE_FLAME_PYTHON_EXEC": "/opt/Autodesk/python/2021/bin/python2.7", + "OPENPYPE_FLAME_PYTHONPATH": "/opt/Autodesk/flame_2021/python" } }, "__dynamic_keys_labels__": { From 8a7c4772aaf4838b1cdaa03bc454b848b9eacf23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 7 Jan 2022 15:09:45 +0100 Subject: [PATCH 162/395] Update openpype/hosts/flame/api/plugin.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/flame/api/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 4f71f9424e..b291a6ea06 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -134,9 +134,9 @@ class CreatorWidget(QtWidgets.QDialog): '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) return " ".join([str(m.group(0)).capitalize() for m in matches]) - def create_row(self, layout, type, text, **kwargs): + def create_row(self, layout, type_name, text, **kwargs): # get type attribute from qwidgets - attr = getattr(QtWidgets, type) + attr = getattr(QtWidgets, type_name) # convert label text to normal capitalized text with spaces label_text = self.camel_case_split(text) From 389e90670ed5181d9dd45211950f67f9aec1cc5e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 15:16:40 +0100 Subject: [PATCH 163/395] flame: improving creator gui --- openpype/hosts/flame/api/plugin.py | 29 ++++--------------- .../flame/plugins/create/create_shot_clip.py | 2 +- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 4f71f9424e..3f93262e6f 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -38,7 +38,7 @@ class CreatorWidget(QtWidgets.QDialog): self.content_widget = [QtWidgets.QWidget(self)] top_layout = QtWidgets.QFormLayout(self.content_widget[0]) top_layout.setObjectName("ContentLayout") - top_layout.addWidget(Spacer(5, self)) + top_layout.addSpacing(5) # first add widget tag line top_layout.addWidget(QtWidgets.QLabel(info)) @@ -202,13 +202,13 @@ class CreatorWidget(QtWidgets.QDialog): v = data[k] tool_tip = v.get("toolTip", "") if v["type"] == "dict": - # adding spacer between sections self.content_layout.append(QtWidgets.QWidget(self)) content_layout.addWidget(self.content_layout[-1]) self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addWidget(Spacer(20, self)) + headline.addSpacing(20) + headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label @@ -225,13 +225,12 @@ class CreatorWidget(QtWidgets.QDialog): v["value"], nested_content_layout) if v["type"] == "section": - # adding spacer between sections self.content_layout.append(QtWidgets.QWidget(self)) content_layout.addWidget(self.content_layout[-1]) self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addWidget(Spacer(20, self)) + headline.addSpacing(20) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label @@ -267,23 +266,6 @@ class CreatorWidget(QtWidgets.QDialog): return data -class Spacer(QtWidgets.QWidget): - def __init__(self, height, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) - - self.setFixedHeight(height) - - real_spacer = QtWidgets.QWidget(self) - real_spacer.setObjectName("Spacer") - real_spacer.setFixedHeight(height) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(real_spacer) - - self.setLayout(layout) - - class Creator(openpype.Creator): """Creator class wrapper """ @@ -304,7 +286,8 @@ class Creator(openpype.Creator): else: self.selected = flib.get_sequence_segments(self.sequence) - self.widget = CreatorWidget + def create_widget(self, *args, **kwargs): + return CreatorWidget(*args, **kwargs) class PublishableClip: diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 70b2908bec..7129b965ac 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -232,7 +232,7 @@ class CreateShotClip(opfapi.Creator): gui_inputs[k]["value"] = presets[k] # open widget for plugins inputs - widget = self.widget(self.gui_name, self.gui_info, gui_inputs) + widget = self.create_widget(self.gui_name, self.gui_info, gui_inputs) widget.exec_() if len(self.selected) < 1: From ae02ad0d86fc7cf1207017729db24e7b48f326ad Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 15:47:35 +0100 Subject: [PATCH 164/395] flame: refactory creator plugin with abstract class --- openpype/hosts/flame/api/plugin.py | 6 +- .../flame/plugins/create/create_shot_clip.py | 413 +++++++++--------- 2 files changed, 208 insertions(+), 211 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index b0f7568e50..6122b7bf1f 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -326,10 +326,8 @@ class PublishableClip: vertical_sync_default = False driving_layer_default = "" - def __init__(self, cls, segment, **kwargs): - # populate input cls attribute onto self.[attr] - self.__dict__.update(cls.__dict__) - + def __init__(self, segment, **kwargs): + self.rename_index = kwargs["rename_index"] # get main parent objects self.current_segment = segment sequence_name = flib.get_current_sequence([segment]).name.get_value() diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 7129b965ac..866b5108fa 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -4,15 +4,6 @@ import openpype.hosts.flame.api as opfapi reload(opfapi) # noqa -def _get_video_track_names(sequence): - track_names = [] - for ver in sequence.versions: - for track in ver.tracks: - track_names.append(track.name.get_value()) - - return track_names - - class CreateShotClip(opfapi.Creator): """Publishable clip""" @@ -21,203 +12,12 @@ class CreateShotClip(opfapi.Creator): icon = "film" defaults = ["Main"] - gui_tracks = _get_video_track_names( - opfapi.get_current_sequence(opfapi.CTX.selection) - ) - gui_name = "Pype publish attributes creator" - gui_info = "Define sequential rename and fill hierarchy data." - gui_inputs = { - "renameHierarchy": { - "type": "section", - "label": "Shot Hierarchy And Rename Settings", - "target": "ui", - "order": 0, - "value": { - "hierarchy": { - "value": "{folder}/{sequence}", - "type": "QLineEdit", - "label": "Shot Parent Hierarchy", - "target": "tag", - "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa - "order": 0}, - "clipRename": { - "value": False, - "type": "QCheckBox", - "label": "Rename clips", - "target": "ui", - "toolTip": "Renaming selected clips on fly", # noqa - "order": 1}, - "clipName": { - "value": "{sequence}{shot}", - "type": "QLineEdit", - "label": "Clip Name Template", - "target": "ui", - "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa - "order": 2}, - "countFrom": { - "value": 10, - "type": "QSpinBox", - "label": "Count sequence from", - "target": "ui", - "toolTip": "Set when the sequence number stafrom", # noqa - "order": 3}, - "countSteps": { - "value": 10, - "type": "QSpinBox", - "label": "Stepping number", - "target": "ui", - "toolTip": "What number is adding every new step", # noqa - "order": 4}, - } - }, - "hierarchyData": { - "type": "dict", - "label": "Shot Template Keywords", - "target": "tag", - "order": 1, - "value": { - "folder": { - "value": "shots", - "type": "QLineEdit", - "label": "{folder}", - "target": "tag", - "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 0}, - "episode": { - "value": "ep01", - "type": "QLineEdit", - "label": "{episode}", - "target": "tag", - "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 1}, - "sequence": { - "value": "sq01", - "type": "QLineEdit", - "label": "{sequence}", - "target": "tag", - "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 2}, - "track": { - "value": "{_track_}", - "type": "QLineEdit", - "label": "{track}", - "target": "tag", - "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 3}, - "shot": { - "value": "sh###", - "type": "QLineEdit", - "label": "{shot}", - "target": "tag", - "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 4} - } - }, - "verticalSync": { - "type": "section", - "label": "Vertical Synchronization Of Attributes", - "target": "ui", - "order": 2, - "value": { - "vSyncOn": { - "value": True, - "type": "QCheckBox", - "label": "Enable Vertical Sync", - "target": "ui", - "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa - "order": 0}, - "vSyncTrack": { - "value": gui_tracks, # noqa - "type": "QComboBox", - "label": "Hero track", - "target": "ui", - "toolTip": "Select driving track name which should be hero for all others", # noqa - "order": 1} - } - }, - "publishSettings": { - "type": "section", - "label": "Publish Settings", - "target": "ui", - "order": 3, - "value": { - "subsetName": { - "value": ["[ track name ]", "main", "bg", "fg", "bg", - "animatic"], - "type": "QComboBox", - "label": "Subset Name", - "target": "ui", - "toolTip": "chose subset name patern, if [ track name ] is selected, name of track layer will be used", # noqa - "order": 0}, - "subsetFamily": { - "value": ["plate", "take"], - "type": "QComboBox", - "label": "Subset Family", - "target": "ui", "toolTip": "What use of this subset is for", # noqa - "order": 1}, - "reviewTrack": { - "value": ["< none >"] + gui_tracks, - "type": "QComboBox", - "label": "Use Review Track", - "target": "ui", - "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa - "order": 2}, - "audio": { - "value": False, - "type": "QCheckBox", - "label": "Include audio", - "target": "tag", - "toolTip": "Process subsets with corresponding audio", # noqa - "order": 3}, - "sourceResolution": { - "value": False, - "type": "QCheckBox", - "label": "Source resolution", - "target": "tag", - "toolTip": "Is resloution taken from timeline or source?", # noqa - "order": 4}, - } - }, - "frameRangeAttr": { - "type": "section", - "label": "Shot Attributes", - "target": "ui", - "order": 4, - "value": { - "workfileFrameStart": { - "value": 1001, - "type": "QSpinBox", - "label": "Workfiles Start Frame", - "target": "tag", - "toolTip": "Set workfile starting frame number", # noqa - "order": 0 - }, - "handleStart": { - "value": 0, - "type": "QSpinBox", - "label": "Handle Start", - "target": "tag", - "toolTip": "Handle at start of clip", # noqa - "order": 1 - }, - "handleEnd": { - "value": 0, - "type": "QSpinBox", - "label": "Handle End", - "target": "tag", - "toolTip": "Handle at end of clip", # noqa - "order": 2 - } - } - } - } - presets = None def process(self): # Creator copy of object attributes that are modified during `process` presets = deepcopy(self.presets) - gui_inputs = deepcopy(self.gui_inputs) + gui_inputs = self.get_gui_inputs() # get key pares from presets and match it on ui inputs for k, v in gui_inputs.items(): @@ -232,7 +32,11 @@ class CreateShotClip(opfapi.Creator): gui_inputs[k]["value"] = presets[k] # open widget for plugins inputs - widget = self.create_widget(self.gui_name, self.gui_info, gui_inputs) + widget = self.create_widget( + "Pype publish attributes creator", + "Define sequential rename and fill hierarchy data.", + gui_inputs + ) widget.exec_() if len(self.selected) < 1: @@ -242,8 +46,6 @@ class CreateShotClip(opfapi.Creator): print("Operation aborted") return - self.rename_add = 0 - # get ui output for track name for vertical sync v_sync_track = widget.result["vSyncTrack"]["value"] @@ -264,7 +66,204 @@ class CreateShotClip(opfapi.Creator): } for i, segment in enumerate(sorted_selected_segments): - self.rename_index = i - + kwargs["rename_index"] = i # convert track item to timeline media pool item - opfapi.PublishableClip(self, segment, **kwargs).convert() + opfapi.PublishableClip(segment, **kwargs).convert() + + def get_gui_inputs(self): + gui_tracks = self._get_video_track_names( + opfapi.get_current_sequence(opfapi.CTX.selection) + ) + return deepcopy({ + "renameHierarchy": { + "type": "section", + "label": "Shot Hierarchy And Rename Settings", + "target": "ui", + "order": 0, + "value": { + "hierarchy": { + "value": "{folder}/{sequence}", + "type": "QLineEdit", + "label": "Shot Parent Hierarchy", + "target": "tag", + "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa + "order": 0}, + "clipRename": { + "value": False, + "type": "QCheckBox", + "label": "Rename clips", + "target": "ui", + "toolTip": "Renaming selected clips on fly", # noqa + "order": 1}, + "clipName": { + "value": "{sequence}{shot}", + "type": "QLineEdit", + "label": "Clip Name Template", + "target": "ui", + "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa + "order": 2}, + "countFrom": { + "value": 10, + "type": "QSpinBox", + "label": "Count sequence from", + "target": "ui", + "toolTip": "Set when the sequence number stafrom", # noqa + "order": 3}, + "countSteps": { + "value": 10, + "type": "QSpinBox", + "label": "Stepping number", + "target": "ui", + "toolTip": "What number is adding every new step", # noqa + "order": 4}, + } + }, + "hierarchyData": { + "type": "dict", + "label": "Shot Template Keywords", + "target": "tag", + "order": 1, + "value": { + "folder": { + "value": "shots", + "type": "QLineEdit", + "label": "{folder}", + "target": "tag", + "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 0}, + "episode": { + "value": "ep01", + "type": "QLineEdit", + "label": "{episode}", + "target": "tag", + "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 1}, + "sequence": { + "value": "sq01", + "type": "QLineEdit", + "label": "{sequence}", + "target": "tag", + "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 2}, + "track": { + "value": "{_track_}", + "type": "QLineEdit", + "label": "{track}", + "target": "tag", + "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 3}, + "shot": { + "value": "sh###", + "type": "QLineEdit", + "label": "{shot}", + "target": "tag", + "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 4} + } + }, + "verticalSync": { + "type": "section", + "label": "Vertical Synchronization Of Attributes", + "target": "ui", + "order": 2, + "value": { + "vSyncOn": { + "value": True, + "type": "QCheckBox", + "label": "Enable Vertical Sync", + "target": "ui", + "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa + "order": 0}, + "vSyncTrack": { + "value": gui_tracks, # noqa + "type": "QComboBox", + "label": "Hero track", + "target": "ui", + "toolTip": "Select driving track name which should be hero for all others", # noqa + "order": 1} + } + }, + "publishSettings": { + "type": "section", + "label": "Publish Settings", + "target": "ui", + "order": 3, + "value": { + "subsetName": { + "value": ["[ track name ]", "main", "bg", "fg", "bg", + "animatic"], + "type": "QComboBox", + "label": "Subset Name", + "target": "ui", + "toolTip": "chose subset name patern, if [ track name ] is selected, name of track layer will be used", # noqa + "order": 0}, + "subsetFamily": { + "value": ["plate", "take"], + "type": "QComboBox", + "label": "Subset Family", + "target": "ui", "toolTip": "What use of this subset is for", # noqa + "order": 1}, + "reviewTrack": { + "value": ["< none >"] + gui_tracks, + "type": "QComboBox", + "label": "Use Review Track", + "target": "ui", + "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa + "order": 2}, + "audio": { + "value": False, + "type": "QCheckBox", + "label": "Include audio", + "target": "tag", + "toolTip": "Process subsets with corresponding audio", # noqa + "order": 3}, + "sourceResolution": { + "value": False, + "type": "QCheckBox", + "label": "Source resolution", + "target": "tag", + "toolTip": "Is resloution taken from timeline or source?", # noqa + "order": 4}, + } + }, + "frameRangeAttr": { + "type": "section", + "label": "Shot Attributes", + "target": "ui", + "order": 4, + "value": { + "workfileFrameStart": { + "value": 1001, + "type": "QSpinBox", + "label": "Workfiles Start Frame", + "target": "tag", + "toolTip": "Set workfile starting frame number", # noqa + "order": 0 + }, + "handleStart": { + "value": 0, + "type": "QSpinBox", + "label": "Handle Start", + "target": "tag", + "toolTip": "Handle at start of clip", # noqa + "order": 1 + }, + "handleEnd": { + "value": 0, + "type": "QSpinBox", + "label": "Handle End", + "target": "tag", + "toolTip": "Handle at end of clip", # noqa + "order": 2 + } + } + } + }) + + def _get_video_track_names(self, sequence): + track_names = [] + for ver in sequence.versions: + for track in ver.tracks: + track_names.append(track.name.get_value()) + + return track_names From d1b4ac5d40cb922dc678e472bef71d61ebeb3582 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 15:52:19 +0100 Subject: [PATCH 165/395] moved timers manager one hierarchy higher --- openpype/modules/{default_modules => }/timers_manager/__init__.py | 0 .../modules/{default_modules => }/timers_manager/exceptions.py | 0 .../modules/{default_modules => }/timers_manager/idle_threads.py | 0 .../timers_manager/launch_hooks/post_start_timer.py | 0 openpype/modules/{default_modules => }/timers_manager/rest_api.py | 0 .../{default_modules => }/timers_manager/timers_manager.py | 0 .../{default_modules => }/timers_manager/widget_user_idle.py | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename openpype/modules/{default_modules => }/timers_manager/__init__.py (100%) rename openpype/modules/{default_modules => }/timers_manager/exceptions.py (100%) rename openpype/modules/{default_modules => }/timers_manager/idle_threads.py (100%) rename openpype/modules/{default_modules => }/timers_manager/launch_hooks/post_start_timer.py (100%) rename openpype/modules/{default_modules => }/timers_manager/rest_api.py (100%) rename openpype/modules/{default_modules => }/timers_manager/timers_manager.py (100%) rename openpype/modules/{default_modules => }/timers_manager/widget_user_idle.py (100%) diff --git a/openpype/modules/default_modules/timers_manager/__init__.py b/openpype/modules/timers_manager/__init__.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/__init__.py rename to openpype/modules/timers_manager/__init__.py diff --git a/openpype/modules/default_modules/timers_manager/exceptions.py b/openpype/modules/timers_manager/exceptions.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/exceptions.py rename to openpype/modules/timers_manager/exceptions.py diff --git a/openpype/modules/default_modules/timers_manager/idle_threads.py b/openpype/modules/timers_manager/idle_threads.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/idle_threads.py rename to openpype/modules/timers_manager/idle_threads.py diff --git a/openpype/modules/default_modules/timers_manager/launch_hooks/post_start_timer.py b/openpype/modules/timers_manager/launch_hooks/post_start_timer.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/launch_hooks/post_start_timer.py rename to openpype/modules/timers_manager/launch_hooks/post_start_timer.py diff --git a/openpype/modules/default_modules/timers_manager/rest_api.py b/openpype/modules/timers_manager/rest_api.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/rest_api.py rename to openpype/modules/timers_manager/rest_api.py diff --git a/openpype/modules/default_modules/timers_manager/timers_manager.py b/openpype/modules/timers_manager/timers_manager.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/timers_manager.py rename to openpype/modules/timers_manager/timers_manager.py diff --git a/openpype/modules/default_modules/timers_manager/widget_user_idle.py b/openpype/modules/timers_manager/widget_user_idle.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/widget_user_idle.py rename to openpype/modules/timers_manager/widget_user_idle.py From 00118b249a6555d1c75e077399d9a3a4a87bf1f6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 15:52:32 +0100 Subject: [PATCH 166/395] added timers manager to default modules --- openpype/modules/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index b5c491a1c0..d566692439 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -42,6 +42,7 @@ DEFAULT_OPENPYPE_MODULES = ( "settings_action", "standalonepublish_action", "job_queue", + "timers_manager", ) From d0faab89f96b35b297d3117bea619d2dc8cc4c5a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 15:55:53 +0100 Subject: [PATCH 167/395] flame: adding wiretap tools dir to app env var --- openpype/hosts/flame/api/scripts/wiretap_com.py | 17 +++++++---------- .../defaults/system_settings/applications.json | 3 ++- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index 4e54dfd913..2cd9a46184 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -43,6 +43,9 @@ class WireTapCom(object): self.volume_name = volume_name or "stonefs" self.group_name = group_name or "staff" + # wiretap tools dir path + self.wiretap_tools_dir = os.getenv("OPENPYPE_WIRETAP_TOOLS") + # initialize WireTap client WireTapClientInit() @@ -166,11 +169,8 @@ class WireTapCom(object): # form cmd arguments project_create_cmd = [ os.path.join( - "/opt/Autodesk/", - "wiretap", - "tools", - FLAME_V, - "wiretap_create_node", + self.wiretap_tools_dir, + "wiretap_create_node" ), '-n', os.path.join("/volumes", self.volume_name), @@ -422,11 +422,8 @@ class WireTapCom(object): color_policy = color_policy or "Legacy" project_colorspace_cmd = [ os.path.join( - "/opt/Autodesk/", - "wiretap", - "tools", - FLAME_V, - "wiretap_duplicate_node", + self.wiretap_tools_dir, + "wiretap_duplicate_node" ), "-s", "/syncolor/policies/Autodesk/{}".format(color_policy), diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 7fe0432fdf..4a8b6d82a2 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -131,7 +131,8 @@ }, "environment": { "OPENPYPE_FLAME_PYTHON_EXEC": "/opt/Autodesk/python/2021/bin/python2.7", - "OPENPYPE_FLAME_PYTHONPATH": "/opt/Autodesk/flame_2021/python" + "OPENPYPE_FLAME_PYTHONPATH": "/opt/Autodesk/flame_2021/python", + "OPENPYPE_WIRETAP_TOOLS": "/opt/Autodesk/wiretap/tools/2021" } }, "__dynamic_keys_labels__": { From a24b43451dde32333eec70d410fd8791a29fb696 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 16:18:11 +0100 Subject: [PATCH 168/395] flame: fix wrong selection import --- openpype/hosts/flame/api/__init__.py | 2 -- openpype/hosts/flame/api/lib.py | 34 ---------------------------- openpype/hosts/flame/api/plugin.py | 3 +-- 3 files changed, 1 insertion(+), 38 deletions(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index c8660aafc4..dc47488dc1 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -11,7 +11,6 @@ from .constants import ( from .lib import ( CTX, FlameAppFramework, - maintain_current_timeline, get_project_manager, get_current_project, get_current_sequence, @@ -68,7 +67,6 @@ __all__ = [ # lib "CTX", "FlameAppFramework", - "maintain_current_timeline", "get_project_manager", "get_current_project", "get_current_sequence", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index ccc664ce63..b37cc35afd 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -225,40 +225,6 @@ class FlameAppFramework(object): return True -@contextlib.contextmanager -def maintain_current_timeline(to_timeline, from_timeline=None): - """Maintain current timeline selection during context - - Attributes: - from_timeline (resolve.Timeline)[optional]: - Example: - >>> print(from_timeline.GetName()) - timeline1 - >>> print(to_timeline.GetName()) - timeline2 - - >>> with maintain_current_timeline(to_timeline): - ... print(get_current_sequence().GetName()) - timeline2 - - >>> print(get_current_sequence().GetName()) - timeline1 - """ - # todo: this is still Resolve's implementation - project = get_current_project() - working_timeline = from_timeline or project.GetCurrentTimeline() - - # swith to the input timeline - project.SetCurrentTimeline(to_timeline) - - try: - # do a work - yield - finally: - # put the original working timeline to context - project.SetCurrentTimeline(working_timeline) - - def get_project_manager(): # TODO: get_project_manager return diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 6122b7bf1f..1ae62f3a8d 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -2,7 +2,6 @@ import re from Qt import QtWidgets, QtCore import openpype.api as openpype from openpype import style -from . import selection as opfapi_selection from . import ( lib as flib, pipeline as fpipeline, @@ -279,7 +278,7 @@ class Creator(openpype.Creator): # adding basic current context flame objects self.project = flib.get_current_project() - self.sequence = flib.get_current_sequence(opfapi_selection) + self.sequence = flib.get_current_sequence(flib.CTX.selection) if (self.options or {}).get("useSelection"): self.selected = flib.get_sequence_segments(self.sequence, True) From b56fabbf8c01e2e5b82112e340c2a2a835018afd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 16:34:28 +0100 Subject: [PATCH 169/395] added new image and define new color for delete button --- openpype/style/data.json | 3 +++ openpype/style/style.css | 7 ++++++ .../project_manager/images/warning.png | Bin 0 -> 9393 bytes .../project_manager/project_manager/style.py | 21 ++++++++++++++++++ 4 files changed, 31 insertions(+) create mode 100644 openpype/tools/project_manager/project_manager/images/warning.png diff --git a/openpype/style/data.json b/openpype/style/data.json index 026eaf4264..205e30563b 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -51,6 +51,9 @@ "border-hover": "rgba(168, 175, 189, .3)", "border-focus": "rgb(92, 173, 214)", + "delete-btn-bg": "rgb(201, 54, 54)", + "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", + "tab-widget": { "bg": "#21252B", "bg-selected": "#434a56", diff --git a/openpype/style/style.css b/openpype/style/style.css index 4159fe1676..8f613f3888 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -713,6 +713,13 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:bg-view-hover}; } +#DeleteButton { + background: {color:delete-btn-bg}; +} +#DeleteButton:disabled { + background: {color:delete-btn-bg-disabled}; +} + /* Launcher specific stylesheets */ #IconView[mode="icon"] { /* font size can't be set on items */ diff --git a/openpype/tools/project_manager/project_manager/images/warning.png b/openpype/tools/project_manager/project_manager/images/warning.png new file mode 100644 index 0000000000000000000000000000000000000000..3b4ae861f9f3982195c82d681d4b3f31cdea9df8 GIT binary patch literal 9393 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 H|MdR=mHnnz literal 0 HcmV?d00001 diff --git a/openpype/tools/project_manager/project_manager/style.py b/openpype/tools/project_manager/project_manager/style.py index d3d6857a63..9fa7a5520b 100644 --- a/openpype/tools/project_manager/project_manager/style.py +++ b/openpype/tools/project_manager/project_manager/style.py @@ -1,6 +1,7 @@ import os from Qt import QtCore, QtGui +from openpype.style import get_objected_colors from avalon.vendor import qtawesome @@ -90,6 +91,17 @@ class ResourceCache: icon.addPixmap(disabled_pix, QtGui.QIcon.Disabled, QtGui.QIcon.Off) return icon + @classmethod + def get_warning_pixmap(cls): + src_image = get_warning_image() + colors = get_objected_colors() + color_value = colors["delete-btn-bg"] + + return paint_image_with_color( + src_image, + color_value.get_qcolor() + ) + def get_remove_image(): image_path = os.path.join( @@ -100,6 +112,15 @@ def get_remove_image(): return QtGui.QImage(image_path) +def get_warning_image(): + image_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "images", + "warning.png" + ) + return QtGui.QImage(image_path) + + def paint_image_with_color(image, color): """TODO: This function should be imported from utils. From 53ad6027c5d30c365a8ea52da59f83b8c708a169 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 16:36:26 +0100 Subject: [PATCH 170/395] changed buttons classes and mark delete btn with DeleteButton object name --- openpype/tools/project_manager/project_manager/widgets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 392f3f4503..20a6955d81 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -352,9 +352,10 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): "Type \"{}\" to confirm...".format(project_name) ) - cancel_btn = _SameSizeBtns("Cancel", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") - confirm_btn = _SameSizeBtns("Delete", self) + confirm_btn = QtWidgets.QPushButton("Permanently Delete Project", self) + confirm_btn.setObjectName("DeleteButton") confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") From 857d22690deb8bc95df8bef3d1c2bf164beeb6b9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 16:36:50 +0100 Subject: [PATCH 171/395] added warning pixmap into dialog --- .../project_manager/widgets.py | 87 +++++++++++-------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 20a6955d81..45599ab747 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -4,6 +4,7 @@ from .constants import ( NAME_ALLOWED_SYMBOLS, NAME_REGEX ) +from .style import ResourceCache from openpype.lib import ( create_project, PROJECT_NAME_ALLOWED_SYMBOLS, @@ -13,7 +14,7 @@ from openpype.style import load_stylesheet from openpype.tools.utils import PlaceholderLineEdit from avalon.api import AvalonMongoDB -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui class NameTextEdit(QtWidgets.QLineEdit): @@ -291,42 +292,41 @@ class CreateProjectDialog(QtWidgets.QDialog): return project_names, project_codes -class _SameSizeBtns(QtWidgets.QPushButton): - """Button that keep width of all button added as related. +# TODO PixmapLabel should be moved to 'utils' in other future PR so should be +# imported from there +class PixmapLabel(QtWidgets.QLabel): + """Label resizing image to height of font.""" + def __init__(self, pixmap, parent): + super(PixmapLabel, self).__init__(parent) + self._empty_pixmap = QtGui.QPixmap(0, 0) + self._source_pixmap = pixmap - This happens without changing min/max/fix size of button. Which is - welcomed for multidisplay desktops with different resolution. - """ - def __init__(self, *args, **kwargs): - super(_SameSizeBtns, self).__init__(*args, **kwargs) - self._related_btns = [] + def set_source_pixmap(self, pixmap): + """Change source image.""" + self._source_pixmap = pixmap + self._set_resized_pix() - def add_related_btn(self, btn): - """Add related button which should be checked for width. + def _get_pix_size(self): + size = self.fontMetrics().height() * 4 + return size, size - Args: - btn (_SameSizeBtns): Other object of _SameSizeBtns. - """ - self._related_btns.append(btn) + def _set_resized_pix(self): + if self._source_pixmap is None: + self.setPixmap(self._empty_pixmap) + return + width, height = self._get_pix_size() + self.setPixmap( + self._source_pixmap.scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + ) - def hint_width(self): - """Get size hint of button not related to others.""" - return super(_SameSizeBtns, self).sizeHint().width() - - def sizeHint(self): - """Calculate size hint based on size hint of this button and related. - - If width is lower than any other button it is changed to higher. - """ - result = super(_SameSizeBtns, self).sizeHint() - width = result.width() - for btn in self._related_btns: - btn_width = btn.hint_width() - if btn_width > width: - width = btn_width - - result.setWidth(width) - return result + def resizeEvent(self, event): + self._set_resized_pix() + super(PixmapLabel, self).resizeEvent(event) class ConfirmProjectDeletion(QtWidgets.QDialog): @@ -336,15 +336,29 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self.setWindowTitle("Delete project?") - message_label = QtWidgets.QLabel(self) + top_widget = QtWidgets.QWidget(self) + + warning_pixmap = ResourceCache.get_warning_pixmap() + warning_icon_label = PixmapLabel(warning_pixmap, top_widget) + + message_label = QtWidgets.QLabel(top_widget) message_label.setWordWrap(True) message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) message_label.setText(( + "WARNING: This cannot be undone.

" "Project \"{}\" with all related data will be" " permanently removed from the database (This actions won't remove" " any files on disk)." ).format(project_name)) + top_layout = QtWidgets.QHBoxLayout(top_widget) + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.addWidget( + warning_icon_label, 0, + QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter + ) + top_layout.addWidget(message_label, 1) + question_label = QtWidgets.QLabel("Are you sure?", self) confirm_input = PlaceholderLineEdit(self) @@ -359,16 +373,13 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") - cancel_btn.add_related_btn(confirm_btn) - confirm_btn.add_related_btn(cancel_btn) - btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) btns_layout.addWidget(cancel_btn, 0) btns_layout.addWidget(confirm_btn, 0) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(message_label, 0) + layout.addWidget(top_widget, 0) layout.addStretch(1) layout.addWidget(question_label, 0) layout.addWidget(confirm_input, 0) From 9913872a2a36bfac2e625e04194a994fd7f97d85 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 16:37:37 +0100 Subject: [PATCH 172/395] flame: small fixes --- openpype/hosts/flame/api/pipeline.py | 20 +++---------------- openpype/hosts/flame/hooks/pre_flame_setup.py | 18 +++++++---------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index f454c33209..9be59990d2 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -27,13 +27,6 @@ log = Logger.get_logger(__name__) def install(): - from .. import ( - PUBLISH_PATH, - LOAD_PATH, - CREATE_PATH, - INVENTORY_PATH - ) - # Disable all families except for the ones we explicitly want to see family_states = [ "imagesequence", @@ -60,13 +53,6 @@ def install(): log.info("OpenPype Flame host installed ...") def uninstall(): - from .. import ( - PUBLISH_PATH, - LOAD_PATH, - CREATE_PATH, - INVENTORY_PATH - ) - pyblish.deregister_host("flame") log.info("Deregistering Flame plug-ins..") @@ -167,11 +153,11 @@ def imprint(segment, data=None): @contextlib.contextmanager def maintained_selection(): import flame - from .. import selection + from .lib import CTX # check if segment is selected - if isinstance(selection[0], flame.PySegment): - sequence = get_current_sequence(selection) + if isinstance(CTX.selection[0], flame.PySegment): + sequence = get_current_sequence(CTX.selection) try: with maintained_segment_selection(sequence): yield diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index fc6b65c958..6c13638f35 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -19,20 +19,17 @@ class FlamePrelaunch(PreLaunchHook): """ app_groups = ["flame"] - # todo: replace version number with avalon launch app version - flame_python_exe = os.getenv("OPENPYPE_FLAME_PYTHON_EXEC") - flame_pythonpath = os.getenv("OPENPYPE_FLAME_PYTHONPATH") - wtc_script_path = os.path.join( opflame.HOST_DIR, "api", "scripts", "wiretap_com.py") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + self._env = self.launch_context.env + self.flame_python_exe = self._env["OPENPYPE_FLAME_PYTHON_EXEC"] + self.flame_pythonpath = self._env["OPENPYPE_FLAME_PYTHONPATH"] self.signature = "( {} )".format(self.__class__.__name__) def execute(self): - _env = self.launch_context.env """Hook entry method.""" project_doc = self.data["project_doc"] user_name = get_openpype_username() @@ -60,9 +57,9 @@ class FlamePrelaunch(PreLaunchHook): data_to_script = { # from settings - "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, - "volume_name": _env.get("FLAME_WIRETAP_VOLUME"), - "group_name": _env.get("FLAME_WIRETAP_GROUP"), + "host_name": self._env.get("FLAME_WIRETAP_HOSTNAME") or hostname, + "volume_name": self._env.get("FLAME_WIRETAP_VOLUME"), + "group_name": self._env.get("FLAME_WIRETAP_GROUP"), "color_policy": "ACES 1.1", # from project @@ -71,7 +68,7 @@ class FlamePrelaunch(PreLaunchHook): "project_data": project_data } - self.log.info(pformat(dict(_env))) + self.log.info(pformat(dict(self._env))) self.log.info(pformat(data_to_script)) # add to python path from settings @@ -92,7 +89,6 @@ class FlamePrelaunch(PreLaunchHook): self.launch_context.env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) - def _get_launch_arguments(self, script_data): # Dump data to string dumped_script_data = json.dumps(script_data) From c08ad5a4cba31a37493ae041ece3a8781f20973d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 16:43:13 +0100 Subject: [PATCH 173/395] flame: adding Spacer class back to plugin creator gui --- openpype/hosts/flame/api/plugin.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 1ae62f3a8d..30a4f3dfc4 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -37,7 +37,7 @@ class CreatorWidget(QtWidgets.QDialog): self.content_widget = [QtWidgets.QWidget(self)] top_layout = QtWidgets.QFormLayout(self.content_widget[0]) top_layout.setObjectName("ContentLayout") - top_layout.addSpacing(5) + top_layout.addWidget(Spacer(5, self)) # first add widget tag line top_layout.addWidget(QtWidgets.QLabel(info)) @@ -206,8 +206,7 @@ class CreatorWidget(QtWidgets.QDialog): self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addSpacing(20) - + headline.addWidget(Spacer(20, self)) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label @@ -229,7 +228,7 @@ class CreatorWidget(QtWidgets.QDialog): self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addSpacing(20) + headline.addWidget(Spacer(20, self)) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label @@ -265,6 +264,23 @@ class CreatorWidget(QtWidgets.QDialog): return data +class Spacer(QtWidgets.QWidget): + def __init__(self, height, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + + self.setFixedHeight(height) + + real_spacer = QtWidgets.QWidget(self) + real_spacer.setObjectName("Spacer") + real_spacer.setFixedHeight(height) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(real_spacer) + + self.setLayout(layout) + + class Creator(openpype.Creator): """Creator class wrapper """ From 7baf6437f323a8b408504f51ff78bbb0e35bb499 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 16:53:09 +0100 Subject: [PATCH 174/395] flame: env not resolving when discovery --- openpype/hosts/flame/hooks/pre_flame_setup.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 6c13638f35..d5ddafde0c 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -24,12 +24,13 @@ class FlamePrelaunch(PreLaunchHook): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._env = self.launch_context.env - self.flame_python_exe = self._env["OPENPYPE_FLAME_PYTHON_EXEC"] - self.flame_pythonpath = self._env["OPENPYPE_FLAME_PYTHONPATH"] self.signature = "( {} )".format(self.__class__.__name__) def execute(self): + _env = self.launch_context.env + self.flame_python_exe = _env["OPENPYPE_FLAME_PYTHON_EXEC"] + self.flame_pythonpath = _env["OPENPYPE_FLAME_PYTHONPATH"] + """Hook entry method.""" project_doc = self.data["project_doc"] user_name = get_openpype_username() @@ -57,9 +58,9 @@ class FlamePrelaunch(PreLaunchHook): data_to_script = { # from settings - "host_name": self._env.get("FLAME_WIRETAP_HOSTNAME") or hostname, - "volume_name": self._env.get("FLAME_WIRETAP_VOLUME"), - "group_name": self._env.get("FLAME_WIRETAP_GROUP"), + "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, + "volume_name": _env.get("FLAME_WIRETAP_VOLUME"), + "group_name": _env.get("FLAME_WIRETAP_GROUP"), "color_policy": "ACES 1.1", # from project @@ -68,7 +69,7 @@ class FlamePrelaunch(PreLaunchHook): "project_data": project_data } - self.log.info(pformat(dict(self._env))) + self.log.info(pformat(dict(_env))) self.log.info(pformat(data_to_script)) # add to python path from settings From a271e05e8c8f30fcb0f98176e6a456f9fb09a3d8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 16:55:33 +0100 Subject: [PATCH 175/395] flame: reduction of project menu items --- openpype/hosts/flame/api/menu.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index c4a18496d3..edb71dd118 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -111,16 +111,6 @@ class FlameMenuProjectConnect(_FlameMenuApp): "name": "Workfiles ...", "execute": lambda x: self.tools_helper.show_workfiles() }) - menu['actions'].append({ - "name": "Create ...", - "execute": lambda x: callback_selection( - x, self.tools_helper.show_creator) - }) - menu['actions'].append({ - "name": "Publish ...", - "execute": lambda x: callback_selection( - x, self.tools_helper.show_publish) - }) menu['actions'].append({ "name": "Load ...", "execute": lambda x: self.tools_helper.show_loader() From 64445d8d21e8209e8894027664cc4770c19cc0aa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 17:32:18 +0100 Subject: [PATCH 176/395] flame: creator debuging --- openpype/hosts/flame/api/pipeline.py | 12 +++++++++--- openpype/hosts/flame/api/plugin.py | 16 +++++++++++----- .../flame/plugins/create/create_shot_clip.py | 1 + 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 9be59990d2..b65c85f5df 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -10,7 +10,8 @@ from .lib import ( set_segment_data_marker, set_publish_attribute, maintained_segment_selection, - get_current_sequence + get_current_sequence, + reset_segment_selection ) from .. import HOST_DIR @@ -158,8 +159,13 @@ def maintained_selection(): # check if segment is selected if isinstance(CTX.selection[0], flame.PySegment): sequence = get_current_sequence(CTX.selection) + try: - with maintained_segment_selection(sequence): + with maintained_segment_selection(sequence) as selected: yield finally: - pass + # reset all selected clips + reset_segment_selection(sequence) + # select only original selection of segments + for segment in selected: + segment.selected = True diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 30a4f3dfc4..f2e67749f2 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -89,7 +89,8 @@ class CreatorWidget(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) def _on_ok_clicked(self): - self.result = self.value(self.items) + log.debug("ok is clicked: {}".format(self.items)) + self.result = self._values(self.items) self.close() def _on_cancel_clicked(self): @@ -100,7 +101,7 @@ class CreatorWidget(QtWidgets.QDialog): self.result = None event.accept() - def value(self, data, new_data=None): + def _values(self, data, new_data=None): new_data = new_data or dict() for k, v in data.items(): new_data[k] = { @@ -109,10 +110,10 @@ class CreatorWidget(QtWidgets.QDialog): } if v["type"] == "dict": new_data[k]["target"] = v["target"] - new_data[k]["value"] = self.value(v["value"]) + new_data[k]["value"] = self._values(v["value"]) if v["type"] == "section": new_data.pop(k) - new_data = self.value(v["value"], new_data) + new_data = self._values(v["value"], new_data) elif getattr(v["value"], "currentText", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].currentText() @@ -343,6 +344,8 @@ class PublishableClip: def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] + self.log = kwargs["log"] + # get main parent objects self.current_segment = segment sequence_name = flib.get_current_sequence([segment]).name.get_value() @@ -369,6 +372,9 @@ class PublishableClip: # adding ui inputs if any self.ui_inputs = kwargs.get("ui_inputs", {}) + self.log.info("Inside of plugin: {}".format( + self.marker_data + )) # populate default data before we get other attributes self._populate_segment_default_data() @@ -430,7 +436,7 @@ class PublishableClip: # define ui inputs if non gui mode was used self.shot_num = self.cs_index - log.debug( + self.log.debug( "____ self.shot_num: {}".format(self.shot_num)) # ui_inputs data or default values if gui was not used diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 866b5108fa..123a1c1575 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -61,6 +61,7 @@ class CreateShotClip(opfapi.Creator): sorted_selected_segments.extend(unsorted_selected_segments) kwargs = { + "log": self.log, "ui_inputs": widget.result, "avalon": self.data } From 4097a5c0a603b9bfc10e2a500176f0a5e3b90e59 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 18:00:51 +0100 Subject: [PATCH 177/395] flame: better selection print --- openpype/hosts/flame/api/menu.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index edb71dd118..b7a94e7866 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -29,7 +29,9 @@ def callback_selection(selection, function): import openpype.hosts.flame.api as opfapi opfapi.CTX.selection = selection print("Hook Selection: \n\t{}".format( - pformat({type(item): item.name for item in opfapi.CTX.selection}) + pformat({ + index: (type(item), item.name) + for index, item in enumerate(opfapi.CTX.selection)}) )) function() From f306de1faa9d04fbe1fbfd64c290e3fdc7039cc1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 21:29:23 +0100 Subject: [PATCH 178/395] cosmetic changes in label --- openpype/tools/project_manager/project_manager/widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 45599ab747..4b5aca35ef 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -347,8 +347,8 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): message_label.setText(( "WARNING: This cannot be undone.

" "Project \"{}\" with all related data will be" - " permanently removed from the database (This actions won't remove" - " any files on disk)." + " permanently removed from the database. (This action won't remove" + " any files on disk.)" ).format(project_name)) top_layout = QtWidgets.QHBoxLayout(top_widget) From 2a67e7bb272b166a258819684cd4932007c2bdf0 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 8 Jan 2022 03:43:20 +0000 Subject: [PATCH 179/395] [Automated] Bump version --- CHANGELOG.md | 16 ++++++++++++++-- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46b1f37e1..20ab087690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.8.0-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.8.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) @@ -10,6 +10,8 @@ **🚀 Enhancements** +- Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) +- Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) - Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) - Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) - Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) @@ -19,8 +21,19 @@ **🐛 Bug fixes** +- General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) +- Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) - General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) - Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) +- General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) +- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) + +**Merged pull requests:** + +- General: Modules import function output fix [\#2492](https://github.com/pypeclub/OpenPype/pull/2492) +- AE: fix hiding of alert window below Publish [\#2491](https://github.com/pypeclub/OpenPype/pull/2491) +- Maya: Validate NGONs re-use polyConstraint code from openpype.host.maya.api.lib [\#2458](https://github.com/pypeclub/OpenPype/pull/2458) +- Version handling [\#2363](https://github.com/pypeclub/OpenPype/pull/2363) ## [3.7.0](https://github.com/pypeclub/OpenPype/tree/3.7.0) (2022-01-04) @@ -67,7 +80,6 @@ - hiero: solve custom ocio path [\#2379](https://github.com/pypeclub/OpenPype/pull/2379) - hiero: fix workio and flatten [\#2378](https://github.com/pypeclub/OpenPype/pull/2378) - Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374) -- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) - Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369) - Houdini: Fix HDA creation [\#2350](https://github.com/pypeclub/OpenPype/pull/2350) diff --git a/openpype/version.py b/openpype/version.py index 4f10deeae1..ed0a96d4de 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.8.0-nightly.1" +__version__ = "3.8.0-nightly.2" diff --git a/pyproject.toml b/pyproject.toml index 20aaf62d06..0ef447e0be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.8.0-nightly.1" # OpenPype +version = "3.8.0-nightly.2" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 1be9a4112a7baff6ae91f324f048b5af849bb32a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 9 Jan 2022 20:42:40 +0100 Subject: [PATCH 180/395] Improve FusionPreLaunch hook error readability + make it a pop-up from the launcher. - I've removed the usage of ` in the string as they would convert into special characters in the pop-up. So those are changed to '. --- .../hosts/fusion/hooks/pre_fusion_setup.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index a0c16a6700..9da7237505 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -1,6 +1,6 @@ import os import importlib -from openpype.lib import PreLaunchHook +from openpype.lib import PreLaunchHook, ApplicationLaunchFailed from openpype.hosts.fusion.api import utils @@ -14,24 +14,26 @@ class FusionPrelaunch(PreLaunchHook): def execute(self): # making sure pyton 3.6 is installed at provided path py36_dir = os.path.normpath(self.launch_context.env.get("PYTHON36", "")) - assert os.path.isdir(py36_dir), ( - "Python 3.6 is not installed at the provided folder path. Either " - "make sure the `environments\resolve.json` is having correctly " - "set `PYTHON36` or make sure Python 3.6 is installed " - f"in given path. \nPYTHON36E: `{py36_dir}`" + if not os.path.isdir(py36_dir): + raise ApplicationLaunchFailed( + "Python 3.6 is not installed at the provided path.\n" + "Either make sure the 'environments/fusion.json' has " + "'PYTHON36' set corectly or make sure Python 3.6 is installed " + f"in the given path.\n\nPYTHON36: {py36_dir}" ) - self.log.info(f"Path to Fusion Python folder: `{py36_dir}`...") + self.log.info(f"Path to Fusion Python folder: '{py36_dir}'...") self.launch_context.env["PYTHON36"] = py36_dir # setting utility scripts dir for scripts syncing us_dir = os.path.normpath( self.launch_context.env.get("FUSION_UTILITY_SCRIPTS_DIR", "") ) - assert os.path.isdir(us_dir), ( - "Fusion utility script dir does not exists. Either make sure " - "the `environments\fusion.json` is having correctly set " - "`FUSION_UTILITY_SCRIPTS_DIR` or reinstall DaVinci Resolve. \n" - f"FUSION_UTILITY_SCRIPTS_DIR: `{us_dir}`" + if not os.path.isdir(us_dir): + raise ApplicationLaunchFailed( + "Fusion utility script dir does not exist. Either make sure " + "the 'environments/fusion.json' has 'FUSION_UTILITY_SCRIPTS_DIR' " + "set correctly or reinstall DaVinci Resolve.\n\n" + f"FUSION_UTILITY_SCRIPTS_DIR: '{us_dir}'" ) try: From ff8643a128e57bb72ad42c8e31ad9925026c2e81 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 9 Jan 2022 20:48:39 +0100 Subject: [PATCH 181/395] Fix indentations --- .../hosts/fusion/hooks/pre_fusion_setup.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index 9da7237505..906c1e7b8a 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -16,11 +16,11 @@ class FusionPrelaunch(PreLaunchHook): py36_dir = os.path.normpath(self.launch_context.env.get("PYTHON36", "")) if not os.path.isdir(py36_dir): raise ApplicationLaunchFailed( - "Python 3.6 is not installed at the provided path.\n" - "Either make sure the 'environments/fusion.json' has " - "'PYTHON36' set corectly or make sure Python 3.6 is installed " - f"in the given path.\n\nPYTHON36: {py36_dir}" - ) + "Python 3.6 is not installed at the provided path.\n" + "Either make sure the 'environments/fusion.json' has " + "'PYTHON36' set corectly or make sure Python 3.6 is installed " + f"in the given path.\n\nPYTHON36: {py36_dir}" + ) self.log.info(f"Path to Fusion Python folder: '{py36_dir}'...") self.launch_context.env["PYTHON36"] = py36_dir @@ -30,11 +30,12 @@ class FusionPrelaunch(PreLaunchHook): ) if not os.path.isdir(us_dir): raise ApplicationLaunchFailed( - "Fusion utility script dir does not exist. Either make sure " - "the 'environments/fusion.json' has 'FUSION_UTILITY_SCRIPTS_DIR' " - "set correctly or reinstall DaVinci Resolve.\n\n" - f"FUSION_UTILITY_SCRIPTS_DIR: '{us_dir}'" - ) + "Fusion utility script dir does not exist. Either make sure " + "the 'environments/fusion.json' has " + "'FUSION_UTILITY_SCRIPTS_DIR' set correctly or reinstall " + "DaVinci Resolve.\n\n" + f"FUSION_UTILITY_SCRIPTS_DIR: '{us_dir}'" + ) try: __import__("avalon.fusion") From 425dbad2ac33cdcb960aa1ed539f2caf9532543e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 9 Jan 2022 20:49:24 +0100 Subject: [PATCH 182/395] Refactor mention of Resolve to Fusion. --- openpype/hosts/fusion/hooks/pre_fusion_setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index 906c1e7b8a..8c4973cf43 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -33,8 +33,7 @@ class FusionPrelaunch(PreLaunchHook): "Fusion utility script dir does not exist. Either make sure " "the 'environments/fusion.json' has " "'FUSION_UTILITY_SCRIPTS_DIR' set correctly or reinstall " - "DaVinci Resolve.\n\n" - f"FUSION_UTILITY_SCRIPTS_DIR: '{us_dir}'" + f"Fusion.\n\nFUSION_UTILITY_SCRIPTS_DIR: '{us_dir}'" ) try: From 5d2c5d7776b68de2b7f37a262f69e16c7f4c4071 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 9 Jan 2022 20:55:14 +0100 Subject: [PATCH 183/395] Fix #2497: reset empty string attributes correctly to "" instead of "None" --- openpype/hosts/maya/api/lib.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..ac22cdc777 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -313,13 +313,7 @@ def attribute_values(attr_values): """ - # NOTE(antirotor): this didn't work for some reason for Yeti attributes - # original = [(attr, cmds.getAttr(attr)) for attr in attr_values] - original = [] - for attr in attr_values: - type = cmds.getAttr(attr, type=True) - value = cmds.getAttr(attr) - original.append((attr, str(value) if type == "string" else value)) + original = [(attr, cmds.getAttr(attr)) for attr in attr_values] try: for attr, value in attr_values.items(): if isinstance(value, string_types): @@ -331,6 +325,12 @@ def attribute_values(attr_values): for attr, value in original: if isinstance(value, string_types): cmds.setAttr(attr, value, type="string") + elif value is None and cmds.getAttr(attr, type=True) == "string": + # In some cases the maya.cmds.getAttr command returns None + # for string attributes but this value cannot assigned. + # Note: After setting it once to "" it will then return "" + # instead of None. So this would only happen once. + cmds.setAttr(attr, "", type="string") else: cmds.setAttr(attr, value) From b66f6b95bb46dbaf671ca70e16c506943fad88be Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 10 Jan 2022 12:00:42 +0100 Subject: [PATCH 184/395] OP-2205 - working version for upload multiple files Won't be used probably as purging of old files would be impossible in this use case. --- .../plugins/publish/integrate_slack_api.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index d7be0c0bfa..5aba372549 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -25,7 +25,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): optional = True # internal, not configurable - bot_user_name = "OpenpypeNotifier" + bot_user_name = "OpenPypeNotifier" icon_url = "https://openpype.io/img/favicon/favicon.ico" def process(self, instance): @@ -37,11 +37,12 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): message = self._get_filled_message(message_profile["message"], instance, review_path) + self.log.info("message:: {}".format(message)) if not message: return - if message_profile["upload_thumbnail"] and thumbnail_path: - publish_files.add(thumbnail_path) + # if message_profile["upload_thumbnail"] and thumbnail_path: + # publish_files.add(thumbnail_path) if message_profile["upload_review"] and review_path: publish_files.add(review_path) @@ -130,14 +131,14 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): from slackclient import SlackClient try: client = SlackClient(token) + self.log.info("publish {}".format(publish_files)) + attachment_str = "\n\n Attachment links: \n" for p_file in publish_files: - attachment_str = "\n\n Attachment links: \n" with open(p_file, 'rb') as pf: response = client.api_call( "files.upload", channels=channel, - file=pf, - title=os.path.basename(p_file), + file=pf ) attachment_str += "\n<{}|{}>".format( response["file"]["permalink"], @@ -149,11 +150,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): response = client.api_call( "chat.postMessage", channel=channel, - text=message, - username=self.bot_user_name, - icon_url=self.icon_url + text=message ) - + self.log.info("repsonse {}".format(response)) if response.get("error"): error_str = self._enrich_error(str(response.get("error")), channel) From e268ad9de1bd6ba4a020931376b335fd7e0acf1e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jan 2022 12:11:06 +0100 Subject: [PATCH 185/395] flame: fixing selected conditions --- openpype/hosts/flame/api/lib.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index b37cc35afd..2cc9fee173 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -446,8 +446,12 @@ def get_sequence_segments(sequence, selected=False): continue # loop all segment in remaining tracks for segment in track.segments: - # ignore all segments not selected - if segment.selected is not True and selected is True: + if segment.name.get_value() == "": + continue + if ( + selected is True + and segment.selected.get_value() is not True + ): continue # add it to original selection segments.append(segment) From ca693c1666aa0e69e92193aee62781bd7dc20be1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jan 2022 12:14:39 +0100 Subject: [PATCH 186/395] flame: fixing creator plugin operation after refactory --- openpype/hosts/flame/api/plugin.py | 27 ++++++++++++++----- .../flame/plugins/create/create_shot_clip.py | 14 +++++----- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index f2e67749f2..7432d61890 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -17,6 +17,7 @@ class CreatorWidget(QtWidgets.QDialog): # output items items = dict() + _results_back = None def __init__(self, name, info, ui_inputs, parent=None): super(CreatorWidget, self).__init__(parent) @@ -88,18 +89,27 @@ class CreatorWidget(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) + @classmethod + def set_results_back(cls, value): + cls._results_back = value + + @classmethod + def get_results_back(cls): + return cls._results_back + def _on_ok_clicked(self): log.debug("ok is clicked: {}".format(self.items)) - self.result = self._values(self.items) + results_back = self._values(self.items) + self.set_results_back(results_back) self.close() def _on_cancel_clicked(self): - self.result = None + self.set_results_back(None) self.close() - def closeEvent(self, event): - self.result = None - event.accept() + def showEvent(self, event): + self.set_results_back(None) + super(CreatorWidget, self).showEvent(event) def _values(self, data, new_data=None): new_data = new_data or dict() @@ -303,7 +313,9 @@ class Creator(openpype.Creator): self.selected = flib.get_sequence_segments(self.sequence) def create_widget(self, *args, **kwargs): - return CreatorWidget(*args, **kwargs) + widget = CreatorWidget(*args, **kwargs) + widget.exec_() + return widget.get_results_back() class PublishableClip: @@ -344,6 +356,7 @@ class PublishableClip: def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] + self.family = kwargs["family"] self.log = kwargs["log"] # get main parent objects @@ -580,7 +593,7 @@ class PublishableClip: "hierarchyData": hierarchy_formating_data, "subset": self.subset, "family": self.subset_family, - "families": [self.data["family"]] + "families": [self.family] } def _convert_to_entity(self, type, template): diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 123a1c1575..edc3e7176c 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -1,8 +1,6 @@ from copy import deepcopy import openpype.hosts.flame.api as opfapi -reload(opfapi) # noqa - class CreateShotClip(opfapi.Creator): """Publishable clip""" @@ -32,22 +30,21 @@ class CreateShotClip(opfapi.Creator): gui_inputs[k]["value"] = presets[k] # open widget for plugins inputs - widget = self.create_widget( + results_back = self.create_widget( "Pype publish attributes creator", "Define sequential rename and fill hierarchy data.", gui_inputs ) - widget.exec_() if len(self.selected) < 1: return - if not widget.result: + if not results_back: print("Operation aborted") return # get ui output for track name for vertical sync - v_sync_track = widget.result["vSyncTrack"]["value"] + v_sync_track = results_back["vSyncTrack"]["value"] # sort selected trackItems by sorted_selected_segments = [] @@ -62,8 +59,9 @@ class CreateShotClip(opfapi.Creator): kwargs = { "log": self.log, - "ui_inputs": widget.result, - "avalon": self.data + "ui_inputs": results_back, + "avalon": self.data, + "family": self.data["family"] } for i, segment in enumerate(sorted_selected_segments): From 1454e718e7722c66c4f0d238fef1398704fc59d2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jan 2022 12:15:15 +0100 Subject: [PATCH 187/395] flame: remove flame api reload destroying selection --- openpype/hosts/flame/plugins/publish/collect_test_selection.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 0431bd1fe3..0c75b3204f 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -3,7 +3,6 @@ import pyblish.api import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export as otio_export from pprint import pformat -reload(opfapi) # noqa reload(otio_export) # noqa From ba382242ce3d80d221c3622f441c0f58efb55c75 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jan 2022 12:45:05 +0100 Subject: [PATCH 188/395] flame: shot number based on segment index --- openpype/hosts/flame/api/plugin.py | 9 ++++++++- .../hosts/flame/plugins/create/create_shot_clip.py | 11 +++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 7432d61890..e6165a6d7e 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -353,6 +353,7 @@ class PublishableClip: count_steps_default = 10 vertical_sync_default = False driving_layer_default = "" + index_from_segment_default = False def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] @@ -462,6 +463,8 @@ class PublishableClip: self.hierarchy_data = self.ui_inputs.get( "hierarchyData", {}).get("value") or \ self.current_segment_default_data.copy() + self.index_from_segment = self.ui_inputs.get( + "segmentIndex", {}).get("value") or self.index_from_segment_default self.count_from = self.ui_inputs.get( "countFrom", {}).get("value") or self.count_from_default self.count_steps = self.ui_inputs.get( @@ -524,8 +527,12 @@ class PublishableClip: self.review_track not in self.review_track_default): # if review layer is defined and not the same as defalut self.review_layer = self.review_track + # shot num calculate - if self.rename_index == 0: + if self.index_from_segment: + # use clip index from timeline + self.shot_num = self.count_steps * self.cs_index + elif self.rename_index == 0: self.shot_num = self.count_from else: self.shot_num = self.count_from + self.count_steps diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index edc3e7176c..f055c77a89 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -101,20 +101,27 @@ class CreateShotClip(opfapi.Creator): "target": "ui", "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa "order": 2}, + "segmentIndex": { + "value": True, + "type": "QCheckBox", + "label": "Segment index", + "target": "ui", + "toolTip": "Take number from segment index", # noqa + "order": 3}, "countFrom": { "value": 10, "type": "QSpinBox", "label": "Count sequence from", "target": "ui", "toolTip": "Set when the sequence number stafrom", # noqa - "order": 3}, + "order": 4}, "countSteps": { "value": 10, "type": "QSpinBox", "label": "Stepping number", "target": "ui", "toolTip": "What number is adding every new step", # noqa - "order": 4}, + "order": 5}, } }, "hierarchyData": { From 4f612a6a169123ea9918d2f2bbb1e3816bc4b07e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jan 2022 12:54:55 +0100 Subject: [PATCH 189/395] flame: improving previous commit --- openpype/hosts/flame/api/plugin.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index e6165a6d7e..f34999bcf3 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -509,7 +509,8 @@ class PublishableClip: hero_track = False # increasing steps by index of rename iteration - self.count_steps *= self.rename_index + if not self.index_from_segment: + self.count_steps *= self.rename_index hierarchy_formating_data = {} hierarchy_data = deepcopy(self.hierarchy_data) @@ -532,10 +533,11 @@ class PublishableClip: if self.index_from_segment: # use clip index from timeline self.shot_num = self.count_steps * self.cs_index - elif self.rename_index == 0: - self.shot_num = self.count_from else: - self.shot_num = self.count_from + self.count_steps + if self.rename_index == 0: + self.shot_num = self.count_from + else: + self.shot_num = self.count_from + self.count_steps # clip name sequence number _data.update({"shot": self.shot_num}) From 2a0f7b48d99d95219db0816b23679e965178cdf2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 14:06:12 +0100 Subject: [PATCH 190/395] fix how run_openpype_process works --- openpype/lib/execute.py | 2 +- openpype/plugins/publish/extract_burnin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 16b98eefb4..3cf67a379c 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -164,7 +164,7 @@ def run_openpype_process(*args, **kwargs): Example: ``` - run_openpype_process(["run", ""]) + run_openpype_process("run", "") ``` Args: diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 1cb8608a56..459c66ee43 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -312,7 +312,7 @@ class ExtractBurnin(openpype.api.Extractor): if platform.system().lower() == "windows": process_kwargs["creationflags"] = CREATE_NO_WINDOW - run_openpype_process(args, **process_kwargs) + run_openpype_process(*args, **process_kwargs) # Remove the temporary json os.remove(temporary_json_filepath) From e94aa5311651c3219c5afe8219b0c5ecaa35e4c7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:00:45 +0100 Subject: [PATCH 191/395] moved photoshop from avalon to openpype --- openpype/hosts/photoshop/api/README.md | 255 ++++ openpype/hosts/photoshop/api/__init__.py | 114 +- openpype/hosts/photoshop/api/extension.zxp | Bin 0 -> 54111 bytes openpype/hosts/photoshop/api/extension/.debug | 9 + .../photoshop/api/extension/CSXS/manifest.xml | 53 + .../api/extension/client/CSInterface.js | 1193 +++++++++++++++++ .../photoshop/api/extension/client/client.js | 300 +++++ .../api/extension/client/loglevel.min.js | 2 + .../photoshop/api/extension/client/wsrpc.js | 393 ++++++ .../api/extension/client/wsrpc.min.js | 1 + .../hosts/photoshop/api/extension/host/JSX.js | 774 +++++++++++ .../photoshop/api/extension/host/index.jsx | 484 +++++++ .../photoshop/api/extension/host/json.js | 530 ++++++++ .../api/extension/icons/avalon-logo-48.png | Bin 0 -> 1362 bytes .../hosts/photoshop/api/extension/index.html | 119 ++ openpype/hosts/photoshop/api/launch_logic.py | 315 +++++ openpype/hosts/photoshop/api/lib.py | 76 ++ openpype/hosts/photoshop/api/panel.PNG | Bin 0 -> 8756 bytes .../hosts/photoshop/api/panel_failure.PNG | Bin 0 -> 13568 bytes openpype/hosts/photoshop/api/pipeline.py | 199 +++ openpype/hosts/photoshop/api/workio.py | 50 + openpype/hosts/photoshop/api/ws_stub.py | 470 +++++++ 22 files changed, 5268 insertions(+), 69 deletions(-) create mode 100644 openpype/hosts/photoshop/api/README.md create mode 100644 openpype/hosts/photoshop/api/extension.zxp create mode 100644 openpype/hosts/photoshop/api/extension/.debug create mode 100644 openpype/hosts/photoshop/api/extension/CSXS/manifest.xml create mode 100644 openpype/hosts/photoshop/api/extension/client/CSInterface.js create mode 100644 openpype/hosts/photoshop/api/extension/client/client.js create mode 100644 openpype/hosts/photoshop/api/extension/client/loglevel.min.js create mode 100644 openpype/hosts/photoshop/api/extension/client/wsrpc.js create mode 100644 openpype/hosts/photoshop/api/extension/client/wsrpc.min.js create mode 100644 openpype/hosts/photoshop/api/extension/host/JSX.js create mode 100644 openpype/hosts/photoshop/api/extension/host/index.jsx create mode 100644 openpype/hosts/photoshop/api/extension/host/json.js create mode 100644 openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png create mode 100644 openpype/hosts/photoshop/api/extension/index.html create mode 100644 openpype/hosts/photoshop/api/launch_logic.py create mode 100644 openpype/hosts/photoshop/api/lib.py create mode 100644 openpype/hosts/photoshop/api/panel.PNG create mode 100644 openpype/hosts/photoshop/api/panel_failure.PNG create mode 100644 openpype/hosts/photoshop/api/pipeline.py create mode 100644 openpype/hosts/photoshop/api/workio.py create mode 100644 openpype/hosts/photoshop/api/ws_stub.py diff --git a/openpype/hosts/photoshop/api/README.md b/openpype/hosts/photoshop/api/README.md new file mode 100644 index 0000000000..b958f53803 --- /dev/null +++ b/openpype/hosts/photoshop/api/README.md @@ -0,0 +1,255 @@ +# Photoshop Integration + +## Setup + +The Photoshop integration requires two components to work; `extension` and `server`. + +### Extension + +To install the extension download [Extension Manager Command Line tool (ExManCmd)](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#option-2---exmancmd). + +``` +ExManCmd /install {path to avalon-core}\avalon\photoshop\extension.zxp +``` + +### Server + +The easiest way to get the server and Photoshop launch is with: + +``` +python -c ^"import avalon.photoshop;avalon.photoshop.launch(""C:\Program Files\Adobe\Adobe Photoshop 2020\Photoshop.exe"")^" +``` + +`avalon.photoshop.launch` launches the application and server, and also closes the server when Photoshop exists. + +## Usage + +The Photoshop extension can be found under `Window > Extensions > Avalon`. Once launched you should be presented with a panel like this: + +![Avalon Panel](panel.PNG "Avalon Panel") + + +## Developing + +### Extension +When developing the extension you can load it [unsigned](https://github.com/Adobe-CEP/CEP-Resources/blob/master/CEP_9.x/Documentation/CEP%209.0%20HTML%20Extension%20Cookbook.md#debugging-unsigned-extensions). + +When signing the extension you can use this [guide](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#package-distribute-install-guide). + +``` +ZXPSignCmd -selfSignedCert NA NA Avalon Avalon-Photoshop avalon extension.p12 +ZXPSignCmd -sign {path to avalon-core}\avalon\photoshop\extension {path to avalon-core}\avalon\photoshop\extension.zxp extension.p12 avalon +``` + +### Plugin Examples + +These plugins were made with the [polly config](https://github.com/mindbender-studio/config). To fully integrate and load, you will have to use this config and add `image` to the [integration plugin](https://github.com/mindbender-studio/config/blob/master/polly/plugins/publish/integrate_asset.py). + +#### Creator Plugin +```python +from avalon import photoshop + + +class CreateImage(photoshop.Creator): + """Image folder for publish.""" + + name = "imageDefault" + label = "Image" + family = "image" + + def __init__(self, *args, **kwargs): + super(CreateImage, self).__init__(*args, **kwargs) +``` + +#### Collector Plugin +```python +import pythoncom + +import pyblish.api + + +class CollectInstances(pyblish.api.ContextPlugin): + """Gather instances by LayerSet and file metadata + + This collector takes into account assets that are associated with + an LayerSet and marked with a unique identifier; + + Identifier: + id (str): "pyblish.avalon.instance" + """ + + label = "Instances" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + families_mapping = { + "image": [] + } + + def process(self, context): + # Necessary call when running in a different thread which pyblish-qml + # can be. + pythoncom.CoInitialize() + + photoshop_client = PhotoshopClientStub() + layers = photoshop_client.get_layers() + layers_meta = photoshop_client.get_layers_metadata() + for layer in layers: + layer_data = photoshop_client.read(layer, layers_meta) + + # Skip layers without metadata. + if layer_data is None: + continue + + # Skip containers. + if "container" in layer_data["id"]: + continue + + # child_layers = [*layer.Layers] + # self.log.debug("child_layers {}".format(child_layers)) + # if not child_layers: + # self.log.info("%s skipped, it was empty." % layer.Name) + # continue + + instance = context.create_instance(layer.name) + instance.append(layer) + instance.data.update(layer_data) + instance.data["families"] = self.families_mapping[ + layer_data["family"] + ] + instance.data["publish"] = layer.visible + + # Produce diagnostic message for any graphical + # user interface interested in visualising it. + self.log.info("Found: \"%s\" " % instance.data["name"]) +``` + +#### Extractor Plugin +```python +import os + +import openpype.api +from avalon import photoshop + + +class ExtractImage(openpype.api.Extractor): + """Produce a flattened image file from instance + + This plug-in takes into account only the layers in the group. + """ + + label = "Extract Image" + hosts = ["photoshop"] + families = ["image"] + formats = ["png", "jpg"] + + def process(self, instance): + + staging_dir = self.staging_dir(instance) + self.log.info("Outputting image to {}".format(staging_dir)) + + # Perform extraction + stub = photoshop.stub() + files = {} + with photoshop.maintained_selection(): + self.log.info("Extracting %s" % str(list(instance))) + with photoshop.maintained_visibility(): + # Hide all other layers. + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers([instance[0]])]) + + for layer in stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + stub.set_visible(layer.id, False) + + save_options = [] + if "png" in self.formats: + save_options.append('png') + if "jpg" in self.formats: + save_options.append('jpg') + + file_basename = os.path.splitext( + stub.get_active_document_name() + )[0] + for extension in save_options: + _filename = "{}.{}".format(file_basename, extension) + files[extension] = _filename + + full_filename = os.path.join(staging_dir, _filename) + stub.saveAs(full_filename, extension, True) + + representations = [] + for extension, filename in files.items(): + representations.append({ + "name": extension, + "ext": extension, + "files": filename, + "stagingDir": staging_dir + }) + instance.data["representations"] = representations + instance.data["stagingDir"] = staging_dir + + self.log.info(f"Extracted {instance} to {staging_dir}") +``` + +#### Loader Plugin +```python +from avalon import api, photoshop + +stub = photoshop.stub() + + +class ImageLoader(api.Loader): + """Load images + + Stores the imported asset in a container named after the asset. + """ + + families = ["image"] + representations = ["*"] + + def load(self, context, name=None, namespace=None, data=None): + with photoshop.maintained_selection(): + layer = stub.import_smart_object(self.fname) + + self[:] = [layer] + + return photoshop.containerise( + name, + namespace, + layer, + context, + self.__class__.__name__ + ) + + def update(self, container, representation): + layer = container.pop("layer") + + with photoshop.maintained_selection(): + stub.replace_smart_object( + layer, api.get_representation_path(representation) + ) + + stub.imprint( + layer, {"representation": str(representation["_id"])} + ) + + def remove(self, container): + container["layer"].Delete() + + def switch(self, container, representation): + self.update(container, representation) +``` +For easier debugging of Javascript: +https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/td-p/10911704?page=1 +Add --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome +then localhost:8078 (port set in `photoshop\extension\.debug`) + +Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 + +Or install CEF client from https://github.com/Adobe-CEP/CEP-Resources/tree/master/CEP_9.x +## Resources + - https://github.com/lohriialo/photoshop-scripting-python + - https://www.adobe.com/devnet/photoshop/scripting.html + - https://github.com/Adobe-CEP/Getting-Started-guides + - https://github.com/Adobe-CEP/CEP-Resources diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index d978d6ecc1..43756b9ee4 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -1,79 +1,55 @@ -import os -import sys -import logging +"""Public API -from Qt import QtWidgets +Anything that isn't defined here is INTERNAL and unreliable for external use. -from avalon import io -from avalon import api as avalon -from openpype import lib -from pyblish import api as pyblish -import openpype.hosts.photoshop +""" -log = logging.getLogger("openpype.hosts.photoshop") +from .pipeline import ( + ls, + list_instances, + remove_instance, + Creator, + install, + containerise +) -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.photoshop.__file__)) -PLUGINS_DIR = os.path.join(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") +from .workio import ( + file_extensions, + has_unsaved_changes, + save_file, + open_file, + current_file, + work_root, +) -def check_inventory(): - if not lib.any_outdated(): - return +from .lib import ( + maintained_selection, + maintained_visibility +) - host = avalon.registered_host() - outdated_containers = [] - for container in host.ls(): - representation = container['representation'] - representation_doc = io.find_one( - { - "_id": io.ObjectId(representation), - "type": "representation" - }, - projection={"parent": True} - ) - if representation_doc and not lib.is_latest(representation_doc): - outdated_containers.append(container) +from .launch_logic import stub - # Warn about outdated containers. - print("Starting new QApplication..") - app = QtWidgets.QApplication(sys.argv) +__all__ = [ + # pipeline + "ls", + "list_instances", + "remove_instance", + "Creator", + "install", + "containerise", - message_box = QtWidgets.QMessageBox() - message_box.setIcon(QtWidgets.QMessageBox.Warning) - msg = "There are outdated containers in the scene." - message_box.setText(msg) - message_box.exec_() + # workfiles + "file_extensions", + "has_unsaved_changes", + "save_file", + "open_file", + "current_file", + "work_root", - # Garbage collect QApplication. - del app + # lib + "maintained_selection", + "maintained_visibility", - -def application_launch(): - check_inventory() - - -def install(): - print("Installing Pype config...") - - pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) - - avalon.on("application.launched", application_launch) - -def uninstall(): - pyblish.deregister_plugin_path(PUBLISH_PATH) - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) - -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value + # launch_logic + "stub" +] diff --git a/openpype/hosts/photoshop/api/extension.zxp b/openpype/hosts/photoshop/api/extension.zxp new file mode 100644 index 0000000000000000000000000000000000000000..a25ec96e7df2348e558277faedf4f74561a7f316 GIT binary patch literal 54111 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� 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¨9*zdQ|q^=Mf~ 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 z + + + + + + + + \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml b/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml new file mode 100644 index 0000000000..6396cd2412 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + ./index.html + + + + true + + + applicationActivate + com.adobe.csxs.events.ApplicationInitialized + + + + Panel + OpenPype + + + 300 + 140 + + + 400 + 200 + + + + ./icons/avalon-logo-48.png + + + + + + diff --git a/openpype/hosts/photoshop/api/extension/client/CSInterface.js b/openpype/hosts/photoshop/api/extension/client/CSInterface.js new file mode 100644 index 0000000000..4239391efd --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/CSInterface.js @@ -0,0 +1,1193 @@ +/************************************************************************************************** +* +* ADOBE SYSTEMS INCORPORATED +* Copyright 2013 Adobe Systems Incorporated +* All Rights Reserved. +* +* NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the +* terms of the Adobe license agreement accompanying it. If you have received this file from a +* source other than Adobe, then your use, modification, or distribution of it requires the prior +* written permission of Adobe. +* +**************************************************************************************************/ + +/** CSInterface - v8.0.0 */ + +/** + * Stores constants for the window types supported by the CSXS infrastructure. + */ +function CSXSWindowType() +{ +} + +/** Constant for the CSXS window type Panel. */ +CSXSWindowType._PANEL = "Panel"; + +/** Constant for the CSXS window type Modeless. */ +CSXSWindowType._MODELESS = "Modeless"; + +/** Constant for the CSXS window type ModalDialog. */ +CSXSWindowType._MODAL_DIALOG = "ModalDialog"; + +/** EvalScript error message */ +EvalScript_ErrMessage = "EvalScript error."; + +/** + * @class Version + * Defines a version number with major, minor, micro, and special + * components. The major, minor and micro values are numeric; the special + * value can be any string. + * + * @param major The major version component, a positive integer up to nine digits long. + * @param minor The minor version component, a positive integer up to nine digits long. + * @param micro The micro version component, a positive integer up to nine digits long. + * @param special The special version component, an arbitrary string. + * + * @return A new \c Version object. + */ +function Version(major, minor, micro, special) +{ + this.major = major; + this.minor = minor; + this.micro = micro; + this.special = special; +} + +/** + * The maximum value allowed for a numeric version component. + * This reflects the maximum value allowed in PlugPlug and the manifest schema. + */ +Version.MAX_NUM = 999999999; + +/** + * @class VersionBound + * Defines a boundary for a version range, which associates a \c Version object + * with a flag for whether it is an inclusive or exclusive boundary. + * + * @param version The \c #Version object. + * @param inclusive True if this boundary is inclusive, false if it is exclusive. + * + * @return A new \c VersionBound object. + */ +function VersionBound(version, inclusive) +{ + this.version = version; + this.inclusive = inclusive; +} + +/** + * @class VersionRange + * Defines a range of versions using a lower boundary and optional upper boundary. + * + * @param lowerBound The \c #VersionBound object. + * @param upperBound The \c #VersionBound object, or null for a range with no upper boundary. + * + * @return A new \c VersionRange object. + */ +function VersionRange(lowerBound, upperBound) +{ + this.lowerBound = lowerBound; + this.upperBound = upperBound; +} + +/** + * @class Runtime + * Represents a runtime related to the CEP infrastructure. + * Extensions can declare dependencies on particular + * CEP runtime versions in the extension manifest. + * + * @param name The runtime name. + * @param version A \c #VersionRange object that defines a range of valid versions. + * + * @return A new \c Runtime object. + */ +function Runtime(name, versionRange) +{ + this.name = name; + this.versionRange = versionRange; +} + +/** +* @class Extension +* Encapsulates a CEP-based extension to an Adobe application. +* +* @param id The unique identifier of this extension. +* @param name The localizable display name of this extension. +* @param mainPath The path of the "index.html" file. +* @param basePath The base path of this extension. +* @param windowType The window type of the main window of this extension. + Valid values are defined by \c #CSXSWindowType. +* @param width The default width in pixels of the main window of this extension. +* @param height The default height in pixels of the main window of this extension. +* @param minWidth The minimum width in pixels of the main window of this extension. +* @param minHeight The minimum height in pixels of the main window of this extension. +* @param maxWidth The maximum width in pixels of the main window of this extension. +* @param maxHeight The maximum height in pixels of the main window of this extension. +* @param defaultExtensionDataXml The extension data contained in the default \c ExtensionDispatchInfo section of the extension manifest. +* @param specialExtensionDataXml The extension data contained in the application-specific \c ExtensionDispatchInfo section of the extension manifest. +* @param requiredRuntimeList An array of \c Runtime objects for runtimes required by this extension. +* @param isAutoVisible True if this extension is visible on loading. +* @param isPluginExtension True if this extension has been deployed in the Plugins folder of the host application. +* +* @return A new \c Extension object. +*/ +function Extension(id, name, mainPath, basePath, windowType, width, height, minWidth, minHeight, maxWidth, maxHeight, + defaultExtensionDataXml, specialExtensionDataXml, requiredRuntimeList, isAutoVisible, isPluginExtension) +{ + this.id = id; + this.name = name; + this.mainPath = mainPath; + this.basePath = basePath; + this.windowType = windowType; + this.width = width; + this.height = height; + this.minWidth = minWidth; + this.minHeight = minHeight; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + this.defaultExtensionDataXml = defaultExtensionDataXml; + this.specialExtensionDataXml = specialExtensionDataXml; + this.requiredRuntimeList = requiredRuntimeList; + this.isAutoVisible = isAutoVisible; + this.isPluginExtension = isPluginExtension; +} + +/** + * @class CSEvent + * A standard JavaScript event, the base class for CEP events. + * + * @param type The name of the event type. + * @param scope The scope of event, can be "GLOBAL" or "APPLICATION". + * @param appId The unique identifier of the application that generated the event. + * @param extensionId The unique identifier of the extension that generated the event. + * + * @return A new \c CSEvent object + */ +function CSEvent(type, scope, appId, extensionId) +{ + this.type = type; + this.scope = scope; + this.appId = appId; + this.extensionId = extensionId; +} + +/** Event-specific data. */ +CSEvent.prototype.data = ""; + +/** + * @class SystemPath + * Stores operating-system-specific location constants for use in the + * \c #CSInterface.getSystemPath() method. + * @return A new \c SystemPath object. + */ +function SystemPath() +{ +} + +/** The path to user data. */ +SystemPath.USER_DATA = "userData"; + +/** The path to common files for Adobe applications. */ +SystemPath.COMMON_FILES = "commonFiles"; + +/** The path to the user's default document folder. */ +SystemPath.MY_DOCUMENTS = "myDocuments"; + +/** @deprecated. Use \c #SystemPath.Extension. */ +SystemPath.APPLICATION = "application"; + +/** The path to current extension. */ +SystemPath.EXTENSION = "extension"; + +/** The path to hosting application's executable. */ +SystemPath.HOST_APPLICATION = "hostApplication"; + +/** + * @class ColorType + * Stores color-type constants. + */ +function ColorType() +{ +} + +/** RGB color type. */ +ColorType.RGB = "rgb"; + +/** Gradient color type. */ +ColorType.GRADIENT = "gradient"; + +/** Null color type. */ +ColorType.NONE = "none"; + +/** + * @class RGBColor + * Stores an RGB color with red, green, blue, and alpha values. + * All values are in the range [0.0 to 255.0]. Invalid numeric values are + * converted to numbers within this range. + * + * @param red The red value, in the range [0.0 to 255.0]. + * @param green The green value, in the range [0.0 to 255.0]. + * @param blue The blue value, in the range [0.0 to 255.0]. + * @param alpha The alpha (transparency) value, in the range [0.0 to 255.0]. + * The default, 255.0, means that the color is fully opaque. + * + * @return A new RGBColor object. + */ +function RGBColor(red, green, blue, alpha) +{ + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; +} + +/** + * @class Direction + * A point value in which the y component is 0 and the x component + * is positive or negative for a right or left direction, + * or the x component is 0 and the y component is positive or negative for + * an up or down direction. + * + * @param x The horizontal component of the point. + * @param y The vertical component of the point. + * + * @return A new \c Direction object. + */ +function Direction(x, y) +{ + this.x = x; + this.y = y; +} + +/** + * @class GradientStop + * Stores gradient stop information. + * + * @param offset The offset of the gradient stop, in the range [0.0 to 1.0]. + * @param rgbColor The color of the gradient at this point, an \c #RGBColor object. + * + * @return GradientStop object. + */ +function GradientStop(offset, rgbColor) +{ + this.offset = offset; + this.rgbColor = rgbColor; +} + +/** + * @class GradientColor + * Stores gradient color information. + * + * @param type The gradient type, must be "linear". + * @param direction A \c #Direction object for the direction of the gradient + (up, down, right, or left). + * @param numStops The number of stops in the gradient. + * @param gradientStopList An array of \c #GradientStop objects. + * + * @return A new \c GradientColor object. + */ +function GradientColor(type, direction, numStops, arrGradientStop) +{ + this.type = type; + this.direction = direction; + this.numStops = numStops; + this.arrGradientStop = arrGradientStop; +} + +/** + * @class UIColor + * Stores color information, including the type, anti-alias level, and specific color + * values in a color object of an appropriate type. + * + * @param type The color type, 1 for "rgb" and 2 for "gradient". + The supplied color object must correspond to this type. + * @param antialiasLevel The anti-alias level constant. + * @param color A \c #RGBColor or \c #GradientColor object containing specific color information. + * + * @return A new \c UIColor object. + */ +function UIColor(type, antialiasLevel, color) +{ + this.type = type; + this.antialiasLevel = antialiasLevel; + this.color = color; +} + +/** + * @class AppSkinInfo + * Stores window-skin properties, such as color and font. All color parameter values are \c #UIColor objects except that systemHighlightColor is \c #RGBColor object. + * + * @param baseFontFamily The base font family of the application. + * @param baseFontSize The base font size of the application. + * @param appBarBackgroundColor The application bar background color. + * @param panelBackgroundColor The background color of the extension panel. + * @param appBarBackgroundColorSRGB The application bar background color, as sRGB. + * @param panelBackgroundColorSRGB The background color of the extension panel, as sRGB. + * @param systemHighlightColor The highlight color of the extension panel, if provided by the host application. Otherwise, the operating-system highlight color. + * + * @return AppSkinInfo object. + */ +function AppSkinInfo(baseFontFamily, baseFontSize, appBarBackgroundColor, panelBackgroundColor, appBarBackgroundColorSRGB, panelBackgroundColorSRGB, systemHighlightColor) +{ + this.baseFontFamily = baseFontFamily; + this.baseFontSize = baseFontSize; + this.appBarBackgroundColor = appBarBackgroundColor; + this.panelBackgroundColor = panelBackgroundColor; + this.appBarBackgroundColorSRGB = appBarBackgroundColorSRGB; + this.panelBackgroundColorSRGB = panelBackgroundColorSRGB; + this.systemHighlightColor = systemHighlightColor; +} + +/** + * @class HostEnvironment + * Stores information about the environment in which the extension is loaded. + * + * @param appName The application's name. + * @param appVersion The application's version. + * @param appLocale The application's current license locale. + * @param appUILocale The application's current UI locale. + * @param appId The application's unique identifier. + * @param isAppOnline True if the application is currently online. + * @param appSkinInfo An \c #AppSkinInfo object containing the application's default color and font styles. + * + * @return A new \c HostEnvironment object. + */ +function HostEnvironment(appName, appVersion, appLocale, appUILocale, appId, isAppOnline, appSkinInfo) +{ + this.appName = appName; + this.appVersion = appVersion; + this.appLocale = appLocale; + this.appUILocale = appUILocale; + this.appId = appId; + this.isAppOnline = isAppOnline; + this.appSkinInfo = appSkinInfo; +} + +/** + * @class HostCapabilities + * Stores information about the host capabilities. + * + * @param EXTENDED_PANEL_MENU True if the application supports panel menu. + * @param EXTENDED_PANEL_ICONS True if the application supports panel icon. + * @param DELEGATE_APE_ENGINE True if the application supports delegated APE engine. + * @param SUPPORT_HTML_EXTENSIONS True if the application supports HTML extensions. + * @param DISABLE_FLASH_EXTENSIONS True if the application disables FLASH extensions. + * + * @return A new \c HostCapabilities object. + */ +function HostCapabilities(EXTENDED_PANEL_MENU, EXTENDED_PANEL_ICONS, DELEGATE_APE_ENGINE, SUPPORT_HTML_EXTENSIONS, DISABLE_FLASH_EXTENSIONS) +{ + this.EXTENDED_PANEL_MENU = EXTENDED_PANEL_MENU; + this.EXTENDED_PANEL_ICONS = EXTENDED_PANEL_ICONS; + this.DELEGATE_APE_ENGINE = DELEGATE_APE_ENGINE; + this.SUPPORT_HTML_EXTENSIONS = SUPPORT_HTML_EXTENSIONS; + this.DISABLE_FLASH_EXTENSIONS = DISABLE_FLASH_EXTENSIONS; // Since 5.0.0 +} + +/** + * @class ApiVersion + * Stores current api version. + * + * Since 4.2.0 + * + * @param major The major version + * @param minor The minor version. + * @param micro The micro version. + * + * @return ApiVersion object. + */ +function ApiVersion(major, minor, micro) +{ + this.major = major; + this.minor = minor; + this.micro = micro; +} + +/** + * @class MenuItemStatus + * Stores flyout menu item status + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function MenuItemStatus(menuItemLabel, enabled, checked) +{ + this.menuItemLabel = menuItemLabel; + this.enabled = enabled; + this.checked = checked; +} + +/** + * @class ContextMenuItemStatus + * Stores the status of the context menu item. + * + * Since 5.2.0 + * + * @param menuItemID The menu item id. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function ContextMenuItemStatus(menuItemID, enabled, checked) +{ + this.menuItemID = menuItemID; + this.enabled = enabled; + this.checked = checked; +} +//------------------------------ CSInterface ---------------------------------- + +/** + * @class CSInterface + * This is the entry point to the CEP extensibility infrastructure. + * Instantiate this object and use it to: + *
    + *
  • Access information about the host application in which an extension is running
  • + *
  • Launch an extension
  • + *
  • Register interest in event notifications, and dispatch events
  • + *
+ * + * @return A new \c CSInterface object + */ +function CSInterface() +{ +} + +/** + * User can add this event listener to handle native application theme color changes. + * Callback function gives extensions ability to fine-tune their theme color after the + * global theme color has been changed. + * The callback function should be like below: + * + * @example + * // event is a CSEvent object, but user can ignore it. + * function OnAppThemeColorChanged(event) + * { + * // Should get a latest HostEnvironment object from application. + * var skinInfo = JSON.parse(window.__adobe_cep__.getHostEnvironment()).appSkinInfo; + * // Gets the style information such as color info from the skinInfo, + * // and redraw all UI controls of your extension according to the style info. + * } + */ +CSInterface.THEME_COLOR_CHANGED_EVENT = "com.adobe.csxs.events.ThemeColorChanged"; + +/** The host environment data object. */ +CSInterface.prototype.hostEnvironment = window.__adobe_cep__ ? JSON.parse(window.__adobe_cep__.getHostEnvironment()) : null; + +/** Retrieves information about the host environment in which the + * extension is currently running. + * + * @return A \c #HostEnvironment object. + */ +CSInterface.prototype.getHostEnvironment = function() +{ + this.hostEnvironment = JSON.parse(window.__adobe_cep__.getHostEnvironment()); + return this.hostEnvironment; +}; + +/** Closes this extension. */ +CSInterface.prototype.closeExtension = function() +{ + window.__adobe_cep__.closeExtension(); +}; + +/** + * Retrieves a path for which a constant is defined in the system. + * + * @param pathType The path-type constant defined in \c #SystemPath , + * + * @return The platform-specific system path string. + */ +CSInterface.prototype.getSystemPath = function(pathType) +{ + var path = decodeURI(window.__adobe_cep__.getSystemPath(pathType)); + var OSVersion = this.getOSInformation(); + if (OSVersion.indexOf("Windows") >= 0) + { + path = path.replace("file:///", ""); + } + else if (OSVersion.indexOf("Mac") >= 0) + { + path = path.replace("file://", ""); + } + return path; +}; + +/** + * Evaluates a JavaScript script, which can use the JavaScript DOM + * of the host application. + * + * @param script The JavaScript script. + * @param callback Optional. A callback function that receives the result of execution. + * If execution fails, the callback function receives the error message \c EvalScript_ErrMessage. + */ +CSInterface.prototype.evalScript = function(script, callback) +{ + if(callback === null || callback === undefined) + { + callback = function(result){}; + } + window.__adobe_cep__.evalScript(script, callback); +}; + +/** + * Retrieves the unique identifier of the application. + * in which the extension is currently running. + * + * @return The unique ID string. + */ +CSInterface.prototype.getApplicationID = function() +{ + var appId = this.hostEnvironment.appId; + return appId; +}; + +/** + * Retrieves host capability information for the application + * in which the extension is currently running. + * + * @return A \c #HostCapabilities object. + */ +CSInterface.prototype.getHostCapabilities = function() +{ + var hostCapabilities = JSON.parse(window.__adobe_cep__.getHostCapabilities() ); + return hostCapabilities; +}; + +/** + * Triggers a CEP event programmatically. Yoy can use it to dispatch + * an event of a predefined type, or of a type you have defined. + * + * @param event A \c CSEvent object. + */ +CSInterface.prototype.dispatchEvent = function(event) +{ + if (typeof event.data == "object") + { + event.data = JSON.stringify(event.data); + } + + window.__adobe_cep__.dispatchEvent(event); +}; + +/** + * Registers an interest in a CEP event of a particular type, and + * assigns an event handler. + * The event infrastructure notifies your extension when events of this type occur, + * passing the event object to the registered handler function. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.addEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.addEventListener(type, listener, obj); +}; + +/** + * Removes a registered event listener. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method that was registered. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.removeEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.removeEventListener(type, listener, obj); +}; + +/** + * Loads and launches another extension, or activates the extension if it is already loaded. + * + * @param extensionId The extension's unique identifier. + * @param startupParams Not currently used, pass "". + * + * @example + * To launch the extension "help" with ID "HLP" from this extension, call: + * requestOpenExtension("HLP", ""); + * + */ +CSInterface.prototype.requestOpenExtension = function(extensionId, params) +{ + window.__adobe_cep__.requestOpenExtension(extensionId, params); +}; + +/** + * Retrieves the list of extensions currently loaded in the current host application. + * The extension list is initialized once, and remains the same during the lifetime + * of the CEP session. + * + * @param extensionIds Optional, an array of unique identifiers for extensions of interest. + * If omitted, retrieves data for all extensions. + * + * @return Zero or more \c #Extension objects. + */ +CSInterface.prototype.getExtensions = function(extensionIds) +{ + var extensionIdsStr = JSON.stringify(extensionIds); + var extensionsStr = window.__adobe_cep__.getExtensions(extensionIdsStr); + + var extensions = JSON.parse(extensionsStr); + return extensions; +}; + +/** + * Retrieves network-related preferences. + * + * @return A JavaScript object containing network preferences. + */ +CSInterface.prototype.getNetworkPreferences = function() +{ + var result = window.__adobe_cep__.getNetworkPreferences(); + var networkPre = JSON.parse(result); + + return networkPre; +}; + +/** + * Initializes the resource bundle for this extension with property values + * for the current application and locale. + * To support multiple locales, you must define a property file for each locale, + * containing keyed display-string values for that locale. + * See localization documentation for Extension Builder and related products. + * + * Keys can be in the + * form key.value="localized string", for use in HTML text elements. + * For example, in this input element, the localized \c key.value string is displayed + * instead of the empty \c value string: + * + * + * + * @return An object containing the resource bundle information. + */ +CSInterface.prototype.initResourceBundle = function() +{ + var resourceBundle = JSON.parse(window.__adobe_cep__.initResourceBundle()); + var resElms = document.querySelectorAll('[data-locale]'); + for (var n = 0; n < resElms.length; n++) + { + var resEl = resElms[n]; + // Get the resource key from the element. + var resKey = resEl.getAttribute('data-locale'); + if (resKey) + { + // Get all the resources that start with the key. + for (var key in resourceBundle) + { + if (key.indexOf(resKey) === 0) + { + var resValue = resourceBundle[key]; + if (key.length == resKey.length) + { + resEl.innerHTML = resValue; + } + else if ('.' == key.charAt(resKey.length)) + { + var attrKey = key.substring(resKey.length + 1); + resEl[attrKey] = resValue; + } + } + } + } + } + return resourceBundle; +}; + +/** + * Writes installation information to a file. + * + * @return The file path. + */ +CSInterface.prototype.dumpInstallationInfo = function() +{ + return window.__adobe_cep__.dumpInstallationInfo(); +}; + +/** + * Retrieves version information for the current Operating System, + * See http://www.useragentstring.com/pages/Chrome/ for Chrome \c navigator.userAgent values. + * + * @return A string containing the OS version, or "unknown Operation System". + * If user customizes the User Agent by setting CEF command parameter "--user-agent", only + * "Mac OS X" or "Windows" will be returned. + */ +CSInterface.prototype.getOSInformation = function() +{ + var userAgent = navigator.userAgent; + + if ((navigator.platform == "Win32") || (navigator.platform == "Windows")) + { + var winVersion = "Windows"; + var winBit = ""; + if (userAgent.indexOf("Windows") > -1) + { + if (userAgent.indexOf("Windows NT 5.0") > -1) + { + winVersion = "Windows 2000"; + } + else if (userAgent.indexOf("Windows NT 5.1") > -1) + { + winVersion = "Windows XP"; + } + else if (userAgent.indexOf("Windows NT 5.2") > -1) + { + winVersion = "Windows Server 2003"; + } + else if (userAgent.indexOf("Windows NT 6.0") > -1) + { + winVersion = "Windows Vista"; + } + else if (userAgent.indexOf("Windows NT 6.1") > -1) + { + winVersion = "Windows 7"; + } + else if (userAgent.indexOf("Windows NT 6.2") > -1) + { + winVersion = "Windows 8"; + } + else if (userAgent.indexOf("Windows NT 6.3") > -1) + { + winVersion = "Windows 8.1"; + } + else if (userAgent.indexOf("Windows NT 10") > -1) + { + winVersion = "Windows 10"; + } + + if (userAgent.indexOf("WOW64") > -1 || userAgent.indexOf("Win64") > -1) + { + winBit = " 64-bit"; + } + else + { + winBit = " 32-bit"; + } + } + + return winVersion + winBit; + } + else if ((navigator.platform == "MacIntel") || (navigator.platform == "Macintosh")) + { + var result = "Mac OS X"; + + if (userAgent.indexOf("Mac OS X") > -1) + { + result = userAgent.substring(userAgent.indexOf("Mac OS X"), userAgent.indexOf(")")); + result = result.replace(/_/g, "."); + } + + return result; + } + + return "Unknown Operation System"; +}; + +/** + * Opens a page in the default system browser. + * + * Since 4.2.0 + * + * @param url The URL of the page/file to open, or the email address. + * Must use HTTP/HTTPS/file/mailto protocol. For example: + * "http://www.adobe.com" + * "https://github.com" + * "file:///C:/log.txt" + * "mailto:test@adobe.com" + * + * @return One of these error codes:\n + *
    \n + *
  • NO_ERROR - 0
  • \n + *
  • ERR_UNKNOWN - 1
  • \n + *
  • ERR_INVALID_PARAMS - 2
  • \n + *
  • ERR_INVALID_URL - 201
  • \n + *
\n + */ +CSInterface.prototype.openURLInDefaultBrowser = function(url) +{ + return cep.util.openURLInDefaultBrowser(url); +}; + +/** + * Retrieves extension ID. + * + * Since 4.2.0 + * + * @return extension ID. + */ +CSInterface.prototype.getExtensionID = function() +{ + return window.__adobe_cep__.getExtensionId(); +}; + +/** + * Retrieves the scale factor of screen. + * On Windows platform, the value of scale factor might be different from operating system's scale factor, + * since host application may use its self-defined scale factor. + * + * Since 4.2.0 + * + * @return One of the following float number. + *
    \n + *
  • -1.0 when error occurs
  • \n + *
  • 1.0 means normal screen
  • \n + *
  • >1.0 means HiDPI screen
  • \n + *
\n + */ +CSInterface.prototype.getScaleFactor = function() +{ + return window.__adobe_cep__.getScaleFactor(); +}; + +/** + * Set a handler to detect any changes of scale factor. This only works on Mac. + * + * Since 4.2.0 + * + * @param handler The function to be called when scale factor is changed. + * + */ +CSInterface.prototype.setScaleFactorChangedHandler = function(handler) +{ + window.__adobe_cep__.setScaleFactorChangedHandler(handler); +}; + +/** + * Retrieves current API version. + * + * Since 4.2.0 + * + * @return ApiVersion object. + * + */ +CSInterface.prototype.getCurrentApiVersion = function() +{ + var apiVersion = JSON.parse(window.__adobe_cep__.getCurrentApiVersion()); + return apiVersion; +}; + +/** + * Set panel flyout menu by an XML. + * + * Since 5.2.0 + * + * Register a callback function for "com.adobe.csxs.events.flyoutMenuClicked" to get notified when a + * menu item is clicked. + * The "data" attribute of event is an object which contains "menuId" and "menuName" attributes. + * + * Register callback functions for "com.adobe.csxs.events.flyoutMenuOpened" and "com.adobe.csxs.events.flyoutMenuClosed" + * respectively to get notified when flyout menu is opened or closed. + * + * @param menu A XML string which describes menu structure. + * An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setPanelFlyoutMenu = function(menu) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeSync("setPanelFlyoutMenu", menu); +}; + +/** + * Updates a menu item in the extension window's flyout menu, by setting the enabled + * and selection status. + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + * + * @return false when the host application does not support this functionality (HostCapabilities.EXTENDED_PANEL_MENU is false). + * Fails silently if menu label is invalid. + * + * @see HostCapabilities.EXTENDED_PANEL_MENU + */ +CSInterface.prototype.updatePanelMenuItem = function(menuItemLabel, enabled, checked) +{ + var ret = false; + if (this.getHostCapabilities().EXTENDED_PANEL_MENU) + { + var itemStatus = new MenuItemStatus(menuItemLabel, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updatePanelMenuItem", JSON.stringify(itemStatus)); + } + return ret; +}; + + +/** + * Set context menu by XML string. + * + * Since 5.2.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item name is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + https://developer.chrome.com/extensions/contextMenus + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + * + * @param menu A XML string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setContextMenu = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenu", menu, callback); +}; + +/** + * Set context menu by JSON string. + * + * Since 6.0.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item label is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + https://developer.chrome.com/extensions/contextMenus + * + * @param menu A JSON string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu JSON: + * + * { + * "menu": [ + * { + * "id": "menuItemId1", + * "label": "testExample1", + * "enabled": true, + * "checkable": true, + * "checked": false, + * "icon": "./image/small_16X16.png" + * }, + * { + * "id": "menuItemId2", + * "label": "testExample2", + * "menu": [ + * { + * "id": "menuItemId2-1", + * "label": "testExample2-1", + * "menu": [ + * { + * "id": "menuItemId2-1-1", + * "label": "testExample2-1-1", + * "enabled": false, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "id": "menuItemId2-2", + * "label": "testExample2-2", + * "enabled": true, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "label": "---" + * }, + * { + * "id": "menuItemId3", + * "label": "testExample3", + * "enabled": false, + * "checkable": true, + * "checked": false + * } + * ] + * } + * + */ +CSInterface.prototype.setContextMenuByJSON = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenuByJSON", menu, callback); +}; + +/** + * Updates a context menu item by setting the enabled and selection status. + * + * Since 5.2.0 + * + * @param menuItemID The menu item ID. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + */ +CSInterface.prototype.updateContextMenuItem = function(menuItemID, enabled, checked) +{ + var itemStatus = new ContextMenuItemStatus(menuItemID, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updateContextMenuItem", JSON.stringify(itemStatus)); +}; + +/** + * Get the visibility status of an extension window. + * + * Since 6.0.0 + * + * @return true if the extension window is visible; false if the extension window is hidden. + */ +CSInterface.prototype.isWindowVisible = function() +{ + return window.__adobe_cep__.invokeSync("isWindowVisible", ""); +}; + +/** + * Resize extension's content to the specified dimensions. + * 1. Works with modal and modeless extensions in all Adobe products. + * 2. Extension's manifest min/max size constraints apply and take precedence. + * 3. For panel extensions + * 3.1 This works in all Adobe products except: + * * Premiere Pro + * * Prelude + * * After Effects + * 3.2 When the panel is in certain states (especially when being docked), + * it will not change to the desired dimensions even when the + * specified size satisfies min/max constraints. + * + * Since 6.0.0 + * + * @param width The new width + * @param height The new height + */ +CSInterface.prototype.resizeContent = function(width, height) +{ + window.__adobe_cep__.resizeContent(width, height); +}; + +/** + * Register the invalid certificate callback for an extension. + * This callback will be triggered when the extension tries to access the web site that contains the invalid certificate on the main frame. + * But if the extension does not call this function and tries to access the web site containing the invalid certificate, a default error page will be shown. + * + * Since 6.1.0 + * + * @param callback the callback function + */ +CSInterface.prototype.registerInvalidCertificateCallback = function(callback) +{ + return window.__adobe_cep__.registerInvalidCertificateCallback(callback); +}; + +/** + * Register an interest in some key events to prevent them from being sent to the host application. + * + * This function works with modeless extensions and panel extensions. + * Generally all the key events will be sent to the host application for these two extensions if the current focused element + * is not text input or dropdown, + * If you want to intercept some key events and want them to be handled in the extension, please call this function + * in advance to prevent them being sent to the host application. + * + * Since 6.1.0 + * + * @param keyEventsInterest A JSON string describing those key events you are interested in. A null object or + an empty string will lead to removing the interest + * + * This JSON string should be an array, each object has following keys: + * + * keyCode: [Required] represents an OS system dependent virtual key code identifying + * the unmodified value of the pressed key. + * ctrlKey: [optional] a Boolean that indicates if the control key was pressed (true) or not (false) when the event occurred. + * altKey: [optional] a Boolean that indicates if the alt key was pressed (true) or not (false) when the event occurred. + * shiftKey: [optional] a Boolean that indicates if the shift key was pressed (true) or not (false) when the event occurred. + * metaKey: [optional] (Mac Only) a Boolean that indicates if the Meta key was pressed (true) or not (false) when the event occurred. + * On Macintosh keyboards, this is the command key. To detect Windows key on Windows, please use keyCode instead. + * An example JSON string: + * + * [ + * { + * "keyCode": 48 + * }, + * { + * "keyCode": 123, + * "ctrlKey": true + * }, + * { + * "keyCode": 123, + * "ctrlKey": true, + * "metaKey": true + * } + * ] + * + */ +CSInterface.prototype.registerKeyEventsInterest = function(keyEventsInterest) +{ + return window.__adobe_cep__.registerKeyEventsInterest(keyEventsInterest); +}; + +/** + * Set the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @param title The window title. + */ +CSInterface.prototype.setWindowTitle = function(title) +{ + window.__adobe_cep__.invokeSync("setWindowTitle", title); +}; + +/** + * Get the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @return The window title. + */ +CSInterface.prototype.getWindowTitle = function() +{ + return window.__adobe_cep__.invokeSync("getWindowTitle", ""); +}; diff --git a/openpype/hosts/photoshop/api/extension/client/client.js b/openpype/hosts/photoshop/api/extension/client/client.js new file mode 100644 index 0000000000..f4ba4cfe47 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/client.js @@ -0,0 +1,300 @@ + // client facing part of extension, creates WSRPC client (jsx cannot + // do that) + // consumes RPC calls from server (OpenPype) calls ./host/index.jsx and + // returns values back (in json format) + + var logReturn = function(result){ log.warn('Result: ' + result);}; + + var csInterface = new CSInterface(); + + log.warn("script start"); + + WSRPC.DEBUG = false; + WSRPC.TRACE = false; + + function myCallBack(){ + log.warn("Triggered index.jsx"); + } + // importing through manifest.xml isn't working because relative paths + // possibly TODO + jsx.evalFile('./host/index.jsx', myCallBack); + + function runEvalScript(script) { + // because of asynchronous nature of functions in jsx + // this waits for response + return new Promise(function(resolve, reject){ + csInterface.evalScript(script, resolve); + }); + } + + /** main entry point **/ + startUp("WEBSOCKET_URL"); + + // get websocket server url from environment value + async function startUp(url){ + log.warn("url", url); + promis = runEvalScript("getEnv('" + url + "')"); + + var res = await promis; + // run rest only after resolved promise + main(res); + } + + function get_extension_version(){ + /** Returns version number from extension manifest.xml **/ + log.debug("get_extension_version") + var path = csInterface.getSystemPath(SystemPath.EXTENSION); + log.debug("extension path " + path); + + var result = window.cep.fs.readFile(path + "/CSXS/manifest.xml"); + var version = undefined; + if(result.err === 0){ + if (window.DOMParser) { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(result.data.toString(), 'text/xml'); + const children = xmlDoc.children; + + for (let i = 0; i <= children.length; i++) { + if (children[i] && children[i].getAttribute('ExtensionBundleVersion')) { + version = children[i].getAttribute('ExtensionBundleVersion'); + } + } + } + } + return version + } + + function main(websocket_url){ + // creates connection to 'websocket_url', registers routes + log.warn("websocket_url", websocket_url); + var default_url = 'ws://localhost:8099/ws/'; + + if (websocket_url == ''){ + websocket_url = default_url; + } + log.warn("connecting to:", websocket_url); + RPC = new WSRPC(websocket_url, 5000); // spin connection + + RPC.connect(); + + log.warn("connected"); + + function EscapeStringForJSX(str){ + // Replaces: + // \ with \\ + // ' with \' + // " with \" + // See: https://stackoverflow.com/a/3967927/5285364 + return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); + } + + RPC.addRoute('Photoshop.open', function (data) { + log.warn('Server called client route "open":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("fileOpen('" + escapedPath +"')") + .then(function(result){ + log.warn("open: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.read', function (data) { + log.warn('Server called client route "read":', data); + return runEvalScript("getHeadline()") + .then(function(result){ + log.warn("getHeadline: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_layers', function (data) { + log.warn('Server called client route "get_layers":', data); + return runEvalScript("getLayers()") + .then(function(result){ + log.warn("getLayers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.set_visible', function (data) { + log.warn('Server called client route "set_visible":', data); + return runEvalScript("setVisible(" + data.layer_id + ", " + + data.visibility + ")") + .then(function(result){ + log.warn("setVisible: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_active_document_name', function (data) { + log.warn('Server called client route "get_active_document_name":', + data); + return runEvalScript("getActiveDocumentName()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_active_document_full_name', function (data) { + log.warn('Server called client route ' + + '"get_active_document_full_name":', data); + return runEvalScript("getActiveDocumentFullName()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.save', function (data) { + log.warn('Server called client route "save":', data); + + return runEvalScript("save()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_selected_layers', function (data) { + log.warn('Server called client route "get_selected_layers":', data); + + return runEvalScript("getSelectedLayers()") + .then(function(result){ + log.warn("get_selected_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.create_group', function (data) { + log.warn('Server called client route "create_group":', data); + + return runEvalScript("createGroup('" + data.name + "')") + .then(function(result){ + log.warn("createGroup: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.group_selected_layers', function (data) { + log.warn('Server called client route "group_selected_layers":', + data); + + return runEvalScript("groupSelectedLayers(null, "+ + "'" + data.name +"')") + .then(function(result){ + log.warn("group_selected_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.import_smart_object', function (data) { + log.warn('Server called client "import_smart_object":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("importSmartObject('" + escapedPath +"', " + + "'"+ data.name +"',"+ + + data.as_reference +")") + .then(function(result){ + log.warn("import_smart_object: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.replace_smart_object', function (data) { + log.warn('Server called route "replace_smart_object":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("replaceSmartObjects("+data.layer_id+"," + + "'" + escapedPath +"',"+ + "'"+ data.name +"')") + .then(function(result){ + log.warn("replaceSmartObjects: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.delete_layer', function (data) { + log.warn('Server called route "delete_layer":', data); + return runEvalScript("deleteLayer("+data.layer_id+")") + .then(function(result){ + log.warn("delete_layer: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.rename_layer', function (data) { + log.warn('Server called route "rename_layer":', data); + return runEvalScript("renameLayer("+data.layer_id+", " + + "'"+ data.name +"')") + .then(function(result){ + log.warn("rename_layer: " + result); + return result; + }); +}); + + RPC.addRoute('Photoshop.select_layers', function (data) { + log.warn('Server called client route "select_layers":', data); + + return runEvalScript("selectLayers('" + data.layers +"')") + .then(function(result){ + log.warn("select_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.is_saved', function (data) { + log.warn('Server called client route "is_saved":', data); + + return runEvalScript("isSaved()") + .then(function(result){ + log.warn("is_saved: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.saveAs', function (data) { + log.warn('Server called client route "saveAsJPEG":', data); + var escapedPath = EscapeStringForJSX(data.image_path); + return runEvalScript("saveAs('" + escapedPath + "', " + + "'" + data.ext + "', " + + data.as_copy + ")") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.imprint', function (data) { + log.warn('Server called client route "imprint":', data); + var escaped = data.payload.replace(/\n/g, "\\n"); + return runEvalScript("imprint('" + escaped + "')") + .then(function(result){ + log.warn("imprint: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_extension_version', function (data) { + log.warn('Server called client route "get_extension_version":', data); + return get_extension_version(); + }); + + RPC.addRoute('Photoshop.close', function (data) { + log.warn('Server called client route "close":', data); + return runEvalScript("close()"); + }); + + RPC.call('Photoshop.ping').then(function (data) { + log.warn('Result for calling server route "ping": ', data); + return runEvalScript("ping()") + .then(function(result){ + log.warn("ping: " + result); + return result; + }); + + }, function (error) { + log.warn(error); + }); + + } + + log.warn("end script"); diff --git a/openpype/hosts/photoshop/api/extension/client/loglevel.min.js b/openpype/hosts/photoshop/api/extension/client/loglevel.min.js new file mode 100644 index 0000000000..648d7e9ff6 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/loglevel.min.js @@ -0,0 +1,2 @@ +/*! loglevel - v1.6.8 - https://github.com/pimterry/loglevel - (c) 2020 Tim Perry - licensed MIT */ +!function(a,b){"use strict";"function"==typeof define&&define.amd?define(b):"object"==typeof module&&module.exports?module.exports=b():a.log=b()}(this,function(){"use strict";function a(a,b){var c=a[b];if("function"==typeof c.bind)return c.bind(a);try{return Function.prototype.bind.call(c,a)}catch(b){return function(){return Function.prototype.apply.apply(c,[a,arguments])}}}function b(){console.log&&(console.log.apply?console.log.apply(console,arguments):Function.prototype.apply.apply(console.log,[console,arguments])),console.trace&&console.trace()}function c(c){return"debug"===c&&(c="log"),typeof console!==i&&("trace"===c&&j?b:void 0!==console[c]?a(console,c):void 0!==console.log?a(console,"log"):h)}function d(a,b){for(var c=0;c=0&&b<=j.levels.SILENT))throw"log.setLevel() called with invalid level: "+b;if(h=b,!1!==c&&e(b),d.call(j,b,a),typeof console===i&&b 1 && arguments[1] !== undefined ? arguments[1] : 1000; + + _classCallCheck(this, WSRPC); + + var self = this; + URL = getAbsoluteWsUrl(URL); + self.id = 1; + self.eventId = 0; + self.socketStarted = false; + self.eventStore = { + onconnect: {}, + onerror: {}, + onclose: {}, + onchange: {} + }; + self.connectionNumber = 0; + self.oneTimeEventStore = { + onconnect: [], + onerror: [], + onclose: [], + onchange: [] + }; + self.callQueue = []; + + function createSocket() { + var ws = new WebSocket(URL); + + var rejectQueue = function rejectQueue() { + self.connectionNumber++; // rejects incoming calls + + var deferred; //reject all pending calls + + while (0 < self.callQueue.length) { + var callObj = self.callQueue.shift(); + deferred = self.store[callObj.id]; + delete self.store[callObj.id]; + + if (deferred && deferred.promise.isPending()) { + deferred.reject('WebSocket error occurred'); + } + } // reject all from the store + + + for (var key in self.store) { + if (!self.store.hasOwnProperty(key)) continue; + deferred = self.store[key]; + + if (deferred && deferred.promise.isPending()) { + deferred.reject('WebSocket error occurred'); + } + } + }; + + function reconnect(callEvents) { + setTimeout(function () { + try { + self.socket = createSocket(); + self.id = 1; + } catch (exc) { + callEvents('onerror', exc); + delete self.socket; + console.error(exc); + } + }, reconnectTimeout); + } + + ws.onclose = function (err) { + log('ONCLOSE CALLED', 'STATE', self.public.state()); + trace(err); + + for (var serial in self.store) { + if (!self.store.hasOwnProperty(serial)) continue; + + if (self.store[serial].hasOwnProperty('reject')) { + self.store[serial].reject('Connection closed'); + } + } + + rejectQueue(); + callEvents('onclose', err); + callEvents('onchange', err); + reconnect(callEvents); + }; + + ws.onerror = function (err) { + log('ONERROR CALLED', 'STATE', self.public.state()); + trace(err); + rejectQueue(); + callEvents('onerror', err); + callEvents('onchange', err); + log('WebSocket has been closed by error: ', err); + }; + + function tryCallEvent(func, event) { + try { + return func(event); + } catch (e) { + if (e.hasOwnProperty('stack')) { + log(e.stack); + } else { + log('Event function', func, 'raised unknown error:', e); + } + + console.error(e); + } + } + + function callEvents(evName, event) { + while (0 < self.oneTimeEventStore[evName].length) { + var deferred = self.oneTimeEventStore[evName].shift(); + if (deferred.hasOwnProperty('resolve') && deferred.promise.isPending()) deferred.resolve(); + } + + for (var i in self.eventStore[evName]) { + if (!self.eventStore[evName].hasOwnProperty(i)) continue; + var cur = self.eventStore[evName][i]; + tryCallEvent(cur, event); + } + } + + ws.onopen = function (ev) { + log('ONOPEN CALLED', 'STATE', self.public.state()); + trace(ev); + + while (0 < self.callQueue.length) { + // noinspection JSUnresolvedFunction + self.socket.send(JSON.stringify(self.callQueue.shift(), 0, 1)); + } + + callEvents('onconnect', ev); + callEvents('onchange', ev); + }; + + function handleCall(self, data) { + if (!self.routes.hasOwnProperty(data.method)) throw new Error('Route not found'); + var connectionNumber = self.connectionNumber; + var deferred = new Deferred(); + deferred.promise.then(function (result) { + if (connectionNumber !== self.connectionNumber) return; + self.socket.send(JSON.stringify({ + id: data.id, + result: result + })); + }, function (error) { + if (connectionNumber !== self.connectionNumber) return; + self.socket.send(JSON.stringify({ + id: data.id, + error: error + })); + }); + var func = self.routes[data.method]; + if (self.asyncRoutes[data.method]) return func.apply(deferred, [data.params]); + + function badPromise() { + throw new Error("You should register route with async flag."); + } + + var promiseMock = { + resolve: badPromise, + reject: badPromise + }; + + try { + deferred.resolve(func.apply(promiseMock, [data.params])); + } catch (e) { + deferred.reject(e); + console.error(e); + } + } + + function handleError(self, data) { + if (!self.store.hasOwnProperty(data.id)) return log('Unknown callback'); + var deferred = self.store[data.id]; + if (typeof deferred === 'undefined') return log('Confirmation without handler'); + delete self.store[data.id]; + log('REJECTING', data.error); + deferred.reject(data.error); + } + + function handleResult(self, data) { + var deferred = self.store[data.id]; + if (typeof deferred === 'undefined') return log('Confirmation without handler'); + delete self.store[data.id]; + + if (data.hasOwnProperty('result')) { + return deferred.resolve(data.result); + } + + return deferred.reject(data.error); + } + + ws.onmessage = function (message) { + log('ONMESSAGE CALLED', 'STATE', self.public.state()); + trace(message); + if (message.type !== 'message') return; + var data; + + try { + data = JSON.parse(message.data); + log(data); + + if (data.hasOwnProperty('method')) { + return handleCall(self, data); + } else if (data.hasOwnProperty('error') && data.error === null) { + return handleError(self, data); + } else { + return handleResult(self, data); + } + } catch (exception) { + var err = { + error: exception.message, + result: null, + id: data ? data.id : null + }; + self.socket.send(JSON.stringify(err)); + console.error(exception); + } + }; + + return ws; + } + + function makeCall(func, args, params) { + self.id += 2; + var deferred = new Deferred(); + var callObj = Object.freeze({ + id: self.id, + method: func, + params: args + }); + var state = self.public.state(); + + if (state === 'OPEN') { + self.store[self.id] = deferred; + self.socket.send(JSON.stringify(callObj)); + } else if (state === 'CONNECTING') { + log('SOCKET IS', state); + self.store[self.id] = deferred; + self.callQueue.push(callObj); + } else { + log('SOCKET IS', state); + + if (params && params['noWait']) { + deferred.reject("Socket is: ".concat(state)); + } else { + self.store[self.id] = deferred; + self.callQueue.push(callObj); + } + } + + return deferred.promise; + } + + self.asyncRoutes = {}; + self.routes = {}; + self.store = {}; + self.public = Object.freeze({ + call: function call(func, args, params) { + return makeCall(func, args, params); + }, + addRoute: function addRoute(route, callback, isAsync) { + self.asyncRoutes[route] = isAsync || false; + self.routes[route] = callback; + }, + deleteRoute: function deleteRoute(route) { + delete self.asyncRoutes[route]; + return delete self.routes[route]; + }, + addEventListener: function addEventListener(event, func) { + var eventId = self.eventId++; + self.eventStore[event][eventId] = func; + return eventId; + }, + removeEventListener: function removeEventListener(event, index) { + if (self.eventStore[event].hasOwnProperty(index)) { + delete self.eventStore[event][index]; + return true; + } else { + return false; + } + }, + onEvent: function onEvent(event) { + var deferred = new Deferred(); + self.oneTimeEventStore[event].push(deferred); + return deferred.promise; + }, + destroy: function destroy() { + return self.socket.close(); + }, + state: function state() { + return readyState[this.stateCode()]; + }, + stateCode: function stateCode() { + if (self.socketStarted && self.socket) return self.socket.readyState; + return 3; + }, + connect: function connect() { + self.socketStarted = true; + self.socket = createSocket(); + } + }); + self.public.addRoute('log', function (argsObj) { + //console.info("Websocket sent: ".concat(argsObj)); + }); + self.public.addRoute('ping', function (data) { + return data; + }); + return self.public; + }; + + WSRPC.DEBUG = false; + WSRPC.TRACE = false; + + return WSRPC; + +})); +//# sourceMappingURL=wsrpc.js.map diff --git a/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js b/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js new file mode 100644 index 0000000000..f1264b91c4 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js @@ -0,0 +1 @@ +!function(global,factory){"object"==typeof exports&&"undefined"!=typeof module?module.exports=factory():"function"==typeof define&&define.amd?define(factory):(global=global||self).WSRPC=factory()}(this,function(){"use strict";function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor))throw new TypeError("Cannot call a class as a function")}function Deferred(){_classCallCheck(this,Deferred);var self=this;function wrapper(func){return function(){if(!self.done)return self.done=!0,func.apply(this,arguments);console.error(new Error("Promise already done"))}}return self.resolve=null,self.reject=null,self.done=!1,self.promise=new Promise(function(resolve,reject){self.resolve=wrapper(resolve),self.reject=wrapper(reject)}),self.promise.isPending=function(){return!self.done},self}function logGroup(group,level,args){console.group(group),console[level].apply(this,args),console.groupEnd()}function log(){WSRPC.DEBUG&&logGroup("WSRPC.DEBUG","trace",arguments)}function trace(msg){if(WSRPC.TRACE){var payload=msg;"data"in msg&&(payload=JSON.parse(msg.data)),logGroup("WSRPC.TRACE","trace",[payload])}}var readyState=Object.freeze({0:"CONNECTING",1:"OPEN",2:"CLOSING",3:"CLOSED"}),WSRPC=function WSRPC(URL){var reconnectTimeout=1 // +// forceEval is now by default true // +// It wraps the scripts in a try catch and an eval providing useful error handling // +// One can set in the jsx engine $.includeStack = true to return the call stack in the event of an error // +/////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/////////////////////////////////////////////////////////////////////////////////////////////////////////// +// JSX.js for calling jsx code from the js engine // +// 2 methods included // +// 1) jsx.evalScript AKA jsx.eval // +// 2) jsx.evalFile AKA jsx.file // +// Special features // +// 1) Allows all changes in your jsx code to be reloaded into your extension at the click of a button // +// 2) Can enable the $.fileName property to work and provides a $.__fileName() method as an alternative // +// 3) Can force a callBack result from InDesign // +// 4) No more csInterface.evalScript('alert("hello "' + title + " " + name + '");') // +// use jsx.evalScript('alert("hello __title__ __name__");', {title: title, name: name}); // +// 5) execute jsx files from your jsx folder like this jsx.evalFile('myFabJsxScript.jsx'); // +// or from a relative path jsx.evalFile('../myFabScripts/myFabJsxScript.jsx'); // +// or from an absolute url jsx.evalFile('/Path/to/my/FabJsxScript.jsx'); (mac) // +// or from an absolute url jsx.evalFile('C:Path/to/my/FabJsxScript.jsx'); (windows) // +// 6) Parameter can be entered in the from of a parameter list which can be in any order or as an object // +// 7) Not camelCase sensitive (very useful for the illiterate) // +// Dead easy to use BUT SPEND THE 3 TO 5 MINUTES IT SHOULD TAKE TO READ THE INSTRUCTIONS // +/////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/* jshint undef:true, unused:true, esversion:6 */ + +////////////////////////////////////// +// jsx is the interface for the API // +////////////////////////////////////// + +var jsx; + +// Wrap everything in an anonymous function to prevent leeks +(function() { + ///////////////////////////////////////////////////////////////////// + // Substitute some CSInterface functions to avoid dependency on it // + ///////////////////////////////////////////////////////////////////// + + var __dirname = (function() { + var path, isMac; + path = decodeURI(window.__adobe_cep__.getSystemPath('extension')); + isMac = navigator.platform[0] === 'M'; // [M]ac + path = path.replace('file://' + (isMac ? '' : '/'), ''); + return path; + })(); + + var evalScript = function(script, callback) { + callback = callback || function() {}; + window.__adobe_cep__.evalScript(script, callback); + }; + + + //////////////////////////////////////////// + // In place of using the node path module // + //////////////////////////////////////////// + + // jshint undef: true, unused: true + + // A very minified version of the NodeJs Path module!! + // For use outside of NodeJs + // Majorly nicked by Trevor from Joyent + var path = (function() { + + var isString = function(arg) { + return typeof arg === 'string'; + }; + + // var isObject = function(arg) { + // return typeof arg === 'object' && arg !== null; + // }; + + var basename = function(path) { + if (!isString(path)) { + throw new TypeError('Argument to path.basename must be a string'); + } + var bits = path.split(/[\/\\]/g); + return bits[bits.length - 1]; + }; + + // jshint undef: true + // Regex to split a windows path into three parts: [*, device, slash, + // tail] windows-only + var splitDeviceRe = + /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/; + + // Regex to split the tail part of the above into [*, dir, basename, ext] + // var splitTailRe = + // /^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/; + + var win32 = {}; + // Function to split a filename into [root, dir, basename, ext] + // var win32SplitPath = function(filename) { + // // Separate device+slash from tail + // var result = splitDeviceRe.exec(filename), + // device = (result[1] || '') + (result[2] || ''), + // tail = result[3] || ''; + // // Split the tail into dir, basename and extension + // var result2 = splitTailRe.exec(tail), + // dir = result2[1], + // basename = result2[2], + // ext = result2[3]; + // return [device, dir, basename, ext]; + // }; + + var win32StatPath = function(path) { + var result = splitDeviceRe.exec(path), + device = result[1] || '', + isUnc = !!device && device[1] !== ':'; + return { + device: device, + isUnc: isUnc, + isAbsolute: isUnc || !!result[2], // UNC paths are always absolute + tail: result[3] + }; + }; + + var normalizeUNCRoot = function(device) { + return '\\\\' + device.replace(/^[\\\/]+/, '').replace(/[\\\/]+/g, '\\'); + }; + + var normalizeArray = function(parts, allowAboveRoot) { + var res = []; + for (var i = 0; i < parts.length; i++) { + var p = parts[i]; + + // ignore empty parts + if (!p || p === '.') + continue; + + if (p === '..') { + if (res.length && res[res.length - 1] !== '..') { + res.pop(); + } else if (allowAboveRoot) { + res.push('..'); + } + } else { + res.push(p); + } + } + + return res; + }; + + win32.normalize = function(path) { + var result = win32StatPath(path), + device = result.device, + isUnc = result.isUnc, + isAbsolute = result.isAbsolute, + tail = result.tail, + trailingSlash = /[\\\/]$/.test(tail); + + // Normalize the tail path + tail = normalizeArray(tail.split(/[\\\/]+/), !isAbsolute).join('\\'); + + if (!tail && !isAbsolute) { + tail = '.'; + } + if (tail && trailingSlash) { + tail += '\\'; + } + + // Convert slashes to backslashes when `device` points to an UNC root. + // Also squash multiple slashes into a single one where appropriate. + if (isUnc) { + device = normalizeUNCRoot(device); + } + + return device + (isAbsolute ? '\\' : '') + tail; + }; + win32.join = function() { + var paths = []; + for (var i = 0; i < arguments.length; i++) { + var arg = arguments[i]; + if (!isString(arg)) { + throw new TypeError('Arguments to path.join must be strings'); + } + if (arg) { + paths.push(arg); + } + } + + var joined = paths.join('\\'); + + // Make sure that the joined path doesn't start with two slashes, because + // normalize() will mistake it for an UNC path then. + // + // This step is skipped when it is very clear that the user actually + // intended to point at an UNC path. This is assumed when the first + // non-empty string arguments starts with exactly two slashes followed by + // at least one more non-slash character. + // + // Note that for normalize() to treat a path as an UNC path it needs to + // have at least 2 components, so we don't filter for that here. + // This means that the user can use join to construct UNC paths from + // a server name and a share name; for example: + // path.join('//server', 'share') -> '\\\\server\\share\') + if (!/^[\\\/]{2}[^\\\/]/.test(paths[0])) { + joined = joined.replace(/^[\\\/]{2,}/, '\\'); + } + return win32.normalize(joined); + }; + + var posix = {}; + + // posix version + posix.join = function() { + var path = ''; + for (var i = 0; i < arguments.length; i++) { + var segment = arguments[i]; + if (!isString(segment)) { + throw new TypeError('Arguments to path.join must be strings'); + } + if (segment) { + if (!path) { + path += segment; + } else { + path += '/' + segment; + } + } + } + return posix.normalize(path); + }; + + // path.normalize(path) + // posix version + posix.normalize = function(path) { + var isAbsolute = path.charAt(0) === '/', + trailingSlash = path && path[path.length - 1] === '/'; + + // Normalize the path + path = normalizeArray(path.split('/'), !isAbsolute).join('/'); + + if (!path && !isAbsolute) { + path = '.'; + } + if (path && trailingSlash) { + path += '/'; + } + + return (isAbsolute ? '/' : '') + path; + }; + + win32.basename = posix.basename = basename; + + this.win32 = win32; + this.posix = posix; + return (navigator.platform[0] === 'M') ? posix : win32; + })(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + // The is the "main" function which is to be prototyped // + // It run a small snippet in the jsx engine that // + // 1) Assigns $.__dirname with the value of the extensions __dirname base path // + // 2) Sets up a method $.__fileName() for retrieving from within the jsx script it's $.fileName value // + // more on that method later // + // At the end of the script the global declaration jsx = new Jsx(); has been made. // + // If you like you can remove that and include in your relevant functions // + // var jsx = new Jsx(); You would never call the Jsx function without the "new" declaration // + //////////////////////////////////////////////////////////////////////////////////////////////////////// + var Jsx = function() { + var jsxScript; + // Setup jsx function to enable the jsx scripts to easily retrieve their file location + jsxScript = [ + '$.level = 0;', + 'if(!$.__fileNames){', + ' $.__fileNames = {};', + ' $.__dirname = "__dirname__";'.replace('__dirname__', __dirname), + ' $.__fileName = function(name){', + ' name = name || $.fileName;', + ' return ($.__fileNames && $.__fileNames[name]) || $.fileName;', + ' };', + '}' + ].join(''); + evalScript(jsxScript); + return this; + }; + + /** + * [evalScript] For calling jsx scripts from the js engine + * + * The jsx.evalScript method is used for calling jsx scripts directly from the js engine + * Allows for easy replacement i.e. variable insertions and for forcing eval. + * For convenience jsx.eval or jsx.script or jsx.evalscript can be used instead of calling jsx.evalScript + * + * @param {String} jsxScript + * The string that makes up the jsx script + * it can contain a simple template like syntax for replacements + * 'alert("__foo__");' + * the __foo__ will be replaced as per the replacements parameter + * + * @param {Function} callback + * The callback function you want the jsx script to trigger on completion + * The result of the jsx script is passed as the argument to that function + * The function can exist in some other file. + * Note that InDesign does not automatically pass the callBack as a string. + * Either write your InDesign in a way that it returns a sting the form of + * return 'this is my result surrounded by quotes' + * or use the force eval option + * [Optional DEFAULT no callBack] + * + * @param {Object} replacements + * The replacements to make on the jsx script + * given the following script (template) + * 'alert("__message__: " + __val__);' + * and we want to change the script to + * 'alert("I was born in the year: " + 1234);' + * we would pass the following object + * {"message": 'I was born in the year', "val": 1234} + * or if not using reserved words like do we can leave out the key quotes + * {message: 'I was born in the year', val: 1234} + * [Optional DEFAULT no replacements] + * + * @param {Bolean} forceEval + * If the script should be wrapped in an eval and try catch + * This will 1) provide useful error feedback if heaven forbid it is needed + * 2) The result will be a string which is required for callback results in InDesign + * [Optional DEFAULT true] + * + * Note 1) The order of the parameters is irrelevant + * Note 2) One can pass the arguments as an object if desired + * jsx.evalScript(myCallBackFunction, 'alert("__myMessage__");', true); + * is the same as + * jsx.evalScript({ + * script: 'alert("__myMessage__");', + * replacements: {myMessage: 'Hi there'}, + * callBack: myCallBackFunction, + * eval: true + * }); + * note that either lower or camelCase key names are valid + * i.e. both callback or callBack will work + * + * The following keys are the same jsx || script || jsxScript || jsxscript || file + * The following keys are the same callBack || callback + * The following keys are the same replacements || replace + * The following keys are the same eval || forceEval || forceeval + * The following keys are the same forceEvalScript || forceevalscript || evalScript || evalscript; + * + * @return {Boolean} if the jsxScript was executed or not + */ + + Jsx.prototype.evalScript = function() { + var arg, i, key, replaceThis, withThis, args, callback, forceEval, replacements, jsxScript, isBin; + + ////////////////////////////////////////////////////////////////////////////////////// + // sort out order which arguments into jsxScript, callback, replacements, forceEval // + ////////////////////////////////////////////////////////////////////////////////////// + + args = arguments; + + // Detect if the parameters were passed as an object and if so allow for various keys + if (args.length === 1 && (arg = args[0]) instanceof Object) { + jsxScript = arg.jsxScript || arg.jsx || arg.script || arg.file || arg.jsxscript; + callback = arg.callBack || arg.callback; + replacements = arg.replacements || arg.replace; + forceEval = arg.eval || arg.forceEval || arg.forceeval; + } else { + for (i = 0; i < 4; i++) { + arg = args[i]; + if (arg === undefined) { + continue; + } + if (arg.constructor === String) { + jsxScript = arg; + continue; + } + if (arg.constructor === Object) { + replacements = arg; + continue; + } + if (arg.constructor === Function) { + callback = arg; + continue; + } + if (arg === false) { + forceEval = false; + } + } + } + + // If no script provide then not too much to do! + if (!jsxScript) { + return false; + } + + // Have changed the forceEval default to be true as I prefer the error handling + if (forceEval !== false) { + forceEval = true; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // On Illustrator and other apps the result of the jsx script is automatically passed as a string // + // if you have a "script" containing the single number 1 and nothing else then the callBack will register as "1" // + // On InDesign that same script will provide a blank callBack // + // Let's say we have a callBack function var callBack = function(result){alert(result);} // + // On Ai your see the 1 in the alert // + // On ID your just see a blank alert // + // To see the 1 in the alert you need to convert the result to a string and then it will show // + // So if we rewrite out 1 byte script to '1' i.e. surround the 1 in quotes then the call back alert will show 1 // + // If the scripts planed one can make sure that the results always passed as a string (including errors) // + // otherwise one can wrap the script in an eval and then have the result passed as a string // + // I have not gone through all the apps but can say // + // for Ai you never need to set the forceEval to true // + // for ID you if you have not coded your script appropriately and your want to send a result to the callBack then set forceEval to true // + // I changed this that even on Illustrator it applies the try catch, Note the try catch will fail if $.level is set to 1 // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + if (forceEval) { + + isBin = (jsxScript.substring(0, 10) === '@JSXBIN@ES') ? '' : '\n'; + jsxScript = ( + // "\n''') + '';} catch(e){(function(e){var n, a=[]; for (n in e){a.push(n + ': ' + e[n])}; return a.join('\n')})(e)}"); + // "\n''') + '';} catch(e){e + (e.line ? ('\\nLine ' + (+e.line - 1)) : '')}"); + [ + "$.level = 0;", + "try{eval('''" + isBin, // need to add an extra line otherwise #targetengine doesn't work ;-] + jsxScript.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"') + "\n''') + '';", + "} catch (e) {", + " (function(e) {", + " var line, sourceLine, name, description, ErrorMessage, fileName, start, end, bug;", + " line = +e.line" + (isBin === '' ? ';' : ' - 1;'), // To take into account the extra line added + " fileName = File(e.fileName).fsName;", + " sourceLine = line && e.source.split(/[\\r\\n]/)[line];", + " name = e.name;", + " description = e.description;", + " ErrorMessage = name + ' ' + e.number + ': ' + description;", + " if (fileName.length && !(/[\\/\\\\]\\d+$/.test(fileName))) {", + " ErrorMessage += '\\nFile: ' + fileName;", + " line++;", + " }", + " if (line){", + " ErrorMessage += '\\nLine: ' + line +", + " '-> ' + ((sourceLine.length < 300) ? sourceLine : sourceLine.substring(0,300) + '...');", + " }", + " if (e.start) {ErrorMessage += '\\nBug: ' + e.source.substring(e.start - 1, e.end)}", + " if ($.includeStack) {ErrorMessage += '\\nStack:' + $.stack;}", + " return ErrorMessage;", + " })(e);", + "}" + ].join('') + ); + + } + + ///////////////////////////////////////////////////////////// + // deal with the replacements // + // Note it's probably better to use ${template} `literals` // + ///////////////////////////////////////////////////////////// + + if (replacements) { + for (key in replacements) { + if (replacements.hasOwnProperty(key)) { + replaceThis = new RegExp('__' + key + '__', 'g'); + withThis = replacements[key]; + jsxScript = jsxScript.replace(replaceThis, withThis + ''); + } + } + } + + + try { + evalScript(jsxScript, callback); + return true; + } catch (err) { + //////////////////////////////////////////////// + // Do whatever error handling you want here ! // + //////////////////////////////////////////////// + var newErr; + newErr = new Error(err); + alert('Error Eek: ' + newErr.stack); + return false; + } + + }; + + + /** + * [evalFile] For calling jsx scripts from the js engine + * + * The jsx.evalFiles method is used for executing saved jsx scripts + * where the jsxScript parameter is a string of the jsx scripts file location. + * For convenience jsx.file or jsx.evalfile can be used instead of jsx.evalFile + * + * @param {String} file + * The path to jsx script + * If only the base name is provided then the path will be presumed to be the + * To execute files stored in the jsx folder located in the __dirname folder use + * jsx.evalFile('myFabJsxScript.jsx'); + * To execute files stored in the a folder myFabScripts located in the __dirname folder use + * jsx.evalFile('./myFabScripts/myFabJsxScript.jsx'); + * To execute files stored in the a folder myFabScripts located at an absolute url use + * jsx.evalFile('/Path/to/my/FabJsxScript.jsx'); (mac) + * or jsx.evalFile('C:Path/to/my/FabJsxScript.jsx'); (windows) + * + * @param {Function} callback + * The callback function you want the jsx script to trigger on completion + * The result of the jsx script is passed as the argument to that function + * The function can exist in some other file. + * Note that InDesign does not automatically pass the callBack as a string. + * Either write your InDesign in a way that it returns a sting the form of + * return 'this is my result surrounded by quotes' + * or use the force eval option + * [Optional DEFAULT no callBack] + * + * @param {Object} replacements + * The replacements to make on the jsx script + * give the following script (template) + * 'alert("__message__: " + __val__);' + * and we want to change the script to + * 'alert("I was born in the year: " + 1234);' + * we would pass the following object + * {"message": 'I was born in the year', "val": 1234} + * or if not using reserved words like do we can leave out the key quotes + * {message: 'I was born in the year', val: 1234} + * By default when possible the forceEvalScript will be set to true + * The forceEvalScript option cannot be true when there are replacements + * To force the forceEvalScript to be false you can send a blank set of replacements + * jsx.evalFile('myFabScript.jsx', {}); Will NOT be executed using the $.evalScript method + * jsx.evalFile('myFabScript.jsx'); Will YES be executed using the $.evalScript method + * see the forceEvalScript parameter for details on this + * [Optional DEFAULT no replacements] + * + * @param {Bolean} forceEval + * If the script should be wrapped in an eval and try catch + * This will 1) provide useful error feedback if heaven forbid it is needed + * 2) The result will be a string which is required for callback results in InDesign + * [Optional DEFAULT true] + * + * If no replacements are needed then the jsx script is be executed by using the $.evalFile method + * This exposes the true value of the $.fileName property + * In such a case it's best to avoid using the $.__fileName() with no base name as it won't work + * BUT one can still use the $.__fileName('baseName') method which is more accurate than the standard $.fileName property + * Let's say you have a Drive called "Graphics" AND YOU HAVE a root folder on your "main" drive called "Graphics" + * You call a script jsx.evalFile('/Volumes/Graphics/myFabScript.jsx'); + * $.fileName will give you '/Graphics/myFabScript.jsx' which is wrong + * $.__fileName('myFabScript.jsx') will give you '/Volumes/Graphics/myFabScript.jsx' which is correct + * $.__fileName() will not give you a reliable result + * Note that if your calling multiple versions of myFabScript.jsx stored in multiple folders then you can get stuffed! + * i.e. if the fileName is important to you then don't do that. + * It also will force the result of the jsx file as a string which is particularly useful for InDesign callBacks + * + * Note 1) The order of the parameters is irrelevant + * Note 2) One can pass the arguments as an object if desired + * jsx.evalScript(myCallBackFunction, 'alert("__myMessage__");', true); + * is the same as + * jsx.evalScript({ + * script: 'alert("__myMessage__");', + * replacements: {myMessage: 'Hi there'}, + * callBack: myCallBackFunction, + * eval: false, + * }); + * note that either lower or camelCase key names or valid + * i.e. both callback or callBack will work + * + * The following keys are the same file || jsx || script || jsxScript || jsxscript + * The following keys are the same callBack || callback + * The following keys are the same replacements || replace + * The following keys are the same eval || forceEval || forceeval + * + * @return {Boolean} if the jsxScript was executed or not + */ + + Jsx.prototype.evalFile = function() { + var arg, args, callback, fileName, fileNameScript, forceEval, forceEvalScript, + i, jsxFolder, jsxScript, newLine, replacements, success; + + success = true; // optimistic + args = arguments; + + jsxFolder = path.join(__dirname, 'jsx'); + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // $.fileName does not return it's correct path in the jsx engine for files called from the js engine // + // In Illustrator it returns an integer in InDesign it returns an empty string // + // This script injection allows for the script to know it's path by calling // + // $.__fileName(); // + // on Illustrator this works pretty well // + // on InDesign it's best to use with a bit of care // + // If the a second script has been called the InDesing will "forget" the path to the first script // + // 2 work-arounds for this // + // 1) at the beginning of your script add var thePathToMeIs = $.fileName(); // + // thePathToMeIs will not be forgotten after running the second script // + // 2) $.__fileName('myBaseName.jsx'); // + // for example you have file with the following path // + // /path/to/me.jsx // + // Call $.__fileName('me.jsx') and you will get /path/to/me.jsx even after executing a second script // + // Note When the forceEvalScript option is used then you just use the regular $.fileName property // + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + fileNameScript = [ + // The if statement should not normally be executed + 'if(!$.__fileNames){', + ' $.__fileNames = {};', + ' $.__dirname = "__dirname__";'.replace('__dirname__', __dirname), + ' $.__fileName = function(name){', + ' name = name || $.fileName;', + ' return ($.__fileNames && $.__fileNames[name]) || $.fileName;', + ' };', + '}', + '$.__fileNames["__basename__"] = $.__fileNames["" + $.fileName] = "__fileName__";' + ].join(''); + + ////////////////////////////////////////////////////////////////////////////////////// + // sort out order which arguments into jsxScript, callback, replacements, forceEval // + ////////////////////////////////////////////////////////////////////////////////////// + + + // Detect if the parameters were passed as an object and if so allow for various keys + if (args.length === 1 && (arg = args[0]) instanceof Object) { + jsxScript = arg.jsxScript || arg.jsx || arg.script || arg.file || arg.jsxscript; + callback = arg.callBack || arg.callback; + replacements = arg.replacements || arg.replace; + forceEval = arg.eval || arg.forceEval || arg.forceeval; + } else { + for (i = 0; i < 5; i++) { + arg = args[i]; + if (arg === undefined) { + continue; + } + if (arg.constructor.name === 'String') { + jsxScript = arg; + continue; + } + if (arg.constructor.name === 'Object') { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // If no replacements are provided then the $.evalScript method will be used // + // This will allow directly for the $.fileName property to be used // + // If one does not want the $.evalScript method to be used then // + // either send a blank object as the replacements {} // + // or explicitly set the forceEvalScript option to false // + // This can only be done if the parameters are passed as an object // + // i.e. jsx.evalFile({file:'myFabScript.jsx', forceEvalScript: false}); // + // if the file was called using // + // i.e. jsx.evalFile('myFabScript.jsx'); // + // then the following jsx code is called $.evalFile(new File('Path/to/myFabScript.jsx', 10000000000)) + ''; // + // forceEval is never needed if the forceEvalScript is triggered // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + replacements = arg; + continue; + } + if (arg.constructor === Function) { + callback = arg; + continue; + } + if (arg === false) { + forceEval = false; + } + } + } + + // If no script provide then not too much to do! + if (!jsxScript) { + return false; + } + + forceEvalScript = !replacements; + + + ////////////////////////////////////////////////////// + // Get path of script // + // Check if it's literal, relative or in jsx folder // + ////////////////////////////////////////////////////// + + if (/^\/|[a-zA-Z]+:/.test(jsxScript)) { // absolute path Mac | Windows + jsxScript = path.normalize(jsxScript); + } else if (/^\.+\//.test(jsxScript)) { + jsxScript = path.join(__dirname, jsxScript); // relative path + } else { + jsxScript = path.join(jsxFolder, jsxScript); // files in the jsxFolder + } + + if (forceEvalScript) { + jsxScript = jsxScript.replace(/"/g, '\\"'); + // Check that the path exist, should change this to asynchronous at some point + if (!window.cep.fs.stat(jsxScript).err) { + jsxScript = fileNameScript.replace(/__fileName__/, jsxScript).replace(/__basename__/, path.basename(jsxScript)) + + '$.evalFile(new File("' + jsxScript.replace(/\\/g, '\\\\') + '")) + "";'; + return this.evalScript(jsxScript, callback, forceEval); + } else { + throw new Error(`The file: {jsxScript} could not be found / read`); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Replacements made so we can't use $.evalFile and need to read the jsx script for ourselves // + //////////////////////////////////////////////////////////////////////////////////////////////// + + fileName = jsxScript.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + try { + jsxScript = window.cep.fs.readFile(jsxScript).data; + } catch (er) { + throw new Error(`The file: ${fileName} could not be read`); + } + // It is desirable that the injected fileNameScript is on the same line as the 1st line of the script + // This is so that the $.line or error.line returns the same value as the actual file + // However if the 1st line contains a # directive then we need to insert a new line and stuff the above problem + // When possible i.e. when there's no replacements then $.evalFile will be used and then the whole issue is avoided + newLine = /^\s*#/.test(jsxScript) ? '\n' : ''; + jsxScript = fileNameScript.replace(/__fileName__/, fileName).replace(/__basename__/, path.basename(fileName)) + newLine + jsxScript; + + try { + // evalScript(jsxScript, callback); + return this.evalScript(jsxScript, callback, replacements, forceEval); + } catch (err) { + //////////////////////////////////////////////// + // Do whatever error handling you want here ! // + //////////////////////////////////////////////// + var newErr; + newErr = new Error(err); + alert('Error Eek: ' + newErr.stack); + return false; + } + + return success; // success should be an array but for now it's a Boolean + }; + + + //////////////////////////////////// + // Setup alternative method names // + //////////////////////////////////// + Jsx.prototype.eval = Jsx.prototype.script = Jsx.prototype.evalscript = Jsx.prototype.evalScript; + Jsx.prototype.file = Jsx.prototype.evalfile = Jsx.prototype.evalFile; + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Examples // + // jsx.evalScript('alert("foo");'); // + // jsx.evalFile('foo.jsx'); // where foo.jsx is stored in the jsx folder at the base of the extensions directory // + // jsx.evalFile('../myFolder/foo.jsx'); // where a relative or absolute file path is given // + // // + // using conventional methods one would use in the case were the values to swap were supplied by variables // + // csInterface.evalScript('var q = "' + name + '"; alert("' + myString + '" ' + myOp + ' q);q;', callback); // + // Using all the '' + foo + '' is very error prone // + // jsx.evalScript('var q = "__name__"; alert(__string__ __opp__ q);q;',{'name':'Fred', 'string':'Hello ', 'opp':'+'}, callBack); // + // is much simpler and less error prone // + // // + // more readable to use object // + // jsx.evalFile({ // + // file: 'yetAnotherFabScript.jsx', // + // replacements: {"this": foo, That: bar, and: "&&", the: foo2, other: bar2}, // + // eval: true // + // }) // + // Enjoy // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + + jsx = new Jsx(); +})(); diff --git a/openpype/hosts/photoshop/api/extension/host/index.jsx b/openpype/hosts/photoshop/api/extension/host/index.jsx new file mode 100644 index 0000000000..2acec1ebc1 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/host/index.jsx @@ -0,0 +1,484 @@ +#include "json.js"; +#target photoshop + +var LogFactory=function(file,write,store,level,defaultStatus,continuing){if(file&&(file.constructor===String||file.constructor===File)){file={file:file};}else if(!file)file={file:{}};write=(file.write!==undefined)?file.write:write;if(write===undefined){write=true;}store=(file.store!==undefined)?file.store||false:store||false;level=(file.level!==undefined)?file.level:level;defaultStatus=(file.defaultStatus!==undefined)?file.defaultStatus:defaultStatus;if(defaultStatus===undefined){defaultStatus='LOG';}continuing=(file.continuing!==undefined)?file.continuing:continuing||false;file=file.file||{};var stack,times,logTime,logPoint,icons,statuses,LOG_LEVEL,LOG_STATUS;stack=[];times=[];logTime=new Date();logPoint='Log Factory Start';icons={"1":"\ud83d\udd50","130":"\ud83d\udd5c","2":"\ud83d\udd51","230":"\ud83d\udd5d","3":"\ud83d\udd52","330":"\ud83d\udd5e","4":"\ud83d\udd53","430":"\ud83d\udd5f","5":"\ud83d\udd54","530":"\ud83d\udd60","6":"\ud83d\udd55","630":"\ud83d\udd61","7":"\ud83d\udd56","730":"\ud83d\udd62","8":"\ud83d\udd57","830":"\ud83d\udd63","9":"\ud83d\udd58","930":"\ud83d\udd64","10":"\ud83d\udd59","1030":"\ud83d\udd65","11":"\ud83d\udd5a","1130":"\ud83d\udd66","12":"\ud83d\udd5b","1230":"\ud83d\udd67","AIRPLANE":"\ud83d\udee9","ALARM":"\u23f0","AMBULANCE":"\ud83d\ude91","ANCHOR":"\u2693","ANGRY":"\ud83d\ude20","ANGUISHED":"\ud83d\ude27","ANT":"\ud83d\udc1c","ANTENNA":"\ud83d\udce1","APPLE":"\ud83c\udf4f","APPLE2":"\ud83c\udf4e","ATM":"\ud83c\udfe7","ATOM":"\u269b","BABYBOTTLE":"\ud83c\udf7c","BAD:":"\ud83d\udc4e","BANANA":"\ud83c\udf4c","BANDAGE":"\ud83e\udd15","BANK":"\ud83c\udfe6","BATTERY":"\ud83d\udd0b","BED":"\ud83d\udecf","BEE":"\ud83d\udc1d","BEER":"\ud83c\udf7a","BELL":"\ud83d\udd14","BELLOFF":"\ud83d\udd15","BIRD":"\ud83d\udc26","BLACKFLAG":"\ud83c\udff4","BLUSH":"\ud83d\ude0a","BOMB":"\ud83d\udca3","BOOK":"\ud83d\udcd5","BOOKMARK":"\ud83d\udd16","BOOKS":"\ud83d\udcda","BOW":"\ud83c\udff9","BOWLING":"\ud83c\udfb3","BRIEFCASE":"\ud83d\udcbc","BROKEN":"\ud83d\udc94","BUG":"\ud83d\udc1b","BUILDING":"\ud83c\udfdb","BUILDINGS":"\ud83c\udfd8","BULB":"\ud83d\udca1","BUS":"\ud83d\ude8c","CACTUS":"\ud83c\udf35","CALENDAR":"\ud83d\udcc5","CAMEL":"\ud83d\udc2a","CAMERA":"\ud83d\udcf7","CANDLE":"\ud83d\udd6f","CAR":"\ud83d\ude98","CAROUSEL":"\ud83c\udfa0","CASTLE":"\ud83c\udff0","CATEYES":"\ud83d\ude3b","CATJOY":"\ud83d\ude39","CATMOUTH":"\ud83d\ude3a","CATSMILE":"\ud83d\ude3c","CD":"\ud83d\udcbf","CHECK":"\u2714","CHEQFLAG":"\ud83c\udfc1","CHICK":"\ud83d\udc25","CHICKEN":"\ud83d\udc14","CHICKHEAD":"\ud83d\udc24","CIRCLEBLACK":"\u26ab","CIRCLEBLUE":"\ud83d\udd35","CIRCLERED":"\ud83d\udd34","CIRCLEWHITE":"\u26aa","CIRCUS":"\ud83c\udfaa","CLAPPER":"\ud83c\udfac","CLAPPING":"\ud83d\udc4f","CLIP":"\ud83d\udcce","CLIPBOARD":"\ud83d\udccb","CLOUD":"\ud83c\udf28","CLOVER":"\ud83c\udf40","CLOWN":"\ud83e\udd21","COLDSWEAT":"\ud83d\ude13","COLDSWEAT2":"\ud83d\ude30","COMPRESS":"\ud83d\udddc","CONFOUNDED":"\ud83d\ude16","CONFUSED":"\ud83d\ude15","CONSTRUCTION":"\ud83d\udea7","CONTROL":"\ud83c\udf9b","COOKIE":"\ud83c\udf6a","COOKING":"\ud83c\udf73","COOL":"\ud83d\ude0e","COOLBOX":"\ud83c\udd92","COPYRIGHT":"\u00a9","CRANE":"\ud83c\udfd7","CRAYON":"\ud83d\udd8d","CREDITCARD":"\ud83d\udcb3","CROSS":"\u2716","CROSSBOX:":"\u274e","CRY":"\ud83d\ude22","CRYCAT":"\ud83d\ude3f","CRYSTALBALL":"\ud83d\udd2e","CUSTOMS":"\ud83d\udec3","DELICIOUS":"\ud83d\ude0b","DERELICT":"\ud83c\udfda","DESKTOP":"\ud83d\udda5","DIAMONDLB":"\ud83d\udd37","DIAMONDLO":"\ud83d\udd36","DIAMONDSB":"\ud83d\udd39","DIAMONDSO":"\ud83d\udd38","DICE":"\ud83c\udfb2","DISAPPOINTED":"\ud83d\ude1e","CRY2":"\ud83d\ude25","DIVISION":"\u2797","DIZZY":"\ud83d\ude35","DOLLAR":"\ud83d\udcb5","DOLLAR2":"\ud83d\udcb2","DOWNARROW":"\u2b07","DVD":"\ud83d\udcc0","EJECT":"\u23cf","ELEPHANT":"\ud83d\udc18","EMAIL":"\ud83d\udce7","ENVELOPE":"\ud83d\udce8","ENVELOPE2":"\u2709","ENVELOPE_DOWN":"\ud83d\udce9","EURO":"\ud83d\udcb6","EVIL":"\ud83d\ude08","EXPRESSIONLESS":"\ud83d\ude11","EYES":"\ud83d\udc40","FACTORY":"\ud83c\udfed","FAX":"\ud83d\udce0","FEARFUL":"\ud83d\ude28","FILEBOX":"\ud83d\uddc3","FILECABINET":"\ud83d\uddc4","FIRE":"\ud83d\udd25","FIREENGINE":"\ud83d\ude92","FIST":"\ud83d\udc4a","FLOWER":"\ud83c\udf37","FLOWER2":"\ud83c\udf38","FLUSHED":"\ud83d\ude33","FOLDER":"\ud83d\udcc1","FOLDER2":"\ud83d\udcc2","FREE":"\ud83c\udd93","FROG":"\ud83d\udc38","FROWN":"\ud83d\ude41","GEAR":"\u2699","GLOBE":"\ud83c\udf0d","GLOWINGSTAR":"\ud83c\udf1f","GOOD:":"\ud83d\udc4d","GRIMACING":"\ud83d\ude2c","GRIN":"\ud83d\ude00","GRINNINGCAT":"\ud83d\ude38","HALO":"\ud83d\ude07","HAMMER":"\ud83d\udd28","HAMSTER":"\ud83d\udc39","HAND":"\u270b","HANDDOWN":"\ud83d\udc47","HANDLEFT":"\ud83d\udc48","HANDRIGHT":"\ud83d\udc49","HANDUP":"\ud83d\udc46","HATCHING":"\ud83d\udc23","HAZARD":"\u2623","HEADPHONE":"\ud83c\udfa7","HEARNOEVIL":"\ud83d\ude49","HEARTBLUE":"\ud83d\udc99","HEARTEYES":"\ud83d\ude0d","HEARTGREEN":"\ud83d\udc9a","HEARTYELLOW":"\ud83d\udc9b","HELICOPTER":"\ud83d\ude81","HERB":"\ud83c\udf3f","HIGH_BRIGHTNESS":"\ud83d\udd06","HIGHVOLTAGE":"\u26a1","HIT":"\ud83c\udfaf","HONEY":"\ud83c\udf6f","HOT":"\ud83c\udf36","HOURGLASS":"\u23f3","HOUSE":"\ud83c\udfe0","HUGGINGFACE":"\ud83e\udd17","HUNDRED":"\ud83d\udcaf","HUSHED":"\ud83d\ude2f","ID":"\ud83c\udd94","INBOX":"\ud83d\udce5","INDEX":"\ud83d\uddc2","JOY":"\ud83d\ude02","KEY":"\ud83d\udd11","KISS":"\ud83d\ude18","KISS2":"\ud83d\ude17","KISS3":"\ud83d\ude19","KISS4":"\ud83d\ude1a","KISSINGCAT":"\ud83d\ude3d","KNIFE":"\ud83d\udd2a","LABEL":"\ud83c\udff7","LADYBIRD":"\ud83d\udc1e","LANDING":"\ud83d\udeec","LAPTOP":"\ud83d\udcbb","LEFTARROW":"\u2b05","LEMON":"\ud83c\udf4b","LIGHTNINGCLOUD":"\ud83c\udf29","LINK":"\ud83d\udd17","LITTER":"\ud83d\udeae","LOCK":"\ud83d\udd12","LOLLIPOP":"\ud83c\udf6d","LOUDSPEAKER":"\ud83d\udce2","LOW_BRIGHTNESS":"\ud83d\udd05","MAD":"\ud83d\ude1c","MAGNIFYING_GLASS":"\ud83d\udd0d","MASK":"\ud83d\ude37","MEDAL":"\ud83c\udf96","MEMO":"\ud83d\udcdd","MIC":"\ud83c\udfa4","MICROSCOPE":"\ud83d\udd2c","MINUS":"\u2796","MOBILE":"\ud83d\udcf1","MONEY":"\ud83d\udcb0","MONEYMOUTH":"\ud83e\udd11","MONKEY":"\ud83d\udc35","MOUSE":"\ud83d\udc2d","MOUSE2":"\ud83d\udc01","MOUTHLESS":"\ud83d\ude36","MOVIE":"\ud83c\udfa5","MUGS":"\ud83c\udf7b","NERD":"\ud83e\udd13","NEUTRAL":"\ud83d\ude10","NEW":"\ud83c\udd95","NOENTRY":"\ud83d\udeab","NOTEBOOK":"\ud83d\udcd4","NOTEPAD":"\ud83d\uddd2","NUTANDBOLT":"\ud83d\udd29","O":"\u2b55","OFFICE":"\ud83c\udfe2","OK":"\ud83c\udd97","OKHAND":"\ud83d\udc4c","OLDKEY":"\ud83d\udddd","OPENLOCK":"\ud83d\udd13","OPENMOUTH":"\ud83d\ude2e","OUTBOX":"\ud83d\udce4","PACKAGE":"\ud83d\udce6","PAGE":"\ud83d\udcc4","PAINTBRUSH":"\ud83d\udd8c","PALETTE":"\ud83c\udfa8","PANDA":"\ud83d\udc3c","PASSPORT":"\ud83d\udec2","PAWS":"\ud83d\udc3e","PEN":"\ud83d\udd8a","PEN2":"\ud83d\udd8b","PENSIVE":"\ud83d\ude14","PERFORMING":"\ud83c\udfad","PHONE":"\ud83d\udcde","PILL":"\ud83d\udc8a","PING":"\u2757","PLATE":"\ud83c\udf7d","PLUG":"\ud83d\udd0c","PLUS":"\u2795","POLICE":"\ud83d\ude93","POLICELIGHT":"\ud83d\udea8","POSTOFFICE":"\ud83c\udfe4","POUND":"\ud83d\udcb7","POUTING":"\ud83d\ude21","POUTINGCAT":"\ud83d\ude3e","PRESENT":"\ud83c\udf81","PRINTER":"\ud83d\udda8","PROJECTOR":"\ud83d\udcfd","PUSHPIN":"\ud83d\udccc","QUESTION":"\u2753","RABBIT":"\ud83d\udc30","RADIOACTIVE":"\u2622","RADIOBUTTON":"\ud83d\udd18","RAINCLOUD":"\ud83c\udf27","RAT":"\ud83d\udc00","RECYCLE":"\u267b","REGISTERED":"\u00ae","RELIEVED":"\ud83d\ude0c","ROBOT":"\ud83e\udd16","ROCKET":"\ud83d\ude80","ROLLING":"\ud83d\ude44","ROOSTER":"\ud83d\udc13","RULER":"\ud83d\udccf","SATELLITE":"\ud83d\udef0","SAVE":"\ud83d\udcbe","SCHOOL":"\ud83c\udfeb","SCISSORS":"\u2702","SCREAMING":"\ud83d\ude31","SCROLL":"\ud83d\udcdc","SEAT":"\ud83d\udcba","SEEDLING":"\ud83c\udf31","SEENOEVIL":"\ud83d\ude48","SHIELD":"\ud83d\udee1","SHIP":"\ud83d\udea2","SHOCKED":"\ud83d\ude32","SHOWER":"\ud83d\udebf","SLEEPING":"\ud83d\ude34","SLEEPY":"\ud83d\ude2a","SLIDER":"\ud83c\udf9a","SLOT":"\ud83c\udfb0","SMILE":"\ud83d\ude42","SMILING":"\ud83d\ude03","SMILINGCLOSEDEYES":"\ud83d\ude06","SMILINGEYES":"\ud83d\ude04","SMILINGSWEAT":"\ud83d\ude05","SMIRK":"\ud83d\ude0f","SNAIL":"\ud83d\udc0c","SNAKE":"\ud83d\udc0d","SOCCER":"\u26bd","SOS":"\ud83c\udd98","SPEAKER":"\ud83d\udd08","SPEAKEROFF":"\ud83d\udd07","SPEAKNOEVIL":"\ud83d\ude4a","SPIDER":"\ud83d\udd77","SPIDERWEB":"\ud83d\udd78","STAR":"\u2b50","STOP":"\u26d4","STOPWATCH":"\u23f1","SULK":"\ud83d\ude26","SUNFLOWER":"\ud83c\udf3b","SUNGLASSES":"\ud83d\udd76","SYRINGE":"\ud83d\udc89","TAKEOFF":"\ud83d\udeeb","TAXI":"\ud83d\ude95","TELESCOPE":"\ud83d\udd2d","TEMPORATURE":"\ud83e\udd12","TENNIS":"\ud83c\udfbe","THERMOMETER":"\ud83c\udf21","THINKING":"\ud83e\udd14","THUNDERCLOUD":"\u26c8","TICKBOX":"\u2705","TICKET":"\ud83c\udf9f","TIRED":"\ud83d\ude2b","TOILET":"\ud83d\udebd","TOMATO":"\ud83c\udf45","TONGUE":"\ud83d\ude1b","TOOLS":"\ud83d\udee0","TORCH":"\ud83d\udd26","TORNADO":"\ud83c\udf2a","TOUNG2":"\ud83d\ude1d","TRADEMARK":"\u2122","TRAFFICLIGHT":"\ud83d\udea6","TRASH":"\ud83d\uddd1","TREE":"\ud83c\udf32","TRIANGLE_LEFT":"\u25c0","TRIANGLE_RIGHT":"\u25b6","TRIANGLEDOWN":"\ud83d\udd3b","TRIANGLEUP":"\ud83d\udd3a","TRIANGULARFLAG":"\ud83d\udea9","TROPHY":"\ud83c\udfc6","TRUCK":"\ud83d\ude9a","TRUMPET":"\ud83c\udfba","TURKEY":"\ud83e\udd83","TURTLE":"\ud83d\udc22","UMBRELLA":"\u26f1","UNAMUSED":"\ud83d\ude12","UPARROW":"\u2b06","UPSIDEDOWN":"\ud83d\ude43","WARNING":"\u26a0","WATCH":"\u231a","WAVING":"\ud83d\udc4b","WEARY":"\ud83d\ude29","WEARYCAT":"\ud83d\ude40","WHITEFLAG":"\ud83c\udff3","WINEGLASS":"\ud83c\udf77","WINK":"\ud83d\ude09","WORRIED":"\ud83d\ude1f","WRENCH":"\ud83d\udd27","X":"\u274c","YEN":"\ud83d\udcb4","ZIPPERFACE":"\ud83e\udd10","UNDEFINED":"","":""};statuses={F:'FATAL',B:'BUG',C:'CRITICAL',E:'ERROR',W:'WARNING',I:'INFO',IM:'IMPORTANT',D:'DEBUG',L:'LOG',CO:'CONSTANT',FU:'FUNCTION',R:'RETURN',V:'VARIABLE',S:'STACK',RE:'RESULT',ST:'STOPPER',TI:'TIMER',T:'TRACE'};LOG_LEVEL={NONE:7,OFF:7,FATAL:6,ERROR:5,WARN:4,INFO:3,UNDEFINED:2,'':2,DEFAULT:2,DEBUG:2,TRACE:1,ON:0,ALL:0,};LOG_STATUS={OFF:LOG_LEVEL.OFF,NONE:LOG_LEVEL.OFF,NO:LOG_LEVEL.OFF,NOPE:LOG_LEVEL.OFF,FALSE:LOG_LEVEL.OFF,FATAL:LOG_LEVEL.FATAL,BUG:LOG_LEVEL.ERROR,CRITICAL:LOG_LEVEL.ERROR,ERROR:LOG_LEVEL.ERROR,WARNING:LOG_LEVEL.WARN,INFO:LOG_LEVEL.INFO,IMPORTANT:LOG_LEVEL.INFO,DEBUG:LOG_LEVEL.DEBUG,LOG:LOG_LEVEL.DEBUG,STACK:LOG_LEVEL.DEBUG,CONSTANT:LOG_LEVEL.DEBUG,FUNCTION:LOG_LEVEL.DEBUG,VARIABLE:LOG_LEVEL.DEBUG,RETURN:LOG_LEVEL.DEBUG,RESULT:LOG_LEVEL.TRACE,STOPPER:LOG_LEVEL.TRACE,TIMER:LOG_LEVEL.TRACE,TRACE:LOG_LEVEL.TRACE,ALL:LOG_LEVEL.ALL,YES:LOG_LEVEL.ALL,YEP:LOG_LEVEL.ALL,TRUE:LOG_LEVEL.ALL};var logFile,logFolder;var LOG=function(message,status,icon){if(LOG.level!==LOG_LEVEL.OFF&&(LOG.write||LOG.store)&&LOG.arguments.length)return LOG.addMessage(message,status,icon);};LOG.logDecodeLevel=function(level){if(level==~~level)return Math.abs(level);var lev;level+='';level=level.toUpperCase();if(level in statuses){level=statuses[level];}lev=LOG_LEVEL[level];if(lev!==undefined)return lev;lev=LOG_STATUS[level];if(lev!==undefined)return lev;return LOG_LEVEL.DEFAULT;};LOG.write=write;LOG.store=store;LOG.level=LOG.logDecodeLevel(level);LOG.status=defaultStatus;LOG.addMessage=function(message,status,icon){var date=new Date(),count,bool,logStatus;if(status&&status.constructor.name==='String'){status=status.toUpperCase();status=statuses[status]||status;}else status=LOG.status;logStatus=LOG_STATUS[status]||LOG_STATUS.ALL;if(logStatus999)?'['+LOG.count+'] ':(' ['+LOG.count+'] ').slice(-7);message=count+status+icon+(message instanceof Object?message.toSource():message)+date;if(LOG.store){stack.push(message);}if(LOG.write){bool=file&&file.writable&&logFile.writeln(message);if(!bool){file.writable=true;LOG.setFile(logFile);logFile.writeln(message);}}LOG.count++;return true;};var logNewFile=function(file,isCookie,overwrite){file.encoding='UTF-8';file.lineFeed=($.os[0]=='M')?'Macintosh':' Windows';if(isCookie)return file.open(overwrite?'w':'e')&&file;file.writable=LOG.write;logFile=file;logFolder=file.parent;if(continuing){LOG.count=LOG.setCount(file);}return(!LOG.write&&file||(file.open('a')&&file));};LOG.setFile=function(file,isCookie,overwrite){var bool,folder,fileName,suffix,newFileName,f,d,safeFileName;d=new Date();f=$.stack.split("\n")[0].replace(/^\[\(?/,'').replace(/\)?\]$/,'');if(f==~~f){f=$.fileName.replace(/[^\/]+\//g,'');}safeFileName=File.encode((isCookie?'/COOKIE_':'/LOG_')+f.replace(/^\//,'')+'_'+(1900+d.getYear())+(''+d).replace(/...(...)(..).+/,'_$1_$2')+(isCookie?'.txt':'.log'));if(file&&file.constructor.name=='String'){file=(file.match('/'))?new File(file):new File((logFolder||Folder.temp)+'/'+file);}if(file instanceof File){folder=file.parent;bool=folder.exists||folder.create();if(!bool)folder=Folder.temp;fileName=File.decode(file.name);suffix=fileName.match(/\.[^.]+$/);suffix=suffix?suffix[0]:'';fileName='/'+fileName;newFileName=fileName.replace(/\.[^.]+$/,'')+'_'+(+(new Date())+suffix);f=logNewFile(file,isCookie,overwrite);if(f)return f;f=logNewFile(new File(folder+newFileName),isCookie,overwrite);if(f)return f;f=logNewFile(new File(folder+safeFileName),isCookie,overwrite);if(f)return f;if(folder!=Folder.temp){f=logNewFile(new File(Folder.temp+fileName),isCookie,overwrite);if(f)return f;f=logNewFile(new File(Folder.temp+safeFileName),isCookie,overwrite);return f||new File(Folder.temp+safeFileName);}}return LOG.setFile(((logFile&&!isCookie)?new File(logFile):new File(Folder.temp+safeFileName)),isCookie,overwrite );};LOG.setCount=function(file){if(~~file===file){LOG.count=file;return LOG.count;}if(file===undefined){file=logFile;}if(file&&file.constructor===String){file=new File(file);}var logNumbers,contents;if(!file.length||!file.exists){LOG.count=1;return 1;}file.open('r');file.encoding='utf-8';file.seek(10000,2);contents='\n'+file.read();logNumbers=contents.match(/\n{0,3}\[\d+\] \[\w+\]+/g);if(logNumbers){logNumbers=+logNumbers[logNumbers.length-1].match(/\d+/)+1;file.close();LOG.count=logNumbers;return logNumbers;}if(file.length<10001){file.close();LOG.count=1;return 1;}file.seek(10000000,2);contents='\n'+file.read();logNumbers=contents.match(/\n{0,3}\[\d+\] \[\w+\]+/g);if(logNumbers){logNumbers=+logNumbers[logNumbers.length-1].match(/\d+/)+1;file.close();LOG.count=logNumbers;return logNumbers;}file.close();LOG.count=1;return 1;};LOG.setLevel=function(level){LOG.level=LOG.logDecodeLevel(level);return LOG.level;};LOG.setStatus=function(status){status=(''+status).toUpperCase();LOG.status=statuses[status]||status;return LOG.status;};LOG.cookie=function(file,level,overwrite,setLevel){var log,cookie;if(!file){file={file:file};}if(file&&(file.constructor===String||file.constructor===File)){file={file:file};}log=file;if(log.level===undefined){log.level=(level!==undefined)?level:'NONE';}if(log.overwrite===undefined){log.overwrite=(overwrite!==undefined)?overwrite:false;}if(log.setLevel===undefined){log.setLevel=(setLevel!==undefined)?setLevel:true;}setLevel=log.setLevel;overwrite=log.overwrite;level=log.level;file=log.file;file=LOG.setFile(file,true,overwrite);if(overwrite){file.write(level);}else{cookie=file.read();if(cookie.length){level=cookie;}else{file.write(level);}}file.close();if(setLevel){LOG.setLevel(level);}return{path:file,level:level};};LOG.args=function(args,funct,line){if(LOG.level>LOG_STATUS.FUNCTION)return;if(!(args&&(''+args.constructor).replace(/\s+/g,'')==='functionObject(){[nativecode]}'))return;if(!LOG.args.STRIP_COMMENTS){LOG.args.STRIP_COMMENTS=/((\/.*$)|(\/\*[\s\S]*?\*\/))/mg;}if(!LOG.args.ARGUMENT_NAMES){LOG.args.ARGUMENT_NAMES=/([^\s,]+)/g;}if(!LOG.args.OUTER_BRACKETS){LOG.args.OUTER_BRACKETS=/^\((.+)?\)$/;}if(!LOG.args.NEW_SOMETHING){LOG.args.NEW_SOMETHING=/^new \w+\((.+)?\)$/;}var functionString,argumentNames,stackInfo,report,functionName,arg,argsL,n,argName,argValue,argsTotal;if(funct===~~funct){line=funct;}if(!(funct instanceof Function)){funct=args.callee;}if(!(funct instanceof Function))return;functionName=funct.name;functionString=(''+funct).replace(LOG.args.STRIP_COMMENTS,'');argumentNames=functionString.slice(functionString.indexOf('(')+1,functionString.indexOf(')')).match(LOG.args.ARGUMENT_NAMES);argumentNames=argumentNames||[];report=[];report.push('--------------');report.push('Function Data:');report.push('--------------');report.push('Function Name:'+functionName);argsL=args.length;stackInfo=$.stack.split(/[\n\r]/);stackInfo.pop();stackInfo=stackInfo.join('\n ');report.push('Call stack:'+stackInfo);if(line){report.push('Function Line around:'+line);}report.push('Arguments Provided:'+argsL);report.push('Named Arguments:'+argumentNames.length);if(argumentNames.length){report.push('Arguments Names:'+argumentNames.join(','));}if(argsL){report.push('----------------');report.push('Argument Values:');report.push('----------------');}argsTotal=Math.max(argsL,argumentNames.length);for(n=0;n=argsL){argValue='NO VALUE PROVIDED';}else if(arg===undefined){argValue='undefined';}else if(arg===null){argValue='null';}else{argValue=arg.toSource().replace(LOG.args.OUTER_BRACKETS,'$1').replace(LOG.args.NEW_SOMETHING,'$1');}report.push((argName?argName:'arguments['+n+']')+':'+argValue);}report.push('');report=report.join('\n ');LOG(report,'f');return report;};LOG.stack=function(reverse){var st=$.stack.split('\n');st.pop();st.pop();if(reverse){st.reverse();}return LOG(st.join('\n '),'s');};LOG.values=function(values){var n,value,map=[];if(!(values instanceof Object||values instanceof Array)){return;}if(!LOG.values.OUTER_BRACKETS){LOG.values.OUTER_BRACKETS=/^\((.+)?\)$/;}if(!LOG.values.NEW_SOMETHING){LOG.values.NEW_SOMETHING=/^new \w+\((.+)?\)$/;}for(n in values){try{value=values[n];if(value===undefined){value='undefined';}else if(value===null){value='null';}else{value=value.toSource().replace(LOG.values.OUTER_BRACKETS,'$1').replace(LOG.values.NEW_SOMETHING,'$1');}}catch(e){value='\uD83D\uDEAB '+e;}map.push(n+':'+value);}if(map.length){map=map.join('\n ')+'\n ';return LOG(map,'v');}};LOG.reset=function(all){stack.length=0;LOG.count=1;if(all!==false){if(logFile instanceof File){logFile.close();}logFile=LOG.store=LOG.writeToFile=undefined;LOG.write=true;logFolder=Folder.temp;logTime=new Date();logPoint='After Log Reset';}};LOG.stopper=function(message){var newLogTime,t,m,newLogPoint;newLogTime=new Date();newLogPoint=(LOG.count!==undefined)?'LOG#'+LOG.count:'BEFORE LOG#1';LOG.time=t=newLogTime-logTime;if(message===false){return;}message=message||'Stopper start point';t=LOG.prettyTime(t);m=message+'\n '+'From '+logPoint+' to '+newLogPoint+' took '+t+' Starting '+logTime+' '+logTime.getMilliseconds()+'ms'+' Ending '+newLogTime+' '+newLogTime.getMilliseconds()+'ms';LOG(m,'st');logPoint=newLogPoint;logTime=newLogTime;return m;};LOG.start=function(message){var t=new Date();times.push([t,(message!==undefined)?message+'':'']);};LOG.stop=function(message){if(!times.length)return;message=(message)?message+' ':'';var nt,startLog,ot,om,td,m;nt=new Date();startLog=times.pop();ot=startLog[0];om=startLog[1];td=nt-ot;if(om.length){om+=' ';}m=om+'STARTED ['+ot+' '+ot.getMilliseconds()+'ms]\n '+message+'FINISHED ['+nt+' '+nt.getMilliseconds()+'ms]\n TOTAL TIME ['+LOG.prettyTime(td)+']';LOG(m,'ti');return m;};LOG.prettyTime=function(t){var h,m,s,ms;h=Math.floor(t / 3600000);m=Math.floor((t % 3600000)/ 60000);s=Math.floor((t % 60000)/ 1000);ms=t % 1000;t=(!t)?'<1ms':((h)?h+' hours ':'')+((m)?m+' minutes ':'')+((s)?s+' seconds ':'')+((ms&&(h||m||s))?'&':'')+((ms)?ms+'ms':'');return t;};LOG.get=function(){if(!stack.length)return 'THE LOG IS NOT SET TO STORE';var a=fetchLogLines(arguments);return a?'\n'+a.join('\n'):'NO LOGS AVAILABLE';};var fetchLogLines=function(){var args=arguments[0];if(!args.length)return stack;var c,n,l,a=[],ln,start,end,j,sl;l=args.length;sl=stack.length-1;n=0;for(c=0;cln)?sl+ln+1:ln-1;if(ln>=0&&ln<=sl)a[n++]=stack[ln];}else if(ln instanceof Array&&ln.length===2){start=ln[0];end=ln[1];if(!(~~start===start&&~~end===end))continue;start=(0>start)?sl+start+1:start-1;end=(0>end)?sl+end+1:end-1;start=Math.max(Math.min(sl,start),0);end=Math.min(Math.max(end,0),sl);if(start<=end)for(j=start;j<=end;j++)a[n++]=stack[j];else for(j=start;j>=end;j--)a[n++]=stack[j];}}return(n)?a:false;};LOG.file=function(){return logFile;};LOG.openFolder=function(){if(logFolder)return logFolder.execute();};LOG.show=LOG.execute=function(){if(logFile)return logFile.execute();};LOG.close=function(){if(logFile)return logFile.close();};LOG.setFile(file);if(!$.summary.difference){$.summary.difference=function(){return $.summary().replace(/ *([0-9]+)([^ ]+)(\n?)/g,$.summary.updateSnapshot );};}if(!$.summary.updateSnapshot){$.summary.updateSnapshot=function(full,count,name,lf){var snapshot=$.summary.snapshot;count=Number(count);var prev=snapshot[name]?snapshot[name]:0;snapshot[name]=count;var diff=count-prev;if(diff===0)return "";return " ".substring(String(diff).length)+diff+" "+name+lf;};}if(!$.summary.snapshot){$.summary.snapshot=[];$.summary.difference();}$.gc();$.gc();$.summary.difference();LOG.sumDiff=function(message){$.gc();$.gc();var diff=$.summary.difference();if(diff.length<8){diff=' - NONE -';}if(message===undefined){message='';}message+=diff;return LOG('$.summary.difference():'+message,'v');};return LOG;}; + +var log = new LogFactory('myLog.log'); // =>; creates the new log factory - put full path where + +function getEnv(variable){ + return $.getenv(variable); +} + +function fileOpen(path){ + return app.open(new File(path)); +} + +function getLayerTypeWithName(layerName) { + var type = 'NA'; + var nameParts = layerName.split('_'); + var namePrefix = nameParts[0]; + namePrefix = namePrefix.toLowerCase(); + switch (namePrefix) { + case 'guide': + case 'tl': + case 'tr': + case 'bl': + case 'br': + type = 'GUIDE'; + break; + case 'fg': + type = 'FG'; + break; + case 'bg': + type = 'BG'; + break; + case 'obj': + default: + type = 'OBJ'; + break; + } + + return type; +} + +function getLayers() { + /** + * Get json representation of list of layers. + * Much faster this way than in DOM traversal (2s vs 45s on same file) + * + * Format of single layer info: + * id : number + * name: string + * group: boolean - true if layer is a group + * parents:array - list of ids of parent groups, useful for selection + * all children layers from parent layerSet (eg. group) + * type: string - type of layer guessed from its name + * visible:boolean - true if visible + **/ + if (documents.length == 0){ + return '[]'; + } + var ref1 = new ActionReference(); + ref1.putEnumerated(charIDToTypeID('Dcmn'), charIDToTypeID('Ordn'), + charIDToTypeID('Trgt')); + var count = executeActionGet(ref1).getInteger(charIDToTypeID('NmbL')); + + // get all layer names + var layers = []; + var layer = {}; + + var parents = []; + for (var i = count; i >= 1; i--) { + var layer = {}; + var ref2 = new ActionReference(); + ref2.putIndex(charIDToTypeID('Lyr '), i); + + var desc = executeActionGet(ref2); // Access layer index #i + var layerSection = typeIDToStringID(desc.getEnumerationValue( + stringIDToTypeID('layerSection'))); + + layer.id = desc.getInteger(stringIDToTypeID("layerID")); + layer.name = desc.getString(stringIDToTypeID("name")); + layer.color_code = typeIDToStringID(desc.getEnumerationValue(stringIDToTypeID('color'))); + layer.group = false; + layer.parents = parents.slice(); + layer.type = getLayerTypeWithName(layer.name); + layer.visible = desc.getBoolean(stringIDToTypeID("visible")); + //log(" name: " + layer.name + " groupId " + layer.groupId + + //" group " + layer.group); + if (layerSection == 'layerSectionStart') { // Group start and end + parents.push(layer.id); + layer.group = true; + } + if (layerSection == 'layerSectionEnd') { + parents.pop(); + continue; + } + layers.push(JSON.stringify(layer)); + } + try{ + var bck = activeDocument.backgroundLayer; + layer.id = bck.id; + layer.name = bck.name; + layer.group = false; + layer.parents = []; + layer.type = 'background'; + layer.visible = bck.visible; + layers.push(JSON.stringify(layer)); + }catch(e){ + // do nothing, no background layer + }; + //log("layers " + layers); + return '[' + layers + ']'; +} + +function setVisible(layer_id, visibility){ + /** + * Sets particular 'layer_id' to 'visibility' if true > show + **/ + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putIdentifier(stringIDToTypeID("layer"), layer_id); + desc.putReference(stringIDToTypeID("null"), ref); + + executeAction(visibility?stringIDToTypeID("show"):stringIDToTypeID("hide"), + desc, DialogModes.NO); + +} + +function getHeadline(){ + /** + * Returns headline of current document with metadata + * + **/ + if (documents.length == 0){ + return ''; + } + var headline = app.activeDocument.info.headline; + + return headline; +} + +function isSaved(){ + return app.activeDocument.saved; +} + +function save(){ + /** Saves active document **/ + return app.activeDocument.save(); +} + +function saveAs(output_path, ext, as_copy){ + /** Exports scene to various formats + * + * Currently implemented: 'jpg', 'png', 'psd' + * + * output_path - escaped file path on local system + * ext - extension for export + * as_copy - create copy, do not overwrite + * + * */ + var saveName = output_path; + var saveOptions; + if (ext == 'jpg'){ + saveOptions = new JPEGSaveOptions(); + saveOptions.quality = 12; + saveOptions.embedColorProfile = true; + saveOptions.formatOptions = FormatOptions.PROGRESSIVE; + if(saveOptions.formatOptions == FormatOptions.PROGRESSIVE){ + saveOptions.scans = 5}; + saveOptions.matte = MatteType.NONE; + } + if (ext == 'png'){ + saveOptions = new PNGSaveOptions(); + saveOptions.interlaced = true; + saveOptions.transparency = true; + } + if (ext == 'psd'){ + saveOptions = null; + return app.activeDocument.saveAs(new File(saveName)); + } + if (ext == 'psb'){ + return savePSB(output_path); + } + + return app.activeDocument.saveAs(new File(saveName), saveOptions, as_copy); + +} + +function getActiveDocumentName(){ + /** + * Returns file name of active document + * */ + if (documents.length == 0){ + return null; + } + return app.activeDocument.name; +} + +function getActiveDocumentFullName(){ + /** + * Returns file name of active document with file path. + * activeDocument.fullName returns path in URI (eg /c/.. insted of c:/) + * */ + if (documents.length == 0){ + return null; + } + var f = new File(app.activeDocument.fullName); + var path = f.fsName; + f.close(); + return path; +} + +function imprint(payload){ + /** + * Sets headline content of current document with metadata. Stores + * information about assets created through Avalon. + * Content accessible in PS through File > File Info + * + **/ + app.activeDocument.info.headline = payload; +} + +function getSelectedLayers(doc) { + /** + * Returns json representation of currently selected layers. + * Works in three steps - 1) creates new group with selected layers + * 2) traverses this group + * 3) deletes newly created group, not neede + * Bit weird, but Adobe.. + **/ + if (doc == null){ + doc = app.activeDocument; + } + + var selLayers = []; + _grp = groupSelectedLayers(doc); + + var group = doc.activeLayer; + var layers = group.layers; + + // // group is fake at this point + // var itself_name = ''; + // if (layers){ + // itself_name = layers[0].name; + // } + + + for (var i = 0; i < layers.length; i++) { + var layer = {}; + layer.id = layers[i].id; + layer.name = layers[i].name; + long_names =_get_parents_names(group.parent, layers[i].name); + var t = layers[i].kind; + if ((typeof t !== 'undefined') && + (layers[i].kind.toString() == 'LayerKind.NORMAL')){ + layer.group = false; + }else{ + layer.group = true; + } + layer.long_name = long_names; + + selLayers.push(layer); + } + + _undo(); + + return JSON.stringify(selLayers); +}; + +function selectLayers(selectedLayers){ + /** + * Selects layers from list of ids + **/ + selectedLayers = JSON.parse(selectedLayers); + var layers = new Array(); + var id54 = charIDToTypeID( "slct" ); + var desc12 = new ActionDescriptor(); + var id55 = charIDToTypeID( "null" ); + var ref9 = new ActionReference(); + + var existing_layers = JSON.parse(getLayers()); + var existing_ids = []; + for (var y = 0; y < existing_layers.length; y++){ + existing_ids.push(existing_layers[y]["id"]); + } + for (var i = 0; i < selectedLayers.length; i++) { + // a check to see if the id stil exists + var id = selectedLayers[i]; + if(existing_ids.toString().indexOf(id)>=0){ + layers[i] = charIDToTypeID( "Lyr " ); + ref9.putIdentifier(layers[i], id); + } + } + desc12.putReference( id55, ref9 ); + var id58 = charIDToTypeID( "MkVs" ); + desc12.putBoolean( id58, false ); + executeAction( id54, desc12, DialogModes.NO ); +} + +function groupSelectedLayers(doc, name) { + /** + * Groups selected layers into new group. + * Returns json representation of Layer for server to consume + * + * Args: + * doc(activeDocument) + * name (str): new name of created group + **/ + if (doc == null){ + doc = app.activeDocument; + } + + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putClass( stringIDToTypeID('layerSection') ); + desc.putReference( charIDToTypeID('null'), ref ); + var lref = new ActionReference(); + lref.putEnumerated( charIDToTypeID('Lyr '), charIDToTypeID('Ordn'), + charIDToTypeID('Trgt') ); + desc.putReference( charIDToTypeID('From'), lref); + executeAction( charIDToTypeID('Mk '), desc, DialogModes.NO ); + + var group = doc.activeLayer; + if (name){ + // Add special character to highlight group that will be published + group.name = name; + } + var layer = {}; + layer.id = group.id; + layer.name = name; // keep name clean + layer.group = true; + + layer.long_name = _get_parents_names(group, name); + + return JSON.stringify(layer); +}; + +function importSmartObject(path, name, link){ + /** + * Creates new layer with an image from 'path' + * + * path: absolute path to loaded file + * name: sets name of newly created laye + * + **/ + var desc1 = new ActionDescriptor(); + desc1.putPath( app.charIDToTypeID("null"), new File(path) ); + link = link || false; + if (link) { + desc1.putBoolean( app.charIDToTypeID('Lnkd'), true ); + } + + desc1.putEnumerated(app.charIDToTypeID("FTcs"), app.charIDToTypeID("QCSt"), + app.charIDToTypeID("Qcsa")); + var desc2 = new ActionDescriptor(); + desc2.putUnitDouble(app.charIDToTypeID("Hrzn"), + app.charIDToTypeID("#Pxl"), 0.0); + desc2.putUnitDouble(app.charIDToTypeID("Vrtc"), + app.charIDToTypeID("#Pxl"), 0.0); + + desc1.putObject(charIDToTypeID("Ofst"), charIDToTypeID("Ofst"), desc2); + executeAction(charIDToTypeID("Plc " ), desc1, DialogModes.NO); + + var docRef = app.activeDocument + var currentActivelayer = app.activeDocument.activeLayer; + if (name){ + currentActivelayer.name = name; + } + var layer = {} + layer.id = currentActivelayer.id; + layer.name = currentActivelayer.name; + return JSON.stringify(layer); +} + +function replaceSmartObjects(layer_id, path, name){ + /** + * Updates content of 'layer' with an image from 'path' + * + **/ + + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putIdentifier(stringIDToTypeID("layer"), layer_id); + desc.putReference(stringIDToTypeID("null"), ref); + + desc.putPath(charIDToTypeID('null'), new File(path) ); + desc.putInteger(charIDToTypeID("PgNm"), 1); + + executeAction(stringIDToTypeID('placedLayerReplaceContents'), + desc, DialogModes.NO ); + var currentActivelayer = app.activeDocument.activeLayer; + if (name){ + currentActivelayer.name = name; + } +} + +function createGroup(name){ + /** + * Creates new group with a 'name' + * Because of asynchronous nature, only group.id is available + **/ + group = app.activeDocument.layerSets.add(); + // Add special character to highlight group that will be published + group.name = name; + + return group.id; // only id available at this time :| +} + +function deleteLayer(layer_id){ + /*** + * Deletes layer by its layer_id + * + * layer_id (int) + **/ + var d = new ActionDescriptor(); + var r = new ActionReference(); + + r.putIdentifier(stringIDToTypeID("layer"), layer_id); + d.putReference(stringIDToTypeID("null"), r); + executeAction(stringIDToTypeID("delete"), d, DialogModes.NO); +} + +function _undo() { + executeAction(charIDToTypeID("undo", undefined, DialogModes.NO)); +}; + +function savePSB(output_path){ + /*** + * Saves file as .psb to 'output_path' + * + * output_path (str) + **/ + var desc1 = new ActionDescriptor(); + var desc2 = new ActionDescriptor(); + desc2.putBoolean( stringIDToTypeID('maximizeCompatibility'), true ); + desc1.putObject( charIDToTypeID('As '), charIDToTypeID('Pht8'), desc2 ); + desc1.putPath( charIDToTypeID('In '), new File(output_path) ); + desc1.putBoolean( charIDToTypeID('LwCs'), true ); + executeAction( charIDToTypeID('save'), desc1, DialogModes.NO ); +} + +function close(){ + executeAction(stringIDToTypeID("quit"), undefined, DialogModes.NO ); +} + +function renameLayer(layer_id, new_name){ + /*** + * Renames 'layer_id' to 'new_name' + * + * Via Action (fast) + * + * Args: + * layer_id(int) + * new_name(str) + * + * output_path (str) + **/ + doc = app.activeDocument; + selectLayers('['+layer_id+']'); + + doc.activeLayer.name = new_name; +} + +function _get_parents_names(layer, itself_name){ + var long_names = [itself_name]; + while (layer.parent){ + if (layer.typename != "LayerSet"){ + break; + } + long_names.push(layer.name); + layer = layer.parent; + } + return long_names; +} + +// triggers when panel is opened, good for debugging +//log(getActiveDocumentName()); +// log.show(); +// var a = app.activeDocument.activeLayer; +// log(a); +//getSelectedLayers(); +// importSmartObject("c:/projects/test.jpg", "a aaNewLayer", true); +// log("dpc"); +// replaceSmartObjects(153, "▼Jungle_imageTest_001", "c:/projects/test_project_test_asset_TestTask_v001.png"); \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/host/json.js b/openpype/hosts/photoshop/api/extension/host/json.js new file mode 100644 index 0000000000..397349bbfd --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/host/json.js @@ -0,0 +1,530 @@ +// json2.js +// 2017-06-12 +// Public Domain. +// NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. + +// USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO +// NOT CONTROL. + +// This file creates a global JSON object containing two methods: stringify +// and parse. This file provides the ES5 JSON capability to ES3 systems. +// If a project might run on IE8 or earlier, then this file should be included. +// This file does nothing on ES5 systems. + +// JSON.stringify(value, replacer, space) +// value any JavaScript value, usually an object or array. +// replacer an optional parameter that determines how object +// values are stringified for objects. It can be a +// function or an array of strings. +// space an optional parameter that specifies the indentation +// of nested structures. If it is omitted, the text will +// be packed without extra whitespace. If it is a number, +// it will specify the number of spaces to indent at each +// level. If it is a string (such as "\t" or " "), +// it contains the characters used to indent at each level. +// This method produces a JSON text from a JavaScript value. +// When an object value is found, if the object contains a toJSON +// method, its toJSON method will be called and the result will be +// stringified. A toJSON method does not serialize: it returns the +// value represented by the name/value pair that should be serialized, +// or undefined if nothing should be serialized. The toJSON method +// will be passed the key associated with the value, and this will be +// bound to the value. + +// For example, this would serialize Dates as ISO strings. + +// Date.prototype.toJSON = function (key) { +// function f(n) { +// // Format integers to have at least two digits. +// return (n < 10) +// ? "0" + n +// : n; +// } +// return this.getUTCFullYear() + "-" + +// f(this.getUTCMonth() + 1) + "-" + +// f(this.getUTCDate()) + "T" + +// f(this.getUTCHours()) + ":" + +// f(this.getUTCMinutes()) + ":" + +// f(this.getUTCSeconds()) + "Z"; +// }; + +// You can provide an optional replacer method. It will be passed the +// key and value of each member, with this bound to the containing +// object. The value that is returned from your method will be +// serialized. If your method returns undefined, then the member will +// be excluded from the serialization. + +// If the replacer parameter is an array of strings, then it will be +// used to select the members to be serialized. It filters the results +// such that only members with keys listed in the replacer array are +// stringified. + +// Values that do not have JSON representations, such as undefined or +// functions, will not be serialized. Such values in objects will be +// dropped; in arrays they will be replaced with null. You can use +// a replacer function to replace those with JSON values. + +// JSON.stringify(undefined) returns undefined. + +// The optional space parameter produces a stringification of the +// value that is filled with line breaks and indentation to make it +// easier to read. + +// If the space parameter is a non-empty string, then that string will +// be used for indentation. If the space parameter is a number, then +// the indentation will be that many spaces. + +// Example: + +// text = JSON.stringify(["e", {pluribus: "unum"}]); +// // text is '["e",{"pluribus":"unum"}]' + +// text = JSON.stringify(["e", {pluribus: "unum"}], null, "\t"); +// // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]' + +// text = JSON.stringify([new Date()], function (key, value) { +// return this[key] instanceof Date +// ? "Date(" + this[key] + ")" +// : value; +// }); +// // text is '["Date(---current time---)"]' + +// JSON.parse(text, reviver) +// This method parses a JSON text to produce an object or array. +// It can throw a SyntaxError exception. + +// The optional reviver parameter is a function that can filter and +// transform the results. It receives each of the keys and values, +// and its return value is used instead of the original value. +// If it returns what it received, then the structure is not modified. +// If it returns undefined then the member is deleted. + +// Example: + +// // Parse the text. Values that look like ISO date strings will +// // be converted to Date objects. + +// myData = JSON.parse(text, function (key, value) { +// var a; +// if (typeof value === "string") { +// a = +// /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); +// if (a) { +// return new Date(Date.UTC( +// +a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6] +// )); +// } +// return value; +// } +// }); + +// myData = JSON.parse( +// "[\"Date(09/09/2001)\"]", +// function (key, value) { +// var d; +// if ( +// typeof value === "string" +// && value.slice(0, 5) === "Date(" +// && value.slice(-1) === ")" +// ) { +// d = new Date(value.slice(5, -1)); +// if (d) { +// return d; +// } +// } +// return value; +// } +// ); + +// This is a reference implementation. You are free to copy, modify, or +// redistribute. + +/*jslint + eval, for, this +*/ + +/*property + JSON, apply, call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours, + getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, + lastIndex, length, parse, prototype, push, replace, slice, stringify, + test, toJSON, toString, valueOf +*/ + + +// Create a JSON object only if one does not already exist. We create the +// methods in a closure to avoid creating global variables. + +if (typeof JSON !== "object") { + JSON = {}; +} + +(function () { + "use strict"; + + var rx_one = /^[\],:{}\s]*$/; + var rx_two = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g; + var rx_three = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g; + var rx_four = /(?:^|:|,)(?:\s*\[)+/g; + var rx_escapable = /[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; + var rx_dangerous = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; + + function f(n) { + // Format integers to have at least two digits. + return (n < 10) + ? "0" + n + : n; + } + + function this_value() { + return this.valueOf(); + } + + if (typeof Date.prototype.toJSON !== "function") { + + Date.prototype.toJSON = function () { + + return isFinite(this.valueOf()) + ? ( + this.getUTCFullYear() + + "-" + + f(this.getUTCMonth() + 1) + + "-" + + f(this.getUTCDate()) + + "T" + + f(this.getUTCHours()) + + ":" + + f(this.getUTCMinutes()) + + ":" + + f(this.getUTCSeconds()) + + "Z" + ) + : null; + }; + + Boolean.prototype.toJSON = this_value; + Number.prototype.toJSON = this_value; + String.prototype.toJSON = this_value; + } + + var gap; + var indent; + var meta; + var rep; + + + function quote(string) { + +// If the string contains no control characters, no quote characters, and no +// backslash characters, then we can safely slap some quotes around it. +// Otherwise we must also replace the offending characters with safe escape +// sequences. + + rx_escapable.lastIndex = 0; + return rx_escapable.test(string) + ? "\"" + string.replace(rx_escapable, function (a) { + var c = meta[a]; + return typeof c === "string" + ? c + : "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4); + }) + "\"" + : "\"" + string + "\""; + } + + + function str(key, holder) { + +// Produce a string from holder[key]. + + var i; // The loop counter. + var k; // The member key. + var v; // The member value. + var length; + var mind = gap; + var partial; + var value = holder[key]; + +// If the value has a toJSON method, call it to obtain a replacement value. + + if ( + value + && typeof value === "object" + && typeof value.toJSON === "function" + ) { + value = value.toJSON(key); + } + +// If we were called with a replacer function, then call the replacer to +// obtain a replacement value. + + if (typeof rep === "function") { + value = rep.call(holder, key, value); + } + +// What happens next depends on the value's type. + + switch (typeof value) { + case "string": + return quote(value); + + case "number": + +// JSON numbers must be finite. Encode non-finite numbers as null. + + return (isFinite(value)) + ? String(value) + : "null"; + + case "boolean": + case "null": + +// If the value is a boolean or null, convert it to a string. Note: +// typeof null does not produce "null". The case is included here in +// the remote chance that this gets fixed someday. + + return String(value); + +// If the type is "object", we might be dealing with an object or an array or +// null. + + case "object": + +// Due to a specification blunder in ECMAScript, typeof null is "object", +// so watch out for that case. + + if (!value) { + return "null"; + } + +// Make an array to hold the partial results of stringifying this object value. + + gap += indent; + partial = []; + +// Is the value an array? + + if (Object.prototype.toString.apply(value) === "[object Array]") { + +// The value is an array. Stringify every element. Use null as a placeholder +// for non-JSON values. + + length = value.length; + for (i = 0; i < length; i += 1) { + partial[i] = str(i, value) || "null"; + } + +// Join all of the elements together, separated with commas, and wrap them in +// brackets. + + v = partial.length === 0 + ? "[]" + : gap + ? ( + "[\n" + + gap + + partial.join(",\n" + gap) + + "\n" + + mind + + "]" + ) + : "[" + partial.join(",") + "]"; + gap = mind; + return v; + } + +// If the replacer is an array, use it to select the members to be stringified. + + if (rep && typeof rep === "object") { + length = rep.length; + for (i = 0; i < length; i += 1) { + if (typeof rep[i] === "string") { + k = rep[i]; + v = str(k, value); + if (v) { + partial.push(quote(k) + ( + (gap) + ? ": " + : ":" + ) + v); + } + } + } + } else { + +// Otherwise, iterate through all of the keys in the object. + + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = str(k, value); + if (v) { + partial.push(quote(k) + ( + (gap) + ? ": " + : ":" + ) + v); + } + } + } + } + +// Join all of the member texts together, separated with commas, +// and wrap them in braces. + + v = partial.length === 0 + ? "{}" + : gap + ? "{\n" + gap + partial.join(",\n" + gap) + "\n" + mind + "}" + : "{" + partial.join(",") + "}"; + gap = mind; + return v; + } + } + +// If the JSON object does not yet have a stringify method, give it one. + + if (typeof JSON.stringify !== "function") { + meta = { // table of character substitutions + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", + "\"": "\\\"", + "\\": "\\\\" + }; + JSON.stringify = function (value, replacer, space) { + +// The stringify method takes a value and an optional replacer, and an optional +// space parameter, and returns a JSON text. The replacer can be a function +// that can replace values, or an array of strings that will select the keys. +// A default replacer method can be provided. Use of the space parameter can +// produce text that is more easily readable. + + var i; + gap = ""; + indent = ""; + +// If the space parameter is a number, make an indent string containing that +// many spaces. + + if (typeof space === "number") { + for (i = 0; i < space; i += 1) { + indent += " "; + } + +// If the space parameter is a string, it will be used as the indent string. + + } else if (typeof space === "string") { + indent = space; + } + +// If there is a replacer, it must be a function or an array. +// Otherwise, throw an error. + + rep = replacer; + if (replacer && typeof replacer !== "function" && ( + typeof replacer !== "object" + || typeof replacer.length !== "number" + )) { + throw new Error("JSON.stringify"); + } + +// Make a fake root object containing our value under the key of "". +// Return the result of stringifying the value. + + return str("", {"": value}); + }; + } + + +// If the JSON object does not yet have a parse method, give it one. + + if (typeof JSON.parse !== "function") { + JSON.parse = function (text, reviver) { + +// The parse method takes a text and an optional reviver function, and returns +// a JavaScript value if the text is a valid JSON text. + + var j; + + function walk(holder, key) { + +// The walk method is used to recursively walk the resulting structure so +// that modifications can be made. + + var k; + var v; + var value = holder[key]; + if (value && typeof value === "object") { + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = walk(value, k); + if (v !== undefined) { + value[k] = v; + } else { + delete value[k]; + } + } + } + } + return reviver.call(holder, key, value); + } + + +// Parsing happens in four stages. In the first stage, we replace certain +// Unicode characters with escape sequences. JavaScript handles many characters +// incorrectly, either silently deleting them, or treating them as line endings. + + text = String(text); + rx_dangerous.lastIndex = 0; + if (rx_dangerous.test(text)) { + text = text.replace(rx_dangerous, function (a) { + return ( + "\\u" + + ("0000" + a.charCodeAt(0).toString(16)).slice(-4) + ); + }); + } + +// In the second stage, we run the text against regular expressions that look +// for non-JSON patterns. We are especially concerned with "()" and "new" +// because they can cause invocation, and "=" because it can cause mutation. +// But just to be safe, we want to reject all unexpected forms. + +// We split the second stage into 4 regexp operations in order to work around +// crippling inefficiencies in IE's and Safari's regexp engines. First we +// replace the JSON backslash pairs with "@" (a non-JSON character). Second, we +// replace all simple value tokens with "]" characters. Third, we delete all +// open brackets that follow a colon or comma or that begin the text. Finally, +// we look to see that the remaining characters are only whitespace or "]" or +// "," or ":" or "{" or "}". If that is so, then the text is safe for eval. + + if ( + rx_one.test( + text + .replace(rx_two, "@") + .replace(rx_three, "]") + .replace(rx_four, "") + ) + ) { + +// In the third stage we use the eval function to compile the text into a +// JavaScript structure. The "{" operator is subject to a syntactic ambiguity +// in JavaScript: it can begin a block or an object literal. We wrap the text +// in parens to eliminate the ambiguity. + + j = eval("(" + text + ")"); + +// In the optional fourth stage, we recursively walk the new structure, passing +// each name/value pair to a reviver function for possible transformation. + + return (typeof reviver === "function") + ? walk({"": j}, "") + : j; + } + +// If the text is not JSON parseable, then a SyntaxError is thrown. + + throw new SyntaxError("JSON.parse"); + }; + } +}()); \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png b/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png new file mode 100644 index 0000000000000000000000000000000000000000..33fe2a606bd1ac9d285eb0d6a90b9b14150ca3c4 GIT binary patch literal 1362 zcmV-Y1+DstP)5JpvKiH|A7SK4P~kT{6`M*9LrdQ!Ns9=$qj3(E_W@4gmP#=ht)#^0_psT^(5Px6qr0)mBB%5&-P}{Y*9ph@Pcn`!ete zwiE<#115v#ScdV2GBk!NTFTzWbF>Xip`p8%&KqcqI~Jb6tC``Vaf&07o~axnzSGF( z(ok|5&-4zgtV5rc$qke?7a8cU$D55m^%IcuOgXaxfTb~yegblyEaWJw%`Qe=-M%S@ zhOXSbt2KkcJv{&)s&PL6vC{g1Y-aKYBs(yc@x{whhk_0fK#=N=)Uup zs)>qe=dc=h3&3Gwr10?^8zc#g%1L4Xs{p!rj(uw=)9Szs&#`@sH{=+ zG+fz{pjE0VR%8l+hOX;W8`PbV32glOJ!~I2VXJkTz5Ufkuk(!F8z4>Ok_kkI+Kb}3)n06_ssJy4_*!y{BAe4)9jbBbSR!>UnLxyMT9bL9_?YdfL@K^^G6aZ)C$Qje z(NzKf2bZq2#ed1=gx1ZJQM{TNMk>CBw!wSvUjy@gS4qs1_a85GREVYsFz!+tU$`&M%7iR@HuBiw5bSa5S}|?)>G0PCUMb-Q{Pf zZt0{hEhroOCi1l=h%&q$mkBdG$MzLns~iea1>hEds{qcP5QbL){0`u*@Qfwke+13^ UGpuMiD*ylh07*qoM6N<$g1d2qT>t<8 literal 0 HcmV?d00001 diff --git a/openpype/hosts/photoshop/api/extension/index.html b/openpype/hosts/photoshop/api/extension/index.html new file mode 100644 index 0000000000..501e753c0b --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/index.html @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py new file mode 100644 index 0000000000..36347b8ce0 --- /dev/null +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -0,0 +1,315 @@ +import os +import subprocess +import collections +import logging +import asyncio +import functools + +from wsrpc_aiohttp import ( + WebSocketRoute, + WebSocketAsync +) + +from Qt import QtCore + +from openpype.tools.utils import host_tools + +from avalon import api +from avalon.tools.webserver.app import WebServerTool + +from .ws_stub import PhotoshopServerStub + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + + +class ConnectionNotEstablishedYet(Exception): + pass + + +def stub(): + """ + Convenience function to get server RPC stub to call methods directed + for host (Photoshop). + It expects already created connection, started from client. + Currently created when panel is opened (PS: Window>Extensions>Avalon) + :return: where functions could be called from + """ + ps_stub = PhotoshopServerStub() + if not ps_stub.client: + raise ConnectionNotEstablishedYet("Connection is not created yet") + + return ps_stub + + +def show_tool_by_name(tool_name): + kwargs = {} + if tool_name == "loader": + kwargs["use_context"] = True + + host_tools.show_tool_by_name(tool_name, **kwargs) + + +class ProcessLauncher(QtCore.QObject): + route_name = "Photoshop" + _main_thread_callbacks = collections.deque() + + def __init__(self, subprocess_args): + self._subprocess_args = subprocess_args + self._log = None + + super(ProcessLauncher, self).__init__() + + # Keep track if launcher was already started + self._started = False + + self._process = None + self._websocket_server = None + + start_process_timer = QtCore.QTimer() + start_process_timer.setInterval(100) + + loop_timer = QtCore.QTimer() + loop_timer.setInterval(200) + + start_process_timer.timeout.connect(self._on_start_process_timer) + loop_timer.timeout.connect(self._on_loop_timer) + + self._start_process_timer = start_process_timer + self._loop_timer = loop_timer + + @property + def log(self): + if self._log is None: + from openpype.api import Logger + + self._log = Logger.get_logger("{}-launcher".format( + self.route_name)) + return self._log + + @property + def websocket_server_is_running(self): + if self._websocket_server is not None: + return self._websocket_server.is_running + return False + + @property + def is_process_running(self): + if self._process is not None: + return self._process.poll() is None + return False + + @property + def is_host_connected(self): + """Returns True if connected, False if app is not running at all.""" + if not self.is_process_running: + return False + + try: + + _stub = stub() + if _stub: + return True + except Exception: + pass + + return None + + @classmethod + def execute_in_main_thread(cls, callback): + cls._main_thread_callbacks.append(callback) + + def start(self): + if self._started: + return + self.log.info("Started launch logic of AfterEffects") + self._started = True + self._start_process_timer.start() + + def exit(self): + """ Exit whole application. """ + if self._start_process_timer.isActive(): + self._start_process_timer.stop() + if self._loop_timer.isActive(): + self._loop_timer.stop() + + if self._websocket_server is not None: + self._websocket_server.stop() + + if self._process: + self._process.kill() + self._process.wait() + + QtCore.QCoreApplication.exit() + + def _on_loop_timer(self): + # TODO find better way and catch errors + # Run only callbacks that are in queue at the moment + cls = self.__class__ + for _ in range(len(cls._main_thread_callbacks)): + if cls._main_thread_callbacks: + callback = cls._main_thread_callbacks.popleft() + callback() + + if not self.is_process_running: + self.log.info("Host process is not running. Closing") + self.exit() + + elif not self.websocket_server_is_running: + self.log.info("Websocket server is not running. Closing") + self.exit() + + def _on_start_process_timer(self): + # TODO add try except validations for each part in this method + # Start server as first thing + if self._websocket_server is None: + self._init_server() + return + + # TODO add waiting time + # Wait for webserver + if not self.websocket_server_is_running: + return + + # Start application process + if self._process is None: + self._start_process() + self.log.info("Waiting for host to connect") + return + + # TODO add waiting time + # Wait until host is connected + if self.is_host_connected: + self._start_process_timer.stop() + self._loop_timer.start() + elif ( + not self.is_process_running + or not self.websocket_server_is_running + ): + self.exit() + + def _init_server(self): + if self._websocket_server is not None: + return + + self.log.debug( + "Initialization of websocket server for host communication" + ) + + self._websocket_server = websocket_server = WebServerTool() + if websocket_server.port_occupied( + websocket_server.host_name, + websocket_server.port + ): + self.log.info( + "Server already running, sending actual context and exit." + ) + asyncio.run(websocket_server.send_context_change(self.route_name)) + self.exit() + return + + # Add Websocket route + websocket_server.add_route("*", "/ws/", WebSocketAsync) + # Add after effects route to websocket handler + + print("Adding {} route".format(self.route_name)) + WebSocketAsync.add_route( + self.route_name, PhotoshopRoute + ) + self.log.info("Starting websocket server for host communication") + websocket_server.start_server() + + def _start_process(self): + if self._process is not None: + return + self.log.info("Starting host process") + try: + self._process = subprocess.Popen( + self._subprocess_args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + except Exception: + self.log.info("exce", exc_info=True) + self.exit() + + +class PhotoshopRoute(WebSocketRoute): + """ + One route, mimicking external application (like Harmony, etc). + All functions could be called from client. + 'do_notify' function calls function on the client - mimicking + notification after long running job on the server or similar + """ + instance = None + + def init(self, **kwargs): + # Python __init__ must be return "self". + # This method might return anything. + log.debug("someone called Photoshop route") + self.instance = self + return kwargs + + # server functions + async def ping(self): + log.debug("someone called Photoshop route ping") + + # This method calls function on the client side + # client functions + async def set_context(self, project, asset, task): + """ + Sets 'project' and 'asset' to envs, eg. setting context + + Args: + project (str) + asset (str) + """ + log.info("Setting context change") + log.info("project {} asset {} ".format(project, asset)) + if project: + api.Session["AVALON_PROJECT"] = project + os.environ["AVALON_PROJECT"] = project + if asset: + api.Session["AVALON_ASSET"] = asset + os.environ["AVALON_ASSET"] = asset + if task: + api.Session["AVALON_TASK"] = task + os.environ["AVALON_TASK"] = task + + async def read(self): + log.debug("photoshop.read client calls server server calls " + "photoshop client") + return await self.socket.call('photoshop.read') + + # panel routes for tools + async def creator_route(self): + self._tool_route("creator") + + async def workfiles_route(self): + self._tool_route("workfiles") + + async def loader_route(self): + self._tool_route("loader") + + async def publish_route(self): + self._tool_route("publish") + + async def sceneinventory_route(self): + self._tool_route("sceneinventory") + + async def subsetmanager_route(self): + self._tool_route("subsetmanager") + + async def experimental_tools_route(self): + self._tool_route("experimental_tools") + + def _tool_route(self, _tool_name): + """The address accessed when clicking on the buttons.""" + + partial_method = functools.partial(show_tool_by_name, + _tool_name) + + ProcessLauncher.execute_in_main_thread(partial_method) + + # Required return statement. + return "nothing" diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py new file mode 100644 index 0000000000..bc1fb36cf3 --- /dev/null +++ b/openpype/hosts/photoshop/api/lib.py @@ -0,0 +1,76 @@ +import os +import sys +import contextlib +import logging +import traceback + +from Qt import QtWidgets + +from openpype.tools.utils import host_tools + +from openpype.lib.remote_publish import headless_publish + +from .launch_logic import ProcessLauncher, stub + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + + +def safe_excepthook(*args): + traceback.print_exception(*args) + + +def main(*subprocess_args): + from avalon import api, photoshop + + api.install(photoshop) + sys.excepthook = safe_excepthook + + # coloring in ConsoleTrayApp + os.environ["OPENPYPE_LOG_NO_COLORS"] = "False" + app = QtWidgets.QApplication([]) + app.setQuitOnLastWindowClosed(False) + + launcher = ProcessLauncher(subprocess_args) + launcher.start() + + if os.environ.get("HEADLESS_PUBLISH"): + launcher.execute_in_main_thread(lambda: headless_publish( + log, + "ClosePS", + os.environ.get("IS_TEST"))) + elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True): + save = False + if os.getenv("WORKFILES_SAVE_AS"): + save = True + + launcher.execute_in_main_thread( + lambda: host_tools.show_workfiles(save=save) + ) + + sys.exit(app.exec_()) + + +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context.""" + selection = stub().get_selected_layers() + try: + yield selection + finally: + stub().select_layers(selection) + + +@contextlib.contextmanager +def maintained_visibility(): + """Maintain visibility during context.""" + visibility = {} + layers = stub().get_layers() + for layer in layers: + visibility[layer.id] = layer.visible + try: + yield + finally: + for layer in layers: + stub().set_visible(layer.id, visibility[layer.id]) + pass diff --git a/openpype/hosts/photoshop/api/panel.PNG b/openpype/hosts/photoshop/api/panel.PNG new file mode 100644 index 0000000000000000000000000000000000000000..be5db3b8df08aa426b92de61eacc9afd00e93d45 GIT binary patch literal 8756 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py new file mode 100644 index 0000000000..ade144e6d4 --- /dev/null +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -0,0 +1,199 @@ +from .. import api, pipeline +from . import lib +from ..vendor import Qt + +import pyblish.api + + +def install(): + """Install Photoshop-specific functionality of avalon-core. + + This function is called automatically on calling `api.install(photoshop)`. + """ + print("Installing Avalon Photoshop...") + pyblish.api.register_host("photoshop") + + +def ls(): + """Yields containers from active Photoshop document + + This is the host-equivalent of api.ls(), but instead of listing + assets on disk, it lists assets already loaded in Photoshop; once loaded + they are called 'containers' + + Yields: + dict: container + + """ + try: + stub = lib.stub() # only after Photoshop is up + except lib.ConnectionNotEstablishedYet: + print("Not connected yet, ignoring") + return + + if not stub.get_active_document_name(): + return + + layers_meta = stub.get_layers_metadata() # minimalize calls to PS + for layer in stub.get_layers(): + data = stub.read(layer, layers_meta) + + # Skip non-tagged layers. + if not data: + continue + + # Filter to only containers. + if "container" not in data["id"]: + continue + + # Append transient data + data["objectName"] = layer.name.replace(stub.LOADED_ICON, '') + data["layer"] = layer + + yield data + + +def list_instances(): + """ + List all created instances from current workfile which + will be published. + + Pulls from File > File Info + + For SubsetManager + + Returns: + (list) of dictionaries matching instances format + """ + stub = _get_stub() + + if not stub: + return [] + + instances = [] + layers_meta = stub.get_layers_metadata() + if layers_meta: + for key, instance in layers_meta.items(): + if instance.get("schema") and \ + "container" in instance.get("schema"): + continue + + instance['uuid'] = key + instances.append(instance) + + return instances + + +def remove_instance(instance): + """ + Remove instance from current workfile metadata. + + Updates metadata of current file in File > File Info and removes + icon highlight on group layer. + + For SubsetManager + + Args: + instance (dict): instance representation from subsetmanager model + """ + stub = _get_stub() + + if not stub: + return + + stub.remove_instance(instance.get("uuid")) + layer = stub.get_layer(instance.get("uuid")) + if layer: + stub.rename_layer(instance.get("uuid"), + layer.name.replace(stub.PUBLISH_ICON, '')) + + +def _get_stub(): + """ + Handle pulling stub from PS to run operations on host + Returns: + (PhotoshopServerStub) or None + """ + try: + stub = lib.stub() # only after Photoshop is up + except lib.ConnectionNotEstablishedYet: + print("Not connected yet, ignoring") + return + + if not stub.get_active_document_name(): + return + + return stub + + +class Creator(api.Creator): + """Creator plugin to create instances in Photoshop + + A LayerSet is created to support any number of layers in an instance. If + the selection is used, these layers will be added to the LayerSet. + """ + + def process(self): + # Photoshop can have multiple LayerSets with the same name, which does + # not work with Avalon. + msg = "Instance with name \"{}\" already exists.".format(self.name) + stub = lib.stub() # only after Photoshop is up + for layer in stub.get_layers(): + if self.name.lower() == layer.Name.lower(): + msg = Qt.QtWidgets.QMessageBox() + msg.setIcon(Qt.QtWidgets.QMessageBox.Warning) + msg.setText(msg) + msg.exec_() + return False + + # Store selection because adding a group will change selection. + with lib.maintained_selection(): + + # Add selection to group. + if (self.options or {}).get("useSelection"): + group = stub.group_selected_layers(self.name) + else: + group = stub.create_group(self.name) + + stub.imprint(group, self.data) + + return group + + +def containerise(name, + namespace, + layer, + context, + loader=None, + suffix="_CON"): + """Imprint layer with metadata + + Containerisation enables a tracking of version, author and origin + for loaded assets. + + Arguments: + name (str): Name of resulting assembly + namespace (str): Namespace under which to host container + layer (PSItem): Layer to containerise + context (dict): Asset information + loader (str, optional): Name of loader used to produce this container. + suffix (str, optional): Suffix of container, defaults to `_CON`. + + Returns: + container (str): Name of container assembly + """ + layer.name = name + suffix + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace, + "loader": str(loader), + "representation": str(context["representation"]["_id"]), + "members": [str(layer.id)] + } + stub = lib.stub() + stub.imprint(layer, data) + + return layer diff --git a/openpype/hosts/photoshop/api/workio.py b/openpype/hosts/photoshop/api/workio.py new file mode 100644 index 0000000000..ddcd351b38 --- /dev/null +++ b/openpype/hosts/photoshop/api/workio.py @@ -0,0 +1,50 @@ +"""Host API required Work Files tool""" +import os + +from . import lib +from avalon import api + + +def _active_document(): + document_name = lib.stub().get_active_document_name() + if not document_name: + return None + + return document_name + + +def file_extensions(): + return api.HOST_WORKFILE_EXTENSIONS["photoshop"] + + +def has_unsaved_changes(): + if _active_document(): + return not lib.stub().is_saved() + + return False + + +def save_file(filepath): + _, ext = os.path.splitext(filepath) + lib.stub().saveAs(filepath, ext[1:], True) + + +def open_file(filepath): + lib.stub().open(filepath) + + return True + + +def current_file(): + try: + full_name = lib.stub().get_active_document_full_name() + if full_name and full_name != "null": + return os.path.normpath(full_name).replace("\\", "/") + except Exception: + pass + + return None + + +def work_root(session): + return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py new file mode 100644 index 0000000000..f7bd03cdab --- /dev/null +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -0,0 +1,470 @@ +""" + Stub handling connection from server to client. + Used anywhere solution is calling client methods. +""" +import json +import sys +from wsrpc_aiohttp import WebSocketAsync +import attr + +from avalon.tools.webserver.app import WebServerTool + + +@attr.s +class PSItem(object): + """ + Object denoting layer or group item in PS. Each item is created in + PS by any Loader, but contains same fields, which are being used + in later processing. + """ + # metadata + id = attr.ib() # id created by AE, could be used for querying + name = attr.ib() # name of item + group = attr.ib(default=None) # item type (footage, folder, comp) + parents = attr.ib(factory=list) + visible = attr.ib(default=True) + type = attr.ib(default=None) + # all imported elements, single for + members = attr.ib(factory=list) + long_name = attr.ib(default=None) + color_code = attr.ib(default=None) # color code of layer + + +class PhotoshopServerStub: + """ + Stub for calling function on client (Photoshop js) side. + Expects that client is already connected (started when avalon menu + is opened). + 'self.websocketserver.call' is used as async wrapper + """ + PUBLISH_ICON = '\u2117 ' + LOADED_ICON = '\u25bc' + + def __init__(self): + self.websocketserver = WebServerTool.get_instance() + self.client = self.get_client() + + @staticmethod + def get_client(): + """ + Return first connected client to WebSocket + TODO implement selection by Route + :return: client + """ + clients = WebSocketAsync.get_clients() + client = None + if len(clients) > 0: + key = list(clients.keys())[0] + client = clients.get(key) + + return client + + def open(self, path): + """ + Open file located at 'path' (local). + Args: + path(string): file path locally + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.open', path=path) + ) + + def read(self, layer, layers_meta=None): + """ + Parses layer metadata from Headline field of active document + Args: + layer: (PSItem) + layers_meta: full list from Headline (for performance in loops) + Returns: + """ + if layers_meta is None: + layers_meta = self.get_layers_metadata() + + return layers_meta.get(str(layer.id)) + + def imprint(self, layer, data, all_layers=None, layers_meta=None): + """ + Save layer metadata to Headline field of active document + + Stores metadata in format: + [{ + "active":true, + "subset":"imageBG", + "family":"image", + "id":"pyblish.avalon.instance", + "asset":"Town", + "uuid": "8" + }] - for created instances + OR + [{ + "schema": "openpype:container-2.0", + "id": "pyblish.avalon.instance", + "name": "imageMG", + "namespace": "Jungle_imageMG_001", + "loader": "ImageLoader", + "representation": "5fbfc0ee30a946093c6ff18a", + "members": [ + "40" + ] + }] - for loaded instances + + Args: + layer (PSItem): + data(string): json representation for single layer + all_layers (list of PSItem): for performance, could be + injected for usage in loop, if not, single call will be + triggered + layers_meta(string): json representation from Headline + (for performance - provide only if imprint is in + loop - value should be same) + Returns: None + """ + if not layers_meta: + layers_meta = self.get_layers_metadata() + + # json.dumps writes integer values in a dictionary to string, so + # anticipating it here. + if str(layer.id) in layers_meta and layers_meta[str(layer.id)]: + if data: + layers_meta[str(layer.id)].update(data) + else: + layers_meta.pop(str(layer.id)) + else: + layers_meta[str(layer.id)] = data + + # Ensure only valid ids are stored. + if not all_layers: + all_layers = self.get_layers() + layer_ids = [layer.id for layer in all_layers] + cleaned_data = [] + + for id in layers_meta: + if int(id) in layer_ids: + cleaned_data.append(layers_meta[id]) + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call(self.client.call + ('Photoshop.imprint', payload=payload) + ) + + def get_layers(self): + """ + Returns JSON document with all(?) layers in active document. + + Returns: + Format of tuple: { 'id':'123', + 'name': 'My Layer 1', + 'type': 'GUIDE'|'FG'|'BG'|'OBJ' + 'visible': 'true'|'false' + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_layers')) + + return self._to_records(res) + + def get_layer(self, layer_id): + """ + Returns PSItem for specific 'layer_id' or None if not found + Args: + layer_id (string): unique layer id, stored in 'uuid' field + + Returns: + (PSItem) or None + """ + layers = self.get_layers() + for layer in layers: + if str(layer.id) == str(layer_id): + return layer + + def get_layers_in_layers(self, layers): + """ + Return all layers that belong to layers (might be groups). + Args: + layers : + Returns: + """ + all_layers = self.get_layers() + ret = [] + parent_ids = set([lay.id for lay in layers]) + + for layer in all_layers: + parents = set(layer.parents) + if len(parent_ids & parents) > 0: + ret.append(layer) + if layer.id in parent_ids: + ret.append(layer) + + return ret + + def create_group(self, name): + """ + Create new group (eg. LayerSet) + Returns: + """ + enhanced_name = self.PUBLISH_ICON + name + ret = self.websocketserver.call(self.client.call + ('Photoshop.create_group', + name=enhanced_name)) + # create group on PS is asynchronous, returns only id + return PSItem(id=ret, name=name, group=True) + + def group_selected_layers(self, name): + """ + Group selected layers into new LayerSet (eg. group) + Returns: (Layer) + """ + enhanced_name = self.PUBLISH_ICON + name + res = self.websocketserver.call(self.client.call + ('Photoshop.group_selected_layers', + name=enhanced_name) + ) + res = self._to_records(res) + if res: + rec = res.pop() + rec.name = rec.name.replace(self.PUBLISH_ICON, '') + return rec + raise ValueError("No group record returned") + + def get_selected_layers(self): + """ + Get a list of actually selected layers + Returns: + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_selected_layers')) + return self._to_records(res) + + def select_layers(self, layers): + """ + Selects specified layers in Photoshop by its ids + Args: + layers: + Returns: None + """ + layers_id = [str(lay.id) for lay in layers] + self.websocketserver.call(self.client.call + ('Photoshop.select_layers', + layers=json.dumps(layers_id)) + ) + + def get_active_document_full_name(self): + """ + Returns full name with path of active document via ws call + Returns(string): full path with name + """ + res = self.websocketserver.call( + self.client.call('Photoshop.get_active_document_full_name')) + + return res + + def get_active_document_name(self): + """ + Returns just a name of active document via ws call + Returns(string): file name + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_active_document_name')) + + return res + + def is_saved(self): + """ + Returns true if no changes in active document + Returns: + """ + return self.websocketserver.call(self.client.call + ('Photoshop.is_saved')) + + def save(self): + """ + Saves active document + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.save')) + + def saveAs(self, image_path, ext, as_copy): + """ + Saves active document to psd (copy) or png or jpg + Args: + image_path(string): full local path + ext: + as_copy: + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.saveAs', + image_path=image_path, + ext=ext, + as_copy=as_copy)) + + def set_visible(self, layer_id, visibility): + """ + Set layer with 'layer_id' to 'visibility' + Args: + layer_id: + visibility: + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.set_visible', + layer_id=layer_id, + visibility=visibility)) + + def get_layers_metadata(self): + """ + Reads layers metadata from Headline from active document in PS. + (Headline accessible by File > File Info) + + Returns: + (string): - json documents + example: + {"8":{"active":true,"subset":"imageBG", + "family":"image","id":"pyblish.avalon.instance", + "asset":"Town"}} + 8 is layer(group) id - used for deletion, update etc. + """ + layers_data = {} + res = self.websocketserver.call(self.client.call('Photoshop.read')) + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + pass + # format of metadata changed from {} to [] because of standardization + # keep current implementation logic as its working + if not isinstance(layers_data, dict): + temp_layers_meta = {} + for layer_meta in layers_data: + layer_id = layer_meta.get("uuid") or \ + (layer_meta.get("members")[0]) + temp_layers_meta[layer_id] = layer_meta + layers_data = temp_layers_meta + else: + # legacy version of metadata + for layer_id, layer_meta in layers_data.items(): + if layer_meta.get("schema") != "openpype:container-2.0": + layer_meta["uuid"] = str(layer_id) + else: + layer_meta["members"] = [str(layer_id)] + + return layers_data + + def import_smart_object(self, path, layer_name, as_reference=False): + """ + Import the file at `path` as a smart object to active document. + + Args: + path (str): File path to import. + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded + as_reference (bool): pull in content or reference + """ + enhanced_name = self.LOADED_ICON + layer_name + res = self.websocketserver.call(self.client.call + ('Photoshop.import_smart_object', + path=path, name=enhanced_name, + as_reference=as_reference + )) + rec = self._to_records(res).pop() + if rec: + rec.name = rec.name.replace(self.LOADED_ICON, '') + return rec + + def replace_smart_object(self, layer, path, layer_name): + """ + Replace the smart object `layer` with file at `path` + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded + + Args: + layer (PSItem): + path (str): File to import. + """ + enhanced_name = self.LOADED_ICON + layer_name + self.websocketserver.call(self.client.call + ('Photoshop.replace_smart_object', + layer_id=layer.id, + path=path, name=enhanced_name)) + + def delete_layer(self, layer_id): + """ + Deletes specific layer by it's id. + Args: + layer_id (int): id of layer to delete + """ + self.websocketserver.call(self.client.call + ('Photoshop.delete_layer', + layer_id=layer_id)) + + def rename_layer(self, layer_id, name): + """ + Renames specific layer by it's id. + Args: + layer_id (int): id of layer to delete + name (str): new name + """ + self.websocketserver.call(self.client.call + ('Photoshop.rename_layer', + layer_id=layer_id, + name=name)) + + def remove_instance(self, instance_id): + cleaned_data = {} + + for key, instance in self.get_layers_metadata().items(): + if key != instance_id: + cleaned_data[key] = instance + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call(self.client.call + ('Photoshop.imprint', payload=payload) + ) + + def get_extension_version(self): + """Returns version number of installed extension.""" + return self.websocketserver.call(self.client.call + ('Photoshop.get_extension_version')) + + def close(self): + """Shutting down PS and process too. + + For webpublishing only. + """ + # TODO change client.call to method with checks for client + self.websocketserver.call(self.client.call('Photoshop.close')) + + def _to_records(self, res): + """ + Converts string json representation into list of PSItem for + dot notation access to work. + Args: + res (string): valid json + Returns: + + """ + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + raise ValueError("Received broken JSON {}".format(res)) + ret = [] + + # convert to AEItem to use dot donation + if isinstance(layers_data, dict): + layers_data = [layers_data] + for d in layers_data: + # currently implemented and expected fields + item = PSItem(d.get('id'), + d.get('name'), + d.get('group'), + d.get('parents'), + d.get('visible'), + d.get('type'), + d.get('members'), + d.get('long_name'), + d.get("color_code")) + + ret.append(item) + return ret From 3ca400949134fc604744086ae74c8b16bd0d8757 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:00:53 +0100 Subject: [PATCH 192/395] removed empty hooks --- openpype/hosts/photoshop/hooks/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 openpype/hosts/photoshop/hooks/__init__.py diff --git a/openpype/hosts/photoshop/hooks/__init__.py b/openpype/hosts/photoshop/hooks/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 From c09d832c73db12455ec56d1429510f5c43772d56 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:04:35 +0100 Subject: [PATCH 193/395] use openpype logger --- openpype/hosts/photoshop/api/launch_logic.py | 13 +++++-------- openpype/hosts/photoshop/api/lib.py | 5 ++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 36347b8ce0..8e0d00636a 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -1,7 +1,6 @@ import os import subprocess import collections -import logging import asyncio import functools @@ -12,6 +11,7 @@ from wsrpc_aiohttp import ( from Qt import QtCore +from openpype.api import Logger from openpype.tools.utils import host_tools from avalon import api @@ -19,8 +19,7 @@ from avalon.tools.webserver.app import WebServerTool from .ws_stub import PhotoshopServerStub -log = logging.getLogger(__name__) -log.setLevel(logging.DEBUG) +log = Logger.get_logger(__name__) class ConnectionNotEstablishedYet(Exception): @@ -81,10 +80,9 @@ class ProcessLauncher(QtCore.QObject): @property def log(self): if self._log is None: - from openpype.api import Logger - - self._log = Logger.get_logger("{}-launcher".format( - self.route_name)) + self._log = Logger.get_logger( + "{}-launcher".format(self.route_name) + ) return self._log @property @@ -106,7 +104,6 @@ class ProcessLauncher(QtCore.QObject): return False try: - _stub = stub() if _stub: return True diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index bc1fb36cf3..0938febf43 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -1,19 +1,18 @@ import os import sys import contextlib -import logging import traceback from Qt import QtWidgets from openpype.tools.utils import host_tools +from openpype.api import Logger from openpype.lib.remote_publish import headless_publish from .launch_logic import ProcessLauncher, stub -log = logging.getLogger(__name__) -log.setLevel(logging.DEBUG) +log = Logger.get_logger(__name__) def safe_excepthook(*args): From 1a623d6ee2a17246a881a2d214e9191e7a54aeab Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:05:05 +0100 Subject: [PATCH 194/395] formatting changes --- openpype/hosts/photoshop/api/workio.py | 5 +- openpype/hosts/photoshop/api/ws_stub.py | 347 +++++++++++++----------- 2 files changed, 189 insertions(+), 163 deletions(-) diff --git a/openpype/hosts/photoshop/api/workio.py b/openpype/hosts/photoshop/api/workio.py index ddcd351b38..0bf3ed2bd9 100644 --- a/openpype/hosts/photoshop/api/workio.py +++ b/openpype/hosts/photoshop/api/workio.py @@ -1,8 +1,9 @@ """Host API required Work Files tool""" import os +import avalon.api + from . import lib -from avalon import api def _active_document(): @@ -14,7 +15,7 @@ def _active_document(): def file_extensions(): - return api.HOST_WORKFILE_EXTENSIONS["photoshop"] + return avalon.api.HOST_WORKFILE_EXTENSIONS["photoshop"] def has_unsaved_changes(): diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index f7bd03cdab..b8f66332c6 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -2,10 +2,10 @@ Stub handling connection from server to client. Used anywhere solution is calling client methods. """ -import json import sys -from wsrpc_aiohttp import WebSocketAsync +import json import attr +from wsrpc_aiohttp import WebSocketAsync from avalon.tools.webserver.app import WebServerTool @@ -60,19 +60,19 @@ class PhotoshopServerStub: return client def open(self, path): - """ - Open file located at 'path' (local). + """Open file located at 'path' (local). + Args: path(string): file path locally Returns: None """ - self.websocketserver.call(self.client.call - ('Photoshop.open', path=path) - ) + self.websocketserver.call( + self.client.call('Photoshop.open', path=path) + ) def read(self, layer, layers_meta=None): - """ - Parses layer metadata from Headline field of active document + """Parses layer metadata from Headline field of active document. + Args: layer: (PSItem) layers_meta: full list from Headline (for performance in loops) @@ -84,30 +84,29 @@ class PhotoshopServerStub: return layers_meta.get(str(layer.id)) def imprint(self, layer, data, all_layers=None, layers_meta=None): - """ - Save layer metadata to Headline field of active document + """Save layer metadata to Headline field of active document - Stores metadata in format: - [{ - "active":true, - "subset":"imageBG", - "family":"image", - "id":"pyblish.avalon.instance", - "asset":"Town", - "uuid": "8" - }] - for created instances - OR - [{ - "schema": "openpype:container-2.0", - "id": "pyblish.avalon.instance", - "name": "imageMG", - "namespace": "Jungle_imageMG_001", - "loader": "ImageLoader", - "representation": "5fbfc0ee30a946093c6ff18a", - "members": [ - "40" - ] - }] - for loaded instances + Stores metadata in format: + [{ + "active":true, + "subset":"imageBG", + "family":"image", + "id":"pyblish.avalon.instance", + "asset":"Town", + "uuid": "8" + }] - for created instances + OR + [{ + "schema": "openpype:container-2.0", + "id": "pyblish.avalon.instance", + "name": "imageMG", + "namespace": "Jungle_imageMG_001", + "loader": "ImageLoader", + "representation": "5fbfc0ee30a946093c6ff18a", + "members": [ + "40" + ] + }] - for loaded instances Args: layer (PSItem): @@ -139,19 +138,18 @@ class PhotoshopServerStub: layer_ids = [layer.id for layer in all_layers] cleaned_data = [] - for id in layers_meta: - if int(id) in layer_ids: - cleaned_data.append(layers_meta[id]) + for layer_id in layers_meta: + if int(layer_id) in layer_ids: + cleaned_data.append(layers_meta[layer_id]) payload = json.dumps(cleaned_data, indent=4) - self.websocketserver.call(self.client.call - ('Photoshop.imprint', payload=payload) - ) + self.websocketserver.call( + self.client.call('Photoshop.imprint', payload=payload) + ) def get_layers(self): - """ - Returns JSON document with all(?) layers in active document. + """Returns JSON document with all(?) layers in active document. Returns: Format of tuple: { 'id':'123', @@ -159,8 +157,9 @@ class PhotoshopServerStub: 'type': 'GUIDE'|'FG'|'BG'|'OBJ' 'visible': 'true'|'false' """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_layers')) + res = self.websocketserver.call( + self.client.call('Photoshop.get_layers') + ) return self._to_records(res) @@ -179,11 +178,13 @@ class PhotoshopServerStub: return layer def get_layers_in_layers(self, layers): - """ - Return all layers that belong to layers (might be groups). + """Return all layers that belong to layers (might be groups). + Args: layers : - Returns: + + Returns: + """ all_layers = self.get_layers() ret = [] @@ -199,27 +200,30 @@ class PhotoshopServerStub: return ret def create_group(self, name): - """ - Create new group (eg. LayerSet) - Returns: + """Create new group (eg. LayerSet) + + Returns: + """ enhanced_name = self.PUBLISH_ICON + name - ret = self.websocketserver.call(self.client.call - ('Photoshop.create_group', - name=enhanced_name)) + ret = self.websocketserver.call( + self.client.call('Photoshop.create_group', name=enhanced_name) + ) # create group on PS is asynchronous, returns only id return PSItem(id=ret, name=name, group=True) def group_selected_layers(self, name): - """ - Group selected layers into new LayerSet (eg. group) - Returns: (Layer) + """Group selected layers into new LayerSet (eg. group) + + Returns: + (Layer) """ enhanced_name = self.PUBLISH_ICON + name - res = self.websocketserver.call(self.client.call - ('Photoshop.group_selected_layers', - name=enhanced_name) - ) + res = self.websocketserver.call( + self.client.call( + 'Photoshop.group_selected_layers', name=enhanced_name + ) + ) res = self._to_records(res) if res: rec = res.pop() @@ -228,103 +232,112 @@ class PhotoshopServerStub: raise ValueError("No group record returned") def get_selected_layers(self): - """ - Get a list of actually selected layers + """Get a list of actually selected layers. + Returns: """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_selected_layers')) + res = self.websocketserver.call( + self.client.call('Photoshop.get_selected_layers') + ) return self._to_records(res) def select_layers(self, layers): - """ - Selects specified layers in Photoshop by its ids + """Selects specified layers in Photoshop by its ids. + Args: layers: - Returns: None """ layers_id = [str(lay.id) for lay in layers] - self.websocketserver.call(self.client.call - ('Photoshop.select_layers', - layers=json.dumps(layers_id)) - ) + self.websocketserver.call( + self.client.call( + 'Photoshop.select_layers', + layers=json.dumps(layers_id) + ) + ) def get_active_document_full_name(self): - """ - Returns full name with path of active document via ws call - Returns(string): full path with name + """Returns full name with path of active document via ws call + + Returns(string): + full path with name """ res = self.websocketserver.call( - self.client.call('Photoshop.get_active_document_full_name')) + self.client.call('Photoshop.get_active_document_full_name') + ) return res def get_active_document_name(self): - """ - Returns just a name of active document via ws call - Returns(string): file name - """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_active_document_name')) + """Returns just a name of active document via ws call - return res + Returns(string): + file name + """ + return self.websocketserver.call( + self.client.call('Photoshop.get_active_document_name') + ) def is_saved(self): + """Returns true if no changes in active document + + Returns: + """ - Returns true if no changes in active document - Returns: - """ - return self.websocketserver.call(self.client.call - ('Photoshop.is_saved')) + return self.websocketserver.call( + self.client.call('Photoshop.is_saved') + ) def save(self): - """ - Saves active document - Returns: None - """ - self.websocketserver.call(self.client.call - ('Photoshop.save')) + """Saves active document""" + self.websocketserver.call( + self.client.call('Photoshop.save') + ) def saveAs(self, image_path, ext, as_copy): - """ - Saves active document to psd (copy) or png or jpg + """Saves active document to psd (copy) or png or jpg + Args: image_path(string): full local path ext: as_copy: Returns: None """ - self.websocketserver.call(self.client.call - ('Photoshop.saveAs', - image_path=image_path, - ext=ext, - as_copy=as_copy)) + self.websocketserver.call( + self.client.call( + 'Photoshop.saveAs', + image_path=image_path, + ext=ext, + as_copy=as_copy + ) + ) def set_visible(self, layer_id, visibility): - """ - Set layer with 'layer_id' to 'visibility' + """Set layer with 'layer_id' to 'visibility' + Args: layer_id: visibility: Returns: None """ - self.websocketserver.call(self.client.call - ('Photoshop.set_visible', - layer_id=layer_id, - visibility=visibility)) + self.websocketserver.call( + self.client.call( + 'Photoshop.set_visible', + layer_id=layer_id, + visibility=visibility + ) + ) def get_layers_metadata(self): - """ - Reads layers metadata from Headline from active document in PS. - (Headline accessible by File > File Info) + """Reads layers metadata from Headline from active document in PS. + (Headline accessible by File > File Info) - Returns: - (string): - json documents - example: - {"8":{"active":true,"subset":"imageBG", - "family":"image","id":"pyblish.avalon.instance", - "asset":"Town"}} - 8 is layer(group) id - used for deletion, update etc. + Returns: + (string): - json documents + example: + {"8":{"active":true,"subset":"imageBG", + "family":"image","id":"pyblish.avalon.instance", + "asset":"Town"}} + 8 is layer(group) id - used for deletion, update etc. """ layers_data = {} res = self.websocketserver.call(self.client.call('Photoshop.read')) @@ -337,8 +350,10 @@ class PhotoshopServerStub: if not isinstance(layers_data, dict): temp_layers_meta = {} for layer_meta in layers_data: - layer_id = layer_meta.get("uuid") or \ - (layer_meta.get("members")[0]) + layer_id = layer_meta.get("uuid") + if not layer_id: + layer_id = layer_meta.get("members")[0] + temp_layers_meta[layer_id] = layer_meta layers_data = temp_layers_meta else: @@ -352,8 +367,7 @@ class PhotoshopServerStub: return layers_data def import_smart_object(self, path, layer_name, as_reference=False): - """ - Import the file at `path` as a smart object to active document. + """Import the file at `path` as a smart object to active document. Args: path (str): File path to import. @@ -362,53 +376,62 @@ class PhotoshopServerStub: as_reference (bool): pull in content or reference """ enhanced_name = self.LOADED_ICON + layer_name - res = self.websocketserver.call(self.client.call - ('Photoshop.import_smart_object', - path=path, name=enhanced_name, - as_reference=as_reference - )) + res = self.websocketserver.call( + self.client.call( + 'Photoshop.import_smart_object', + path=path, + name=enhanced_name, + as_reference=as_reference + ) + ) rec = self._to_records(res).pop() if rec: rec.name = rec.name.replace(self.LOADED_ICON, '') return rec def replace_smart_object(self, layer, path, layer_name): - """ - Replace the smart object `layer` with file at `path` - layer_name (str): Unique layer name to differentiate how many times - same smart object was loaded + """Replace the smart object `layer` with file at `path` Args: layer (PSItem): path (str): File to import. + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded """ enhanced_name = self.LOADED_ICON + layer_name - self.websocketserver.call(self.client.call - ('Photoshop.replace_smart_object', - layer_id=layer.id, - path=path, name=enhanced_name)) + self.websocketserver.call( + self.client.call( + 'Photoshop.replace_smart_object', + layer_id=layer.id, + path=path, + name=enhanced_name + ) + ) def delete_layer(self, layer_id): - """ - Deletes specific layer by it's id. + """Deletes specific layer by it's id. + Args: layer_id (int): id of layer to delete """ - self.websocketserver.call(self.client.call - ('Photoshop.delete_layer', - layer_id=layer_id)) + self.websocketserver.call( + self.client.call('Photoshop.delete_layer', layer_id=layer_id) + ) def rename_layer(self, layer_id, name): - """ - Renames specific layer by it's id. + """Renames specific layer by it's id. + Args: layer_id (int): id of layer to delete name (str): new name """ - self.websocketserver.call(self.client.call - ('Photoshop.rename_layer', - layer_id=layer_id, - name=name)) + self.websocketserver.call( + self.client.call( + 'Photoshop.rename_layer', + layer_id=layer_id, + name=name + ) + ) def remove_instance(self, instance_id): cleaned_data = {} @@ -419,14 +442,15 @@ class PhotoshopServerStub: payload = json.dumps(cleaned_data, indent=4) - self.websocketserver.call(self.client.call - ('Photoshop.imprint', payload=payload) - ) + self.websocketserver.call( + self.client.call('Photoshop.imprint', payload=payload) + ) def get_extension_version(self): """Returns version number of installed extension.""" - return self.websocketserver.call(self.client.call - ('Photoshop.get_extension_version')) + return self.websocketserver.call( + self.client.call('Photoshop.get_extension_version') + ) def close(self): """Shutting down PS and process too. @@ -437,11 +461,12 @@ class PhotoshopServerStub: self.websocketserver.call(self.client.call('Photoshop.close')) def _to_records(self, res): - """ - Converts string json representation into list of PSItem for - dot notation access to work. + """Converts string json representation into list of PSItem for + dot notation access to work. + Args: res (string): valid json + Returns: """ @@ -456,15 +481,15 @@ class PhotoshopServerStub: layers_data = [layers_data] for d in layers_data: # currently implemented and expected fields - item = PSItem(d.get('id'), - d.get('name'), - d.get('group'), - d.get('parents'), - d.get('visible'), - d.get('type'), - d.get('members'), - d.get('long_name'), - d.get("color_code")) - - ret.append(item) + ret.append(PSItem( + d.get('id'), + d.get('name'), + d.get('group'), + d.get('parents'), + d.get('visible'), + d.get('type'), + d.get('members'), + d.get('long_name'), + d.get("color_code") + )) return ret From faf7e7bfebb0412ca360ab22373e75b53db612f9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:06:41 +0100 Subject: [PATCH 195/395] extended main thread exection --- openpype/hosts/photoshop/api/launch_logic.py | 71 +++++++++++++++++--- openpype/hosts/photoshop/api/lib.py | 8 ++- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 8e0d00636a..16a1d23244 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -2,7 +2,6 @@ import os import subprocess import collections import asyncio -import functools from wsrpc_aiohttp import ( WebSocketRoute, @@ -26,6 +25,61 @@ class ConnectionNotEstablishedYet(Exception): pass +class MainThreadItem: + """Structure to store information about callback in main thread. + + Item should be used to execute callback in main thread which may be needed + for execution of Qt objects. + + Item store callback (callable variable), arguments and keyword arguments + for the callback. Item hold information about it's process. + """ + not_set = object() + + def __init__(self, callback, *args, **kwargs): + self._done = False + self._exception = self.not_set + self._result = self.not_set + self._callback = callback + self._args = args + self._kwargs = kwargs + + @property + def done(self): + return self._done + + @property + def exception(self): + return self._exception + + @property + def result(self): + return self._result + + def execute(self): + """Execute callback and store it's result. + + Method must be called from main thread. Item is marked as `done` + when callback execution finished. Store output of callback of exception + information when callback raise one. + """ + log.debug("Executing process in main thread") + if self.done: + log.warning("- item is already processed") + return + + log.info("Running callback: {}".format(str(self._callback))) + try: + result = self._callback(*self._args, **self._kwargs) + self._result = result + + except Exception as exc: + self._exception = exc + + finally: + self._done = True + + def stub(): """ Convenience function to get server RPC stub to call methods directed @@ -113,8 +167,10 @@ class ProcessLauncher(QtCore.QObject): return None @classmethod - def execute_in_main_thread(cls, callback): - cls._main_thread_callbacks.append(callback) + def execute_in_main_thread(cls, callback, *args, **kwargs): + item = MainThreadItem(callback, *args, **kwargs) + cls._main_thread_callbacks.append(item) + return item def start(self): if self._started: @@ -145,8 +201,8 @@ class ProcessLauncher(QtCore.QObject): cls = self.__class__ for _ in range(len(cls._main_thread_callbacks)): if cls._main_thread_callbacks: - callback = cls._main_thread_callbacks.popleft() - callback() + item = cls._main_thread_callbacks.popleft() + item.execute() if not self.is_process_running: self.log.info("Host process is not running. Closing") @@ -303,10 +359,7 @@ class PhotoshopRoute(WebSocketRoute): def _tool_route(self, _tool_name): """The address accessed when clicking on the buttons.""" - partial_method = functools.partial(show_tool_by_name, - _tool_name) - - ProcessLauncher.execute_in_main_thread(partial_method) + ProcessLauncher.execute_in_main_thread(show_tool_by_name, _tool_name) # Required return statement. return "nothing" diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 0938febf43..4c80c04cae 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -34,17 +34,19 @@ def main(*subprocess_args): launcher.start() if os.environ.get("HEADLESS_PUBLISH"): - launcher.execute_in_main_thread(lambda: headless_publish( + launcher.execute_in_main_thread( + headless_publish, log, "ClosePS", - os.environ.get("IS_TEST"))) + os.environ.get("IS_TEST") + ) elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True): save = False if os.getenv("WORKFILES_SAVE_AS"): save = True launcher.execute_in_main_thread( - lambda: host_tools.show_workfiles(save=save) + host_tools.show_workfiles, save=save ) sys.exit(app.exec_()) From 1b36e7e73039169164b98cf1408134f43b634541 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:07:29 +0100 Subject: [PATCH 196/395] added code from openpype __init__.py to pipeline.py --- openpype/hosts/photoshop/api/pipeline.py | 126 +++++++++++++++++------ 1 file changed, 95 insertions(+), 31 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index ade144e6d4..ed7b94e249 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -1,8 +1,61 @@ -from .. import api, pipeline -from . import lib -from ..vendor import Qt +import os +import sys +from Qt import QtWidgets import pyblish.api +import avalon.api +from avalon import pipeline, io + +from openpype.api import Logger +import openpype.hosts.photoshop + +from . import lib + +log = Logger.get_logger(__name__) + +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.photoshop.__file__)) +PLUGINS_DIR = os.path.join(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") + + +def check_inventory(): + if not lib.any_outdated(): + return + + host = avalon.api.registered_host() + outdated_containers = [] + for container in host.ls(): + representation = container['representation'] + representation_doc = io.find_one( + { + "_id": io.ObjectId(representation), + "type": "representation" + }, + projection={"parent": True} + ) + if representation_doc and not lib.is_latest(representation_doc): + outdated_containers.append(container) + + # Warn about outdated containers. + print("Starting new QApplication..") + + message_box = QtWidgets.QMessageBox() + message_box.setIcon(QtWidgets.QMessageBox.Warning) + msg = "There are outdated containers in the scene." + message_box.setText(msg) + message_box.exec_() + + +def on_application_launch(): + check_inventory() + + +def on_pyblish_instance_toggled(instance, old_value, new_value): + """Toggle layer visibility on instance toggles.""" + instance[0].Visible = new_value def install(): @@ -10,9 +63,26 @@ def install(): This function is called automatically on calling `api.install(photoshop)`. """ - print("Installing Avalon Photoshop...") + log.info("Installing OpenPype Photoshop...") pyblish.api.register_host("photoshop") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + log.info(PUBLISH_PATH) + + pyblish.api.register_callback( + "instanceToggled", on_pyblish_instance_toggled + ) + + avalon.api.on("application.launched", on_application_launch) + + +def uninstall(): + pyblish.api.deregister_plugin_path(PUBLISH_PATH) + avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + def ls(): """Yields containers from active Photoshop document @@ -54,16 +124,14 @@ def ls(): def list_instances(): - """ - List all created instances from current workfile which - will be published. + """List all created instances to publish from current workfile. - Pulls from File > File Info + Pulls from File > File Info - For SubsetManager + For SubsetManager - Returns: - (list) of dictionaries matching instances format + Returns: + (list) of dictionaries matching instances format """ stub = _get_stub() @@ -74,8 +142,8 @@ def list_instances(): layers_meta = stub.get_layers_metadata() if layers_meta: for key, instance in layers_meta.items(): - if instance.get("schema") and \ - "container" in instance.get("schema"): + schema = instance.get("schema") + if schema and "container" in schema: continue instance['uuid'] = key @@ -85,16 +153,15 @@ def list_instances(): def remove_instance(instance): - """ - Remove instance from current workfile metadata. + """Remove instance from current workfile metadata. - Updates metadata of current file in File > File Info and removes - icon highlight on group layer. + Updates metadata of current file in File > File Info and removes + icon highlight on group layer. - For SubsetManager + For SubsetManager - Args: - instance (dict): instance representation from subsetmanager model + Args: + instance (dict): instance representation from subsetmanager model """ stub = _get_stub() @@ -109,8 +176,8 @@ def remove_instance(instance): def _get_stub(): - """ - Handle pulling stub from PS to run operations on host + """Handle pulling stub from PS to run operations on host + Returns: (PhotoshopServerStub) or None """ @@ -126,7 +193,7 @@ def _get_stub(): return stub -class Creator(api.Creator): +class Creator(avalon.api.Creator): """Creator plugin to create instances in Photoshop A LayerSet is created to support any number of layers in an instance. If @@ -140,8 +207,8 @@ class Creator(api.Creator): stub = lib.stub() # only after Photoshop is up for layer in stub.get_layers(): if self.name.lower() == layer.Name.lower(): - msg = Qt.QtWidgets.QMessageBox() - msg.setIcon(Qt.QtWidgets.QMessageBox.Warning) + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) msg.setText(msg) msg.exec_() return False @@ -160,12 +227,9 @@ class Creator(api.Creator): return group -def containerise(name, - namespace, - layer, - context, - loader=None, - suffix="_CON"): +def containerise( + name, namespace, layer, context, loader=None, suffix="_CON" +): """Imprint layer with metadata Containerisation enables a tracking of version, author and origin From 56446e0c4c4dc51ebbb600fb4656d7a1a851f0cf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:07:45 +0100 Subject: [PATCH 197/395] changed registered host --- openpype/hosts/photoshop/api/lib.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 4c80c04cae..509c5d5c48 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -1,13 +1,15 @@ import os import sys +import re import contextlib import traceback from Qt import QtWidgets -from openpype.tools.utils import host_tools +import avalon.api from openpype.api import Logger +from openpype.tools.utils import host_tools from openpype.lib.remote_publish import headless_publish from .launch_logic import ProcessLauncher, stub @@ -20,9 +22,9 @@ def safe_excepthook(*args): def main(*subprocess_args): - from avalon import api, photoshop + from openpype.hosts.photoshop import api - api.install(photoshop) + avalon.api.install(api) sys.excepthook = safe_excepthook # coloring in ConsoleTrayApp From c1d6eaa5f948f823eea373b723a405176c27278b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:08:51 +0100 Subject: [PATCH 198/395] changed import of photoshop in plugins --- openpype/hosts/photoshop/plugins/create/create_image.py | 2 +- openpype/hosts/photoshop/plugins/load/load_image.py | 3 ++- .../hosts/photoshop/plugins/load/load_image_from_sequence.py | 2 +- openpype/hosts/photoshop/plugins/load/load_reference.py | 3 ++- openpype/hosts/photoshop/plugins/publish/closePS.py | 2 +- .../hosts/photoshop/plugins/publish/collect_current_file.py | 2 +- .../photoshop/plugins/publish/collect_extension_version.py | 2 +- .../hosts/photoshop/plugins/publish/collect_instances.py | 2 +- .../photoshop/plugins/publish/collect_remote_instances.py | 5 +++-- openpype/hosts/photoshop/plugins/publish/collect_workfile.py | 2 +- openpype/hosts/photoshop/plugins/publish/extract_image.py | 2 +- openpype/hosts/photoshop/plugins/publish/extract_review.py | 2 +- .../hosts/photoshop/plugins/publish/extract_save_scene.py | 2 +- .../hosts/photoshop/plugins/publish/increment_workfile.py | 2 +- .../photoshop/plugins/publish/validate_instance_asset.py | 2 +- openpype/hosts/photoshop/plugins/publish/validate_naming.py | 2 +- 16 files changed, 20 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 657d41aa93..cf41bb4020 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -1,6 +1,6 @@ from Qt import QtWidgets import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CreateImage(openpype.api.Creator): diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 981a1ed204..3756eba54e 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -1,6 +1,7 @@ import re -from avalon import api, photoshop +from avalon import api +from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index 8704627b12..158bdc2940 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -1,12 +1,12 @@ import os from avalon import api -from avalon import photoshop from avalon.pipeline import get_representation_path_from_context from avalon.vendor import qargparse from openpype.lib import Anatomy from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name +from openpype.hosts.photoshop import api as photoshop stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 0cb4e4a69f..844bb2463a 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -1,7 +1,8 @@ import re -from avalon import api, photoshop +from avalon import api +from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/publish/closePS.py b/openpype/hosts/photoshop/plugins/publish/closePS.py index 2f0eab0ee5..b4ded96001 100644 --- a/openpype/hosts/photoshop/plugins/publish/closePS.py +++ b/openpype/hosts/photoshop/plugins/publish/closePS.py @@ -4,7 +4,7 @@ import os import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ClosePS(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_current_file.py b/openpype/hosts/photoshop/plugins/publish/collect_current_file.py index 4d4829555e..5daf47c6ac 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_current_file.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_current_file.py @@ -2,7 +2,7 @@ import os import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectCurrentFile(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py b/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py index f07ff0b0ff..64c99b4fc1 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py @@ -2,7 +2,7 @@ import os import re import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectExtensionVersion(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index 5390df768b..f67cc0cbac 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -1,6 +1,6 @@ import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py index c76e15484e..e264d04d9f 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py @@ -1,10 +1,11 @@ -import pyblish.api import os import re -from avalon import photoshop +import pyblish.api + from openpype.lib import prepare_template_data from openpype.lib.plugin_tools import parse_json +from openpype.hosts.photoshop import api as photoshop class CollectRemoteInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index 88817c3969..db1ede14d5 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -1,5 +1,5 @@ -import pyblish.api import os +import pyblish.api class CollectWorkfile(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index ae9892e290..2ba81e0bac 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -1,7 +1,7 @@ import os import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractImage(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 8c4d05b282..1ad442279a 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -2,7 +2,7 @@ import os import openpype.api import openpype.lib -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractReview(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py b/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py index 0180640c90..03086f389f 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py @@ -1,5 +1,5 @@ import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractSaveScene(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/increment_workfile.py b/openpype/hosts/photoshop/plugins/publish/increment_workfile.py index 709fb988fc..92132c393b 100644 --- a/openpype/hosts/photoshop/plugins/publish/increment_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/increment_workfile.py @@ -3,7 +3,7 @@ import pyblish.api from openpype.action import get_errored_plugins_from_data from openpype.lib import version_up -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class IncrementWorkfile(pyblish.api.InstancePlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py b/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py index 4dc1972074..ebe9cc21ea 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py @@ -1,7 +1,7 @@ from avalon import api import pyblish.api import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ValidateInstanceAssetRepair(pyblish.api.Action): diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py index 1635096f4b..b40e44d016 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py @@ -2,7 +2,7 @@ import re import pyblish.api import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ValidateNamingRepair(pyblish.api.Action): From 551d40b62487618f2c821c40b034c93c92ae2bce Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:09:08 +0100 Subject: [PATCH 199/395] changed from where is 'main' imported --- openpype/scripts/non_python_host_launch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/scripts/non_python_host_launch.py b/openpype/scripts/non_python_host_launch.py index 32c4b23f4f..6b17e6a037 100644 --- a/openpype/scripts/non_python_host_launch.py +++ b/openpype/scripts/non_python_host_launch.py @@ -81,7 +81,7 @@ def main(argv): host_name = os.environ["AVALON_APP"].lower() if host_name == "photoshop": - from avalon.photoshop.lib import main + from openpype.hosts.photoshop.api.lib import main elif host_name == "aftereffects": from avalon.aftereffects.lib import main elif host_name == "harmony": From d7bc9c4124c38b4f5ae804508939afaf48f572d1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:17:50 +0100 Subject: [PATCH 200/395] moved lib functions outside of plugins dir --- openpype/hosts/photoshop/api/__init__.py | 7 ++++++- openpype/hosts/photoshop/{plugins/lib.py => api/plugin.py} | 0 openpype/hosts/photoshop/plugins/__init__.py | 0 openpype/hosts/photoshop/plugins/load/load_image.py | 2 +- .../photoshop/plugins/load/load_image_from_sequence.py | 2 +- openpype/hosts/photoshop/plugins/load/load_reference.py | 2 +- 6 files changed, 9 insertions(+), 4 deletions(-) rename openpype/hosts/photoshop/{plugins/lib.py => api/plugin.py} (100%) delete mode 100644 openpype/hosts/photoshop/plugins/__init__.py diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index 43756b9ee4..a25dfe7044 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -12,7 +12,9 @@ from .pipeline import ( install, containerise ) - +from .plugin import ( + get_unique_layer_name +) from .workio import ( file_extensions, has_unsaved_changes, @@ -38,6 +40,9 @@ __all__ = [ "install", "containerise", + # Plugin + "get_unique_layer_name", + # workfiles "file_extensions", "has_unsaved_changes", diff --git a/openpype/hosts/photoshop/plugins/lib.py b/openpype/hosts/photoshop/api/plugin.py similarity index 100% rename from openpype/hosts/photoshop/plugins/lib.py rename to openpype/hosts/photoshop/api/plugin.py diff --git a/openpype/hosts/photoshop/plugins/__init__.py b/openpype/hosts/photoshop/plugins/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 3756eba54e..25f47b0257 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -2,8 +2,8 @@ import re from avalon import api from openpype.hosts.photoshop import api as photoshop +from openpype.hosts.photoshop.api import get_unique_layer_name -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index 158bdc2940..bbf4c60242 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -5,8 +5,8 @@ from avalon.pipeline import get_representation_path_from_context from avalon.vendor import qargparse from openpype.lib import Anatomy -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name from openpype.hosts.photoshop import api as photoshop +from openpype.hosts.photoshop.api import get_unique_layer_name stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 844bb2463a..0f3c148155 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -3,7 +3,7 @@ import re from avalon import api from openpype.hosts.photoshop import api as photoshop -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name +from openpype.hosts.photoshop.api import get_unique_layer_name stub = photoshop.stub() From ab1b2bdd7d1f82360a2e066fc704581af6b3fd66 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:19:53 +0100 Subject: [PATCH 201/395] moved getting of stub to default photoshop loader class instead of loading it in global scope --- openpype/hosts/photoshop/api/__init__.py | 12 +++--- openpype/hosts/photoshop/api/lib.py | 1 - openpype/hosts/photoshop/api/plugin.py | 9 +++++ .../photoshop/plugins/load/load_image.py | 27 +++++++------ .../plugins/load/load_image_from_sequence.py | 20 ++++------ .../photoshop/plugins/load/load_reference.py | 38 ++++++++++--------- 6 files changed, 60 insertions(+), 47 deletions(-) diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index a25dfe7044..17a371f002 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -4,6 +4,8 @@ Anything that isn't defined here is INTERNAL and unreliable for external use. """ +from .launch_logic import stub + from .pipeline import ( ls, list_instances, @@ -13,6 +15,7 @@ from .pipeline import ( containerise ) from .plugin import ( + PhotoshopLoader, get_unique_layer_name ) from .workio import ( @@ -29,9 +32,10 @@ from .lib import ( maintained_visibility ) -from .launch_logic import stub - __all__ = [ + # launch_logic + "stub" + # pipeline "ls", "list_instances", @@ -41,6 +45,7 @@ __all__ = [ "containerise", # Plugin + "PhotoshopLoader", "get_unique_layer_name", # workfiles @@ -54,7 +59,4 @@ __all__ = [ # lib "maintained_selection", "maintained_visibility", - - # launch_logic - "stub" ] diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 509c5d5c48..707cd476c5 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -1,6 +1,5 @@ import os import sys -import re import contextlib import traceback diff --git a/openpype/hosts/photoshop/api/plugin.py b/openpype/hosts/photoshop/api/plugin.py index 74aff06114..c577c67d82 100644 --- a/openpype/hosts/photoshop/api/plugin.py +++ b/openpype/hosts/photoshop/api/plugin.py @@ -1,5 +1,8 @@ import re +import avalon.api +from .launch_logic import stub + def get_unique_layer_name(layers, asset_name, subset_name): """ @@ -24,3 +27,9 @@ def get_unique_layer_name(layers, asset_name, subset_name): occurrences = names.get(name, 0) return "{}_{:0>3d}".format(name, occurrences + 1) + + +class PhotoshopLoader(avalon.api.Loader): + @staticmethod + def get_stub(): + return stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 25f47b0257..3b1cfe9636 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -5,9 +5,7 @@ from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name -stub = photoshop.stub() - -class ImageLoader(api.Loader): +class ImageLoader(photoshop.PhotoshopLoader): """Load images Stores the imported asset in a container named after the asset. @@ -17,11 +15,14 @@ class ImageLoader(api.Loader): representations = ["*"] def load(self, context, name=None, namespace=None, data=None): - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), + context["asset"]["name"], + name + ) with photoshop.maintained_selection(): - layer = self.import_layer(self.fname, layer_name) + layer = self.import_layer(self.fname, layer_name, stub) self[:] = [layer] namespace = namespace or layer_name @@ -36,6 +37,8 @@ class ImageLoader(api.Loader): def update(self, container, representation): """ Switch asset or change version """ + stub = self.get_stub() + layer = container.pop("layer") context = representation.get("context", {}) @@ -45,9 +48,9 @@ class ImageLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"], - context["subset"]) + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"], context["subset"] + ) else: # switching version - keep same name layer_name = container["namespace"] @@ -67,6 +70,8 @@ class ImageLoader(api.Loader): Args: container (dict): container to be removed - used to get layer_id """ + stub = self.get_stub() + layer = container.pop("layer") stub.imprint(layer, {}) stub.delete_layer(layer.id) @@ -74,5 +79,5 @@ class ImageLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) - def import_layer(self, file_name, layer_name): + def import_layer(self, file_name, layer_name, stub): return stub.import_smart_object(file_name, layer_name) diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index bbf4c60242..ab4682e63e 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -1,17 +1,13 @@ import os -from avalon import api from avalon.pipeline import get_representation_path_from_context from avalon.vendor import qargparse -from openpype.lib import Anatomy from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name -stub = photoshop.stub() - -class ImageFromSequenceLoader(api.Loader): +class ImageFromSequenceLoader(photoshop.PhotoshopLoader): """ Load specifing image from sequence Used only as quick load of reference file from a sequence. @@ -35,15 +31,16 @@ class ImageFromSequenceLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): if data.get("frame"): - self.fname = os.path.join(os.path.dirname(self.fname), - data["frame"]) + self.fname = os.path.join( + os.path.dirname(self.fname), data["frame"] + ) if not os.path.exists(self.fname): return - stub = photoshop.stub() - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"]["name"], name + ) with photoshop.maintained_selection(): layer = stub.import_smart_object(self.fname, layer_name) @@ -95,4 +92,3 @@ class ImageFromSequenceLoader(api.Loader): def remove(self, container): """No update possible, not containerized.""" pass - diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 0f3c148155..60142d4a1f 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -5,27 +5,26 @@ from avalon import api from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name -stub = photoshop.stub() - -class ReferenceLoader(api.Loader): +class ReferenceLoader(photoshop.PhotoshopLoader): """Load reference images - Stores the imported asset in a container named after the asset. + Stores the imported asset in a container named after the asset. - Inheriting from 'load_image' didn't work because of - "Cannot write to closing transport", possible refactor. + Inheriting from 'load_image' didn't work because of + "Cannot write to closing transport", possible refactor. """ families = ["image", "render"] representations = ["*"] def load(self, context, name=None, namespace=None, data=None): - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"]["name"], name + ) with photoshop.maintained_selection(): - layer = self.import_layer(self.fname, layer_name) + layer = self.import_layer(self.fname, layer_name, stub) self[:] = [layer] namespace = namespace or layer_name @@ -40,6 +39,7 @@ class ReferenceLoader(api.Loader): def update(self, container, representation): """ Switch asset or change version """ + stub = self.get_stub() layer = container.pop("layer") context = representation.get("context", {}) @@ -49,9 +49,9 @@ class ReferenceLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"], - context["subset"]) + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"], context["subset"] + ) else: # switching version - keep same name layer_name = container["namespace"] @@ -66,11 +66,12 @@ class ReferenceLoader(api.Loader): ) def remove(self, container): - """ - Removes element from scene: deletes layer + removes from Headline + """Removes element from scene: deletes layer + removes from Headline + Args: container (dict): container to be removed - used to get layer_id """ + stub = self.get_stub() layer = container.pop("layer") stub.imprint(layer, {}) stub.delete_layer(layer.id) @@ -78,6 +79,7 @@ class ReferenceLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) - def import_layer(self, file_name, layer_name): - return stub.import_smart_object(file_name, layer_name, - as_reference=True) + def import_layer(self, file_name, layer_name, stub): + return stub.import_smart_object( + file_name, layer_name, as_reference=True + ) From c2b6cf8714a8caf5d6e49efbb44b386fdcdf713a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:38:32 +0100 Subject: [PATCH 202/395] fixed init file --- openpype/hosts/photoshop/api/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index 17a371f002..4cc2aa2c78 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -10,12 +10,13 @@ from .pipeline import ( ls, list_instances, remove_instance, - Creator, install, + uninstall, containerise ) from .plugin import ( PhotoshopLoader, + Creator, get_unique_layer_name ) from .workio import ( @@ -34,18 +35,18 @@ from .lib import ( __all__ = [ # launch_logic - "stub" + "stub", # pipeline "ls", "list_instances", "remove_instance", - "Creator", "install", "containerise", # Plugin "PhotoshopLoader", + "Creator", "get_unique_layer_name", # workfiles From b6a5123210d8f278cbb320f7378bce877c798949 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:38:43 +0100 Subject: [PATCH 203/395] moved Creator to plugin.py --- openpype/hosts/photoshop/api/pipeline.py | 34 ------------------------ openpype/hosts/photoshop/api/plugin.py | 34 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index ed7b94e249..25983f2471 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -193,40 +193,6 @@ def _get_stub(): return stub -class Creator(avalon.api.Creator): - """Creator plugin to create instances in Photoshop - - A LayerSet is created to support any number of layers in an instance. If - the selection is used, these layers will be added to the LayerSet. - """ - - def process(self): - # Photoshop can have multiple LayerSets with the same name, which does - # not work with Avalon. - msg = "Instance with name \"{}\" already exists.".format(self.name) - stub = lib.stub() # only after Photoshop is up - for layer in stub.get_layers(): - if self.name.lower() == layer.Name.lower(): - msg = QtWidgets.QMessageBox() - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setText(msg) - msg.exec_() - return False - - # Store selection because adding a group will change selection. - with lib.maintained_selection(): - - # Add selection to group. - if (self.options or {}).get("useSelection"): - group = stub.group_selected_layers(self.name) - else: - group = stub.create_group(self.name) - - stub.imprint(group, self.data) - - return group - - def containerise( name, namespace, layer, context, loader=None, suffix="_CON" ): diff --git a/openpype/hosts/photoshop/api/plugin.py b/openpype/hosts/photoshop/api/plugin.py index c577c67d82..e0db67de2c 100644 --- a/openpype/hosts/photoshop/api/plugin.py +++ b/openpype/hosts/photoshop/api/plugin.py @@ -33,3 +33,37 @@ class PhotoshopLoader(avalon.api.Loader): @staticmethod def get_stub(): return stub() + + +class Creator(avalon.api.Creator): + """Creator plugin to create instances in Photoshop + + A LayerSet is created to support any number of layers in an instance. If + the selection is used, these layers will be added to the LayerSet. + """ + + def process(self): + # Photoshop can have multiple LayerSets with the same name, which does + # not work with Avalon. + msg = "Instance with name \"{}\" already exists.".format(self.name) + stub = lib.stub() # only after Photoshop is up + for layer in stub.get_layers(): + if self.name.lower() == layer.Name.lower(): + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) + msg.setText(msg) + msg.exec_() + return False + + # Store selection because adding a group will change selection. + with lib.maintained_selection(): + + # Add selection to group. + if (self.options or {}).get("useSelection"): + group = stub.group_selected_layers(self.name) + else: + group = stub.create_group(self.name) + + stub.imprint(group, self.data) + + return group From 3e53a45bfadc50ae5dd2167f714ebba657edeec2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:20:27 +0100 Subject: [PATCH 204/395] Flame: collect timeline ocio plugin --- .../plugins/publish/precollect_workfile.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/precollect_workfile.py diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py new file mode 100644 index 0000000000..0533d01e00 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -0,0 +1,26 @@ +import pyblish.api +import openpype.hosts.flame.api as opfapi +from openpype.hosts.flame.otio import flame_export + + +class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): + """Inject the current working context into publish context""" + + label = "Precollect Timeline OTIO" + order = pyblish.api.CollectorOrder - 0.5 + + def process(self, context): + project = opfapi.get_current_project() + sequence = opfapi.get_current_sequence(opfapi.CTX.selection) + + # adding otio timeline to context + otio_timeline = flame_export.create_otio_timeline(sequence) + + # update context with main project attributes + context.data.update({ + "otioTimeline": otio_timeline, + "currentFile": "Flame/{}/{}".format( + project.name, sequence.name + ), + "fps": float(str(sequence.frame_rate)[:-4]) + }) From 104b57120c64d3095c492848adca11a47a958749 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:32:35 +0100 Subject: [PATCH 205/395] Flame: collect instance in otio timeline plugin --- .../flame/plugins/publish/precollect_workfile.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 0533d01e00..3497d19d15 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -1,4 +1,5 @@ import pyblish.api +import avalon.api as avalon import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export @@ -10,12 +11,25 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.5 def process(self, context): + asset = avalon.Session["AVALON_ASSET"] + subset = "otioTimeline" project = opfapi.get_current_project() sequence = opfapi.get_current_sequence(opfapi.CTX.selection) # adding otio timeline to context otio_timeline = flame_export.create_otio_timeline(sequence) + instance_data = { + "name": "{}_{}".format(asset, subset), + "asset": asset, + "subset": "{}{}".format(asset, subset.capitalize()), + "family": "workfile" + } + + # create instance with workfile + instance = context.create_instance(**instance_data) + self.log.info("Creating instance: {}".format(instance)) + # update context with main project attributes context.data.update({ "otioTimeline": otio_timeline, From 9e70f67f4716d8af3956af3486ffc47256b9db96 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:32:53 +0100 Subject: [PATCH 206/395] Flame: exctracting otio file --- .../plugins/publish/extract_otio_file.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/extract_otio_file.py diff --git a/openpype/hosts/flame/plugins/publish/extract_otio_file.py b/openpype/hosts/flame/plugins/publish/extract_otio_file.py new file mode 100644 index 0000000000..7dd75974fc --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/extract_otio_file.py @@ -0,0 +1,43 @@ +import os +import pyblish.api +import openpype.api +import opentimelineio as otio + + +class ExtractOTIOFile(openpype.api.Extractor): + """ + Extractor export OTIO file + """ + + label = "Extract OTIO file" + order = pyblish.api.ExtractorOrder - 0.45 + families = ["workfile"] + hosts = ["flame"] + + def process(self, instance): + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + name = instance.data["name"] + staging_dir = self.staging_dir(instance) + + otio_timeline = instance.context.data["otioTimeline"] + # create otio timeline representation + otio_file_name = name + ".otio" + otio_file_path = os.path.join(staging_dir, otio_file_name) + + # export otio file to temp dir + otio.adapters.write_to_file(otio_timeline, otio_file_path) + + representation_otio = { + 'name': "otio", + 'ext': "otio", + 'files': otio_file_name, + "stagingDir": staging_dir, + } + + instance.data["representations"].append(representation_otio) + + self.log.info("Added OTIO file representation: {}".format( + representation_otio)) From 100ff46421ceb688e7d2e20dec58f43d20f5902b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 11 Jan 2022 13:27:16 +0100 Subject: [PATCH 207/395] OP-2049 - fix frame content for sequence starting with 0 Previously expression didn't trigger as repre.get("frameStart") returned 0 which translated into False --- openpype/plugins/publish/integrate_new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 1b0b8da2ff..cec2e470b3 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -580,7 +580,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): if repre.get("outputName"): representation["context"]["output"] = repre['outputName'] - if sequence_repre and repre.get("frameStart"): + if sequence_repre and repre.get("frameStart") is not None: representation['context']['frame'] = ( dst_padding_exp % int(repre.get("frameStart")) ) From 32ceb9e9a98fa662bab525be4b8a007f4e8624f6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:24:09 +0100 Subject: [PATCH 208/395] flame: enhancing code of api lib --- openpype/hosts/flame/api/lib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 2cc9fee173..787ecf4569 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -448,6 +448,8 @@ def get_sequence_segments(sequence, selected=False): for segment in track.segments: if segment.name.get_value() == "": continue + if segment.hidden: + continue if ( selected is True and segment.selected.get_value() is not True @@ -522,7 +524,7 @@ def _get_shot_tokens_values(clip, tokens): def get_segment_attributes(segment): - if str(segment.name)[1:-1] == "": + if segment.name.get_value() == "": return None # Add timeline segment to tree From 02af9b69a195dca87a109fbfd28880372f4feaf4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:25:16 +0100 Subject: [PATCH 209/395] flame: adding flameSequnce attribute to publishing context --- openpype/hosts/flame/plugins/publish/precollect_workfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 3497d19d15..3d2ce97755 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -32,6 +32,7 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): # update context with main project attributes context.data.update({ + "flameSequence": sequence, "otioTimeline": otio_timeline, "currentFile": "Flame/{}/{}".format( project.name, sequence.name From 281ae76794f2c04ba9081c402b8632bb37b3cafc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:55:18 +0100 Subject: [PATCH 210/395] flame: adding functions to lib and api --- openpype/hosts/flame/api/__init__.py | 10 ++- openpype/hosts/flame/api/lib.py | 114 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index dc47488dc1..308682b884 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -23,7 +23,11 @@ from .lib import ( get_sequence_segments, maintained_segment_selection, reset_segment_selection, - get_segment_attributes + get_segment_attributes, + get_clips_in_reels, + get_reformated_path, + get_frame_from_path, + get_padding_from_path ) from .utils import ( setup @@ -80,6 +84,10 @@ __all__ = [ "maintained_segment_selection", "reset_segment_selection", "get_segment_attributes", + "get_clips_in_reels", + "get_reformated_path", + "get_frame_from_path", + "get_padding_from_path", # pipeline "install", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 787ecf4569..4404f7a612 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -537,6 +537,12 @@ def get_segment_attributes(segment): "PySegment": segment } + # head and tail with forward compatibility + if segment.head: + clip_data["segment_head"] = int(segment.head) + if segment.tail: + clip_data["segment_tail"] = int(segment.tail) + # add all available shot tokens shot_tokens = _get_shot_tokens_values(segment, [ "", "", "", "", "", @@ -564,3 +570,111 @@ def get_segment_attributes(segment): clip_data["segment_timecodes"] = segment_attrs_data return clip_data + + +def get_clips_in_reels(project): + output_clips = [] + project_desktop = project.current_workspace.desktop + + for reel_group in project_desktop.reel_groups: + for reel in reel_group.reels: + for clip in reel.clips: + clip_data = { + "PyClip": clip, + "fps": float(str(clip.frame_rate)[:-4]) + } + + attrs = [ + "name", "width", "height", + "ratio", "sample_rate", "bit_depth" + ] + + for attr in attrs: + val = getattr(clip, attr) + clip_data[attr] = val + + version = clip.versions[-1] + track = version.tracks[-1] + for segment in track.segments: + segment_data = get_segment_attributes(segment) + clip_data.update(segment_data) + + output_clips.append(clip_data) + + return output_clips + + +def get_reformated_path(path, padded=True): + """ + Return fixed python expression path + + Args: + path (str): path url or simple file name + + Returns: + type: string with reformated path + + Example: + get_reformated_path("plate.1001.exr") > plate.%04d.exr + + """ + padding = get_padding_from_path(path) + found = get_frame_from_path(path) + + if not found: + log.info("Path is not sequence: {}".format(path)) + return path + + if padded: + path = path.replace(found, "%0{}d".format(padding)) + else: + path = path.replace(found, "%d") + + return path + + +def get_padding_from_path(path): + """ + Return padding number from Flame path style + + Args: + path (str): path url or simple file name + + Returns: + int: padding number + + Example: + get_padding_from_path("plate.0001.exr") > 4 + + """ + found = get_frame_from_path(path) + + if found: + return len(found) + else: + return None + + +def get_frame_from_path(path): + """ + Return sequence number from Flame path style + + Args: + path (str): path url or simple file name + + Returns: + int: sequence frame number + + Example: + def get_frame_from_path(path): + ("plate.0001.exr") > 0001 + + """ + frame_pattern = re.compile(r"[._](\d+)[.]") + + found = re.findall(frame_pattern, path) + + if found: + return found.pop() + else: + return None From 50e1cbf31e38e7923aceba97da4d1d37eee7c47c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:55:40 +0100 Subject: [PATCH 211/395] flame: adding flameProject to publishing context attributes --- openpype/hosts/flame/plugins/publish/precollect_workfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 3d2ce97755..e7383ddec8 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -32,6 +32,7 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): # update context with main project attributes context.data.update({ + "flameProject": project, "flameSequence": sequence, "otioTimeline": otio_timeline, "currentFile": "Flame/{}/{}".format( From 460048ef4c1a5b6c90ef8161f6394acb85a95d0c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:58:12 +0100 Subject: [PATCH 212/395] flame: collect instances wip --- .../plugins/publish/precollect_instances.py | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/precollect_instances.py diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py new file mode 100644 index 0000000000..5f3b71eba4 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -0,0 +1,251 @@ +import pyblish +# import openpype +import openpype.hosts.flame.api as opfapi + +# # developer reload modules +from pprint import pformat + + +class PrecollectInstances(pyblish.api.ContextPlugin): + """Collect all Track items selection.""" + + order = pyblish.api.CollectorOrder - 0.49 + label = "Precollect Instances" + hosts = ["flame"] + + audio_track_items = [] + + def process(self, context): + project = context.data["flameProject"] + sequence = context.data["flameSequence"] + self.otio_timeline = context.data["otioTimeline"] + self.clips_in_reels = opfapi.get_clips_in_reels(project) + + # return only actually selected and enabled segments + selected_segments = opfapi.get_sequence_segments(sequence, True) + + # only return enabled segments + if not selected_segments: + selected_segments = opfapi.get_sequence_segments( + sequence) + + self.log.info( + "Processing following segments: {}".format( + [s.name for s in selected_segments])) + + # process all sellected timeline track items + for segment in selected_segments: + + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) + + # get openpype tag data + marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format(pformat(marker_data))) + + if not marker_data: + continue + + if marker_data.get("id") != "pyblish.avalon.instance": + continue + + file_path = clip_data["fpath"] + first_frame = opfapi.get_frame_from_path(file_path) or 0 + + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") + + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) + ) + + # solve handles length + marker_data["handleStart"] = min( + marker_data["handleStart"], head) + marker_data["handleEnd"] = min( + marker_data["handleEnd"], tail) + + # add audio to families + with_audio = False + if marker_data.pop("audio"): + with_audio = True + + # add tag data to instance data + data = { + k: v for k, v in marker_data.items() + if k not in ("id", "applieswhole", "label") + } + + asset = marker_data["asset"] + subset = marker_data["subset"] + + # insert family into families + family = marker_data["family"] + families = [str(f) for f in marker_data["families"]] + families.insert(0, str(family)) + + # form label + label = asset + if asset != clip_name: + label += " ({})".format(clip_name) + label += " {}".format(subset) + label += " {}".format("[" + ", ".join(families) + "]") + + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "asset": asset, + "item": segment, + "families": families, + "publish": marker_data["publish"], + "fps": context.data["fps"], + }) + + # # otio clip data + # otio_data = self.get_otio_clip_instance_data(segment) or {} + # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # data.update(otio_data) + # self.log.debug("__ data: {}".format(pformat(data))) + + # # add resolution + # self.get_resolution_to_data(data, context) + + # create instance + instance = context.create_instance(**data) + + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": clip_data["colour_space"], + } + }) + + # create shot instance for shot attributes create/update + self.create_shot_instance(context, clip_name, **data) + + self.log.info("Creating instance: {}".format(instance)) + self.log.info( + "_ instance.data: {}".format(pformat(instance.data))) + + if not with_audio: + continue + + # add audioReview attribute to plate instance data + # if reviewTrack is on + if marker_data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True + + def get_resolution_to_data(self, data, context): + assert data.get("otioClip"), "Missing `otioClip` data" + + # solve source resolution option + if data.get("sourceResolution", None): + otio_clip_metadata = data[ + "otioClip"].media_reference.metadata + data.update({ + "resolutionWidth": otio_clip_metadata[ + "openpype.source.width"], + "resolutionHeight": otio_clip_metadata[ + "openpype.source.height"], + "pixelAspect": otio_clip_metadata[ + "openpype.source.pixelAspect"] + }) + else: + otio_tl_metadata = context.data["otioTimeline"].metadata + data.update({ + "resolutionWidth": otio_tl_metadata["openpype.timeline.width"], + "resolutionHeight": otio_tl_metadata[ + "openpype.timeline.height"], + "pixelAspect": otio_tl_metadata[ + "openpype.timeline.pixelAspect"] + }) + + def create_shot_instance(self, context, clip_name, **data): + master_layer = data.get("heroTrack") + hierarchy_data = data.get("hierarchyData") + asset = data.get("asset") + + if not master_layer: + return + + if not hierarchy_data: + return + + asset = data["asset"] + subset = "shotMain" + + # insert family into families + family = "shot" + + # form label + label = asset + if asset != clip_name: + label += " ({}) ".format(clip_name) + label += " {}".format(subset) + label += " [{}]".format(family) + + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "subset": subset, + "asset": asset, + "family": family, + "families": [] + }) + + instance = context.create_instance(**data) + self.log.info("Creating instance: {}".format(instance)) + self.log.debug( + "_ instance.data: {}".format(pformat(instance.data))) + + # def get_otio_clip_instance_data(self, segment): + # """ + # Return otio objects for timeline, track and clip + + # Args: + # timeline_item_data (dict): timeline_item_data from list returned by + # resolve.get_current_timeline_items() + # otio_timeline (otio.schema.Timeline): otio object + + # Returns: + # dict: otio clip object + + # """ + # ti_track_name = segment.parent().name() + # timeline_range = self.create_otio_time_range_from_timeline_item_data( + # segment) + # for otio_clip in self.otio_timeline.each_clip(): + # track_name = otio_clip.parent().name + # parent_range = otio_clip.range_in_parent() + # if ti_track_name not in track_name: + # continue + # if otio_clip.name not in segment.name(): + # continue + # if openpype.lib.is_overlapping_otio_ranges( + # parent_range, timeline_range, strict=True): + + # # add pypedata marker to otio_clip metadata + # for marker in otio_clip.markers: + # if phiero.pype_tag_name in marker.name: + # otio_clip.metadata.update(marker.metadata) + # return {"otioClip": otio_clip} + + # return None + + # @staticmethod + # def create_otio_time_range_from_timeline_item_data(segment): + # speed = segment.playbackSpeed() + # timeline = phiero.get_current_sequence() + # frame_start = int(segment.timelineIn()) + # frame_duration = int(segment.sourceDuration() / speed) + # fps = timeline.framerate().toFloat() + + # return hiero_export.create_otio_time_range( + # frame_start, frame_duration, fps) From 4fa7eb25ffabc6f83a7af09400f42d3e61addbb3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 16:32:00 +0100 Subject: [PATCH 213/395] flame: fix selection --- openpype/hosts/flame/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 4404f7a612..a409e731e3 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -448,7 +448,7 @@ def get_sequence_segments(sequence, selected=False): for segment in track.segments: if segment.name.get_value() == "": continue - if segment.hidden: + if segment.hidden.get_value() is True: continue if ( selected is True From a326ab429040c799ac6b45683b326aba65da3fc4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:01:39 +0100 Subject: [PATCH 214/395] flame: deactivating test plugin --- openpype/hosts/flame/plugins/publish/collect_test_selection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 0c75b3204f..84fd4fafe8 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -14,6 +14,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder label = "test selection" hosts = ["flame"] + active = False def process(self, context): self.log.info( From 1669f1782b08c2906dc2c0a705e66bda8031e73c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:03:03 +0100 Subject: [PATCH 215/395] flame: adding maintained selection to publish plugins --- .../plugins/publish/precollect_instances.py | 198 ++++++++---------- .../plugins/publish/precollect_workfile.py | 3 +- 2 files changed, 95 insertions(+), 106 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index 5f3b71eba4..e302bc42a4 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -21,126 +21,114 @@ class PrecollectInstances(pyblish.api.ContextPlugin): self.otio_timeline = context.data["otioTimeline"] self.clips_in_reels = opfapi.get_clips_in_reels(project) - # return only actually selected and enabled segments - selected_segments = opfapi.get_sequence_segments(sequence, True) + # process all sellected + with opfapi.maintained_segment_selection(sequence) as selected_segments: + for segment in selected_segments: + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) - # only return enabled segments - if not selected_segments: - selected_segments = opfapi.get_sequence_segments( - sequence) + # get openpype tag data + marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format(pformat(marker_data))) - self.log.info( - "Processing following segments: {}".format( - [s.name for s in selected_segments])) + if not marker_data: + continue - # process all sellected timeline track items - for segment in selected_segments: + if marker_data.get("id") != "pyblish.avalon.instance": + continue - clip_data = opfapi.get_segment_attributes(segment) - clip_name = clip_data["segment_name"] - self.log.debug("clip_name: {}".format(clip_name)) + file_path = clip_data["fpath"] + first_frame = opfapi.get_frame_from_path(file_path) or 0 - # get openpype tag data - marker_data = opfapi.get_segment_data_marker(segment) - self.log.debug("__ marker_data: {}".format(pformat(marker_data))) + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") - if not marker_data: - continue - - if marker_data.get("id") != "pyblish.avalon.instance": - continue - - file_path = clip_data["fpath"] - first_frame = opfapi.get_frame_from_path(file_path) or 0 - - # calculate head and tail with forward compatibility - head = clip_data.get("segment_head") - tail = clip_data.get("segment_tail") - - if not head: - head = int(clip_data["source_in"]) - int(first_frame) - if not tail: - tail = int( - clip_data["source_duration"] - ( - head + clip_data["record_duration"] + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) ) - ) - # solve handles length - marker_data["handleStart"] = min( - marker_data["handleStart"], head) - marker_data["handleEnd"] = min( - marker_data["handleEnd"], tail) + # solve handles length + marker_data["handleStart"] = min( + marker_data["handleStart"], head) + marker_data["handleEnd"] = min( + marker_data["handleEnd"], tail) - # add audio to families - with_audio = False - if marker_data.pop("audio"): - with_audio = True + # add audio to families + with_audio = False + if marker_data.pop("audio"): + with_audio = True - # add tag data to instance data - data = { - k: v for k, v in marker_data.items() - if k not in ("id", "applieswhole", "label") - } - - asset = marker_data["asset"] - subset = marker_data["subset"] - - # insert family into families - family = marker_data["family"] - families = [str(f) for f in marker_data["families"]] - families.insert(0, str(family)) - - # form label - label = asset - if asset != clip_name: - label += " ({})".format(clip_name) - label += " {}".format(subset) - label += " {}".format("[" + ", ".join(families) + "]") - - data.update({ - "name": "{}_{}".format(asset, subset), - "label": label, - "asset": asset, - "item": segment, - "families": families, - "publish": marker_data["publish"], - "fps": context.data["fps"], - }) - - # # otio clip data - # otio_data = self.get_otio_clip_instance_data(segment) or {} - # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) - # data.update(otio_data) - # self.log.debug("__ data: {}".format(pformat(data))) - - # # add resolution - # self.get_resolution_to_data(data, context) - - # create instance - instance = context.create_instance(**data) - - # add colorspace data - instance.data.update({ - "versionData": { - "colorspace": clip_data["colour_space"], + # add tag data to instance data + data = { + k: v for k, v in marker_data.items() + if k not in ("id", "applieswhole", "label") } - }) - # create shot instance for shot attributes create/update - self.create_shot_instance(context, clip_name, **data) + asset = marker_data["asset"] + subset = marker_data["subset"] - self.log.info("Creating instance: {}".format(instance)) - self.log.info( - "_ instance.data: {}".format(pformat(instance.data))) + # insert family into families + family = marker_data["family"] + families = [str(f) for f in marker_data["families"]] + families.insert(0, str(family)) - if not with_audio: - continue + # form label + label = asset + if asset != clip_name: + label += " ({})".format(clip_name) + label += " {}".format(subset) + label += " {}".format("[" + ", ".join(families) + "]") - # add audioReview attribute to plate instance data - # if reviewTrack is on - if marker_data.get("reviewTrack") is not None: - instance.data["reviewAudio"] = True + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "asset": asset, + "item": segment, + "families": families, + "publish": marker_data["publish"], + "fps": context.data["fps"], + }) + + # # otio clip data + # otio_data = self.get_otio_clip_instance_data(segment) or {} + # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # data.update(otio_data) + # self.log.debug("__ data: {}".format(pformat(data))) + + # # add resolution + # self.get_resolution_to_data(data, context) + + # create instance + instance = context.create_instance(**data) + + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": clip_data["colour_space"], + } + }) + + # create shot instance for shot attributes create/update + self.create_shot_instance(context, clip_name, **data) + + self.log.info("Creating instance: {}".format(instance)) + self.log.info( + "_ instance.data: {}".format(pformat(instance.data))) + + if not with_audio: + continue + + # add audioReview attribute to plate instance data + # if reviewTrack is on + if marker_data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True def get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index e7383ddec8..aff85e22e6 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -17,7 +17,8 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): sequence = opfapi.get_current_sequence(opfapi.CTX.selection) # adding otio timeline to context - otio_timeline = flame_export.create_otio_timeline(sequence) + with opfapi.maintained_segment_selection(sequence): + otio_timeline = flame_export.create_otio_timeline(sequence) instance_data = { "name": "{}_{}".format(asset, subset), From 74a4fbfea60ff34866000a49962a26d40e6b9fd8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:22:24 +0100 Subject: [PATCH 216/395] flame: testing export of otio timeline --- .../flame/plugins/publish/collect_test_selection.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 0c75b3204f..3e3ff27035 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -1,7 +1,9 @@ import os import pyblish.api +import tempfile import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export as otio_export +import opentimelineio as otio from pprint import pformat reload(otio_export) # noqa @@ -25,16 +27,22 @@ class CollectTestSelection(pyblish.api.ContextPlugin): self.test_otio_export(sequence) def test_otio_export(self, sequence): - home_dir = os.path.expanduser("~") + test_dir = os.path.normpath( + tempfile.mkdtemp(prefix="test_pyblish_tmp_") + ) export_path = os.path.normpath( os.path.join( - home_dir, "otio_timeline_export.otio" + test_dir, "otio_timeline_export.otio" ) ) otio_timeline = otio_export.create_otio_timeline(sequence) otio_export.write_to_file( otio_timeline, export_path ) + read_timeline_otio = otio.adapters.read_from_file(export_path) + + if otio_timeline != read_timeline_otio: + raise Exception("Exported otio timeline is different from original") self.log.info(pformat(otio_timeline)) self.log.info("Otio exported to: {}".format(export_path)) From 896ba23730a0fdedbffe5820e036d058a7e36d39 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:23:56 +0100 Subject: [PATCH 217/395] flame: hound fixes --- .../hosts/flame/plugins/publish/collect_test_selection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 3e3ff27035..73401368b1 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -28,7 +28,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): def test_otio_export(self, sequence): test_dir = os.path.normpath( - tempfile.mkdtemp(prefix="test_pyblish_tmp_") + tempfile.mkdtemp(prefix="test_pyblish_tmp_") ) export_path = os.path.normpath( os.path.join( @@ -42,7 +42,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): read_timeline_otio = otio.adapters.read_from_file(export_path) if otio_timeline != read_timeline_otio: - raise Exception("Exported otio timeline is different from original") + raise Exception("Exported timeline is different from original") self.log.info(pformat(otio_timeline)) self.log.info("Otio exported to: {}".format(export_path)) From 66cedb61e929ae475ce525bb538a8d3166471a52 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:29:11 +0100 Subject: [PATCH 218/395] flame: addressing CTX.apps renamed to CTX.flame_apps --- openpype/hosts/flame/api/lib.py | 2 +- .../api/utility_scripts/openpype_in_flame.py | 23 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 2cc9fee173..3ec57c6434 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -20,7 +20,7 @@ log = Logger.get_logger(__name__) class CTX: # singleton used for passing data between api modules app_framework = None - apps = [] + flame_apps = [] selection = None diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py index c385fbb8cb..72614f2b5d 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py @@ -45,14 +45,14 @@ sys.excepthook = exeption_handler def cleanup(): """Cleaning up Flame framework context """ - if opfapi.CTX.apps: - print('`{}` cleaning up apps:\n {}\n'.format( - __file__, pformat(opfapi.CTX.apps))) - while len(opfapi.CTX.apps): - app = opfapi.CTX.apps.pop() + if opfapi.CTX.flame_apps: + print('`{}` cleaning up flame_apps:\n {}\n'.format( + __file__, pformat(opfapi.CTX.flame_apps))) + while len(opfapi.CTX.flame_apps): + app = opfapi.CTX.flame_apps.pop() print('`{}` removing : {}'.format(__file__, app.name)) del app - opfapi.CTX.apps = [] + opfapi.CTX.flame_apps = [] if opfapi.CTX.app_framework: print('openpype\t: {} cleaning up'.format( @@ -66,11 +66,12 @@ atexit.register(cleanup) def load_apps(): - """Load available apps into Flame framework + """Load available flame_apps into Flame framework """ - opfapi.CTX.apps.append( + opfapi.CTX.flame_apps.append( opfapi.FlameMenuProjectConnect(opfapi.CTX.app_framework)) - opfapi.CTX.apps.append(opfapi.FlameMenuTimeline(opfapi.CTX.app_framework)) + opfapi.CTX.flame_apps.append( + opfapi.FlameMenuTimeline(opfapi.CTX.app_framework)) opfapi.CTX.app_framework.log.info("Apps are loaded") @@ -103,7 +104,7 @@ Initialisation of the hook is starting from here First it needs to test if it can import the flame modul. This will happen only in case a project has been loaded. Then `app_initialized` will load main Framework which will load -all menu objects as apps. +all menu objects as flame_apps. """ try: @@ -131,7 +132,7 @@ def _build_app_menu(app_name): # first find the relative appname app = None - for _app in opfapi.CTX.apps: + for _app in opfapi.CTX.flame_apps: if _app.__class__.__name__ == app_name: app = _app From c025d4c8e4dc5f4c9c4f68e209e990db05f35a89 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:34:02 +0100 Subject: [PATCH 219/395] flame: removing constant True return --- openpype/hosts/flame/api/lib.py | 5 +---- openpype/hosts/flame/api/pipeline.py | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 3ec57c6434..dd212297e2 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -374,8 +374,6 @@ def set_segment_data_marker(segment, data=None): # add tag data to marker's comment marker.comment = json.dumps(data) - return True - def set_publish_attribute(segment, value): """ Set Publish attribute in input Tag object @@ -388,8 +386,7 @@ def set_publish_attribute(segment, value): tag_data["publish"] = value # set data to the publish attribute - if not set_segment_data_marker(segment, tag_data): - raise AttributeError("Not imprint data to segment") + set_segment_data_marker(segment, tag_data) def get_publish_attribute(segment): diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index b65c85f5df..30c70b491b 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -144,8 +144,7 @@ def imprint(segment, data=None): """ data = data or {} - if not set_segment_data_marker(segment, data): - raise AttributeError("Not imprint data to segment") + set_segment_data_marker(segment, data) # add publish attribute set_publish_attribute(segment, True) From 384edda56fba3ab6ea20b351a81e3fd8e5ea65e8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:39:25 +0100 Subject: [PATCH 220/395] flame: improving code from suggestion --- openpype/hosts/flame/api/lib.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index dd212297e2..7788a6b3f4 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -545,16 +545,16 @@ def get_segment_attributes(segment): "source_duration", "source_in", "source_out" ] segment_attrs_data = {} - for attr in segment_attrs: - if not hasattr(segment, attr): + for attr_name in segment_attrs: + if not hasattr(segment, attr_name): continue - _value = getattr(segment, attr) - segment_attrs_data[attr] = str(_value).replace("+", ":") + attr = getattr(segment, attr_name) + segment_attrs_data[attr] = str(attr).replace("+", ":") if attr in ["record_in", "record_out"]: - clip_data[attr] = _value.relative_frame + clip_data[attr_name] = attr.relative_frame else: - clip_data[attr] = _value.frame + clip_data[attr_name] = attr.frame clip_data["segment_timecodes"] = segment_attrs_data From 74958ba642643dc78988ad1b6b9fbfcaa2127148 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:20:27 +0100 Subject: [PATCH 221/395] Flame: collect timeline ocio plugin --- .../plugins/publish/precollect_workfile.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/precollect_workfile.py diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py new file mode 100644 index 0000000000..0533d01e00 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -0,0 +1,26 @@ +import pyblish.api +import openpype.hosts.flame.api as opfapi +from openpype.hosts.flame.otio import flame_export + + +class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): + """Inject the current working context into publish context""" + + label = "Precollect Timeline OTIO" + order = pyblish.api.CollectorOrder - 0.5 + + def process(self, context): + project = opfapi.get_current_project() + sequence = opfapi.get_current_sequence(opfapi.CTX.selection) + + # adding otio timeline to context + otio_timeline = flame_export.create_otio_timeline(sequence) + + # update context with main project attributes + context.data.update({ + "otioTimeline": otio_timeline, + "currentFile": "Flame/{}/{}".format( + project.name, sequence.name + ), + "fps": float(str(sequence.frame_rate)[:-4]) + }) From 4f4efea936d4a198fe1b220c07e71ae77a065621 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:32:35 +0100 Subject: [PATCH 222/395] Flame: collect instance in otio timeline plugin --- .../flame/plugins/publish/precollect_workfile.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 0533d01e00..3497d19d15 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -1,4 +1,5 @@ import pyblish.api +import avalon.api as avalon import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export @@ -10,12 +11,25 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.5 def process(self, context): + asset = avalon.Session["AVALON_ASSET"] + subset = "otioTimeline" project = opfapi.get_current_project() sequence = opfapi.get_current_sequence(opfapi.CTX.selection) # adding otio timeline to context otio_timeline = flame_export.create_otio_timeline(sequence) + instance_data = { + "name": "{}_{}".format(asset, subset), + "asset": asset, + "subset": "{}{}".format(asset, subset.capitalize()), + "family": "workfile" + } + + # create instance with workfile + instance = context.create_instance(**instance_data) + self.log.info("Creating instance: {}".format(instance)) + # update context with main project attributes context.data.update({ "otioTimeline": otio_timeline, From 65fe3a28bb74b26055913fe909208fd6e97becdf Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:32:53 +0100 Subject: [PATCH 223/395] Flame: exctracting otio file --- .../plugins/publish/extract_otio_file.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/extract_otio_file.py diff --git a/openpype/hosts/flame/plugins/publish/extract_otio_file.py b/openpype/hosts/flame/plugins/publish/extract_otio_file.py new file mode 100644 index 0000000000..7dd75974fc --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/extract_otio_file.py @@ -0,0 +1,43 @@ +import os +import pyblish.api +import openpype.api +import opentimelineio as otio + + +class ExtractOTIOFile(openpype.api.Extractor): + """ + Extractor export OTIO file + """ + + label = "Extract OTIO file" + order = pyblish.api.ExtractorOrder - 0.45 + families = ["workfile"] + hosts = ["flame"] + + def process(self, instance): + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + name = instance.data["name"] + staging_dir = self.staging_dir(instance) + + otio_timeline = instance.context.data["otioTimeline"] + # create otio timeline representation + otio_file_name = name + ".otio" + otio_file_path = os.path.join(staging_dir, otio_file_name) + + # export otio file to temp dir + otio.adapters.write_to_file(otio_timeline, otio_file_path) + + representation_otio = { + 'name': "otio", + 'ext': "otio", + 'files': otio_file_name, + "stagingDir": staging_dir, + } + + instance.data["representations"].append(representation_otio) + + self.log.info("Added OTIO file representation: {}".format( + representation_otio)) From 42bdd8db7f3c1127f376372f9c5c12bc70daad89 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:24:09 +0100 Subject: [PATCH 224/395] flame: enhancing code of api lib --- openpype/hosts/flame/api/lib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 7788a6b3f4..b5c7f2031b 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -445,6 +445,8 @@ def get_sequence_segments(sequence, selected=False): for segment in track.segments: if segment.name.get_value() == "": continue + if segment.hidden: + continue if ( selected is True and segment.selected.get_value() is not True @@ -519,7 +521,7 @@ def _get_shot_tokens_values(clip, tokens): def get_segment_attributes(segment): - if str(segment.name)[1:-1] == "": + if segment.name.get_value() == "": return None # Add timeline segment to tree From eb6c6a5c9fc96cd8596484f06ab91b5bbad1db64 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:25:16 +0100 Subject: [PATCH 225/395] flame: adding flameSequnce attribute to publishing context --- openpype/hosts/flame/plugins/publish/precollect_workfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 3497d19d15..3d2ce97755 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -32,6 +32,7 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): # update context with main project attributes context.data.update({ + "flameSequence": sequence, "otioTimeline": otio_timeline, "currentFile": "Flame/{}/{}".format( project.name, sequence.name From 093015bf34b438d66c6b773c248bf7d67168a6ae Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:55:18 +0100 Subject: [PATCH 226/395] flame: adding functions to lib and api --- openpype/hosts/flame/api/__init__.py | 10 ++- openpype/hosts/flame/api/lib.py | 114 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index dc47488dc1..308682b884 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -23,7 +23,11 @@ from .lib import ( get_sequence_segments, maintained_segment_selection, reset_segment_selection, - get_segment_attributes + get_segment_attributes, + get_clips_in_reels, + get_reformated_path, + get_frame_from_path, + get_padding_from_path ) from .utils import ( setup @@ -80,6 +84,10 @@ __all__ = [ "maintained_segment_selection", "reset_segment_selection", "get_segment_attributes", + "get_clips_in_reels", + "get_reformated_path", + "get_frame_from_path", + "get_padding_from_path", # pipeline "install", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index b5c7f2031b..b204230d9a 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -534,6 +534,12 @@ def get_segment_attributes(segment): "PySegment": segment } + # head and tail with forward compatibility + if segment.head: + clip_data["segment_head"] = int(segment.head) + if segment.tail: + clip_data["segment_tail"] = int(segment.tail) + # add all available shot tokens shot_tokens = _get_shot_tokens_values(segment, [ "", "", "", "", "", @@ -561,3 +567,111 @@ def get_segment_attributes(segment): clip_data["segment_timecodes"] = segment_attrs_data return clip_data + + +def get_clips_in_reels(project): + output_clips = [] + project_desktop = project.current_workspace.desktop + + for reel_group in project_desktop.reel_groups: + for reel in reel_group.reels: + for clip in reel.clips: + clip_data = { + "PyClip": clip, + "fps": float(str(clip.frame_rate)[:-4]) + } + + attrs = [ + "name", "width", "height", + "ratio", "sample_rate", "bit_depth" + ] + + for attr in attrs: + val = getattr(clip, attr) + clip_data[attr] = val + + version = clip.versions[-1] + track = version.tracks[-1] + for segment in track.segments: + segment_data = get_segment_attributes(segment) + clip_data.update(segment_data) + + output_clips.append(clip_data) + + return output_clips + + +def get_reformated_path(path, padded=True): + """ + Return fixed python expression path + + Args: + path (str): path url or simple file name + + Returns: + type: string with reformated path + + Example: + get_reformated_path("plate.1001.exr") > plate.%04d.exr + + """ + padding = get_padding_from_path(path) + found = get_frame_from_path(path) + + if not found: + log.info("Path is not sequence: {}".format(path)) + return path + + if padded: + path = path.replace(found, "%0{}d".format(padding)) + else: + path = path.replace(found, "%d") + + return path + + +def get_padding_from_path(path): + """ + Return padding number from Flame path style + + Args: + path (str): path url or simple file name + + Returns: + int: padding number + + Example: + get_padding_from_path("plate.0001.exr") > 4 + + """ + found = get_frame_from_path(path) + + if found: + return len(found) + else: + return None + + +def get_frame_from_path(path): + """ + Return sequence number from Flame path style + + Args: + path (str): path url or simple file name + + Returns: + int: sequence frame number + + Example: + def get_frame_from_path(path): + ("plate.0001.exr") > 0001 + + """ + frame_pattern = re.compile(r"[._](\d+)[.]") + + found = re.findall(frame_pattern, path) + + if found: + return found.pop() + else: + return None From 402b18640967070ad8fb2079f7ec0d92fb5a222b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:55:40 +0100 Subject: [PATCH 227/395] flame: adding flameProject to publishing context attributes --- openpype/hosts/flame/plugins/publish/precollect_workfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 3d2ce97755..e7383ddec8 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -32,6 +32,7 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): # update context with main project attributes context.data.update({ + "flameProject": project, "flameSequence": sequence, "otioTimeline": otio_timeline, "currentFile": "Flame/{}/{}".format( From 70d31f2ef16b9a97e03c5f956b344c64bb25c1df Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:58:12 +0100 Subject: [PATCH 228/395] flame: collect instances wip --- .../plugins/publish/precollect_instances.py | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/precollect_instances.py diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py new file mode 100644 index 0000000000..5f3b71eba4 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -0,0 +1,251 @@ +import pyblish +# import openpype +import openpype.hosts.flame.api as opfapi + +# # developer reload modules +from pprint import pformat + + +class PrecollectInstances(pyblish.api.ContextPlugin): + """Collect all Track items selection.""" + + order = pyblish.api.CollectorOrder - 0.49 + label = "Precollect Instances" + hosts = ["flame"] + + audio_track_items = [] + + def process(self, context): + project = context.data["flameProject"] + sequence = context.data["flameSequence"] + self.otio_timeline = context.data["otioTimeline"] + self.clips_in_reels = opfapi.get_clips_in_reels(project) + + # return only actually selected and enabled segments + selected_segments = opfapi.get_sequence_segments(sequence, True) + + # only return enabled segments + if not selected_segments: + selected_segments = opfapi.get_sequence_segments( + sequence) + + self.log.info( + "Processing following segments: {}".format( + [s.name for s in selected_segments])) + + # process all sellected timeline track items + for segment in selected_segments: + + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) + + # get openpype tag data + marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format(pformat(marker_data))) + + if not marker_data: + continue + + if marker_data.get("id") != "pyblish.avalon.instance": + continue + + file_path = clip_data["fpath"] + first_frame = opfapi.get_frame_from_path(file_path) or 0 + + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") + + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) + ) + + # solve handles length + marker_data["handleStart"] = min( + marker_data["handleStart"], head) + marker_data["handleEnd"] = min( + marker_data["handleEnd"], tail) + + # add audio to families + with_audio = False + if marker_data.pop("audio"): + with_audio = True + + # add tag data to instance data + data = { + k: v for k, v in marker_data.items() + if k not in ("id", "applieswhole", "label") + } + + asset = marker_data["asset"] + subset = marker_data["subset"] + + # insert family into families + family = marker_data["family"] + families = [str(f) for f in marker_data["families"]] + families.insert(0, str(family)) + + # form label + label = asset + if asset != clip_name: + label += " ({})".format(clip_name) + label += " {}".format(subset) + label += " {}".format("[" + ", ".join(families) + "]") + + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "asset": asset, + "item": segment, + "families": families, + "publish": marker_data["publish"], + "fps": context.data["fps"], + }) + + # # otio clip data + # otio_data = self.get_otio_clip_instance_data(segment) or {} + # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # data.update(otio_data) + # self.log.debug("__ data: {}".format(pformat(data))) + + # # add resolution + # self.get_resolution_to_data(data, context) + + # create instance + instance = context.create_instance(**data) + + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": clip_data["colour_space"], + } + }) + + # create shot instance for shot attributes create/update + self.create_shot_instance(context, clip_name, **data) + + self.log.info("Creating instance: {}".format(instance)) + self.log.info( + "_ instance.data: {}".format(pformat(instance.data))) + + if not with_audio: + continue + + # add audioReview attribute to plate instance data + # if reviewTrack is on + if marker_data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True + + def get_resolution_to_data(self, data, context): + assert data.get("otioClip"), "Missing `otioClip` data" + + # solve source resolution option + if data.get("sourceResolution", None): + otio_clip_metadata = data[ + "otioClip"].media_reference.metadata + data.update({ + "resolutionWidth": otio_clip_metadata[ + "openpype.source.width"], + "resolutionHeight": otio_clip_metadata[ + "openpype.source.height"], + "pixelAspect": otio_clip_metadata[ + "openpype.source.pixelAspect"] + }) + else: + otio_tl_metadata = context.data["otioTimeline"].metadata + data.update({ + "resolutionWidth": otio_tl_metadata["openpype.timeline.width"], + "resolutionHeight": otio_tl_metadata[ + "openpype.timeline.height"], + "pixelAspect": otio_tl_metadata[ + "openpype.timeline.pixelAspect"] + }) + + def create_shot_instance(self, context, clip_name, **data): + master_layer = data.get("heroTrack") + hierarchy_data = data.get("hierarchyData") + asset = data.get("asset") + + if not master_layer: + return + + if not hierarchy_data: + return + + asset = data["asset"] + subset = "shotMain" + + # insert family into families + family = "shot" + + # form label + label = asset + if asset != clip_name: + label += " ({}) ".format(clip_name) + label += " {}".format(subset) + label += " [{}]".format(family) + + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "subset": subset, + "asset": asset, + "family": family, + "families": [] + }) + + instance = context.create_instance(**data) + self.log.info("Creating instance: {}".format(instance)) + self.log.debug( + "_ instance.data: {}".format(pformat(instance.data))) + + # def get_otio_clip_instance_data(self, segment): + # """ + # Return otio objects for timeline, track and clip + + # Args: + # timeline_item_data (dict): timeline_item_data from list returned by + # resolve.get_current_timeline_items() + # otio_timeline (otio.schema.Timeline): otio object + + # Returns: + # dict: otio clip object + + # """ + # ti_track_name = segment.parent().name() + # timeline_range = self.create_otio_time_range_from_timeline_item_data( + # segment) + # for otio_clip in self.otio_timeline.each_clip(): + # track_name = otio_clip.parent().name + # parent_range = otio_clip.range_in_parent() + # if ti_track_name not in track_name: + # continue + # if otio_clip.name not in segment.name(): + # continue + # if openpype.lib.is_overlapping_otio_ranges( + # parent_range, timeline_range, strict=True): + + # # add pypedata marker to otio_clip metadata + # for marker in otio_clip.markers: + # if phiero.pype_tag_name in marker.name: + # otio_clip.metadata.update(marker.metadata) + # return {"otioClip": otio_clip} + + # return None + + # @staticmethod + # def create_otio_time_range_from_timeline_item_data(segment): + # speed = segment.playbackSpeed() + # timeline = phiero.get_current_sequence() + # frame_start = int(segment.timelineIn()) + # frame_duration = int(segment.sourceDuration() / speed) + # fps = timeline.framerate().toFloat() + + # return hiero_export.create_otio_time_range( + # frame_start, frame_duration, fps) From da1bb80b62d8e606e5c1b5bdc1fa0a53685c3fba Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 16:32:00 +0100 Subject: [PATCH 229/395] flame: fix selection --- openpype/hosts/flame/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index b204230d9a..e53127503b 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -445,7 +445,7 @@ def get_sequence_segments(sequence, selected=False): for segment in track.segments: if segment.name.get_value() == "": continue - if segment.hidden: + if segment.hidden.get_value() is True: continue if ( selected is True From 8f786f325541e5b8282eef515789333044727a8e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:01:39 +0100 Subject: [PATCH 230/395] flame: deactivating test plugin --- openpype/hosts/flame/plugins/publish/collect_test_selection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 73401368b1..9f982321cc 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -16,6 +16,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder label = "test selection" hosts = ["flame"] + active = False def process(self, context): self.log.info( From 28341de97f283f51043530b581fb7a34ffb6337a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:03:03 +0100 Subject: [PATCH 231/395] flame: adding maintained selection to publish plugins --- .../plugins/publish/precollect_instances.py | 198 ++++++++---------- .../plugins/publish/precollect_workfile.py | 3 +- 2 files changed, 95 insertions(+), 106 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index 5f3b71eba4..e302bc42a4 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -21,126 +21,114 @@ class PrecollectInstances(pyblish.api.ContextPlugin): self.otio_timeline = context.data["otioTimeline"] self.clips_in_reels = opfapi.get_clips_in_reels(project) - # return only actually selected and enabled segments - selected_segments = opfapi.get_sequence_segments(sequence, True) + # process all sellected + with opfapi.maintained_segment_selection(sequence) as selected_segments: + for segment in selected_segments: + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) - # only return enabled segments - if not selected_segments: - selected_segments = opfapi.get_sequence_segments( - sequence) + # get openpype tag data + marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format(pformat(marker_data))) - self.log.info( - "Processing following segments: {}".format( - [s.name for s in selected_segments])) + if not marker_data: + continue - # process all sellected timeline track items - for segment in selected_segments: + if marker_data.get("id") != "pyblish.avalon.instance": + continue - clip_data = opfapi.get_segment_attributes(segment) - clip_name = clip_data["segment_name"] - self.log.debug("clip_name: {}".format(clip_name)) + file_path = clip_data["fpath"] + first_frame = opfapi.get_frame_from_path(file_path) or 0 - # get openpype tag data - marker_data = opfapi.get_segment_data_marker(segment) - self.log.debug("__ marker_data: {}".format(pformat(marker_data))) + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") - if not marker_data: - continue - - if marker_data.get("id") != "pyblish.avalon.instance": - continue - - file_path = clip_data["fpath"] - first_frame = opfapi.get_frame_from_path(file_path) or 0 - - # calculate head and tail with forward compatibility - head = clip_data.get("segment_head") - tail = clip_data.get("segment_tail") - - if not head: - head = int(clip_data["source_in"]) - int(first_frame) - if not tail: - tail = int( - clip_data["source_duration"] - ( - head + clip_data["record_duration"] + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) ) - ) - # solve handles length - marker_data["handleStart"] = min( - marker_data["handleStart"], head) - marker_data["handleEnd"] = min( - marker_data["handleEnd"], tail) + # solve handles length + marker_data["handleStart"] = min( + marker_data["handleStart"], head) + marker_data["handleEnd"] = min( + marker_data["handleEnd"], tail) - # add audio to families - with_audio = False - if marker_data.pop("audio"): - with_audio = True + # add audio to families + with_audio = False + if marker_data.pop("audio"): + with_audio = True - # add tag data to instance data - data = { - k: v for k, v in marker_data.items() - if k not in ("id", "applieswhole", "label") - } - - asset = marker_data["asset"] - subset = marker_data["subset"] - - # insert family into families - family = marker_data["family"] - families = [str(f) for f in marker_data["families"]] - families.insert(0, str(family)) - - # form label - label = asset - if asset != clip_name: - label += " ({})".format(clip_name) - label += " {}".format(subset) - label += " {}".format("[" + ", ".join(families) + "]") - - data.update({ - "name": "{}_{}".format(asset, subset), - "label": label, - "asset": asset, - "item": segment, - "families": families, - "publish": marker_data["publish"], - "fps": context.data["fps"], - }) - - # # otio clip data - # otio_data = self.get_otio_clip_instance_data(segment) or {} - # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) - # data.update(otio_data) - # self.log.debug("__ data: {}".format(pformat(data))) - - # # add resolution - # self.get_resolution_to_data(data, context) - - # create instance - instance = context.create_instance(**data) - - # add colorspace data - instance.data.update({ - "versionData": { - "colorspace": clip_data["colour_space"], + # add tag data to instance data + data = { + k: v for k, v in marker_data.items() + if k not in ("id", "applieswhole", "label") } - }) - # create shot instance for shot attributes create/update - self.create_shot_instance(context, clip_name, **data) + asset = marker_data["asset"] + subset = marker_data["subset"] - self.log.info("Creating instance: {}".format(instance)) - self.log.info( - "_ instance.data: {}".format(pformat(instance.data))) + # insert family into families + family = marker_data["family"] + families = [str(f) for f in marker_data["families"]] + families.insert(0, str(family)) - if not with_audio: - continue + # form label + label = asset + if asset != clip_name: + label += " ({})".format(clip_name) + label += " {}".format(subset) + label += " {}".format("[" + ", ".join(families) + "]") - # add audioReview attribute to plate instance data - # if reviewTrack is on - if marker_data.get("reviewTrack") is not None: - instance.data["reviewAudio"] = True + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "asset": asset, + "item": segment, + "families": families, + "publish": marker_data["publish"], + "fps": context.data["fps"], + }) + + # # otio clip data + # otio_data = self.get_otio_clip_instance_data(segment) or {} + # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # data.update(otio_data) + # self.log.debug("__ data: {}".format(pformat(data))) + + # # add resolution + # self.get_resolution_to_data(data, context) + + # create instance + instance = context.create_instance(**data) + + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": clip_data["colour_space"], + } + }) + + # create shot instance for shot attributes create/update + self.create_shot_instance(context, clip_name, **data) + + self.log.info("Creating instance: {}".format(instance)) + self.log.info( + "_ instance.data: {}".format(pformat(instance.data))) + + if not with_audio: + continue + + # add audioReview attribute to plate instance data + # if reviewTrack is on + if marker_data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True def get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index e7383ddec8..aff85e22e6 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -17,7 +17,8 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): sequence = opfapi.get_current_sequence(opfapi.CTX.selection) # adding otio timeline to context - otio_timeline = flame_export.create_otio_timeline(sequence) + with opfapi.maintained_segment_selection(sequence): + otio_timeline = flame_export.create_otio_timeline(sequence) instance_data = { "name": "{}_{}".format(asset, subset), From dbf9c6899632c3ec0ed11da1a1d0e17d35a70dc4 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 12 Jan 2022 03:43:49 +0000 Subject: [PATCH 232/395] [Automated] Bump version --- CHANGELOG.md | 13 ++++++++++--- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ab087690..e92c16dc5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.8.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.8.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) @@ -10,23 +10,29 @@ **🚀 Enhancements** +- Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510) +- TimersManager: Move module one hierarchy higher [\#2501](https://github.com/pypeclub/OpenPype/pull/2501) - Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) - Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) +- Project Manager: Remove project button cleanup [\#2482](https://github.com/pypeclub/OpenPype/pull/2482) - Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) - Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) - Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) - Maya: Validate Rig Controllers - fix Error: in script editor [\#2459](https://github.com/pypeclub/OpenPype/pull/2459) - Maya: Optimize Validate Locked Normals speed for dense polymeshes [\#2457](https://github.com/pypeclub/OpenPype/pull/2457) +- Fix \#2453 Refactor missing \_get\_reference\_node method [\#2455](https://github.com/pypeclub/OpenPype/pull/2455) +- Houdini: Remove broken unique name counter [\#2450](https://github.com/pypeclub/OpenPype/pull/2450) +- Maya: Improve lib.polyConstraint performance when Select tool is not the active tool context [\#2447](https://github.com/pypeclub/OpenPype/pull/2447) - Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) **🐛 Bug fixes** - General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) +- General: PYTHONPATH may break OpenPype dependencies [\#2493](https://github.com/pypeclub/OpenPype/pull/2493) - Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) - General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) - Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) - General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) -- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) **Merged pull requests:** @@ -63,7 +69,6 @@ - Maya add render image path to settings [\#2375](https://github.com/pypeclub/OpenPype/pull/2375) - Hiero: python3 compatibility [\#2365](https://github.com/pypeclub/OpenPype/pull/2365) - Maya: Add is\_static\_image\_plane and is\_in\_all\_views option in imagePlaneLoader [\#2356](https://github.com/pypeclub/OpenPype/pull/2356) -- TVPaint: Move implementation to OpenPype [\#2336](https://github.com/pypeclub/OpenPype/pull/2336) **🐛 Bug fixes** @@ -80,7 +85,9 @@ - hiero: solve custom ocio path [\#2379](https://github.com/pypeclub/OpenPype/pull/2379) - hiero: fix workio and flatten [\#2378](https://github.com/pypeclub/OpenPype/pull/2378) - Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374) +- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) - Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369) +- Tools: Placeholder color [\#2359](https://github.com/pypeclub/OpenPype/pull/2359) - Houdini: Fix HDA creation [\#2350](https://github.com/pypeclub/OpenPype/pull/2350) **Merged pull requests:** diff --git a/openpype/version.py b/openpype/version.py index ed0a96d4de..1f005d6952 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.8.0-nightly.2" +__version__ = "3.8.0-nightly.3" diff --git a/pyproject.toml b/pyproject.toml index 0ef447e0be..f9155f05a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.8.0-nightly.2" # OpenPype +version = "3.8.0-nightly.3" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 95be2c3bc8fc5a91ac2f65072fd56a6a30cda872 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 11:16:50 +0100 Subject: [PATCH 233/395] flame: adding pathsep instead of ";" --- openpype/hosts/flame/hooks/pre_flame_setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index d5ddafde0c..fe8acda257 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -85,7 +85,7 @@ class FlamePrelaunch(PreLaunchHook): pythonpath = self.launch_context.env.get("PYTHONPATH") # separate it explicity by `;` that is what we use in settings - new_pythonpath = self.flame_pythonpath.split(";") + new_pythonpath = self.flame_pythonpath.split(os.pathsep) new_pythonpath += pythonpath.split(os.pathsep) self.launch_context.env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) From e4368e69b1088ea3345932b9109a20a5c0d83de7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 12:25:50 +0100 Subject: [PATCH 234/395] moved nuke implementation from avalon --- openpype/hosts/nuke/api/__init__.py | 164 ++--- openpype/hosts/nuke/api/actions.py | 5 +- openpype/hosts/nuke/api/command.py | 135 ++++ openpype/hosts/nuke/api/lib.py | 616 ++++++++++++++++-- openpype/hosts/nuke/api/menu.py | 166 ----- openpype/hosts/nuke/api/pipeline.py | 421 ++++++++++++ openpype/hosts/nuke/api/plugin.py | 67 +- openpype/hosts/nuke/api/utils.py | 5 +- openpype/hosts/nuke/api/workio.py | 55 ++ .../nuke/plugins/create/create_backdrop.py | 15 +- .../nuke/plugins/create/create_camera.py | 12 +- .../hosts/nuke/plugins/create/create_gizmo.py | 26 +- .../hosts/nuke/plugins/create/create_model.py | 12 +- .../hosts/nuke/plugins/create/create_read.py | 15 +- .../plugins/create/create_write_prerender.py | 11 +- .../plugins/create/create_write_render.py | 11 +- .../nuke/plugins/create/create_write_still.py | 11 +- .../plugins/inventory/repair_old_loaders.py | 9 +- .../plugins/inventory/select_containers.py | 4 +- .../hosts/nuke/plugins/load/load_backdrop.py | 40 +- .../nuke/plugins/load/load_camera_abc.py | 18 +- openpype/hosts/nuke/plugins/load/load_clip.py | 13 +- .../hosts/nuke/plugins/load/load_effects.py | 17 +- .../nuke/plugins/load/load_effects_ip.py | 17 +- .../hosts/nuke/plugins/load/load_gizmo.py | 23 +- .../hosts/nuke/plugins/load/load_gizmo_ip.py | 31 +- .../hosts/nuke/plugins/load/load_image.py | 17 +- .../hosts/nuke/plugins/load/load_model.py | 15 +- .../nuke/plugins/load/load_script_precomp.py | 17 +- .../nuke/plugins/publish/extract_backdrop.py | 25 +- .../nuke/plugins/publish/extract_camera.py | 10 +- .../nuke/plugins/publish/extract_gizmo.py | 20 +- .../nuke/plugins/publish/extract_model.py | 13 +- .../plugins/publish/extract_ouput_node.py | 2 +- .../publish/extract_review_data_lut.py | 6 +- .../publish/extract_review_data_mov.py | 6 +- .../plugins/publish/extract_slate_frame.py | 4 +- .../nuke/plugins/publish/extract_thumbnail.py | 4 +- .../plugins/publish/precollect_instances.py | 9 +- .../plugins/publish/precollect_workfile.py | 15 +- .../nuke/plugins/publish/validate_backdrop.py | 6 +- .../nuke/plugins/publish/validate_gizmo.py | 6 +- .../publish/validate_instance_in_context.py | 13 +- .../plugins/publish/validate_write_legacy.py | 5 +- .../plugins/publish/validate_write_nodes.py | 15 +- openpype/hosts/nuke/startup/init.py | 2 + openpype/hosts/nuke/startup/menu.py | 15 +- 47 files changed, 1581 insertions(+), 563 deletions(-) create mode 100644 openpype/hosts/nuke/api/command.py delete mode 100644 openpype/hosts/nuke/api/menu.py create mode 100644 openpype/hosts/nuke/api/pipeline.py create mode 100644 openpype/hosts/nuke/api/workio.py diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index 1567189ed1..d3b7f74d6d 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -1,130 +1,52 @@ -import os -import nuke +from .workio import ( + file_extensions, + has_unsaved_changes, + save_file, + open_file, + current_file, + work_root, +) -import avalon.api -import pyblish.api -import openpype -from . import lib, menu +from .command import ( + reset_frame_range, + get_handles, + reset_resolution, + viewer_update_and_undo_stop +) -log = openpype.api.Logger().get_logger(__name__) +from .plugin import OpenPypeCreator +from .pipeline import ( + install, + uninstall, -AVALON_CONFIG = os.getenv("AVALON_CONFIG", "pype") -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.nuke.__file__)) -PLUGINS_DIR = os.path.join(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") + ls, + + containerise, + parse_container, + update_container, +) -# registering pyblish gui regarding settings in presets -if os.getenv("PYBLISH_GUI", None): - pyblish.api.register_gui(os.getenv("PYBLISH_GUI", None)) +__all__ = ( + "file_extensions", + "has_unsaved_changes", + "save_file", + "open_file", + "current_file", + "work_root", + "reset_frame_range", + "get_handles", + "reset_resolution", + "viewer_update_and_undo_stop", -def reload_config(): - """Attempt to reload pipeline at run-time. + "OpenPypeCreator", + "install", + "uninstall", - CAUTION: This is primarily for development and debugging purposes. + "ls", - """ - - import importlib - - for module in ( - "{}.api".format(AVALON_CONFIG), - "{}.hosts.nuke.api.actions".format(AVALON_CONFIG), - "{}.hosts.nuke.api.menu".format(AVALON_CONFIG), - "{}.hosts.nuke.api.plugin".format(AVALON_CONFIG), - "{}.hosts.nuke.api.lib".format(AVALON_CONFIG), - ): - log.info("Reloading module: {}...".format(module)) - - module = importlib.import_module(module) - - try: - importlib.reload(module) - except AttributeError as e: - from importlib import reload - log.warning("Cannot reload module: {}".format(e)) - reload(module) - - -def install(): - ''' Installing all requarements for Nuke host - ''' - - # remove all registred callbacks form avalon.nuke - from avalon import pipeline - pipeline._registered_event_handlers.clear() - - log.info("Registering Nuke plug-ins..") - pyblish.api.register_plugin_path(PUBLISH_PATH) - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) - avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) - - # Register Avalon event for workfiles loading. - avalon.api.on("workio.open_file", lib.check_inventory_versions) - avalon.api.on("taskChanged", menu.change_context_label) - - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled) - workfile_settings = lib.WorkfileSettings() - # Disable all families except for the ones we explicitly want to see - family_states = [ - "write", - "review", - "nukenodes", - "model", - "gizmo" - ] - - avalon.api.data["familiesStateDefault"] = False - avalon.api.data["familiesStateToggled"] = family_states - - # Set context settings. - nuke.addOnCreate(workfile_settings.set_context_settings, nodeClass="Root") - nuke.addOnCreate(workfile_settings.set_favorites, nodeClass="Root") - nuke.addOnCreate(lib.process_workfile_builder, nodeClass="Root") - nuke.addOnCreate(lib.launch_workfiles_app, nodeClass="Root") - menu.install() - - -def uninstall(): - '''Uninstalling host's integration - ''' - log.info("Deregistering Nuke plug-ins..") - pyblish.api.deregister_plugin_path(PUBLISH_PATH) - avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) - - pyblish.api.deregister_callback( - "instanceToggled", on_pyblish_instance_toggled) - - reload_config() - menu.uninstall() - - -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle node passthrough states on instance toggles.""" - - log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( - instance, old_value, new_value)) - - from avalon.nuke import ( - viewer_update_and_undo_stop, - add_publish_knob - ) - - # Whether instances should be passthrough based on new value - - with viewer_update_and_undo_stop(): - n = instance[0] - try: - n["publish"].value() - except ValueError: - n = add_publish_knob(n) - log.info(" `Publish` knob was added to write node..") - - n["publish"].setValue(new_value) + "containerise", + "parse_container", + "update_container", +) diff --git a/openpype/hosts/nuke/api/actions.py b/openpype/hosts/nuke/api/actions.py index fd18c787c4..c4a6f0fb84 100644 --- a/openpype/hosts/nuke/api/actions.py +++ b/openpype/hosts/nuke/api/actions.py @@ -1,12 +1,11 @@ import pyblish.api -from avalon.nuke.lib import ( +from openpype.api import get_errored_instances_from_context +from .lib import ( reset_selection, select_nodes ) -from openpype.api import get_errored_instances_from_context - class SelectInvalidAction(pyblish.api.Action): """Select invalid nodes in Nuke when plug-in failed. diff --git a/openpype/hosts/nuke/api/command.py b/openpype/hosts/nuke/api/command.py new file mode 100644 index 0000000000..212d4757c6 --- /dev/null +++ b/openpype/hosts/nuke/api/command.py @@ -0,0 +1,135 @@ +import logging +import contextlib +import nuke + +from avalon import api, io + + +log = logging.getLogger(__name__) + + +def reset_frame_range(): + """ Set frame range to current asset + Also it will set a Viewer range with + displayed handles + """ + + fps = float(api.Session.get("AVALON_FPS", 25)) + + nuke.root()["fps"].setValue(fps) + name = api.Session["AVALON_ASSET"] + asset = io.find_one({"name": name, "type": "asset"}) + asset_data = asset["data"] + + handles = get_handles(asset) + + frame_start = int(asset_data.get( + "frameStart", + asset_data.get("edit_in"))) + + frame_end = int(asset_data.get( + "frameEnd", + asset_data.get("edit_out"))) + + if not all([frame_start, frame_end]): + missing = ", ".join(["frame_start", "frame_end"]) + msg = "'{}' are not set for asset '{}'!".format(missing, name) + log.warning(msg) + nuke.message(msg) + return + + frame_start -= handles + frame_end += handles + + nuke.root()["first_frame"].setValue(frame_start) + nuke.root()["last_frame"].setValue(frame_end) + + # setting active viewers + vv = nuke.activeViewer().node() + vv["frame_range_lock"].setValue(True) + vv["frame_range"].setValue("{0}-{1}".format( + int(asset_data["frameStart"]), + int(asset_data["frameEnd"])) + ) + + +def get_handles(asset): + """ Gets handles data + + Arguments: + asset (dict): avalon asset entity + + Returns: + handles (int) + """ + data = asset["data"] + if "handles" in data and data["handles"] is not None: + return int(data["handles"]) + + parent_asset = None + if "visualParent" in data: + vp = data["visualParent"] + if vp is not None: + parent_asset = io.find_one({"_id": io.ObjectId(vp)}) + + if parent_asset is None: + parent_asset = io.find_one({"_id": io.ObjectId(asset["parent"])}) + + if parent_asset is not None: + return get_handles(parent_asset) + else: + return 0 + + +def reset_resolution(): + """Set resolution to project resolution.""" + project = io.find_one({"type": "project"}) + p_data = project["data"] + + width = p_data.get("resolution_width", + p_data.get("resolutionWidth")) + height = p_data.get("resolution_height", + p_data.get("resolutionHeight")) + + if not all([width, height]): + missing = ", ".join(["width", "height"]) + msg = "No resolution information `{0}` found for '{1}'.".format( + missing, + project["name"]) + log.warning(msg) + nuke.message(msg) + return + + current_width = nuke.root()["format"].value().width() + current_height = nuke.root()["format"].value().height() + + if width != current_width or height != current_height: + + fmt = None + for f in nuke.formats(): + if f.width() == width and f.height() == height: + fmt = f.name() + + if not fmt: + nuke.addFormat( + "{0} {1} {2}".format(int(width), int(height), project["name"]) + ) + fmt = project["name"] + + nuke.root()["format"].setValue(fmt) + + +@contextlib.contextmanager +def viewer_update_and_undo_stop(): + """Lock viewer from updating and stop recording undo steps""" + try: + # stop active viewer to update any change + viewer = nuke.activeViewer() + if viewer: + viewer.stop() + else: + log.warning("No available active Viewer") + nuke.Undo.disable() + yield + finally: + nuke.Undo.enable() diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index e36a5aa5ba..0508de9f1d 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3,15 +3,15 @@ import re import sys import six import platform +import contextlib from collections import OrderedDict +import clique + +import nuke from avalon import api, io, lib -import avalon.nuke -from avalon.nuke import lib as anlib -from avalon.nuke import ( - save_file, open_file -) + from openpype.api import ( Logger, Anatomy, @@ -28,21 +28,476 @@ from openpype.lib.path_tools import HostDirmap from openpype.settings import get_project_settings from openpype.modules import ModulesManager -import nuke +from .workio import ( + save_file, + open_file +) -from .utils import set_context_favorites +log = Logger.get_logger(__name__) -log = Logger().get_logger(__name__) +_NODE_TAB_NAME = "{}".format(os.getenv("AVALON_LABEL") or "Avalon") +AVALON_LABEL = os.getenv("AVALON_LABEL") or "Avalon" +AVALON_TAB = "{}".format(AVALON_LABEL) +AVALON_DATA_GROUP = "{}DataGroup".format(AVALON_LABEL.capitalize()) +EXCLUDED_KNOB_TYPE_ON_READ = ( + 20, # Tab Knob + 26, # Text Knob (But for backward compatibility, still be read + # if value is not an empty string.) +) -opnl = sys.modules[__name__] -opnl._project = None -opnl.project_name = os.getenv("AVALON_PROJECT") -opnl.workfiles_launched = False -opnl._node_tab_name = "{}".format(os.getenv("AVALON_LABEL") or "Avalon") + +class Context: + main_window = None + context_label = None + project_name = os.getenv("AVALON_PROJECT") + workfiles_launched = False + # Seems unused + _project_doc = None + + +class Knobby(object): + """For creating knob which it's type isn't mapped in `create_knobs` + + Args: + type (string): Nuke knob type name + value: Value to be set with `Knob.setValue`, put `None` if not required + flags (list, optional): Knob flags to be set with `Knob.setFlag` + *args: Args other than knob name for initializing knob class + + """ + + def __init__(self, type, value, flags=None, *args): + self.type = type + self.value = value + self.flags = flags or [] + self.args = args + + def create(self, name, nice=None): + knob_cls = getattr(nuke, self.type) + knob = knob_cls(name, nice, *self.args) + if self.value is not None: + knob.setValue(self.value) + for flag in self.flags: + knob.setFlag(flag) + return knob + + +def create_knobs(data, tab=None): + """Create knobs by data + + Depending on the type of each dict value and creates the correct Knob. + + Mapped types: + bool: nuke.Boolean_Knob + int: nuke.Int_Knob + float: nuke.Double_Knob + list: nuke.Enumeration_Knob + six.string_types: nuke.String_Knob + + dict: If it's a nested dict (all values are dict), will turn into + A tabs group. Or just a knobs group. + + Args: + data (dict): collection of attributes and their value + tab (string, optional): Knobs' tab name + + Returns: + list: A list of `nuke.Knob` objects + + """ + def nice_naming(key): + """Convert camelCase name into UI Display Name""" + words = re.findall('[A-Z][^A-Z]*', key[0].upper() + key[1:]) + return " ".join(words) + + # Turn key-value pairs into knobs + knobs = list() + + if tab: + knobs.append(nuke.Tab_Knob(tab)) + + for key, value in data.items(): + # Knob name + if isinstance(key, tuple): + name, nice = key + else: + name, nice = key, nice_naming(key) + + # Create knob by value type + if isinstance(value, Knobby): + knobby = value + knob = knobby.create(name, nice) + + elif isinstance(value, float): + knob = nuke.Double_Knob(name, nice) + knob.setValue(value) + + elif isinstance(value, bool): + knob = nuke.Boolean_Knob(name, nice) + knob.setValue(value) + knob.setFlag(nuke.STARTLINE) + + elif isinstance(value, int): + knob = nuke.Int_Knob(name, nice) + knob.setValue(value) + + elif isinstance(value, six.string_types): + knob = nuke.String_Knob(name, nice) + knob.setValue(value) + + elif isinstance(value, list): + knob = nuke.Enumeration_Knob(name, nice, value) + + elif isinstance(value, dict): + if all(isinstance(v, dict) for v in value.values()): + # Create a group of tabs + begain = nuke.BeginTabGroup_Knob() + end = nuke.EndTabGroup_Knob() + begain.setName(name) + end.setName(name + "_End") + knobs.append(begain) + for k, v in value.items(): + knobs += create_knobs(v, tab=k) + knobs.append(end) + else: + # Create a group of knobs + knobs.append(nuke.Tab_Knob( + name, nice, nuke.TABBEGINCLOSEDGROUP)) + knobs += create_knobs(value) + knobs.append( + nuke.Tab_Knob(name + "_End", nice, nuke.TABENDGROUP)) + continue + + else: + raise TypeError("Unsupported type: %r" % type(value)) + + knobs.append(knob) + + return knobs + + +def imprint(node, data, tab=None): + """Store attributes with value on node + + Parse user data into Node knobs. + Use `collections.OrderedDict` to ensure knob order. + + Args: + node(nuke.Node): node object from Nuke + data(dict): collection of attributes and their value + + Returns: + None + + Examples: + ``` + import nuke + from avalon.nuke import lib + + node = nuke.createNode("NoOp") + data = { + # Regular type of attributes + "myList": ["x", "y", "z"], + "myBool": True, + "myFloat": 0.1, + "myInt": 5, + + # Creating non-default imprint type of knob + "MyFilePath": lib.Knobby("File_Knob", "/file/path"), + "divider": lib.Knobby("Text_Knob", ""), + + # Manual nice knob naming + ("my_knob", "Nice Knob Name"): "some text", + + # dict type will be created as knob group + "KnobGroup": { + "knob1": 5, + "knob2": "hello", + "knob3": ["a", "b"], + }, + + # Nested dict will be created as tab group + "TabGroup": { + "tab1": {"count": 5}, + "tab2": {"isGood": True}, + "tab3": {"direction": ["Left", "Right"]}, + }, + } + lib.imprint(node, data, tab="Demo") + + ``` + + """ + for knob in create_knobs(data, tab): + node.addKnob(knob) + + +def add_publish_knob(node): + """Add Publish knob to node + + Arguments: + node (nuke.Node): nuke node to be processed + + Returns: + node (nuke.Node): processed nuke node + + """ + if "publish" not in node.knobs(): + body = OrderedDict() + body[("divd", "Publishing")] = Knobby("Text_Knob", '') + body["publish"] = True + imprint(node, body) + return node + + +def set_avalon_knob_data(node, data=None, prefix="avalon:"): + """ Sets data into nodes's avalon knob + + Arguments: + node (nuke.Node): Nuke node to imprint with data, + data (dict, optional): Data to be imprinted into AvalonTab + prefix (str, optional): filtering prefix + + Returns: + node (nuke.Node) + + Examples: + data = { + 'asset': 'sq020sh0280', + 'family': 'render', + 'subset': 'subsetMain' + } + """ + data = data or dict() + create = OrderedDict() + + tab_name = AVALON_TAB + editable = ["asset", "subset", "name", "namespace"] + + existed_knobs = node.knobs() + + for key, value in data.items(): + knob_name = prefix + key + gui_name = key + + if knob_name in existed_knobs: + # Set value + try: + node[knob_name].setValue(value) + except TypeError: + node[knob_name].setValue(str(value)) + else: + # New knob + name = (knob_name, gui_name) # Hide prefix on GUI + if key in editable: + create[name] = value + else: + create[name] = Knobby("String_Knob", + str(value), + flags=[nuke.READ_ONLY]) + if tab_name in existed_knobs: + tab_name = None + else: + tab = OrderedDict() + warn = Knobby("Text_Knob", "Warning! Do not change following data!") + divd = Knobby("Text_Knob", "") + head = [ + (("warn", ""), warn), + (("divd", ""), divd), + ] + tab[AVALON_DATA_GROUP] = OrderedDict(head + list(create.items())) + create = tab + + imprint(node, create, tab=tab_name) + return node + + +def get_avalon_knob_data(node, prefix="avalon:"): + """ Gets a data from nodes's avalon knob + + Arguments: + node (obj): Nuke node to search for data, + prefix (str, optional): filtering prefix + + Returns: + data (dict) + """ + + # check if lists + if not isinstance(prefix, list): + prefix = list([prefix]) + + data = dict() + + # loop prefix + for p in prefix: + # check if the node is avalon tracked + if AVALON_TAB not in node.knobs(): + continue + try: + # check if data available on the node + test = node[AVALON_DATA_GROUP].value() + log.debug("Only testing if data avalable: `{}`".format(test)) + except NameError as e: + # if it doesn't then create it + log.debug("Creating avalon knob: `{}`".format(e)) + node = set_avalon_knob_data(node) + return get_avalon_knob_data(node) + + # get data from filtered knobs + data.update({k.replace(p, ''): node[k].value() + for k in node.knobs().keys() + if p in k}) + + return data + + +def fix_data_for_node_create(data): + """Fixing data to be used for nuke knobs + """ + for k, v in data.items(): + if isinstance(v, six.text_type): + data[k] = str(v) + if str(v).startswith("0x"): + data[k] = int(v, 16) + return data + + +def add_write_node(name, **kwarg): + """Adding nuke write node + + Arguments: + name (str): nuke node name + kwarg (attrs): data for nuke knobs + + Returns: + node (obj): nuke write node + """ + frame_range = kwarg.get("frame_range", None) + + w = nuke.createNode( + "Write", + "name {}".format(name)) + + w["file"].setValue(kwarg["file"]) + + for k, v in kwarg.items(): + if "frame_range" in k: + continue + log.info([k, v]) + try: + w[k].setValue(v) + except KeyError as e: + log.debug(e) + continue + + if frame_range: + w["use_limit"].setValue(True) + w["first"].setValue(frame_range[0]) + w["last"].setValue(frame_range[1]) + + return w + + +def read(node): + """Return user-defined knobs from given `node` + + Args: + node (nuke.Node): Nuke node object + + Returns: + list: A list of nuke.Knob object + + """ + def compat_prefixed(knob_name): + if knob_name.startswith("avalon:"): + return knob_name[len("avalon:"):] + elif knob_name.startswith("ak:"): + return knob_name[len("ak:"):] + else: + return knob_name + + data = dict() + + pattern = ("(?<=addUserKnob {)" + "([0-9]*) (\\S*)" # Matching knob type and knob name + "(?=[ |}])") + tcl_script = node.writeKnobs(nuke.WRITE_USER_KNOB_DEFS) + result = re.search(pattern, tcl_script) + + if result: + first_user_knob = result.group(2) + # Collect user knobs from the end of the knob list + for knob in reversed(node.allKnobs()): + knob_name = knob.name() + if not knob_name: + # Ignore unnamed knob + continue + + knob_type = nuke.knob(knob.fullyQualifiedName(), type=True) + value = knob.value() + + if ( + knob_type not in EXCLUDED_KNOB_TYPE_ON_READ or + # For compating read-only string data that imprinted + # by `nuke.Text_Knob`. + (knob_type == 26 and value) + ): + key = compat_prefixed(knob_name) + data[key] = value + + if knob_name == first_user_knob: + break + + return data + + +def get_node_path(path, padding=4): + """Get filename for the Nuke write with padded number as '#' + + Arguments: + path (str): The path to render to. + + Returns: + tuple: head, padding, tail (extension) + + Examples: + >>> get_frame_path("test.exr") + ('test', 4, '.exr') + + >>> get_frame_path("filename.#####.tif") + ('filename.', 5, '.tif') + + >>> get_frame_path("foobar##.tif") + ('foobar', 2, '.tif') + + >>> get_frame_path("foobar_%08d.tif") + ('foobar_', 8, '.tif') + """ + filename, ext = os.path.splitext(path) + + # Find a final number group + if '%' in filename: + match = re.match('.*?(%[0-9]+d)$', filename) + if match: + padding = int(match.group(1).replace('%', '').replace('d', '')) + # remove number from end since fusion + # will swap it with the frame number + filename = filename.replace(match.group(1), '') + elif '#' in filename: + match = re.match('.*?(#+)$', filename) + + if match: + padding = len(match.group(1)) + # remove number from end since fusion + # will swap it with the frame number + filename = filename.replace(match.group(1), '') + + return filename, padding, ext def get_nuke_imageio_settings(): - return get_anatomy_settings(opnl.project_name)["imageio"]["nuke"] + return get_anatomy_settings(Context.project_name)["imageio"]["nuke"] def get_created_node_imageio_setting(**kwarg): @@ -103,14 +558,15 @@ def check_inventory_versions(): and check if the node is having actual version. If not then it will color it to red. """ + from .pipeline import parse_container + # get all Loader nodes by avalon attribute metadata for each in nuke.allNodes(): - container = avalon.nuke.parse_container(each) + container = parse_container(each) if container: node = nuke.toNode(container["objectName"]) - avalon_knob_data = avalon.nuke.read( - node) + avalon_knob_data = read(node) # get representation from io representation = io.find_one({ @@ -163,11 +619,10 @@ def writes_version_sync(): for each in nuke.allNodes(filter="Write"): # check if the node is avalon tracked - if opnl._node_tab_name not in each.knobs(): + if _NODE_TAB_NAME not in each.knobs(): continue - avalon_knob_data = avalon.nuke.read( - each) + avalon_knob_data = read(each) try: if avalon_knob_data['families'] not in ["render"]: @@ -209,14 +664,14 @@ def check_subsetname_exists(nodes, subset_name): bool: True of False """ return next((True for n in nodes - if subset_name in avalon.nuke.read(n).get("subset", "")), + if subset_name in read(n).get("subset", "")), False) def get_render_path(node): ''' Generate Render path from presets regarding avalon knob data ''' - data = {'avalon': avalon.nuke.read(node)} + data = {'avalon': read(node)} data_preset = { "nodeclass": data['avalon']['family'], "families": [data['avalon']['families']], @@ -385,7 +840,7 @@ def create_write_node(name, data, input=None, prenodes=None, for knob in imageio_writes["knobs"]: _data.update({knob["name"]: knob["value"]}) - _data = anlib.fix_data_for_node_create(_data) + _data = fix_data_for_node_create(_data) log.debug("_data: `{}`".format(_data)) @@ -466,7 +921,7 @@ def create_write_node(name, data, input=None, prenodes=None, prev_node = now_node # creating write node - write_node = now_node = anlib.add_write_node( + write_node = now_node = add_write_node( "inside_{}".format(name), **_data ) @@ -484,8 +939,8 @@ def create_write_node(name, data, input=None, prenodes=None, now_node.setInput(0, prev_node) # imprinting group node - anlib.set_avalon_knob_data(GN, data["avalon"]) - anlib.add_publish_knob(GN) + set_avalon_knob_data(GN, data["avalon"]) + add_publish_knob(GN) add_rendering_knobs(GN, farm) if review: @@ -537,7 +992,7 @@ def create_write_node(name, data, input=None, prenodes=None, add_deadline_tab(GN) # open the our Tab as default - GN[opnl._node_tab_name].setFlag(0) + GN[_NODE_TAB_NAME].setFlag(0) # set tile color tile_color = _data.get("tile_color", "0xff0000ff") @@ -663,7 +1118,7 @@ class WorkfileSettings(object): root_node=None, nodes=None, **kwargs): - opnl._project = kwargs.get( + Context._project_doc = kwargs.get( "project") or io.find_one({"type": "project"}) self._asset = kwargs.get("asset_name") or api.Session["AVALON_ASSET"] self._asset_entity = get_asset(self._asset) @@ -804,8 +1259,6 @@ class WorkfileSettings(object): ''' Adds correct colorspace to write node dict ''' - from avalon.nuke import read - for node in nuke.allNodes(filter="Group"): # get data from avalon knob @@ -1005,7 +1458,7 @@ class WorkfileSettings(object): node['frame_range_lock'].setValue(True) # adding handle_start/end to root avalon knob - if not anlib.set_avalon_knob_data(self._root_node, { + if not set_avalon_knob_data(self._root_node, { "handleStart": int(handle_start), "handleEnd": int(handle_end) }): @@ -1089,6 +1542,8 @@ class WorkfileSettings(object): self.set_colorspace() def set_favorites(self): + from .utils import set_context_favorites + work_dir = os.getenv("AVALON_WORKDIR") asset = os.getenv("AVALON_ASSET") favorite_items = OrderedDict() @@ -1096,9 +1551,9 @@ class WorkfileSettings(object): # project # get project's root and split to parts projects_root = os.path.normpath(work_dir.split( - opnl.project_name)[0]) + Context.project_name)[0]) # add project name - project_dir = os.path.join(projects_root, opnl.project_name) + "/" + project_dir = os.path.join(projects_root, Context.project_name) + "/" # add to favorites favorite_items.update({"Project dir": project_dir.replace("\\", "/")}) @@ -1145,8 +1600,7 @@ def get_write_node_template_attr(node): ''' # get avalon data from node data = dict() - data['avalon'] = avalon.nuke.read( - node) + data['avalon'] = read(node) data_preset = { "nodeclass": data['avalon']['family'], "families": [data['avalon']['families']], @@ -1167,7 +1621,7 @@ def get_write_node_template_attr(node): if k not in ["_id", "_previous"]} # fix badly encoded data - return anlib.fix_data_for_node_create(correct_data) + return fix_data_for_node_create(correct_data) def get_dependent_nodes(nodes): @@ -1274,13 +1728,53 @@ def find_free_space_to_paste_nodes( return xpos, ypos +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context + + Example: + >>> with maintained_selection(): + ... node['selected'].setValue(True) + >>> print(node['selected'].value()) + False + """ + previous_selection = nuke.selectedNodes() + try: + yield + finally: + # unselect all selection in case there is some + current_seletion = nuke.selectedNodes() + [n['selected'].setValue(False) for n in current_seletion] + # and select all previously selected nodes + if previous_selection: + [n['selected'].setValue(True) for n in previous_selection] + + +def reset_selection(): + """Deselect all selected nodes""" + for node in nuke.selectedNodes(): + node["selected"].setValue(False) + + +def select_nodes(nodes): + """Selects all inputed nodes + + Arguments: + nodes (list): nuke nodes to be selected + """ + assert isinstance(nodes, (list, tuple)), "nodes has to be list or tuple" + + for node in nodes: + node["selected"].setValue(True) + + def launch_workfiles_app(): '''Function letting start workfiles after start of host ''' from openpype.lib import ( env_value_to_bool ) - from avalon.nuke.pipeline import get_main_window + from .pipeline import get_main_window # get all imortant settings open_at_start = env_value_to_bool( @@ -1291,8 +1785,8 @@ def launch_workfiles_app(): if not open_at_start: return - if not opnl.workfiles_launched: - opnl.workfiles_launched = True + if not Context.workfiles_launched: + Context.workfiles_launched = True main_window = get_main_window() host_tools.show_workfiles(parent=main_window) @@ -1378,7 +1872,7 @@ def recreate_instance(origin_node, avalon_data=None): knobs_wl = ["render", "publish", "review", "ypos", "use_limit", "first", "last"] # get data from avalon knobs - data = anlib.get_avalon_knob_data( + data = get_avalon_knob_data( origin_node) # add input data to avalon data @@ -1494,3 +1988,45 @@ def dirmap_file_name_filter(file_name): if os.path.exists(dirmap_processor.file_name): return dirmap_processor.file_name return file_name + + +# ------------------------------------ +# This function seems to be deprecated +# ------------------------------------ +def ls_img_sequence(path): + """Listing all available coherent image sequence from path + + Arguments: + path (str): A nuke's node object + + Returns: + data (dict): with nuke formated path and frameranges + """ + file = os.path.basename(path) + dirpath = os.path.dirname(path) + base, ext = os.path.splitext(file) + name, padding = os.path.splitext(base) + + # populate list of files + files = [ + f for f in os.listdir(dirpath) + if name in f + if ext in f + ] + + # create collection from list of files + collections, reminder = clique.assemble(files) + + if len(collections) > 0: + head = collections[0].format("{head}") + padding = collections[0].format("{padding}") % 1 + padding = "#" * len(padding) + tail = collections[0].format("{tail}") + file = head + padding + tail + + return { + "path": os.path.join(dirpath, file).replace("\\", "/"), + "frames": collections[0].format("[{ranges}]") + } + + return False diff --git a/openpype/hosts/nuke/api/menu.py b/openpype/hosts/nuke/api/menu.py deleted file mode 100644 index 86293edb99..0000000000 --- a/openpype/hosts/nuke/api/menu.py +++ /dev/null @@ -1,166 +0,0 @@ -import os -import nuke -from avalon.nuke.pipeline import get_main_window - -from .lib import WorkfileSettings -from openpype.api import Logger, BuildWorkfile, get_current_project_settings -from openpype.tools.utils import host_tools - - -log = Logger().get_logger(__name__) - -menu_label = os.environ["AVALON_LABEL"] -context_label = None - - -def change_context_label(*args): - global context_label - menubar = nuke.menu("Nuke") - menu = menubar.findItem(menu_label) - - label = "{0}, {1}".format( - os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"] - ) - - rm_item = [ - (i, item) for i, item in enumerate(menu.items()) - if context_label in item.name() - ][0] - - menu.removeItem(rm_item[1].name()) - - context_action = menu.addCommand( - label, - index=(rm_item[0]) - ) - context_action.setEnabled(False) - - log.info("Task label changed from `{}` to `{}`".format( - context_label, label)) - - context_label = label - - - -def install(): - from openpype.hosts.nuke.api import reload_config - - global context_label - - # uninstall original avalon menu - uninstall() - - main_window = get_main_window() - menubar = nuke.menu("Nuke") - menu = menubar.addMenu(menu_label) - - label = "{0}, {1}".format( - os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"] - ) - context_label = label - context_action = menu.addCommand(label) - context_action.setEnabled(False) - - menu.addSeparator() - menu.addCommand( - "Work Files...", - lambda: host_tools.show_workfiles(parent=main_window) - ) - - menu.addSeparator() - menu.addCommand( - "Create...", - lambda: host_tools.show_creator(parent=main_window) - ) - menu.addCommand( - "Load...", - lambda: host_tools.show_loader( - parent=main_window, - use_context=True - ) - ) - menu.addCommand( - "Publish...", - lambda: host_tools.show_publish(parent=main_window) - ) - menu.addCommand( - "Manage...", - lambda: host_tools.show_scene_inventory(parent=main_window) - ) - - menu.addSeparator() - menu.addCommand( - "Set Resolution", - lambda: WorkfileSettings().reset_resolution() - ) - menu.addCommand( - "Set Frame Range", - lambda: WorkfileSettings().reset_frame_range_handles() - ) - menu.addCommand( - "Set Colorspace", - lambda: WorkfileSettings().set_colorspace() - ) - menu.addCommand( - "Apply All Settings", - lambda: WorkfileSettings().set_context_settings() - ) - - menu.addSeparator() - menu.addCommand( - "Build Workfile", - lambda: BuildWorkfile().process() - ) - - menu.addSeparator() - menu.addCommand( - "Experimental tools...", - lambda: host_tools.show_experimental_tools_dialog(parent=main_window) - ) - - # add reload pipeline only in debug mode - if bool(os.getenv("NUKE_DEBUG")): - menu.addSeparator() - menu.addCommand("Reload Pipeline", reload_config) - - # adding shortcuts - add_shortcuts_from_presets() - - -def uninstall(): - - menubar = nuke.menu("Nuke") - menu = menubar.findItem(menu_label) - - for item in menu.items(): - log.info("Removing menu item: {}".format(item.name())) - menu.removeItem(item.name()) - - -def add_shortcuts_from_presets(): - menubar = nuke.menu("Nuke") - nuke_presets = get_current_project_settings()["nuke"]["general"] - - if nuke_presets.get("menu"): - menu_label_mapping = { - "manage": "Manage...", - "create": "Create...", - "load": "Load...", - "build_workfile": "Build Workfile", - "publish": "Publish..." - } - - for command_name, shortcut_str in nuke_presets.get("menu").items(): - log.info("menu_name `{}` | menu_label `{}`".format( - command_name, menu_label - )) - log.info("Adding Shortcut `{}` to `{}`".format( - shortcut_str, command_name - )) - try: - menu = menubar.findItem(menu_label) - item_label = menu_label_mapping[command_name] - menuitem = menu.findItem(item_label) - menuitem.setShortcut(shortcut_str) - except AttributeError as e: - log.error(e) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py new file mode 100644 index 0000000000..c47187666b --- /dev/null +++ b/openpype/hosts/nuke/api/pipeline.py @@ -0,0 +1,421 @@ +import os +import importlib +from collections import OrderedDict + +import nuke + +import pyblish.api +import avalon.api +from avalon import pipeline + +import openpype +from openpype.api import ( + Logger, + BuildWorkfile, + get_current_project_settings +) +from openpype.tools.utils import host_tools + +from .command import viewer_update_and_undo_stop +from .lib import ( + add_publish_knob, + WorkfileSettings, + process_workfile_builder, + launch_workfiles_app, + check_inventory_versions, + set_avalon_knob_data, + read, + Context +) + +log = Logger.get_logger(__name__) + +AVALON_CONFIG = os.getenv("AVALON_CONFIG", "pype") +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.nuke.__file__)) +PLUGINS_DIR = os.path.join(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") + +MENU_LABEL = os.environ["AVALON_LABEL"] + + +# registering pyblish gui regarding settings in presets +if os.getenv("PYBLISH_GUI", None): + pyblish.api.register_gui(os.getenv("PYBLISH_GUI", None)) + + +def get_main_window(): + """Acquire Nuke's main window""" + if Context.main_window is None: + from Qt import QtWidgets + + top_widgets = QtWidgets.QApplication.topLevelWidgets() + name = "Foundry::UI::DockMainWindow" + for widget in top_widgets: + if ( + widget.inherits("QMainWindow") + and widget.metaObject().className() == name + ): + Context.main_window = widget + break + return Context.main_window + + +def reload_config(): + """Attempt to reload pipeline at run-time. + + CAUTION: This is primarily for development and debugging purposes. + + """ + + for module in ( + "{}.api".format(AVALON_CONFIG), + "{}.hosts.nuke.api.actions".format(AVALON_CONFIG), + "{}.hosts.nuke.api.menu".format(AVALON_CONFIG), + "{}.hosts.nuke.api.plugin".format(AVALON_CONFIG), + "{}.hosts.nuke.api.lib".format(AVALON_CONFIG), + ): + log.info("Reloading module: {}...".format(module)) + + module = importlib.import_module(module) + + try: + importlib.reload(module) + except AttributeError as e: + from importlib import reload + log.warning("Cannot reload module: {}".format(e)) + reload(module) + + +def install(): + ''' Installing all requarements for Nuke host + ''' + + pyblish.api.register_host("nuke") + + log.info("Registering Nuke plug-ins..") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) + + # Register Avalon event for workfiles loading. + avalon.api.on("workio.open_file", check_inventory_versions) + avalon.api.on("taskChanged", change_context_label) + + pyblish.api.register_callback( + "instanceToggled", on_pyblish_instance_toggled) + workfile_settings = WorkfileSettings() + # Disable all families except for the ones we explicitly want to see + family_states = [ + "write", + "review", + "nukenodes", + "model", + "gizmo" + ] + + avalon.api.data["familiesStateDefault"] = False + avalon.api.data["familiesStateToggled"] = family_states + + # Set context settings. + nuke.addOnCreate(workfile_settings.set_context_settings, nodeClass="Root") + nuke.addOnCreate(workfile_settings.set_favorites, nodeClass="Root") + nuke.addOnCreate(process_workfile_builder, nodeClass="Root") + nuke.addOnCreate(launch_workfiles_app, nodeClass="Root") + _install_menu() + + +def uninstall(): + '''Uninstalling host's integration + ''' + log.info("Deregistering Nuke plug-ins..") + pyblish.deregister_host("nuke") + pyblish.api.deregister_plugin_path(PUBLISH_PATH) + avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + + pyblish.api.deregister_callback( + "instanceToggled", on_pyblish_instance_toggled) + + reload_config() + _uninstall_menu() + + +def _install_menu(): + # uninstall original avalon menu + main_window = get_main_window() + menubar = nuke.menu("Nuke") + menu = menubar.addMenu(MENU_LABEL) + + label = "{0}, {1}".format( + os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"] + ) + Context.context_label = label + context_action = menu.addCommand(label) + context_action.setEnabled(False) + + menu.addSeparator() + menu.addCommand( + "Work Files...", + lambda: host_tools.show_workfiles(parent=main_window) + ) + + menu.addSeparator() + menu.addCommand( + "Create...", + lambda: host_tools.show_creator(parent=main_window) + ) + menu.addCommand( + "Load...", + lambda: host_tools.show_loader( + parent=main_window, + use_context=True + ) + ) + menu.addCommand( + "Publish...", + lambda: host_tools.show_publish(parent=main_window) + ) + menu.addCommand( + "Manage...", + lambda: host_tools.show_scene_inventory(parent=main_window) + ) + + menu.addSeparator() + menu.addCommand( + "Set Resolution", + lambda: WorkfileSettings().reset_resolution() + ) + menu.addCommand( + "Set Frame Range", + lambda: WorkfileSettings().reset_frame_range_handles() + ) + menu.addCommand( + "Set Colorspace", + lambda: WorkfileSettings().set_colorspace() + ) + menu.addCommand( + "Apply All Settings", + lambda: WorkfileSettings().set_context_settings() + ) + + menu.addSeparator() + menu.addCommand( + "Build Workfile", + lambda: BuildWorkfile().process() + ) + + menu.addSeparator() + menu.addCommand( + "Experimental tools...", + lambda: host_tools.show_experimental_tools_dialog(parent=main_window) + ) + + # add reload pipeline only in debug mode + if bool(os.getenv("NUKE_DEBUG")): + menu.addSeparator() + menu.addCommand("Reload Pipeline", reload_config) + + # adding shortcuts + add_shortcuts_from_presets() + + +def _uninstall_menu(): + menubar = nuke.menu("Nuke") + menu = menubar.findItem(MENU_LABEL) + + for item in menu.items(): + log.info("Removing menu item: {}".format(item.name())) + menu.removeItem(item.name()) + + +def change_context_label(*args): + menubar = nuke.menu("Nuke") + menu = menubar.findItem(MENU_LABEL) + + label = "{0}, {1}".format( + os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"] + ) + + rm_item = [ + (i, item) for i, item in enumerate(menu.items()) + if Context.context_label in item.name() + ][0] + + menu.removeItem(rm_item[1].name()) + + context_action = menu.addCommand( + label, + index=(rm_item[0]) + ) + context_action.setEnabled(False) + + log.info("Task label changed from `{}` to `{}`".format( + Context.context_label, label)) + + +def add_shortcuts_from_presets(): + menubar = nuke.menu("Nuke") + nuke_presets = get_current_project_settings()["nuke"]["general"] + + if nuke_presets.get("menu"): + menu_label_mapping = { + "manage": "Manage...", + "create": "Create...", + "load": "Load...", + "build_workfile": "Build Workfile", + "publish": "Publish..." + } + + for command_name, shortcut_str in nuke_presets.get("menu").items(): + log.info("menu_name `{}` | menu_label `{}`".format( + command_name, MENU_LABEL + )) + log.info("Adding Shortcut `{}` to `{}`".format( + shortcut_str, command_name + )) + try: + menu = menubar.findItem(MENU_LABEL) + item_label = menu_label_mapping[command_name] + menuitem = menu.findItem(item_label) + menuitem.setShortcut(shortcut_str) + except AttributeError as e: + log.error(e) + + +def on_pyblish_instance_toggled(instance, old_value, new_value): + """Toggle node passthrough states on instance toggles.""" + + log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( + instance, old_value, new_value)) + + # Whether instances should be passthrough based on new value + + with viewer_update_and_undo_stop(): + n = instance[0] + try: + n["publish"].value() + except ValueError: + n = add_publish_knob(n) + log.info(" `Publish` knob was added to write node..") + + n["publish"].setValue(new_value) + + +def containerise(node, + name, + namespace, + context, + loader=None, + data=None): + """Bundle `node` into an assembly and imprint it with metadata + + Containerisation enables a tracking of version, author and origin + for loaded assets. + + Arguments: + node (nuke.Node): Nuke's node object to imprint as container + name (str): Name of resulting assembly + namespace (str): Namespace under which to host container + context (dict): Asset information + loader (str, optional): Name of node used to produce this container. + + Returns: + node (nuke.Node): containerised nuke's node object + + """ + data = OrderedDict( + [ + ("schema", "openpype:container-2.0"), + ("id", pipeline.AVALON_CONTAINER_ID), + ("name", name), + ("namespace", namespace), + ("loader", str(loader)), + ("representation", context["representation"]["_id"]), + ], + + **data or dict() + ) + + set_avalon_knob_data(node, data) + + return node + + +def parse_container(node): + """Returns containerised data of a node + + Reads the imprinted data from `containerise`. + + Arguments: + node (nuke.Node): Nuke's node object to read imprinted data + + Returns: + dict: The container schema data for this container node. + + """ + data = read(node) + + # (TODO) Remove key validation when `ls` has re-implemented. + # + # If not all required data return the empty container + required = ["schema", "id", "name", + "namespace", "loader", "representation"] + if not all(key in data for key in required): + return + + # Store the node's name + data["objectName"] = node["name"].value() + + return data + + +def update_container(node, keys=None): + """Returns node with updateted containder data + + Arguments: + node (nuke.Node): The node in Nuke to imprint as container, + keys (dict, optional): data which should be updated + + Returns: + node (nuke.Node): nuke node with updated container data + + Raises: + TypeError on given an invalid container node + + """ + keys = keys or dict() + + container = parse_container(node) + if not container: + raise TypeError("Not a valid container node.") + + container.update(keys) + node = set_avalon_knob_data(node, container) + + return node + + +def ls(): + """List available containers. + + This function is used by the Container Manager in Nuke. You'll + need to implement a for-loop that then *yields* one Container at + a time. + + See the `container.json` schema for details on how it should look, + and the Maya equivalent, which is in `avalon.maya.pipeline` + """ + all_nodes = nuke.allNodes(recurseGroups=False) + + # TODO: add readgeo, readcamera, readimage + nodes = [n for n in all_nodes] + + for n in nodes: + log.debug("name: `{}`".format(n.name())) + container = parse_container(n) + if container: + yield container diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 82299dd354..66b42f7bb1 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -2,23 +2,30 @@ import os import random import string -import avalon.nuke -from avalon.nuke import lib as anlib -from avalon import api +import nuke + +import avalon.api from openpype.api import ( get_current_project_settings, PypeCreatorMixin ) -from .lib import check_subsetname_exists -import nuke +from .lib import ( + Knobby, + check_subsetname_exists, + reset_selection, + maintained_selection, + set_avalon_knob_data, + add_publish_knob +) -class PypeCreator(PypeCreatorMixin, avalon.nuke.pipeline.Creator): - """Pype Nuke Creator class wrapper - """ +class OpenPypeCreator(PypeCreatorMixin, avalon.api.Creator): + """Pype Nuke Creator class wrapper""" + node_color = "0xdfea5dff" + def __init__(self, *args, **kwargs): - super(PypeCreator, self).__init__(*args, **kwargs) + super(OpenPypeCreator, self).__init__(*args, **kwargs) self.presets = get_current_project_settings()["nuke"]["create"].get( self.__class__.__name__, {} ) @@ -31,6 +38,38 @@ class PypeCreator(PypeCreatorMixin, avalon.nuke.pipeline.Creator): raise NameError("`{0}: {1}".format(__name__, msg)) return + def process(self): + from nukescripts import autoBackdrop + + instance = None + + if (self.options or {}).get("useSelection"): + + nodes = nuke.selectedNodes() + if not nodes: + nuke.message("Please select nodes that you " + "wish to add to a container") + return + + elif len(nodes) == 1: + # only one node is selected + instance = nodes[0] + + if not instance: + # Not using selection or multiple nodes selected + bckd_node = autoBackdrop() + bckd_node["tile_color"].setValue(int(self.node_color, 16)) + bckd_node["note_font_size"].setValue(24) + bckd_node["label"].setValue("[{}]".format(self.name)) + + instance = bckd_node + + # add avalon knobs + set_avalon_knob_data(instance, self.data) + add_publish_knob(instance) + + return instance + def get_review_presets_config(): settings = get_current_project_settings() @@ -48,7 +87,7 @@ def get_review_presets_config(): return [str(name) for name, _prop in outputs.items()] -class NukeLoader(api.Loader): +class NukeLoader(avalon.api.Loader): container_id_knob = "containerId" container_id = None @@ -74,7 +113,7 @@ class NukeLoader(api.Loader): node[self.container_id_knob].setValue(source_id) else: HIDEN_FLAG = 0x00040000 - _knob = anlib.Knobby( + _knob = Knobby( "String_Knob", self.container_id, flags=[ @@ -183,7 +222,7 @@ class ExporterReview(object): Returns: nuke.Node: copy node of Input Process node """ - anlib.reset_selection() + reset_selection() ipn_orig = None for v in nuke.allNodes(filter="Viewer"): ip = v["input_process"].getValue() @@ -196,7 +235,7 @@ class ExporterReview(object): # copy selected to clipboard nuke.nodeCopy("%clipboard%") # reset selection - anlib.reset_selection() + reset_selection() # paste node and selection is on it only nuke.nodePaste("%clipboard%") # assign to variable @@ -396,7 +435,7 @@ class ExporterReviewMov(ExporterReview): def save_file(self): import shutil - with anlib.maintained_selection(): + with maintained_selection(): self.log.info("Saving nodes as file... ") # create nk path path = os.path.splitext(self.path)[0] + ".nk" diff --git a/openpype/hosts/nuke/api/utils.py b/openpype/hosts/nuke/api/utils.py index e43c11a380..f8f248357b 100644 --- a/openpype/hosts/nuke/api/utils.py +++ b/openpype/hosts/nuke/api/utils.py @@ -1,7 +1,8 @@ import os import nuke -from avalon.nuke import lib as anlib + from openpype.api import resources +from .lib import maintained_selection def set_context_favorites(favorites=None): @@ -55,7 +56,7 @@ def bake_gizmos_recursively(in_group=nuke.Root()): is_group (nuke.Node)[optonal]: group node or all nodes """ # preserve selection after all is done - with anlib.maintained_selection(): + with maintained_selection(): # jump to the group with in_group: for node in nuke.allNodes(): diff --git a/openpype/hosts/nuke/api/workio.py b/openpype/hosts/nuke/api/workio.py new file mode 100644 index 0000000000..dbc24fdc9b --- /dev/null +++ b/openpype/hosts/nuke/api/workio.py @@ -0,0 +1,55 @@ +"""Host API required Work Files tool""" +import os +import nuke +import avalon.api + + +def file_extensions(): + return avalon.api.HOST_WORKFILE_EXTENSIONS["nuke"] + + +def has_unsaved_changes(): + return nuke.root().modified() + + +def save_file(filepath): + path = filepath.replace("\\", "/") + nuke.scriptSaveAs(path) + nuke.Root()["name"].setValue(path) + nuke.Root()["project_directory"].setValue(os.path.dirname(path)) + nuke.Root().setModified(False) + + +def open_file(filepath): + filepath = filepath.replace("\\", "/") + + # To remain in the same window, we have to clear the script and read + # in the contents of the workfile. + nuke.scriptClear() + nuke.scriptReadFile(filepath) + nuke.Root()["name"].setValue(filepath) + nuke.Root()["project_directory"].setValue(os.path.dirname(filepath)) + nuke.Root().setModified(False) + return True + + +def current_file(): + current_file = nuke.root().name() + + # Unsaved current file + if current_file == 'Root': + return None + + return os.path.normpath(current_file).replace("\\", "/") + + +def work_root(session): + + work_dir = session["AVALON_WORKDIR"] + scene_dir = session.get("AVALON_SCENEDIR") + if scene_dir: + path = os.path.join(work_dir, scene_dir) + else: + path = work_dir + + return os.path.normpath(path).replace("\\", "/") diff --git a/openpype/hosts/nuke/plugins/create/create_backdrop.py b/openpype/hosts/nuke/plugins/create/create_backdrop.py index cda2629587..0c11b3f274 100644 --- a/openpype/hosts/nuke/plugins/create/create_backdrop.py +++ b/openpype/hosts/nuke/plugins/create/create_backdrop.py @@ -1,9 +1,12 @@ -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import ( + select_nodes, + set_avalon_knob_data +) -class CreateBackdrop(plugin.PypeCreator): +class CreateBackdrop(plugin.OpenPypeCreator): """Add Publishable Backdrop""" name = "nukenodes" @@ -25,14 +28,14 @@ class CreateBackdrop(plugin.PypeCreator): nodes = self.nodes if len(nodes) >= 1: - anlib.select_nodes(nodes) + select_nodes(nodes) bckd_node = autoBackdrop() bckd_node["name"].setValue("{}_BDN".format(self.name)) bckd_node["tile_color"].setValue(int(self.node_color, 16)) bckd_node["note_font_size"].setValue(24) bckd_node["label"].setValue("[{}]".format(self.name)) # add avalon knobs - instance = anlib.set_avalon_knob_data(bckd_node, self.data) + instance = set_avalon_knob_data(bckd_node, self.data) return instance else: @@ -48,6 +51,6 @@ class CreateBackdrop(plugin.PypeCreator): bckd_node["note_font_size"].setValue(24) bckd_node["label"].setValue("[{}]".format(self.name)) # add avalon knobs - instance = anlib.set_avalon_knob_data(bckd_node, self.data) + instance = set_avalon_knob_data(bckd_node, self.data) return instance diff --git a/openpype/hosts/nuke/plugins/create/create_camera.py b/openpype/hosts/nuke/plugins/create/create_camera.py index 359086d48f..3b13c80dc4 100644 --- a/openpype/hosts/nuke/plugins/create/create_camera.py +++ b/openpype/hosts/nuke/plugins/create/create_camera.py @@ -1,9 +1,11 @@ -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import ( + set_avalon_knob_data +) -class CreateCamera(plugin.PypeCreator): +class CreateCamera(plugin.OpenPypeCreator): """Add Publishable Backdrop""" name = "camera" @@ -36,7 +38,7 @@ class CreateCamera(plugin.PypeCreator): # change node color n["tile_color"].setValue(int(self.node_color, 16)) # add avalon knobs - anlib.set_avalon_knob_data(n, data) + set_avalon_knob_data(n, data) return True else: msg = str("Please select nodes you " @@ -49,5 +51,5 @@ class CreateCamera(plugin.PypeCreator): camera_node = nuke.createNode("Camera2") camera_node["tile_color"].setValue(int(self.node_color, 16)) # add avalon knobs - instance = anlib.set_avalon_knob_data(camera_node, self.data) + instance = set_avalon_knob_data(camera_node, self.data) return instance diff --git a/openpype/hosts/nuke/plugins/create/create_gizmo.py b/openpype/hosts/nuke/plugins/create/create_gizmo.py index c59713cff1..de73623a1e 100644 --- a/openpype/hosts/nuke/plugins/create/create_gizmo.py +++ b/openpype/hosts/nuke/plugins/create/create_gizmo.py @@ -1,9 +1,14 @@ -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + select_nodes, + set_avalon_knob_data +) -class CreateGizmo(plugin.PypeCreator): + +class CreateGizmo(plugin.OpenPypeCreator): """Add Publishable "gizmo" group The name is symbolically gizmo as presumably @@ -28,13 +33,13 @@ class CreateGizmo(plugin.PypeCreator): nodes = self.nodes self.log.info(len(nodes)) if len(nodes) == 1: - anlib.select_nodes(nodes) + select_nodes(nodes) node = nodes[-1] # check if Group node if node.Class() in "Group": node["name"].setValue("{}_GZM".format(self.name)) node["tile_color"].setValue(int(self.node_color, 16)) - return anlib.set_avalon_knob_data(node, self.data) + return set_avalon_knob_data(node, self.data) else: msg = ("Please select a group node " "you wish to publish as the gizmo") @@ -42,7 +47,7 @@ class CreateGizmo(plugin.PypeCreator): nuke.message(msg) if len(nodes) >= 2: - anlib.select_nodes(nodes) + select_nodes(nodes) nuke.makeGroup() gizmo_node = nuke.selectedNode() gizmo_node["name"].setValue("{}_GZM".format(self.name)) @@ -57,16 +62,15 @@ class CreateGizmo(plugin.PypeCreator): "- create User knobs on the group") # add avalon knobs - return anlib.set_avalon_knob_data(gizmo_node, self.data) + return set_avalon_knob_data(gizmo_node, self.data) else: - msg = ("Please select nodes you " - "wish to add to the gizmo") + msg = "Please select nodes you wish to add to the gizmo" self.log.error(msg) nuke.message(msg) return else: - with anlib.maintained_selection(): + with maintained_selection(): gizmo_node = nuke.createNode("Group") gizmo_node["name"].setValue("{}_GZM".format(self.name)) gizmo_node["tile_color"].setValue(int(self.node_color, 16)) @@ -80,4 +84,4 @@ class CreateGizmo(plugin.PypeCreator): "- create User knobs on the group") # add avalon knobs - return anlib.set_avalon_knob_data(gizmo_node, self.data) + return set_avalon_knob_data(gizmo_node, self.data) diff --git a/openpype/hosts/nuke/plugins/create/create_model.py b/openpype/hosts/nuke/plugins/create/create_model.py index 4e30860e05..15a4e3ab8a 100644 --- a/openpype/hosts/nuke/plugins/create/create_model.py +++ b/openpype/hosts/nuke/plugins/create/create_model.py @@ -1,9 +1,11 @@ -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import ( + set_avalon_knob_data +) -class CreateModel(plugin.PypeCreator): +class CreateModel(plugin.OpenPypeCreator): """Add Publishable Model Geometry""" name = "model" @@ -68,7 +70,7 @@ class CreateModel(plugin.PypeCreator): # change node color n["tile_color"].setValue(int(self.node_color, 16)) # add avalon knobs - anlib.set_avalon_knob_data(n, data) + set_avalon_knob_data(n, data) return True else: msg = str("Please select nodes you " @@ -81,5 +83,5 @@ class CreateModel(plugin.PypeCreator): model_node = nuke.createNode("WriteGeo") model_node["tile_color"].setValue(int(self.node_color, 16)) # add avalon knobs - instance = anlib.set_avalon_knob_data(model_node, self.data) + instance = set_avalon_knob_data(model_node, self.data) return instance diff --git a/openpype/hosts/nuke/plugins/create/create_read.py b/openpype/hosts/nuke/plugins/create/create_read.py index bf5de23346..bdc67add42 100644 --- a/openpype/hosts/nuke/plugins/create/create_read.py +++ b/openpype/hosts/nuke/plugins/create/create_read.py @@ -1,13 +1,16 @@ from collections import OrderedDict -import avalon.api -import avalon.nuke -from openpype import api as pype -from openpype.hosts.nuke.api import plugin import nuke +import avalon.api +from openpype import api as pype +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import ( + set_avalon_knob_data +) -class CrateRead(plugin.PypeCreator): + +class CrateRead(plugin.OpenPypeCreator): # change this to template preset name = "ReadCopy" label = "Create Read Copy" @@ -45,7 +48,7 @@ class CrateRead(plugin.PypeCreator): continue avalon_data = self.data avalon_data['subset'] = "{}".format(self.name) - avalon.nuke.lib.set_avalon_knob_data(node, avalon_data) + set_avalon_knob_data(node, avalon_data) node['tile_color'].setValue(16744935) count_reads += 1 diff --git a/openpype/hosts/nuke/plugins/create/create_write_prerender.py b/openpype/hosts/nuke/plugins/create/create_write_prerender.py index 1b925014ad..3285e5f92d 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_prerender.py +++ b/openpype/hosts/nuke/plugins/create/create_write_prerender.py @@ -1,11 +1,12 @@ from collections import OrderedDict -from openpype.hosts.nuke.api import ( - plugin, - lib) + import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import create_write_node -class CreateWritePrerender(plugin.PypeCreator): + +class CreateWritePrerender(plugin.OpenPypeCreator): # change this to template preset name = "WritePrerender" label = "Create Write Prerender" @@ -98,7 +99,7 @@ class CreateWritePrerender(plugin.PypeCreator): self.log.info("write_data: {}".format(write_data)) - write_node = lib.create_write_node( + write_node = create_write_node( self.data["subset"], write_data, input=selected_node, diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index 5f13fddf4e..a9c4b5341e 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -1,11 +1,12 @@ from collections import OrderedDict -from openpype.hosts.nuke.api import ( - plugin, - lib) + import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import create_write_node -class CreateWriteRender(plugin.PypeCreator): + +class CreateWriteRender(plugin.OpenPypeCreator): # change this to template preset name = "WriteRender" label = "Create Write Render" @@ -119,7 +120,7 @@ class CreateWriteRender(plugin.PypeCreator): } ] - write_node = lib.create_write_node( + write_node = create_write_node( self.data["subset"], write_data, input=selected_node, diff --git a/openpype/hosts/nuke/plugins/create/create_write_still.py b/openpype/hosts/nuke/plugins/create/create_write_still.py index eebb5613c3..0037b64ce3 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_still.py +++ b/openpype/hosts/nuke/plugins/create/create_write_still.py @@ -1,11 +1,12 @@ from collections import OrderedDict -from openpype.hosts.nuke.api import ( - plugin, - lib) + import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import create_write_node -class CreateWriteStill(plugin.PypeCreator): + +class CreateWriteStill(plugin.OpenPypeCreator): # change this to template preset name = "WriteStillFrame" label = "Create Write Still Image" @@ -108,7 +109,7 @@ class CreateWriteStill(plugin.PypeCreator): } ] - write_node = lib.create_write_node( + write_node = create_write_node( self.name, write_data, input=selected_node, diff --git a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py index e7ae51fa86..49405fd213 100644 --- a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py +++ b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py @@ -1,7 +1,6 @@ from avalon import api, style -from avalon.nuke import lib as anlib -from openpype.api import ( - Logger) +from openpype.api import Logger +from openpype.hosts.nuke.api.lib import set_avalon_knob_data class RepairOldLoaders(api.InventoryAction): @@ -10,7 +9,7 @@ class RepairOldLoaders(api.InventoryAction): icon = "gears" color = style.colors.alert - log = Logger().get_logger(__name__) + log = Logger.get_logger(__name__) def process(self, containers): import nuke @@ -34,4 +33,4 @@ class RepairOldLoaders(api.InventoryAction): }) node["name"].setValue(new_name) # get data from avalon knob - anlib.set_avalon_knob_data(node, cdata) + set_avalon_knob_data(node, cdata) diff --git a/openpype/hosts/nuke/plugins/inventory/select_containers.py b/openpype/hosts/nuke/plugins/inventory/select_containers.py index bd00983172..3f174b3562 100644 --- a/openpype/hosts/nuke/plugins/inventory/select_containers.py +++ b/openpype/hosts/nuke/plugins/inventory/select_containers.py @@ -1,4 +1,5 @@ from avalon import api +from openpype.hosts.nuke.api.commands import viewer_update_and_undo_stop class SelectContainers(api.InventoryAction): @@ -9,11 +10,10 @@ class SelectContainers(api.InventoryAction): def process(self, containers): import nuke - import avalon.nuke nodes = [nuke.toNode(i["objectName"]) for i in containers] - with avalon.nuke.viewer_update_and_undo_stop(): + with viewer_update_and_undo_stop(): # clear previous_selection [n['selected'].setValue(False) for n in nodes] # Select tool diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index 9148260e9e..a2bd458948 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -1,9 +1,18 @@ from avalon import api, style, io import nuke import nukescripts -from openpype.hosts.nuke.api import lib as pnlib -from avalon.nuke import lib as anlib -from avalon.nuke import containerise, update_container + +from openpype.hosts.nuke.api.lib import ( + find_free_space_to_paste_nodes, + maintained_selection, + reset_selection, + select_nodes, + get_avalon_knob_data, + set_avalon_knob_data +) +from openpype.hosts.nuke.api.commands import viewer_update_and_undo_stop +from openpype.hosts.nuke.api import containerise, update_container + class LoadBackdropNodes(api.Loader): """Loading Published Backdrop nodes (workfile, nukenodes)""" @@ -66,12 +75,12 @@ class LoadBackdropNodes(api.Loader): # Get mouse position n = nuke.createNode("NoOp") xcursor, ycursor = (n.xpos(), n.ypos()) - anlib.reset_selection() + reset_selection() nuke.delete(n) bdn_frame = 50 - with anlib.maintained_selection(): + with maintained_selection(): # add group from nk nuke.nodePaste(file) @@ -81,11 +90,13 @@ class LoadBackdropNodes(api.Loader): nodes = nuke.selectedNodes() # get pointer position in DAG - xpointer, ypointer = pnlib.find_free_space_to_paste_nodes(nodes, direction="right", offset=200+bdn_frame) + xpointer, ypointer = find_free_space_to_paste_nodes( + nodes, direction="right", offset=200 + bdn_frame + ) # reset position to all nodes and replace inputs and output for n in nodes: - anlib.reset_selection() + reset_selection() xpos = (n.xpos() - xcursor) + xpointer ypos = (n.ypos() - ycursor) + ypointer n.setXYpos(xpos, ypos) @@ -108,7 +119,7 @@ class LoadBackdropNodes(api.Loader): d.setInput(index, dot) # remove Input node - anlib.reset_selection() + reset_selection() nuke.delete(n) continue @@ -127,15 +138,15 @@ class LoadBackdropNodes(api.Loader): dot.setInput(0, dep) # remove Input node - anlib.reset_selection() + reset_selection() nuke.delete(n) continue else: new_nodes.append(n) # reselect nodes with new Dot instead of Inputs and Output - anlib.reset_selection() - anlib.select_nodes(new_nodes) + reset_selection() + select_nodes(new_nodes) # place on backdrop bdn = nukescripts.autoBackdrop() @@ -208,16 +219,16 @@ class LoadBackdropNodes(api.Loader): # just in case we are in group lets jump out of it nuke.endGroup() - with anlib.maintained_selection(): + with maintained_selection(): xpos = GN.xpos() ypos = GN.ypos() - avalon_data = anlib.get_avalon_knob_data(GN) + avalon_data = get_avalon_knob_data(GN) nuke.delete(GN) # add group from nk nuke.nodePaste(file) GN = nuke.selectedNode() - anlib.set_avalon_knob_data(GN, avalon_data) + set_avalon_knob_data(GN, avalon_data) GN.setXYpos(xpos, ypos) GN["name"].setValue(object_name) @@ -243,7 +254,6 @@ class LoadBackdropNodes(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index 377d60e84b..b9d4bb358f 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -1,8 +1,15 @@ -from avalon import api, io -from avalon.nuke import lib as anlib -from avalon.nuke import containerise, update_container import nuke +from avalon import api, io +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) +from openpype.hosts.nuke.api.lib import ( + maintained_selection +) + class AlembicCameraLoader(api.Loader): """ @@ -43,7 +50,7 @@ class AlembicCameraLoader(api.Loader): # getting file path file = self.fname.replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): camera_node = nuke.createNode( "Camera2", "name {} file {} read_from_file True".format( @@ -122,7 +129,7 @@ class AlembicCameraLoader(api.Loader): # getting file path file = api.get_representation_path(representation).replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): camera_node = nuke.toNode(object_name) camera_node['selected'].setValue(True) @@ -181,7 +188,6 @@ class AlembicCameraLoader(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 9ce72c0519..712cdf213f 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -3,13 +3,13 @@ from avalon.vendor import qargparse from avalon import api, io from openpype.hosts.nuke.api.lib import ( - get_imageio_input_colorspace + get_imageio_input_colorspace, + maintained_selection ) -from avalon.nuke import ( +from openpype.hosts.nuke.api import ( containerise, update_container, - viewer_update_and_undo_stop, - maintained_selection + viewer_update_and_undo_stop ) from openpype.hosts.nuke.api import plugin @@ -280,9 +280,6 @@ class LoadClip(plugin.NukeLoader): self.set_as_member(read_node) def remove(self, container): - - from avalon.nuke import viewer_update_and_undo_stop - read_node = nuke.toNode(container['objectName']) assert read_node.Class() == "Read", "Must be Read" @@ -378,4 +375,4 @@ class LoadClip(plugin.NukeLoader): "class_name": self.__class__.__name__ } - return self.node_name_template.format(**name_data) \ No newline at end of file + return self.node_name_template.format(**name_data) diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index 8ba1b6b7c1..8b8867feba 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -1,7 +1,12 @@ -from avalon import api, style, io -import nuke import json from collections import OrderedDict +import nuke +from avalon import api, style, io +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LoadEffects(api.Loader): @@ -30,9 +35,6 @@ class LoadEffects(api.Loader): Returns: nuke node: containerised nuke node object """ - # import dependencies - from avalon.nuke import containerise - # get main variables version = context['version'] version_data = version.get("data", {}) @@ -138,10 +140,6 @@ class LoadEffects(api.Loader): inputs: """ - - from avalon.nuke import ( - update_container - ) # get main variables # Get version from io version = io.find_one({ @@ -338,7 +336,6 @@ class LoadEffects(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index d0cab26842..7948cbba9a 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -1,8 +1,15 @@ -from avalon import api, style, io -import nuke import json from collections import OrderedDict + +import nuke + +from avalon import api, style, io from openpype.hosts.nuke.api import lib +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LoadEffectsInputProcess(api.Loader): @@ -30,8 +37,6 @@ class LoadEffectsInputProcess(api.Loader): Returns: nuke node: containerised nuke node object """ - # import dependencies - from avalon.nuke import containerise # get main variables version = context['version'] @@ -142,9 +147,6 @@ class LoadEffectsInputProcess(api.Loader): """ - from avalon.nuke import ( - update_container - ) # get main variables # Get version from io version = io.find_one({ @@ -355,7 +357,6 @@ class LoadEffectsInputProcess(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index c6228b95f6..f549623b88 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -1,7 +1,15 @@ -from avalon import api, style, io import nuke -from avalon.nuke import lib as anlib -from avalon.nuke import containerise, update_container +from avalon import api, style, io +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + get_avalon_knob_data, + set_avalon_knob_data +) +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LoadGizmo(api.Loader): @@ -61,7 +69,7 @@ class LoadGizmo(api.Loader): # just in case we are in group lets jump out of it nuke.endGroup() - with anlib.maintained_selection(): + with maintained_selection(): # add group from nk nuke.nodePaste(file) @@ -122,16 +130,16 @@ class LoadGizmo(api.Loader): # just in case we are in group lets jump out of it nuke.endGroup() - with anlib.maintained_selection(): + with maintained_selection(): xpos = GN.xpos() ypos = GN.ypos() - avalon_data = anlib.get_avalon_knob_data(GN) + avalon_data = get_avalon_knob_data(GN) nuke.delete(GN) # add group from nk nuke.nodePaste(file) GN = nuke.selectedNode() - anlib.set_avalon_knob_data(GN, avalon_data) + set_avalon_knob_data(GN, avalon_data) GN.setXYpos(xpos, ypos) GN["name"].setValue(object_name) @@ -157,7 +165,6 @@ class LoadGizmo(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index 5ca101d6cb..4f17446673 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -1,8 +1,16 @@ from avalon import api, style, io import nuke -from openpype.hosts.nuke.api import lib as pnlib -from avalon.nuke import lib as anlib -from avalon.nuke import containerise, update_container +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + create_backdrop, + get_avalon_knob_data, + set_avalon_knob_data +) +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LoadGizmoInputProcess(api.Loader): @@ -62,7 +70,7 @@ class LoadGizmoInputProcess(api.Loader): # just in case we are in group lets jump out of it nuke.endGroup() - with anlib.maintained_selection(): + with maintained_selection(): # add group from nk nuke.nodePaste(file) @@ -128,16 +136,16 @@ class LoadGizmoInputProcess(api.Loader): # just in case we are in group lets jump out of it nuke.endGroup() - with anlib.maintained_selection(): + with maintained_selection(): xpos = GN.xpos() ypos = GN.ypos() - avalon_data = anlib.get_avalon_knob_data(GN) + avalon_data = get_avalon_knob_data(GN) nuke.delete(GN) # add group from nk nuke.nodePaste(file) GN = nuke.selectedNode() - anlib.set_avalon_knob_data(GN, avalon_data) + set_avalon_knob_data(GN, avalon_data) GN.setXYpos(xpos, ypos) GN["name"].setValue(object_name) @@ -197,8 +205,12 @@ class LoadGizmoInputProcess(api.Loader): viewer["input_process_node"].setValue(group_node_name) # put backdrop under - pnlib.create_backdrop(label="Input Process", layer=2, - nodes=[viewer, group_node], color="0x7c7faaff") + create_backdrop( + label="Input Process", + layer=2, + nodes=[viewer, group_node], + color="0x7c7faaff" + ) return True @@ -234,7 +246,6 @@ class LoadGizmoInputProcess(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 02a5b55c18..427167ca98 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -7,6 +7,11 @@ from avalon import api, io from openpype.hosts.nuke.api.lib import ( get_imageio_input_colorspace ) +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LoadImage(api.Loader): @@ -46,10 +51,6 @@ class LoadImage(api.Loader): return cls.representations + cls._representations def load(self, context, name, namespace, options): - from avalon.nuke import ( - containerise, - viewer_update_and_undo_stop - ) self.log.info("__ options: `{}`".format(options)) frame_number = options.get("frame_number", 1) @@ -154,11 +155,6 @@ class LoadImage(api.Loader): inputs: """ - - from avalon.nuke import ( - update_container - ) - node = nuke.toNode(container["objectName"]) frame_number = node["first"].value() @@ -234,9 +230,6 @@ class LoadImage(api.Loader): self.log.info("udated to version: {}".format(version.get("name"))) def remove(self, container): - - from avalon.nuke import viewer_update_and_undo_stop - node = nuke.toNode(container['objectName']) assert node.Class() == "Read", "Must be Read" diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 15fa4fa35c..8c8dc7f37d 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -1,7 +1,11 @@ -from avalon import api, io -from avalon.nuke import lib as anlib -from avalon.nuke import containerise, update_container import nuke +from avalon import api, io +from openpype.hosts.nuke.api.lib import maintained_selection +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class AlembicModelLoader(api.Loader): @@ -43,7 +47,7 @@ class AlembicModelLoader(api.Loader): # getting file path file = self.fname.replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): model_node = nuke.createNode( "ReadGeo2", "name {} file {} ".format( @@ -122,7 +126,7 @@ class AlembicModelLoader(api.Loader): # getting file path file = api.get_representation_path(representation).replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): model_node = nuke.toNode(object_name) model_node['selected'].setValue(True) @@ -181,7 +185,6 @@ class AlembicModelLoader(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index 7444dd6e96..8489283e8c 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -1,6 +1,11 @@ -from avalon import api, style, io -from avalon.nuke import get_avalon_knob_data import nuke +from avalon import api, style, io +from openpype.hosts.nuke.api.lib import get_avalon_knob_data +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LinkAsGroup(api.Loader): @@ -15,8 +20,6 @@ class LinkAsGroup(api.Loader): color = style.colors.alert def load(self, context, name, namespace, data): - - from avalon.nuke import containerise # for k, v in context.items(): # log.info("key: `{}`, value: {}\n".format(k, v)) version = context['version'] @@ -103,11 +106,6 @@ class LinkAsGroup(api.Loader): inputs: """ - - from avalon.nuke import ( - update_container - ) - node = nuke.toNode(container['objectName']) root = api.get_representation_path(representation).replace("\\", "/") @@ -155,7 +153,6 @@ class LinkAsGroup(api.Loader): self.log.info("udated to version: {}".format(version.get("name"))) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/publish/extract_backdrop.py b/openpype/hosts/nuke/plugins/publish/extract_backdrop.py index 0747c15ea7..0a2df0898e 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/extract_backdrop.py @@ -1,9 +1,16 @@ -import pyblish.api -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import lib as pnlib -import nuke import os + +import nuke + +import pyblish.api + import openpype +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + reset_selection, + select_nodes +) + class ExtractBackdropNode(openpype.api.Extractor): """Extracting content of backdrop nodes @@ -27,7 +34,7 @@ class ExtractBackdropNode(openpype.api.Extractor): path = os.path.join(stagingdir, filename) # maintain selection - with anlib.maintained_selection(): + with maintained_selection(): # all connections outside of backdrop connections_in = instance.data["nodeConnectionsIn"] connections_out = instance.data["nodeConnectionsOut"] @@ -44,7 +51,7 @@ class ExtractBackdropNode(openpype.api.Extractor): nodes.append(inpn) tmp_nodes.append(inpn) - anlib.reset_selection() + reset_selection() # connect output node for n, output in connections_out.items(): @@ -58,11 +65,11 @@ class ExtractBackdropNode(openpype.api.Extractor): opn.autoplace() nodes.append(opn) tmp_nodes.append(opn) - anlib.reset_selection() + reset_selection() # select nodes to copy - anlib.reset_selection() - anlib.select_nodes(nodes) + reset_selection() + select_nodes(nodes) # create tmp nk file # save file to the path nuke.nodeCopy(path) diff --git a/openpype/hosts/nuke/plugins/publish/extract_camera.py b/openpype/hosts/nuke/plugins/publish/extract_camera.py index bc50dac108..942cdc537d 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_camera.py +++ b/openpype/hosts/nuke/plugins/publish/extract_camera.py @@ -1,10 +1,12 @@ -import nuke import os import math +from pprint import pformat + +import nuke + import pyblish.api import openpype.api -from avalon.nuke import lib as anlib -from pprint import pformat +from openpype.hosts.nuke.api.lib import maintained_selection class ExtractCamera(openpype.api.Extractor): @@ -52,7 +54,7 @@ class ExtractCamera(openpype.api.Extractor): filename = subset + ".{}".format(extension) file_path = os.path.join(staging_dir, filename).replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): # bake camera with axeses onto word coordinate XYZ rm_n = bakeCameraWithAxeses( nuke.toNode(instance.data["name"]), output_range) diff --git a/openpype/hosts/nuke/plugins/publish/extract_gizmo.py b/openpype/hosts/nuke/plugins/publish/extract_gizmo.py index 78bf9c998d..2d5bfdeb5e 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_gizmo.py +++ b/openpype/hosts/nuke/plugins/publish/extract_gizmo.py @@ -1,9 +1,15 @@ -import pyblish.api -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import utils as pnutils -import nuke import os +import nuke + +import pyblish.api + import openpype +from openpype.hosts.nuke.api import utils as pnutils +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + reset_selection, + select_nodes +) class ExtractGizmo(openpype.api.Extractor): @@ -26,17 +32,17 @@ class ExtractGizmo(openpype.api.Extractor): path = os.path.join(stagingdir, filename) # maintain selection - with anlib.maintained_selection(): + with maintained_selection(): orig_grpn_name = orig_grpn.name() tmp_grpn_name = orig_grpn_name + "_tmp" # select original group node - anlib.select_nodes([orig_grpn]) + select_nodes([orig_grpn]) # copy to clipboard nuke.nodeCopy("%clipboard%") # reset selection to none - anlib.reset_selection() + reset_selection() # paste clipboard nuke.nodePaste("%clipboard%") diff --git a/openpype/hosts/nuke/plugins/publish/extract_model.py b/openpype/hosts/nuke/plugins/publish/extract_model.py index 43214bf3e9..0375263338 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_model.py +++ b/openpype/hosts/nuke/plugins/publish/extract_model.py @@ -1,9 +1,12 @@ -import nuke import os +from pprint import pformat +import nuke import pyblish.api import openpype.api -from avalon.nuke import lib as anlib -from pprint import pformat +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + select_nodes +) class ExtractModel(openpype.api.Extractor): @@ -49,9 +52,9 @@ class ExtractModel(openpype.api.Extractor): filename = subset + ".{}".format(extension) file_path = os.path.join(staging_dir, filename).replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): # select model node - anlib.select_nodes([model_node]) + select_nodes([model_node]) # create write geo node wg_n = nuke.createNode("WriteGeo") diff --git a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py index c3a6a3b167..e38927c3a7 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py +++ b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py @@ -1,6 +1,6 @@ import nuke import pyblish.api -from avalon.nuke import maintained_selection +from openpype.hosts.nuke.api.lib import maintained_selection class CreateOutputNode(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index 8ba746a3c4..4cf2fd7d9f 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -1,8 +1,8 @@ import os import pyblish.api -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import openpype +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import maintained_selection class ExtractReviewDataLut(openpype.api.Extractor): @@ -37,7 +37,7 @@ class ExtractReviewDataLut(openpype.api.Extractor): "StagingDir `{0}`...".format(instance.data["stagingDir"])) # generate data - with anlib.maintained_selection(): + with maintained_selection(): exporter = plugin.ExporterReviewLut( self, instance ) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 32962b57a6..13d23ffb9c 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -1,8 +1,8 @@ import os import pyblish.api -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import openpype +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import maintained_selection class ExtractReviewDataMov(openpype.api.Extractor): @@ -41,7 +41,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): self.log.info(self.outputs) # generate data - with anlib.maintained_selection(): + with maintained_selection(): generated_repres = [] for o_name, o_data in self.outputs.items(): f_families = o_data["filter"]["families"] diff --git a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py index 0f68680742..50e5f995f4 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -1,8 +1,8 @@ import os import nuke -from avalon.nuke import lib as anlib import pyblish.api import openpype +from openpype.hosts.nuke.api.lib import maintained_selection class ExtractSlateFrame(openpype.api.Extractor): @@ -25,7 +25,7 @@ class ExtractSlateFrame(openpype.api.Extractor): else: self.viewer_lut_raw = False - with anlib.maintained_selection(): + with maintained_selection(): self.log.debug("instance: {}".format(instance)) self.log.debug("instance.data[families]: {}".format( instance.data["families"])) diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index 0c9af66435..ef6d486ca2 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -1,9 +1,9 @@ import sys import os import nuke -from avalon.nuke import lib as anlib import pyblish.api import openpype +from openpype.hosts.nuke.api.lib import maintained_selection if sys.version_info[0] >= 3: @@ -30,7 +30,7 @@ class ExtractThumbnail(openpype.api.Extractor): if "render.farm" in instance.data["families"]: return - with anlib.maintained_selection(): + with maintained_selection(): self.log.debug("instance: {}".format(instance)) self.log.debug("instance.data[families]: {}".format( instance.data["families"])) diff --git a/openpype/hosts/nuke/plugins/publish/precollect_instances.py b/openpype/hosts/nuke/plugins/publish/precollect_instances.py index 5c30df9a62..97ddef0a59 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_instances.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_instances.py @@ -1,7 +1,10 @@ import nuke import pyblish.api from avalon import io, api -from avalon.nuke import lib as anlib +from openpype.hosts.nuke.api.lib import ( + add_publish_knob, + get_avalon_knob_data +) @pyblish.api.log @@ -39,7 +42,7 @@ class PreCollectNukeInstances(pyblish.api.ContextPlugin): self.log.warning(E) # get data from avalon knob - avalon_knob_data = anlib.get_avalon_knob_data( + avalon_knob_data = get_avalon_knob_data( node, ["avalon:", "ak:"]) self.log.debug("avalon_knob_data: {}".format(avalon_knob_data)) @@ -115,7 +118,7 @@ class PreCollectNukeInstances(pyblish.api.ContextPlugin): # get publish knob value if "publish" not in node.knobs(): - anlib.add_publish_knob(node) + add_publish_knob(node) # sync workfile version _families_test = [family] + families diff --git a/openpype/hosts/nuke/plugins/publish/precollect_workfile.py b/openpype/hosts/nuke/plugins/publish/precollect_workfile.py index 0e27273ceb..a2d1c80628 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_workfile.py @@ -1,8 +1,13 @@ -import nuke -import pyblish.api import os + +import nuke + +import pyblish.api import openpype.api as pype -from avalon.nuke import lib as anlib +from openpype.hosts.nuke.api.lib import ( + add_publish_knob, + get_avalon_knob_data +) class CollectWorkfile(pyblish.api.ContextPlugin): @@ -17,9 +22,9 @@ class CollectWorkfile(pyblish.api.ContextPlugin): current_file = os.path.normpath(nuke.root().name()) - knob_data = anlib.get_avalon_knob_data(root) + knob_data = get_avalon_knob_data(root) - anlib.add_publish_knob(root) + add_publish_knob(root) family = "workfile" task = os.getenv("AVALON_TASK", None) diff --git a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py index f280ad4af1..7694c3d2ba 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py @@ -1,6 +1,6 @@ -import pyblish -from avalon.nuke import lib as anlib import nuke +import pyblish +from openpype.hosts.nuke.api.lib import maintained_selection class SelectCenterInNodeGraph(pyblish.api.Action): @@ -28,7 +28,7 @@ class SelectCenterInNodeGraph(pyblish.api.Action): all_yC = list() # maintain selection - with anlib.maintained_selection(): + with maintained_selection(): # collect all failed nodes xpos and ypos for instance in instances: bdn = instance[0] diff --git a/openpype/hosts/nuke/plugins/publish/validate_gizmo.py b/openpype/hosts/nuke/plugins/publish/validate_gizmo.py index 9c94ea88ef..d0d930f50c 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_gizmo.py +++ b/openpype/hosts/nuke/plugins/publish/validate_gizmo.py @@ -1,6 +1,6 @@ -import pyblish -from avalon.nuke import lib as anlib import nuke +import pyblish +from openpype.hosts.nuke.api.lib import maintained_selection class OpenFailedGroupNode(pyblish.api.Action): @@ -25,7 +25,7 @@ class OpenFailedGroupNode(pyblish.api.Action): instances = pyblish.api.instances_by_plugin(failed, plugin) # maintain selection - with anlib.maintained_selection(): + with maintained_selection(): # collect all failed nodes xpos and ypos for instance in instances: grpn = instance[0] diff --git a/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py b/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py index ddf46a0873..842f74b6f6 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py +++ b/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py @@ -6,8 +6,11 @@ import nuke import pyblish.api import openpype.api -import avalon.nuke.lib -import openpype.hosts.nuke.api as nuke_api +from openpype.hosts.nuke.api.lib import ( + recreate_instance, + reset_selection, + select_nodes +) class SelectInvalidInstances(pyblish.api.Action): @@ -47,12 +50,12 @@ class SelectInvalidInstances(pyblish.api.Action): self.deselect() def select(self, instances): - avalon.nuke.lib.select_nodes( + select_nodes( [nuke.toNode(str(x)) for x in instances] ) def deselect(self): - avalon.nuke.lib.reset_selection() + reset_selection() class RepairSelectInvalidInstances(pyblish.api.Action): @@ -82,7 +85,7 @@ class RepairSelectInvalidInstances(pyblish.api.Action): context_asset = context.data["assetEntity"]["name"] for instance in instances: origin_node = instance[0] - nuke_api.lib.recreate_instance( + recreate_instance( origin_node, avalon_data={"asset": context_asset} ) diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py b/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py index ba34ec8338..a73bed8edd 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py @@ -1,13 +1,12 @@ -import toml import os +import toml import nuke from avalon import api -import re import pyblish.api import openpype.api -from avalon.nuke import get_avalon_knob_data +from openpype.hosts.nuke.api.lib import get_avalon_knob_data class ValidateWriteLegacy(pyblish.api.InstancePlugin): diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py index 732f321b85..c0d5c8f402 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py @@ -1,8 +1,11 @@ import os import pyblish.api import openpype.utils -import openpype.hosts.nuke.lib as nukelib -import avalon.nuke +from openpype.hosts.nuke.api.lib import ( + get_write_node_template_attr, + get_node_path +) + @pyblish.api.log class RepairNukeWriteNodeAction(pyblish.api.Action): @@ -15,7 +18,7 @@ class RepairNukeWriteNodeAction(pyblish.api.Action): for instance in instances: node = instance[1] - correct_data = nukelib.get_write_node_template_attr(node) + correct_data = get_write_node_template_attr(node) for k, v in correct_data.items(): node[k].setValue(v) self.log.info("Node attributes were fixed") @@ -34,14 +37,14 @@ class ValidateNukeWriteNode(pyblish.api.InstancePlugin): def process(self, instance): node = instance[1] - correct_data = nukelib.get_write_node_template_attr(node) + correct_data = get_write_node_template_attr(node) check = [] for k, v in correct_data.items(): if k is 'file': padding = len(v.split('#')) - ref_path = avalon.nuke.lib.get_node_path(v, padding) - n_path = avalon.nuke.lib.get_node_path(node[k].value(), padding) + ref_path = get_node_path(v, padding) + n_path = get_node_path(node[k].value(), padding) isnt = False for i, p in enumerate(ref_path): if str(n_path[i]) not in str(p): diff --git a/openpype/hosts/nuke/startup/init.py b/openpype/hosts/nuke/startup/init.py index 0ea5d1ad7d..d7560814bf 100644 --- a/openpype/hosts/nuke/startup/init.py +++ b/openpype/hosts/nuke/startup/init.py @@ -1,2 +1,4 @@ +import nuke + # default write mov nuke.knobDefault('Write.mov.colorspace', 'sRGB') diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index b7ed35b3b4..2cac6d09e7 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -1,14 +1,19 @@ +import nuke +import avalon.api + +from openpype.api import Logger +from openpype.hosts.nuke import api from openpype.hosts.nuke.api.lib import ( on_script_load, check_inventory_versions, - WorkfileSettings + WorkfileSettings, + dirmap_file_name_filter ) -import nuke -from openpype.api import Logger -from openpype.hosts.nuke.api.lib import dirmap_file_name_filter +log = Logger.get_logger(__name__) -log = Logger().get_logger(__name__) + +avalon.api.install(api) # fix ffmpeg settings on script nuke.addOnScriptLoad(on_script_load) From 26d8304fd9704f04bd9ac076d193dc1646e4a38b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 12:27:09 +0100 Subject: [PATCH 235/395] removed avalon nuke path from add implementation environments --- openpype/hosts/nuke/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/__init__.py b/openpype/hosts/nuke/__init__.py index 366f704dd8..60b37ce1dd 100644 --- a/openpype/hosts/nuke/__init__.py +++ b/openpype/hosts/nuke/__init__.py @@ -6,10 +6,7 @@ def add_implementation_envs(env, _app): # Add requirements to NUKE_PATH pype_root = os.environ["OPENPYPE_REPOS_ROOT"] new_nuke_paths = [ - os.path.join(pype_root, "openpype", "hosts", "nuke", "startup"), - os.path.join( - pype_root, "repos", "avalon-core", "setup", "nuke", "nuke_path" - ) + os.path.join(pype_root, "openpype", "hosts", "nuke", "startup") ] old_nuke_path = env.get("NUKE_PATH") or "" for path in old_nuke_path.split(os.pathsep): From 9980aa90fa196eb07e57ea7155b7ce98469d81e9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 12:32:21 +0100 Subject: [PATCH 236/395] fix default value of function argument --- openpype/hosts/nuke/api/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/utils.py b/openpype/hosts/nuke/api/utils.py index f8f248357b..205b23efe6 100644 --- a/openpype/hosts/nuke/api/utils.py +++ b/openpype/hosts/nuke/api/utils.py @@ -49,12 +49,14 @@ def gizmo_is_nuke_default(gizmo): return gizmo.filename().startswith(plug_dir) -def bake_gizmos_recursively(in_group=nuke.Root()): +def bake_gizmos_recursively(in_group=None): """Converting a gizmo to group Argumets: is_group (nuke.Node)[optonal]: group node or all nodes """ + if in_group is None: + in_group = nuke.Root() # preserve selection after all is done with maintained_selection(): # jump to the group From 197b2d33a672e4ece48bfe7b6f5b38076c2209bd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 13:30:49 +0100 Subject: [PATCH 237/395] flame: instance collector update --- .../plugins/publish/precollect_instances.py | 151 ++++++++++-------- 1 file changed, 88 insertions(+), 63 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index e302bc42a4..fa007b3efd 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -1,6 +1,7 @@ import pyblish -# import openpype +import openpype import openpype.hosts.flame.api as opfapi +from openpype.hosts.flame.otio import flame_export # # developer reload modules from pprint import pformat @@ -20,10 +21,11 @@ class PrecollectInstances(pyblish.api.ContextPlugin): sequence = context.data["flameSequence"] self.otio_timeline = context.data["otioTimeline"] self.clips_in_reels = opfapi.get_clips_in_reels(project) + self.fps = context.data["fps"] # process all sellected - with opfapi.maintained_segment_selection(sequence) as selected_segments: - for segment in selected_segments: + with opfapi.maintained_segment_selection(sequence) as segments: + for segment in segments: clip_data = opfapi.get_segment_attributes(segment) clip_name = clip_data["segment_name"] self.log.debug("clip_name: {}".format(clip_name)) @@ -38,21 +40,15 @@ class PrecollectInstances(pyblish.api.ContextPlugin): if marker_data.get("id") != "pyblish.avalon.instance": continue + # get file path file_path = clip_data["fpath"] + + # get source clip + source_clip = self._get_reel_clip(file_path) + first_frame = opfapi.get_frame_from_path(file_path) or 0 - # calculate head and tail with forward compatibility - head = clip_data.get("segment_head") - tail = clip_data.get("segment_tail") - - if not head: - head = int(clip_data["source_in"]) - int(first_frame) - if not tail: - tail = int( - clip_data["source_duration"] - ( - head + clip_data["record_duration"] - ) - ) + head, tail = self._get_head_tail(clip_data, first_frame) # solve handles length marker_data["handleStart"] = min( @@ -93,17 +89,19 @@ class PrecollectInstances(pyblish.api.ContextPlugin): "item": segment, "families": families, "publish": marker_data["publish"], - "fps": context.data["fps"], + "fps": self.fps, + "flameSourceClip": source_clip, + "sourceFirstFrame": first_frame }) - # # otio clip data - # otio_data = self.get_otio_clip_instance_data(segment) or {} - # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) - # data.update(otio_data) - # self.log.debug("__ data: {}".format(pformat(data))) + # otio clip data + otio_data = self._get_otio_clip_instance_data(clip_data) or {} + self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + data.update(otio_data) + self.log.debug("__ data: {}".format(pformat(data))) - # # add resolution - # self.get_resolution_to_data(data, context) + # add resolution + self._get_resolution_to_data(data, context) # create instance instance = context.create_instance(**data) @@ -116,7 +114,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): }) # create shot instance for shot attributes create/update - self.create_shot_instance(context, clip_name, **data) + self._create_shot_instance(context, clip_name, **data) self.log.info("Creating instance: {}".format(instance)) self.log.info( @@ -130,7 +128,30 @@ class PrecollectInstances(pyblish.api.ContextPlugin): if marker_data.get("reviewTrack") is not None: instance.data["reviewAudio"] = True - def get_resolution_to_data(self, data, context): + def _get_head_tail(self, clip_data, first_frame): + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") + + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) + ) + return head, tail + + def _get_reel_clip(self, path): + match_reel_clip = [ + clip for clip in self.clips_in_reels + if clip["fpath"] == path + ] + if match_reel_clip: + return match_reel_clip.pop() + + def _get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" # solve source resolution option @@ -155,7 +176,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): "openpype.timeline.pixelAspect"] }) - def create_shot_instance(self, context, clip_name, **data): + def _create_shot_instance(self, context, clip_name, **data): master_layer = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") asset = data.get("asset") @@ -193,47 +214,51 @@ class PrecollectInstances(pyblish.api.ContextPlugin): self.log.debug( "_ instance.data: {}".format(pformat(instance.data))) - # def get_otio_clip_instance_data(self, segment): - # """ - # Return otio objects for timeline, track and clip + def _get_otio_clip_instance_data(self, clip_data): + """ + Return otio objects for timeline, track and clip - # Args: - # timeline_item_data (dict): timeline_item_data from list returned by - # resolve.get_current_timeline_items() - # otio_timeline (otio.schema.Timeline): otio object + Args: + timeline_item_data (dict): timeline_item_data from list returned by + resolve.get_current_timeline_items() + otio_timeline (otio.schema.Timeline): otio object - # Returns: - # dict: otio clip object + Returns: + dict: otio clip object - # """ - # ti_track_name = segment.parent().name() - # timeline_range = self.create_otio_time_range_from_timeline_item_data( - # segment) - # for otio_clip in self.otio_timeline.each_clip(): - # track_name = otio_clip.parent().name - # parent_range = otio_clip.range_in_parent() - # if ti_track_name not in track_name: - # continue - # if otio_clip.name not in segment.name(): - # continue - # if openpype.lib.is_overlapping_otio_ranges( - # parent_range, timeline_range, strict=True): + """ + segment = clip_data["PySegment"] - # # add pypedata marker to otio_clip metadata - # for marker in otio_clip.markers: - # if phiero.pype_tag_name in marker.name: - # otio_clip.metadata.update(marker.metadata) - # return {"otioClip": otio_clip} + self.log.debug( + ">> flame Track.dir: {}".format(dir(segment.parent))) + s_track_name = segment.parent.name.get_value() - # return None + timeline_range = self._create_otio_time_range_from_timeline_item_data( + clip_data) - # @staticmethod - # def create_otio_time_range_from_timeline_item_data(segment): - # speed = segment.playbackSpeed() - # timeline = phiero.get_current_sequence() - # frame_start = int(segment.timelineIn()) - # frame_duration = int(segment.sourceDuration() / speed) - # fps = timeline.framerate().toFloat() + for otio_clip in self.otio_timeline.each_clip(): + self.log.debug( + ">> OTIO Track.dir: {}".format(dir(otio_clip.parent()))) + track_name = otio_clip.parent().name + parent_range = otio_clip.range_in_parent() + if s_track_name not in track_name: + continue + if otio_clip.name not in segment.name.get_value(): + continue + if openpype.lib.is_overlapping_otio_ranges( + parent_range, timeline_range, strict=True): - # return hiero_export.create_otio_time_range( - # frame_start, frame_duration, fps) + # add pypedata marker to otio_clip metadata + for marker in otio_clip.markers: + if opfapi.MARKER_NAME in marker.name: + otio_clip.metadata.update(marker.metadata) + return {"otioClip": otio_clip} + + return None + + def _create_otio_time_range_from_timeline_item_data(self, clip_data): + frame_start = int(clip_data["record_in"]) + frame_duration = int(clip_data["record_duration"]) + + return flame_export.create_otio_time_range( + frame_start, frame_duration, self.fps) From ac4302793b58c0e62f89ddee1764b3697bfc097b Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 Jan 2022 13:34:27 +0100 Subject: [PATCH 238/395] fix multiple copies of loaded proxy assignment --- openpype/tools/mayalookassigner/commands.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py index a1f26f01ab..b9402d8ea1 100644 --- a/openpype/tools/mayalookassigner/commands.py +++ b/openpype/tools/mayalookassigner/commands.py @@ -116,8 +116,9 @@ def create_asset_id_hash(nodes): ids = get_alembic_ids_cache(path) for k, _ in ids.items(): pid = k.split(":")[0] - if not node_id_hash.get(pid): - node_id_hash[pid] = [node] + if node not in node_id_hash[pid]: + node_id_hash[pid].append(node) + else: value = lib.get_id(node) if value is None: @@ -150,20 +151,6 @@ def create_items_from_nodes(nodes): id_hashes = create_asset_id_hash(nodes) - # get ids from alembic - if cmds.pluginInfo('vrayformaya', query=True, loaded=True): - vray_proxy_nodes = cmds.ls(nodes, type="VRayProxy") - for vp in vray_proxy_nodes: - path = cmds.getAttr("{}.fileName".format(vp)) - ids = get_alembic_ids_cache(path) - parent_id = {} - for k, _ in ids.items(): - pid = k.split(":")[0] - if not parent_id.get(pid): - parent_id[pid] = [vp] - print("Adding ids from alembic {}".format(path)) - id_hashes.update(parent_id) - if not id_hashes: log.warning("No id hashes") return asset_view_items From c01ed46157fe70346a5a6e3b639624fe6ca551b9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 14:11:49 +0100 Subject: [PATCH 239/395] added ability to skip 3rd part lib validations --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 92cc76dc7a..6891b3c419 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,9 @@ def validate_thirdparty_binaries(): raise RuntimeError(error_msg.format("OpenImageIO")) -validate_thirdparty_binaries() +# Give ability to skip vaidation +if not os.getenv("SKIP_THIRD_PARTY_VALIDATION"): + validate_thirdparty_binaries() version = {} From 34a9269688290169d072fe1786c89674d5015047 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 12 Jan 2022 14:33:31 +0100 Subject: [PATCH 240/395] Refactor publish families `mayaAscii` -> `mayaScene` --- .../projects_schema/schemas/template_publish_families.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json index 9db1427562..b5e33e2cf9 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json @@ -17,7 +17,7 @@ {"editorial": "editorial"}, {"layout": "layout"}, {"look": "look"}, - {"mayaAscii": "mayaAscii"}, + {"mayaScene": "mayaScene"}, {"model": "model"}, {"pointcache": "pointcache"}, {"reference": "reference"}, From 67138f2787bb871b79ab865e162e53a542414779 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 14:35:20 +0100 Subject: [PATCH 241/395] flame: fix correct search condition --- openpype/hosts/flame/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index e53127503b..b963a1cb39 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -559,7 +559,7 @@ def get_segment_attributes(segment): attr = getattr(segment, attr_name) segment_attrs_data[attr] = str(attr).replace("+", ":") - if attr in ["record_in", "record_out"]: + if attr_name in ["record_in", "record_out"]: clip_data[attr_name] = attr.relative_frame else: clip_data[attr_name] = attr.frame From 56035a1dbbf7a91953ed85e685e188c2fc068b85 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 12 Jan 2022 14:43:05 +0100 Subject: [PATCH 242/395] Allow space in filenames when converting with `maketx` --- openpype/hosts/maya/plugins/publish/extract_look.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 953539f65c..a101a627cf 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -75,8 +75,12 @@ def maketx(source, destination, *args): "--filter lanczos3", ] + def _escape(path): + # Ensure path is enclosed by quotes to allow paths with spaces + return '"{}"'.format(path) + cmd.extend(args) - cmd.extend(["-o", destination, source]) + cmd.extend(["-o", _escape(destination), _escape(source)]) cmd = " ".join(cmd) From 36a4261db7e35c86c906adebce86321b2eb3bd10 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 12 Jan 2022 14:56:50 +0100 Subject: [PATCH 243/395] Escape space in filename in sourceHash too by enclosing in quotes --- .../hosts/maya/plugins/publish/extract_look.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index a101a627cf..6f7b438408 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -22,6 +22,11 @@ COPY = 1 HARDLINK = 2 +def escape_space(path): + """Ensure path is enclosed by quotes to allow paths with spaces""" + return '"{}"'.format(path) if " " in path else path + + def find_paths_by_hash(texture_hash): """Find the texture hash key in the dictionary. @@ -57,7 +62,7 @@ def maketx(source, destination, *args): """ from openpype.lib import get_oiio_tools_path - maketx_path = get_oiio_tools_path("maketx") + maketx_path = get_oiio_tools_path("maketx") + ".exe" if not os.path.exists(maketx_path): print( "OIIO tool not found in {}".format(maketx_path)) @@ -75,12 +80,8 @@ def maketx(source, destination, *args): "--filter lanczos3", ] - def _escape(path): - # Ensure path is enclosed by quotes to allow paths with spaces - return '"{}"'.format(path) - cmd.extend(args) - cmd.extend(["-o", _escape(destination), _escape(source)]) + cmd.extend(["-o", escape_space(destination), escape_space(source)]) cmd = " ".join(cmd) @@ -318,7 +319,6 @@ class ExtractLook(openpype.api.Extractor): do_maketx = instance.data.get("maketx", False) # Collect all unique files used in the resources - files = set() files_metadata = {} for resource in resources: # Preserve color space values (force value after filepath change) @@ -329,7 +329,6 @@ class ExtractLook(openpype.api.Extractor): for f in resource["files"]: files_metadata[os.path.normpath(f)] = { "color_space": color_space} - # files.update(os.path.normpath(f)) # Process the resource files transfers = [] @@ -337,7 +336,6 @@ class ExtractLook(openpype.api.Extractor): hashes = {} force_copy = instance.data.get("forceCopy", False) - self.log.info(files) for filepath in files_metadata: linearize = False @@ -496,7 +494,7 @@ class ExtractLook(openpype.api.Extractor): # Include `source-hash` as string metadata "-sattrib", "sourceHash", - texture_hash, + escape_space(texture_hash), colorconvert, ) From 3b101627978709a1bf5d648a5616286642a7f8d7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 12 Jan 2022 15:36:16 +0100 Subject: [PATCH 244/395] Revert adding the .exe to filepath (was for testing on my end only) --- openpype/hosts/maya/plugins/publish/extract_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 6f7b438408..bf79ddbf44 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -62,7 +62,7 @@ def maketx(source, destination, *args): """ from openpype.lib import get_oiio_tools_path - maketx_path = get_oiio_tools_path("maketx") + ".exe" + maketx_path = get_oiio_tools_path("maketx") if not os.path.exists(maketx_path): print( "OIIO tool not found in {}".format(maketx_path)) From f0a11fa0bfdf4ac33218c4154cd150cbe5a99490 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 15:37:11 +0100 Subject: [PATCH 245/395] flame: fix otio path padding --- openpype/hosts/flame/otio/flame_export.py | 2 +- openpype/hosts/flame/otio/utils.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index aea1f387e8..615904ec09 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -295,7 +295,7 @@ def create_otio_reference(clip_data): if is_sequence: metadata.update({ "isSequence": True, - "padding": padding + "padding": len(padding) }) otio_ex_ref_item = None diff --git a/openpype/hosts/flame/otio/utils.py b/openpype/hosts/flame/otio/utils.py index 229946343b..57a15d65a1 100644 --- a/openpype/hosts/flame/otio/utils.py +++ b/openpype/hosts/flame/otio/utils.py @@ -1,4 +1,5 @@ import re +import os import opentimelineio as otio import logging log = logging.getLogger(__name__) @@ -33,19 +34,21 @@ def get_reformated_path(path, padded=True): get_reformated_path("plate.1001.exr") > plate.%04d.exr """ - padding = get_padding_from_path(path) - found = get_frame_from_path(path) + basename = os.path.basename(path) + dirpath = os.path.dirname(path) + padding = get_padding_from_path(basename) + found = get_frame_from_path(basename) if not found: log.info("Path is not sequence: {}".format(path)) return path if padded: - path = path.replace(found, "%0{}d".format(padding)) + basename = basename.replace(found, "%0{}d".format(padding)) else: - path = path.replace(found, "%d") + basename = basename.replace(found, "%d") - return path + return os.path.join(dirpath, basename) def get_padding_from_path(path): From 10de030e133d7beca656515638da48312b33751f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 15:40:33 +0100 Subject: [PATCH 246/395] flame: adding host to global plugins --- openpype/plugins/publish/collect_hierarchy.py | 2 +- openpype/plugins/publish/collect_otio_frame_ranges.py | 2 +- openpype/plugins/publish/collect_otio_review.py | 2 +- openpype/plugins/publish/collect_otio_subset_resources.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index f7d1c6b4be..7f7306f73b 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -15,7 +15,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): label = "Collect Hierarchy" order = pyblish.api.CollectorOrder - 0.47 families = ["shot"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, context): temp_context = {} diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index a35ef47e79..511ed757b3 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -20,7 +20,7 @@ class CollectOcioFrameRanges(pyblish.api.InstancePlugin): label = "Collect OTIO Frame Ranges" order = pyblish.api.CollectorOrder - 0.48 families = ["shot", "clip"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, instance): # get basic variables diff --git a/openpype/plugins/publish/collect_otio_review.py b/openpype/plugins/publish/collect_otio_review.py index 10ceafdcca..6634be0671 100644 --- a/openpype/plugins/publish/collect_otio_review.py +++ b/openpype/plugins/publish/collect_otio_review.py @@ -22,7 +22,7 @@ class CollectOcioReview(pyblish.api.InstancePlugin): label = "Collect OTIO Review" order = pyblish.api.CollectorOrder - 0.47 families = ["clip"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, instance): # get basic variables diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index 571d0d56a4..d740ceb508 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -20,7 +20,7 @@ class CollectOcioSubsetResources(pyblish.api.InstancePlugin): label = "Collect OTIO Subset Resources" order = pyblish.api.CollectorOrder - 0.47 families = ["clip"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, instance): From 35f721fc8ac41de47473f93eeb8a650dfa76c8a0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 16:08:09 +0100 Subject: [PATCH 247/395] flame: adding file path to instance data --- .../hosts/flame/plugins/publish/precollect_instances.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index fa007b3efd..a093bb82fa 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -91,7 +91,8 @@ class PrecollectInstances(pyblish.api.ContextPlugin): "publish": marker_data["publish"], "fps": self.fps, "flameSourceClip": source_clip, - "sourceFirstFrame": first_frame + "sourceFirstFrame": first_frame, + "path": file_path }) # otio clip data @@ -228,17 +229,11 @@ class PrecollectInstances(pyblish.api.ContextPlugin): """ segment = clip_data["PySegment"] - - self.log.debug( - ">> flame Track.dir: {}".format(dir(segment.parent))) s_track_name = segment.parent.name.get_value() - timeline_range = self._create_otio_time_range_from_timeline_item_data( clip_data) for otio_clip in self.otio_timeline.each_clip(): - self.log.debug( - ">> OTIO Track.dir: {}".format(dir(otio_clip.parent()))) track_name = otio_clip.parent().name parent_range = otio_clip.range_in_parent() if s_track_name not in track_name: From 39578a4a5104737a5e5bbcaa44bd8eebc64cebe2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 16:11:19 +0100 Subject: [PATCH 248/395] flame: adding host to ftrack plugin --- .../ftrack/plugins/publish/integrate_hierarchy_ftrack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py b/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py index fbd64d9f70..61892240d7 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py @@ -63,7 +63,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder - 0.04 label = 'Integrate Hierarchy To Ftrack' families = ["shot"] - hosts = ["hiero", "resolve", "standalonepublisher"] + hosts = ["hiero", "resolve", "standalonepublisher", "flame"] optional = False def process(self, context): From 95dcc57d0f175490a73e37852238c875cad1816f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jan 2022 16:41:51 +0100 Subject: [PATCH 249/395] OP-1730 - add permission to list channels Will be used to delete messages or files --- openpype/modules/slack/manifest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/modules/slack/manifest.yml b/openpype/modules/slack/manifest.yml index bd920ac266..7a65cc5915 100644 --- a/openpype/modules/slack/manifest.yml +++ b/openpype/modules/slack/manifest.yml @@ -18,6 +18,7 @@ oauth_config: - chat:write.customize - chat:write.public - files:write + - channels:read settings: org_deploy_enabled: false socket_mode_enabled: false From 57a51c28a38a6313f3df08e54acefe69f650227a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jan 2022 16:43:12 +0100 Subject: [PATCH 250/395] OP-1730 - added icon and documentation Slack requires peculiar format of icon, provided one. --- .../modules/slack/resources/openpype_icon.png | Bin 0 -> 105495 bytes website/docs/module_slack.md | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 openpype/modules/slack/resources/openpype_icon.png diff --git a/openpype/modules/slack/resources/openpype_icon.png b/openpype/modules/slack/resources/openpype_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bb38dcf5778d06b75793b70c8c92176062e8c90b GIT binary patch literal 105495 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≫Lb-DaL0}w^*iUAX- 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

-|1{^JYf%l_9# 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 Basic information > Display Information. +You can upload any image you want, or for your convenience locate prepared OpenPype icon in your installed Openpype installation in `openpype\modules\slac\resources`. ## System Settings From e9cbcf917c1cce6984254cbee7e72e4e163568b0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jan 2022 16:58:04 +0100 Subject: [PATCH 251/395] OP-1730 - added logging sent messages and uploaded files into db --- .../plugins/publish/integrate_slack_api.py | 65 ++++++++++++------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 5aba372549..6afbbf5849 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -2,8 +2,10 @@ import os import six import pyblish.api import copy +from datetime import datetime from openpype.lib.plugin_tools import prepare_template_data +from openpype.lib import OpenPypeMongoConnection class IntegrateSlackAPI(pyblish.api.InstancePlugin): @@ -41,23 +43,38 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): if not message: return - # if message_profile["upload_thumbnail"] and thumbnail_path: - # publish_files.add(thumbnail_path) + if message_profile["upload_thumbnail"] and thumbnail_path: + publish_files.add(thumbnail_path) if message_profile["upload_review"] and review_path: publish_files.add(review_path) + project = instance.context.data["anatomyData"]["project"]["code"] for channel in message_profile["channels"]: if six.PY2: - self._python2_call(instance.data["slack_token"], - channel, - message, - publish_files) + msg_id, file_ids = \ + self._python2_call(instance.data["slack_token"], + channel, + message, + publish_files) else: - self._python3_call(instance.data["slack_token"], - channel, - message, - publish_files) + msg_id, file_ids = \ + self._python3_call(instance.data["slack_token"], + channel, + message, + publish_files) + + msg = { + "type": "slack", + "msg_id": msg_id, + "file_ids": file_ids, + "project": project, + "created_dt": datetime.now() + } + mongo_client = OpenPypeMongoConnection.get_mongo_client() + database_name = os.environ["OPENPYPE_DATABASE_NAME"] + dbcon = mongo_client[database_name]["notification_messages"] + dbcon.insert_one(msg) def _get_filled_message(self, message_templ, instance, review_path=None): """Use message_templ and data from instance to get message content. @@ -85,7 +102,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): task_data = fill_data.get("task") for key, value in task_data.items(): fill_key = "task[{}]".format(key) - fill_pairs.append((fill_key , value)) + fill_pairs.append((fill_key, value)) fill_pairs.append(("task", task_data["name"])) self.log.debug("fill_pairs ::{}".format(fill_pairs)) @@ -126,23 +143,24 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): break return published_path - def _python2_call(self, token, channel, message, - publish_files): + def _python2_call(self, token, channel, message, publish_files): from slackclient import SlackClient try: client = SlackClient(token) - self.log.info("publish {}".format(publish_files)) attachment_str = "\n\n Attachment links: \n" + file_ids = [] for p_file in publish_files: with open(p_file, 'rb') as pf: response = client.api_call( "files.upload", - channels=channel, - file=pf + file=pf, + channel=channel, + title=os.path.basename(p_file) ) attachment_str += "\n<{}|{}>".format( response["file"]["permalink"], os.path.basename(p_file)) + file_ids.append(response["file"]["id"]) if publish_files: message += attachment_str @@ -152,23 +170,24 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): channel=channel, text=message ) - self.log.info("repsonse {}".format(response)) if response.get("error"): error_str = self._enrich_error(str(response.get("error")), channel) self.log.warning("Error happened: {}".format(error_str)) + else: + return response["ts"], file_ids except Exception as e: # You will get a SlackApiError if "ok" is False error_str = self._enrich_error(str(e), channel) self.log.warning("Error happened: {}".format(error_str)) - def _python3_call(self, token, channel, message, - publish_files): + def _python3_call(self, token, channel, message, publish_files): from slack_sdk import WebClient from slack_sdk.errors import SlackApiError try: client = WebClient(token=token) attachment_str = "\n\n Attachment links: \n" + file_ids = [] for published_file in publish_files: response = client.files_upload( file=published_file, @@ -176,16 +195,16 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): attachment_str += "\n<{}|{}>".format( response["file"]["permalink"], os.path.basename(published_file)) + file_ids.append(response["file"]["id"]) if publish_files: message += attachment_str - _ = client.chat_postMessage( + response = client.chat_postMessage( channel=channel, - text=message, - username=self.bot_user_name, - icon_url=self.icon_url + text=message ) + return response.data["ts"], file_ids except SlackApiError as e: # You will get a SlackApiError if "ok" is False error_str = self._enrich_error(str(e.response["error"]), channel) From 5e8f0e0152df9547caed72d4b401a5fb9200fb30 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jan 2022 17:02:58 +0100 Subject: [PATCH 252/395] OP-1730 - removed obsolete variables Modification username and icon via message payload doesn't work for both post_method and upload_file. Icon must be set in Slack app configuration. --- openpype/modules/slack/plugins/publish/integrate_slack_api.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 6afbbf5849..5d014382a3 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -26,10 +26,6 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): optional = True - # internal, not configurable - bot_user_name = "OpenPypeNotifier" - icon_url = "https://openpype.io/img/favicon/favicon.ico" - def process(self, instance): thumbnail_path = self._get_thumbnail_path(instance) review_path = self._get_review_path(instance) From 87c5eb549786fa2e166dd381fe4b97204b146b48 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 12 Jan 2022 17:55:22 +0100 Subject: [PATCH 253/395] Expose toggle publish plug-in settings for Maya Look Shading Engine Naming --- openpype/settings/defaults/project_settings/maya.json | 5 +++++ .../projects_schema/schemas/schema_maya_publish.json | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index b75b0168ec..a756071106 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -166,6 +166,11 @@ "enabled": false, "regex": "(?P.*)_(.*)_SHD" }, + "ValidateShadingEngine": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateAttributes": { "enabled": false, "attributes": {} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 606dd6c2bb..7c9a5a6b46 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -72,6 +72,17 @@ ] }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateShadingEngine", + "label": "Validate Look Shading Engine Naming" + } + ] + }, + { "type": "dict", "collapsible": true, From 6df5488ccec9f9621b5fdb00e68791c0fcc7781e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 Jan 2022 18:07:31 +0100 Subject: [PATCH 254/395] fix type --- igniter/bootstrap_repos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index db62cbbe91..637f821366 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -762,7 +762,7 @@ class BootstrapRepos: destination = self._move_zip_to_data_dir(temp_zip) - return OpenPypeVersion(version=version, path=destination) + return OpenPypeVersion(version=version, path=Path(destination)) def _move_zip_to_data_dir(self, zip_file) -> Union[None, Path]: """Move zip with OpenPype version to user data directory. From 6c2204c92d577a9cddc9533b13d16fd2829f1974 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 18:44:33 +0100 Subject: [PATCH 255/395] added ability to hide publish if plugin need it --- openpype/tools/pyblish_pype/window.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/tools/pyblish_pype/window.py b/openpype/tools/pyblish_pype/window.py index fdd2d80e23..edcf6f53b6 100644 --- a/openpype/tools/pyblish_pype/window.py +++ b/openpype/tools/pyblish_pype/window.py @@ -909,6 +909,13 @@ class Window(QtWidgets.QDialog): self.tr("Processing"), plugin_item.data(QtCore.Qt.DisplayRole) )) + visibility = True + if hasattr(plugin, "hide_ui_on_process") and plugin.hide_ui_on_process: + visibility = False + + if self.isVisible() != visibility: + self.setVisible(visibility) + def on_plugin_action_menu_requested(self, pos): """The user right-clicked on a plug-in __________ From 91930038880fb24c68518f5d135b5eba44bdbf05 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 Jan 2022 18:46:44 +0100 Subject: [PATCH 256/395] fix dir/file resolution --- tools/create_zip.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/create_zip.py b/tools/create_zip.py index 32a4d27e8b..2fc351469a 100644 --- a/tools/create_zip.py +++ b/tools/create_zip.py @@ -31,7 +31,9 @@ def main(path): bs = bootstrap_repos.BootstrapRepos(progress_callback=progress) if path: out_path = Path(path) - bs.data_dir = out_path.parent + bs.data_dir = out_path + if out_path.is_file(): + bs.data_dir = out_path.parent _print(f"Creating zip in {bs.data_dir} ...") repo_file = bs.create_version_from_live_code() From 226903ea0b45d7ff09e5dd284a80566d2b88402d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 20:24:06 +0100 Subject: [PATCH 257/395] hound: flake8 fix --- openpype/hosts/flame/plugins/publish/precollect_instances.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index a093bb82fa..b4b2ebf63f 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -32,7 +32,8 @@ class PrecollectInstances(pyblish.api.ContextPlugin): # get openpype tag data marker_data = opfapi.get_segment_data_marker(segment) - self.log.debug("__ marker_data: {}".format(pformat(marker_data))) + self.log.debug("__ marker_data: {}".format( + pformat(marker_data))) if not marker_data: continue From 5d9ddca7d0aef5c84d0119fac0396fb2790f3f1c Mon Sep 17 00:00:00 2001 From: "IGOTGAMES\\jesse.d" Date: Wed, 12 Jan 2022 17:25:22 -0800 Subject: [PATCH 258/395] Fixed bug: File list would be 1 file long if node frame range is 2 frames long. --- openpype/hosts/houdini/plugins/publish/collect_frames.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index ef77c3230b..8d21794c1b 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -37,7 +37,7 @@ class CollectFrames(pyblish.api.InstancePlugin): # Check if frames are bigger than 1 (file collection) # override the result - if end_frame - start_frame > 1: + if end_frame - start_frame > 0: result = self.create_file_list( match, int(start_frame), int(end_frame) ) From 365368554f4ebf6d34ff6139e4193de4edd022d4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 10:38:39 +0100 Subject: [PATCH 259/395] flame: starting render utlis modul --- openpype/hosts/flame/api/render_utils.py | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 openpype/hosts/flame/api/render_utils.py diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py new file mode 100644 index 0000000000..01efc9e5b9 --- /dev/null +++ b/openpype/hosts/flame/api/render_utils.py @@ -0,0 +1,46 @@ +import os + +SHARED_PRESET_PATH = '/opt/Autodesk/shared/export/presets/file_sequence' +SHARED_PRESETS = ['Default Jpeg'] + [ + preset[:-4] for preset in os.listdir(SHARED_PRESET_PATH)] + + +def export_clip(export_path, clip, export_preset, **kwargs): + import flame + + # Set exporter + exporter = flame.PyExporter() + exporter.foreground = True + exporter.export_between_marks = True + + if "in_mark" not in kwargs.keys(): + exporter.export_between_marks = False + + # Duplicate the clip to avoid modifying the original clip + duplicate_clip = flame.duplicate(clip) + + # Set export preset path + if export_preset == 'Default Jpeg': + # Get default export preset path + preset_dir = flame.PyExporter.get_presets_dir( + flame.PyExporter.PresetVisibility.Autodesk, + flame.PyExporter.PresetType.Image_Sequence) + export_preset_path = os.path.join( + preset_dir, "Jpeg", "Jpeg (8-bit).xml") + else: + export_preset_path = os.path.join( + SHARED_PRESET_PATH, export_preset + '.xml') + + try: + if kwargs.get("in_mark") and kwargs.get("out_mark"): + duplicate_clip.in_mark = int(kwargs["in_mark"]) + duplicate_clip.in_mark = int(kwargs["out_mark"]) + + exporter.export(duplicate_clip, export_preset_path, export_path) + finally: + print('Exported: {} at {}-{}'.format( + clip.name.get_value(), + duplicate_clip.in_mark, + duplicate_clip.out_mark + )) + flame.delete(duplicate_clip) \ No newline at end of file From 9d9f9514c1bbc25046b16a6c882505b428fa3c61 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 11:02:21 +0100 Subject: [PATCH 260/395] format output arguments with anatomy data --- openpype/plugins/publish/extract_review.py | 25 +++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index b6c2e49385..be29c7bf9c 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -187,6 +187,7 @@ class ExtractReview(pyblish.api.InstancePlugin): outputs_per_repres = self._get_outputs_per_representations( instance, profile_outputs ) + fill_data = copy.deepcopy(instance.data["anatomyData"]) for repre, outputs in outputs_per_repres: # Check if input should be preconverted before processing # Store original staging dir (it's value may change) @@ -293,7 +294,7 @@ class ExtractReview(pyblish.api.InstancePlugin): try: # temporary until oiiotool is supported cross platform ffmpeg_args = self._ffmpeg_arguments( - output_def, instance, new_repre, temp_data + output_def, instance, new_repre, temp_data, fill_data ) except ZeroDivisionError: if 'exr' in temp_data["origin_repre"]["ext"]: @@ -446,7 +447,9 @@ class ExtractReview(pyblish.api.InstancePlugin): "handles_are_set": handles_are_set } - def _ffmpeg_arguments(self, output_def, instance, new_repre, temp_data): + def _ffmpeg_arguments( + self, output_def, instance, new_repre, temp_data, fill_data + ): """Prepares ffmpeg arguments for expected extraction. Prepares input and output arguments based on output definition and @@ -472,9 +475,6 @@ class ExtractReview(pyblish.api.InstancePlugin): ffmpeg_input_args = [ value for value in _ffmpeg_input_args if value.strip() ] - ffmpeg_output_args = [ - value for value in _ffmpeg_output_args if value.strip() - ] ffmpeg_video_filters = [ value for value in _ffmpeg_video_filters if value.strip() ] @@ -482,6 +482,21 @@ class ExtractReview(pyblish.api.InstancePlugin): value for value in _ffmpeg_audio_filters if value.strip() ] + ffmpeg_output_args = [] + for value in _ffmpeg_output_args: + value = value.strip() + if not value: + continue + try: + value = value.format(**fill_data) + except Exception: + self.log.warning( + "Failed to format ffmpeg argument: {}".format(value), + exc_info=True + ) + pass + ffmpeg_output_args.append(value) + # Prepare input and output filepaths self.input_output_paths(new_repre, output_def, temp_data) From aa19d699d3283c7db94d1de5d13a53700bf011a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 12:03:19 +0100 Subject: [PATCH 261/395] flame: updating render_utils modul --- openpype/hosts/flame/api/render_utils.py | 88 ++++++++++++++++++++---- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py index 01efc9e5b9..d2e312785f 100644 --- a/openpype/hosts/flame/api/render_utils.py +++ b/openpype/hosts/flame/api/render_utils.py @@ -1,41 +1,99 @@ import os -SHARED_PRESET_PATH = '/opt/Autodesk/shared/export/presets/file_sequence' -SHARED_PRESETS = ['Default Jpeg'] + [ - preset[:-4] for preset in os.listdir(SHARED_PRESET_PATH)] +SHARED_PRESET_PATH = '/opt/Autodesk/shared/export/presets' def export_clip(export_path, clip, export_preset, **kwargs): + """Flame exported wrapper + + Args: + export_path (str): exporting directory path + clip (PyClip): flame api object + export_preset (str): name of exporting preset xml file + + Kwargs: + export_type (str)[optional]: name of export type folder + thumb_frame_number (int)[optional]: source frame number + in_mark (int)[optional]: cut in mark + out_mark (int)[optional]: cut out mark + + Raises: + KeyError: Missing input kwarg `thumb_frame_number` + in case `thumbnail` in `export_preset` + KeyError: Missing input kwarg `export_type` + in case of other `export_preset` then `thumbnail` + FileExistsError: Missing export preset in shared folder + """ import flame + in_mark = out_mark = None + # Set exporter exporter = flame.PyExporter() exporter.foreground = True exporter.export_between_marks = True - if "in_mark" not in kwargs.keys(): - exporter.export_between_marks = False - # Duplicate the clip to avoid modifying the original clip duplicate_clip = flame.duplicate(clip) - # Set export preset path - if export_preset == 'Default Jpeg': - # Get default export preset path + if export_preset == 'thumbnail': + thumb_frame_number = kwargs.get("thumb_frame_number") + # make sure it exists in kwargs + if not thumb_frame_number: + raise KeyError( + "Missing key `thumb_frame_number` in input kwargs") + + in_mark = int(thumb_frame_number) + out_mark = int(thumb_frame_number) + 1 + + # In case Thumbnail is needed preset_dir = flame.PyExporter.get_presets_dir( flame.PyExporter.PresetVisibility.Autodesk, flame.PyExporter.PresetType.Image_Sequence) export_preset_path = os.path.join( preset_dir, "Jpeg", "Jpeg (8-bit).xml") + else: + # In case other output is needed + # get compulsory kwargs + export_type = kwargs.get("export_type") + # make sure it exists in kwargs + if not export_type: + raise KeyError( + "Missing key `export_type` in input kwargs") + + # create full shared preset path + shared_preset_dir = os.path.join( + SHARED_PRESET_PATH, export_type + ) + + # check if export preset is available in shared presets + shared_presets = [ + preset[:-4] for preset in os.listdir(shared_preset_dir)] + if export_preset not in shared_presets: + raise FileExistsError( + "Missing preset file `{}` in `{}`".format( + export_preset, + shared_preset_dir + )) + export_preset_path = os.path.join( - SHARED_PRESET_PATH, export_preset + '.xml') + shared_preset_dir, export_preset + '.xml') + + # check if mark in/out is set in kwargs + if kwargs.get("in_mark") and kwargs.get("out_mark"): + in_mark = int(kwargs["in_mark"]) + out_mark = int(kwargs["out_mark"]) + else: + exporter.export_between_marks = False try: - if kwargs.get("in_mark") and kwargs.get("out_mark"): - duplicate_clip.in_mark = int(kwargs["in_mark"]) - duplicate_clip.in_mark = int(kwargs["out_mark"]) + # set in and out marks if they are available + if in_mark and out_mark: + duplicate_clip.in_mark = in_mark + duplicate_clip.out_mark = out_mark + # export with exporter exporter.export(duplicate_clip, export_preset_path, export_path) finally: print('Exported: {} at {}-{}'.format( @@ -43,4 +101,6 @@ def export_clip(export_path, clip, export_preset, **kwargs): duplicate_clip.in_mark, duplicate_clip.out_mark )) - flame.delete(duplicate_clip) \ No newline at end of file + + # delete duplicated clip it is not needed anymore + flame.delete(duplicate_clip) From 574466f6dcea84d6a09bff0ac13493d4a5179c36 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 12:52:22 +0100 Subject: [PATCH 262/395] flame: adding export clip to api --- openpype/hosts/flame/api/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 308682b884..fce59af506 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -59,6 +59,9 @@ from .workio import ( file_extensions, work_root ) +from .render_utils import ( + export_clip +) __all__ = [ # constants @@ -119,5 +122,8 @@ __all__ = [ "current_file", "has_unsaved_changes", "file_extensions", - "work_root" + "work_root", + + # render utils + "export_clip" ] From a2d414c64657d11ddbbacfd546db3613ee91ab85 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 12:53:02 +0100 Subject: [PATCH 263/395] flame: adding exporter plugin --- .../publish/extract_subset_resources.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/extract_subset_resources.py diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py new file mode 100644 index 0000000000..ffa01eb1b3 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -0,0 +1,91 @@ +import os +import pyblish.api +import openpype.api +from openpype.hosts.flame import api as opfapi + + +class ExtractSubsetResources(openpype.api.Extractor): + """ + Extractor for transcoding files from Flame clip + """ + + label = "Extract subset resources" + order = pyblish.api.CollectorOrder + 0.49 + families = ["clip"] + hosts = ["flame"] + + # hide publisher during exporting + hide_ui_on_process = True + + export_presets_mapping = { + "thumbnail": { + "ext": "jpg", + "uniqueName": "thumbnail" + }, + "OpenEXR (16-bit fp DWAA)_custom": { + "ext": "exr", + "preset_type": "file_sequence", + "uniqueName": "exr16fpdwaa" + }, + "QuickTime (H.264 1080p 8Mbits)_custom": { + "ext": "mov", + "preset_type": "movie_file", + "uniqueName": "ftrackpreview" + } + } + + def process(self, instance): + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + name = instance.data["name"] + clip = instance.data["flameSourceClip"] + staging_dir = self.staging_dir(instance) + + # prepare full export path + export_dir_path = os.path.join( + staging_dir, name + ) + # loop all preset names and + for preset_name, preset_config in self.export_presets_mapping: + kwargs = {} + unique_name = preset_config["uniqueName"] + preset_type = None + + # define kwargs based on preset type + if "thumbnail" in preset_name: + kwargs["thumb_frame_number"] = 2 + else: + preset_type = preset_config["preset_type"] + kwargs.update({ + "in_mark": 2, + "out_mark": 5, + "preset_type": preset_type + }) + + _export_dir_path = os.path.join( + export_dir_path, unique_name + ) + # export + opfapi.export_clip( + _export_dir_path, clip, preset_name, **kwargs) + + # create representation data + representation_data = { + 'name': unique_name, + 'ext': preset_config["ext"], + "stagingDir": _export_dir_path, + } + + files = os.listdir(_export_dir_path) + + if preset_type and preset_type == "movie_file": + representation_data["files"] = files + else: + representation_data["files"] = files.pop() + + instance.data["representations"].append(representation_data) + + self.log.info("Added representation: {}".format( + representation_data)) From 29445314346e644ea02e1df8f4479ce8b587a32d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 13:02:51 +0100 Subject: [PATCH 264/395] implemented callback warpper for execution in main thread --- openpype/tools/tray/pype_tray.py | 24 ++++++----- openpype/tools/utils/__init__.py | 5 ++- openpype/tools/utils/lib.py | 70 +++++++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 12 deletions(-) diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index df0238c848..e7ac390c30 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -22,6 +22,7 @@ from openpype.settings import ( ProjectSettings, DefaultsNotDefined ) +from openpype.tools.utils import WrappedCallbackItem from .pype_info_widget import PypeInfoWidget @@ -61,21 +62,24 @@ class TrayManager: if callback: self.execute_in_main_thread(callback) - def execute_in_main_thread(self, callback): - self._main_thread_callbacks.append(callback) + def execute_in_main_thread(self, callback, *args, **kwargs): + if isinstance(callback, WrappedCallbackItem): + item = callback + else: + item = WrappedCallbackItem(callback, *args, **kwargs) + + self._main_thread_callbacks.append(item) + + return item def _main_thread_execution(self): if self._execution_in_progress: return self._execution_in_progress = True - while self._main_thread_callbacks: - try: - callback = self._main_thread_callbacks.popleft() - callback() - except: - self.log.warning( - "Failed to execute {} in main thread".format(callback), - exc_info=True) + for _ in range(len(self._main_thread_callbacks)): + if self._main_thread_callbacks: + item = self._main_thread_callbacks.popleft() + item.execute() self._execution_in_progress = False diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 4dd6bdd05f..65025ac358 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -6,6 +6,7 @@ from .widgets import ( ) from .error_dialog import ErrorMessageBox +from .lib import WrappedCallbackItem __all__ = ( @@ -14,5 +15,7 @@ __all__ = ( "ClickableFrame", "ExpandBtn", - "ErrorMessageBox" + "ErrorMessageBox", + + "WrappedCallbackItem", ) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 6742df8557..5f3456ae3e 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -9,7 +9,10 @@ import avalon.api from avalon import style from avalon.vendor import qtawesome -from openpype.api import get_project_settings +from openpype.api import ( + get_project_settings, + Logger +) from openpype.lib import filter_profiles @@ -598,3 +601,68 @@ def is_remove_site_loader(loader): def is_add_site_loader(loader): return hasattr(loader, "add_site_to_representation") + + +class WrappedCallbackItem: + """Structure to store information about callback and args/kwargs for it. + + Item can be used to execute callback in main thread which may be needed + for execution of Qt objects. + + Item store callback (callable variable), arguments and keyword arguments + for the callback. Item hold information about it's process. + """ + not_set = object() + _log = None + + def __init__(self, callback, *args, **kwargs): + self._done = False + self._exception = self.not_set + self._result = self.not_set + self._callback = callback + self._args = args + self._kwargs = kwargs + + def __call__(self): + self.execute() + + @property + def log(self): + cls = self.__class__ + if cls._log is None: + cls._log = Logger.get_logger(cls.__name__) + return cls._log + + @property + def done(self): + return self._done + + @property + def exception(self): + return self._exception + + @property + def result(self): + return self._result + + def execute(self): + """Execute callback and store it's result. + + Method must be called from main thread. Item is marked as `done` + when callback execution finished. Store output of callback of exception + information when callback raise one. + """ + if self.done: + self.log.warning("- item is already processed") + return + + self.log.debug("Running callback: {}".format(str(self._callback))) + try: + result = self._callback(*self._args, **self._kwargs) + self._result = result + + except Exception as exc: + self._exception = exc + + finally: + self._done = True From d7fb171f101bf36495a106fbca296515f692b080 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 13:08:35 +0100 Subject: [PATCH 265/395] added check if current running openpype has expected version --- openpype/lib/__init__.py | 6 ++- openpype/lib/pype_info.py | 48 +++++++++++++++++---- openpype/tools/tray/pype_tray.py | 74 ++++++++++++++++++++++++++++++-- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 12e47a8961..65019f3fab 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -170,7 +170,9 @@ from .editorial import ( from .pype_info import ( get_openpype_version, - get_build_version + get_build_version, + is_running_from_build, + is_current_version_studio_latest ) terminal = Terminal @@ -304,4 +306,6 @@ __all__ = [ "get_openpype_version", "get_build_version", + "is_running_from_build", + "is_current_version_studio_latest", ] diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index 15856bfb19..ea804c8a18 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -10,6 +10,12 @@ from openpype.settings.lib import get_local_settings from .execute import get_openpype_execute_args from .local_settings import get_local_site_id from .python_module_tools import import_filepath +from .openpype_version import ( + op_version_control_available, + openpype_path_is_accessible, + get_expected_studio_version, + get_OpenPypeVersion +) def get_openpype_version(): @@ -17,15 +23,6 @@ def get_openpype_version(): return openpype.version.__version__ -def get_pype_version(): - """Backwards compatibility. Remove when 100% not used.""" - print(( - "Using deprecated function 'openpype.lib.pype_info.get_pype_version'" - " replace with 'openpype.lib.pype_info.get_openpype_version'." - )) - return get_openpype_version() - - def get_build_version(): """OpenPype version of build.""" # Return OpenPype version if is running from code @@ -138,3 +135,36 @@ def extract_pype_info_to_file(dirpath): with open(filepath, "w") as file_stream: json.dump(data, file_stream, indent=4) return filepath + + +def is_current_version_studio_latest(): + """Is currently running OpenPype version which is defined by studio. + + It is not recommended to ask in each process as there may be situations + when older OpenPype should be used. For example on farm. But it does make + sense in processes that can run for a long time. + + Returns: + None: Can't determine. e.g. when running from code or the build is + too old. + bool: True when is using studio + """ + output = None + # Skip if is not running from build + if not is_running_from_build(): + return output + + # Skip if build does not support version control + if not op_version_control_available(): + return output + + # Skip if path to folder with zip files is not accessible + if not openpype_path_is_accessible(): + return output + + # Check if current version is expected version + OpenPypeVersion = get_OpenPypeVersion() + current_version = OpenPypeVersion(get_openpype_version()) + expected_version = get_expected_studio_version(is_running_staging()) + + return current_version == expected_version diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index e7ac390c30..5af82b2c64 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -14,7 +14,11 @@ from openpype.api import ( resources, get_system_settings ) -from openpype.lib import get_openpype_execute_args +from openpype.lib import ( + get_openpype_execute_args, + is_current_version_studio_latest, + is_running_from_build +) from openpype.modules import TrayModulesManager from openpype import style from openpype.settings import ( @@ -27,11 +31,43 @@ from openpype.tools.utils import WrappedCallbackItem from .pype_info_widget import PypeInfoWidget +class VersionDialog(QtWidgets.QDialog): + def __init__(self, parent=None): + super(VersionDialog, self).__init__(parent) + + label_widget = QtWidgets.QLabel( + "Your version does not match to studio version", self + ) + + ignore_btn = QtWidgets.QPushButton("Ignore", self) + restart_btn = QtWidgets.QPushButton("Restart and Install", self) + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ignore_btn, 0) + btns_layout.addWidget(restart_btn, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(label_widget, 0) + layout.addStretch(1) + layout.addLayout(btns_layout, 0) + + ignore_btn.clicked.connect(self._on_ignore) + restart_btn.clicked.connect(self._on_reset) + + def _on_ignore(self): + self.reject() + + def _on_reset(self): + self.accept() + + class TrayManager: """Cares about context of application. Load submenus, actions, separators and modules into tray's context. """ + _version_check_interval = 5 * 60 * 1000 def __init__(self, tray_widget, main_window): self.tray_widget = tray_widget @@ -46,6 +82,9 @@ class TrayManager: self.errors = [] + self._version_check_timer = None + self._version_dialog = None + self.main_thread_timer = None self._main_thread_callbacks = collections.deque() self._execution_in_progress = None @@ -62,6 +101,24 @@ class TrayManager: if callback: self.execute_in_main_thread(callback) + def _on_version_check_timer(self): + # Check if is running from build and stop future validations if yes + if not is_running_from_build(): + self._version_check_timer.stop() + return + + self.validate_openpype_version() + + def validate_openpype_version(self): + if is_current_version_studio_latest(): + return + + if self._version_dialog is None: + self._version_dialog = VersionDialog() + result = self._version_dialog.exec_() + if result: + self.restart() + def execute_in_main_thread(self, callback, *args, **kwargs): if isinstance(callback, WrappedCallbackItem): item = callback @@ -123,6 +180,12 @@ class TrayManager: self.main_thread_timer = main_thread_timer + version_check_timer = QtCore.QTimer() + version_check_timer.setInterval(self._version_check_interval) + version_check_timer.timeout.connect(self._on_version_check_timer) + version_check_timer.start() + self._version_check_timer = version_check_timer + # For storing missing settings dialog self._settings_validation_dialog = None @@ -207,7 +270,7 @@ class TrayManager: self.tray_widget.menu.addAction(version_action) self.tray_widget.menu.addSeparator() - def restart(self): + def restart(self, reset_version=True): """Restart Tray tool. First creates new process with same argument and close current tray. @@ -221,7 +284,9 @@ class TrayManager: additional_args.pop(0) args.extend(additional_args) - kwargs = {} + kwargs = { + "env": dict(os.environ.items()) + } if platform.system().lower() == "windows": flags = ( subprocess.CREATE_NEW_PROCESS_GROUP @@ -229,6 +294,9 @@ class TrayManager: ) kwargs["creationflags"] = flags + if reset_version: + kwargs["env"].pop("OPENPYPE_VERSION", None) + subprocess.Popen(args, **kwargs) self.exit() From 9c20580d699c77ad5f5f3462050069172145d3dd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 15:32:40 +0100 Subject: [PATCH 266/395] flame: export clip to correct frame range --- .../publish/extract_subset_resources.py | 55 ++++++++++++------- .../plugins/publish/precollect_instances.py | 2 +- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index ffa01eb1b3..ea782845ef 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -15,7 +15,7 @@ class ExtractSubsetResources(openpype.api.Extractor): hosts = ["flame"] # hide publisher during exporting - hide_ui_on_process = True + # hide_ui_on_process = True export_presets_mapping = { "thumbnail": { @@ -39,51 +39,66 @@ class ExtractSubsetResources(openpype.api.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] - name = instance.data["name"] - clip = instance.data["flameSourceClip"] + source_first_frame = instance.data["sourceFirstFrame"] + source_start_handles = instance.data["sourceStartH"] + source_end_handles = instance.data["sourceEndH"] + source_duration_handles = ( + source_end_handles - source_start_handles) + 1 + + clip_data = instance.data["flameSourceClip"] + clip = clip_data["PyClip"] + + in_mark = (source_start_handles - source_first_frame) + 1 + out_mark = in_mark + source_duration_handles + staging_dir = self.staging_dir(instance) - # prepare full export path - export_dir_path = os.path.join( - staging_dir, name - ) # loop all preset names and - for preset_name, preset_config in self.export_presets_mapping: + for preset_name, preset_config in self.export_presets_mapping.items(): kwargs = {} unique_name = preset_config["uniqueName"] preset_type = None # define kwargs based on preset type if "thumbnail" in preset_name: - kwargs["thumb_frame_number"] = 2 + kwargs["thumb_frame_number"] = in_mark + ( + source_duration_handles / 2) else: preset_type = preset_config["preset_type"] kwargs.update({ - "in_mark": 2, - "out_mark": 5, - "preset_type": preset_type + "in_mark": in_mark, + "out_mark": out_mark, + "export_type": preset_type }) - _export_dir_path = os.path.join( - export_dir_path, unique_name + export_dir_path = os.path.join( + staging_dir, unique_name ) + os.makedirs(export_dir_path) + # export opfapi.export_clip( - _export_dir_path, clip, preset_name, **kwargs) + export_dir_path, clip, preset_name, **kwargs) # create representation data representation_data = { 'name': unique_name, 'ext': preset_config["ext"], - "stagingDir": _export_dir_path, + "stagingDir": export_dir_path, } - files = os.listdir(_export_dir_path) + files = os.listdir(export_dir_path) - if preset_type and preset_type == "movie_file": - representation_data["files"] = files - else: + # add files to represetation but add + # imagesequence as list + if ( + preset_type + and preset_type == "movie_file" + or preset_name == "thumbnail" + ): representation_data["files"] = files.pop() + else: + representation_data["files"] = files instance.data["representations"].append(representation_data) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index b4b2ebf63f..bda583fe8e 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -92,7 +92,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): "publish": marker_data["publish"], "fps": self.fps, "flameSourceClip": source_clip, - "sourceFirstFrame": first_frame, + "sourceFirstFrame": int(first_frame), "path": file_path }) From 7d283f55558f203cd98265ec327be6e2caa5fd53 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 17:08:37 +0100 Subject: [PATCH 267/395] moved code from pype_info to openpype_version and fixed few bugs --- openpype/lib/__init__.py | 2 +- openpype/lib/openpype_version.py | 117 ++++++++++++++++++++++++++++++- openpype/lib/pype_info.py | 89 +---------------------- openpype/resources/__init__.py | 2 +- 4 files changed, 118 insertions(+), 92 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 65019f3fab..c556f2adc1 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -168,7 +168,7 @@ from .editorial import ( make_sequence_collection ) -from .pype_info import ( +from .openpype_version import ( get_openpype_version, get_build_version, is_running_from_build, diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index e3a4e1fa3e..839222018c 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -9,9 +9,69 @@ OpenPype version located in build but versions available in remote versions repository or locally available. """ +import os import sys +import openpype.version +from .python_module_tools import import_filepath + + +# ---------------------------------------- +# Functions independent on OpenPypeVersion +# ---------------------------------------- +def get_openpype_version(): + """Version of pype that is currently used.""" + return openpype.version.__version__ + + +def get_build_version(): + """OpenPype version of build.""" + # Return OpenPype version if is running from code + if not is_running_from_build(): + return get_openpype_version() + + # Import `version.py` from build directory + version_filepath = os.path.join( + os.environ["OPENPYPE_ROOT"], + "openpype", + "version.py" + ) + if not os.path.exists(version_filepath): + return None + + module = import_filepath(version_filepath, "openpype_build_version") + return getattr(module, "__version__", None) + + +def is_running_from_build(): + """Determine if current process is running from build or code. + + Returns: + bool: True if running from build. + """ + executable_path = os.environ["OPENPYPE_EXECUTABLE"] + executable_filename = os.path.basename(executable_path) + if "python" in executable_filename.lower(): + return False + return True + + +def is_running_staging(): + """Currently used OpenPype is staging version. + + Returns: + bool: True if openpype version containt 'staging'. + """ + if "staging" in get_openpype_version(): + return True + return False + + +# ---------------------------------------- +# Functions dependent on OpenPypeVersion +# - Make sense to call only in OpenPype process +# ---------------------------------------- def get_OpenPypeVersion(): """Access to OpenPypeVersion class stored in sys modules.""" return sys.modules.get("OpenPypeVersion") @@ -71,15 +131,66 @@ def get_remote_versions(*args, **kwargs): return None -def get_latest_version(*args, **kwargs): +def get_latest_version(staging=None, local=None, remote=None): """Get latest version from repository path.""" + if staging is None: + staging = is_running_staging() if op_version_control_available(): - return get_OpenPypeVersion().get_latest_version(*args, **kwargs) + return get_OpenPypeVersion().get_latest_version( + staging=staging, + local=local, + remote=remote + ) return None -def get_expected_studio_version(staging=False): +def get_expected_studio_version(staging=None): """Expected production or staging version in studio.""" + if staging is None: + staging = is_running_staging() if op_version_control_available(): return get_OpenPypeVersion().get_expected_studio_version(staging) return None + + +def is_current_version_studio_latest(): + """Is currently running OpenPype version which is defined by studio. + + It is not recommended to ask in each process as there may be situations + when older OpenPype should be used. For example on farm. But it does make + sense in processes that can run for a long time. + + Returns: + None: Can't determine. e.g. when running from code or the build is + too old. + bool: True when is using studio + """ + output = None + # Skip if is not running from build + if not is_running_from_build(): + return output + + # Skip if build does not support version control + if not op_version_control_available(): + return output + + # Skip if path to folder with zip files is not accessible + if not openpype_path_is_accessible(): + return output + + # Get OpenPypeVersion class + OpenPypeVersion = get_OpenPypeVersion() + # Convert current version to OpenPypeVersion object + current_version = OpenPypeVersion(version=get_openpype_version()) + + staging = is_running_staging() + # Get expected version (from settings) + expected_version = get_expected_studio_version(staging) + if expected_version is None: + # Look for latest if expected version is not set in settings + expected_version = get_latest_version( + staging=staging, + remote=True + ) + # Check if current version is expected version + return current_version == expected_version diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index ea804c8a18..848a505187 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -5,67 +5,15 @@ import platform import getpass import socket -import openpype.version from openpype.settings.lib import get_local_settings from .execute import get_openpype_execute_args from .local_settings import get_local_site_id -from .python_module_tools import import_filepath from .openpype_version import ( - op_version_control_available, - openpype_path_is_accessible, - get_expected_studio_version, - get_OpenPypeVersion + is_running_from_build, + get_openpype_version ) -def get_openpype_version(): - """Version of pype that is currently used.""" - return openpype.version.__version__ - - -def get_build_version(): - """OpenPype version of build.""" - # Return OpenPype version if is running from code - if not is_running_from_build(): - return get_openpype_version() - - # Import `version.py` from build directory - version_filepath = os.path.join( - os.environ["OPENPYPE_ROOT"], - "openpype", - "version.py" - ) - if not os.path.exists(version_filepath): - return None - - module = import_filepath(version_filepath, "openpype_build_version") - return getattr(module, "__version__", None) - - -def is_running_from_build(): - """Determine if current process is running from build or code. - - Returns: - bool: True if running from build. - """ - executable_path = os.environ["OPENPYPE_EXECUTABLE"] - executable_filename = os.path.basename(executable_path) - if "python" in executable_filename.lower(): - return False - return True - - -def is_running_staging(): - """Currently used OpenPype is staging version. - - Returns: - bool: True if openpype version containt 'staging'. - """ - if "staging" in get_openpype_version(): - return True - return False - - def get_pype_info(): """Information about currently used Pype process.""" executable_args = get_openpype_execute_args() @@ -135,36 +83,3 @@ def extract_pype_info_to_file(dirpath): with open(filepath, "w") as file_stream: json.dump(data, file_stream, indent=4) return filepath - - -def is_current_version_studio_latest(): - """Is currently running OpenPype version which is defined by studio. - - It is not recommended to ask in each process as there may be situations - when older OpenPype should be used. For example on farm. But it does make - sense in processes that can run for a long time. - - Returns: - None: Can't determine. e.g. when running from code or the build is - too old. - bool: True when is using studio - """ - output = None - # Skip if is not running from build - if not is_running_from_build(): - return output - - # Skip if build does not support version control - if not op_version_control_available(): - return output - - # Skip if path to folder with zip files is not accessible - if not openpype_path_is_accessible(): - return output - - # Check if current version is expected version - OpenPypeVersion = get_OpenPypeVersion() - current_version = OpenPypeVersion(get_openpype_version()) - expected_version = get_expected_studio_version(is_running_staging()) - - return current_version == expected_version diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py index f463933525..34a833d080 100644 --- a/openpype/resources/__init__.py +++ b/openpype/resources/__init__.py @@ -1,5 +1,5 @@ import os -from openpype.lib.pype_info import is_running_staging +from openpype.lib.openpype_version import is_running_staging RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) From 9fa024daae060fbbfc88aecb7b9639bf2cc7c087 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 17:45:58 +0100 Subject: [PATCH 268/395] flame: hide gui when processing plugin --- .../hosts/flame/plugins/publish/extract_subset_resources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index ea782845ef..6061c80762 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -10,12 +10,12 @@ class ExtractSubsetResources(openpype.api.Extractor): """ label = "Extract subset resources" - order = pyblish.api.CollectorOrder + 0.49 + order = pyblish.api.ExtractorOrder families = ["clip"] hosts = ["flame"] # hide publisher during exporting - # hide_ui_on_process = True + hide_ui_on_process = True export_presets_mapping = { "thumbnail": { From 12156b6d90f723d6a96016fb51c3e876415dca8c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 17:57:27 +0100 Subject: [PATCH 269/395] tray will show info that is outdated and user should restart --- openpype/lib/__init__.py | 2 + openpype/lib/openpype_version.py | 37 ++++++------ openpype/style/data.json | 6 +- openpype/style/style.css | 8 +-- .../project_manager/project_manager/style.py | 2 +- .../project_manager/widgets.py | 2 +- openpype/tools/tray/pype_tray.py | 58 ++++++++++++++++--- 7 files changed, 81 insertions(+), 34 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index c556f2adc1..a2a16bcc00 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -171,6 +171,7 @@ from .editorial import ( from .openpype_version import ( get_openpype_version, get_build_version, + get_expected_version, is_running_from_build, is_current_version_studio_latest ) @@ -306,6 +307,7 @@ __all__ = [ "get_openpype_version", "get_build_version", + "get_expected_version", "is_running_from_build", "is_current_version_studio_latest", ] diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 839222018c..201bf646e9 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -153,6 +153,17 @@ def get_expected_studio_version(staging=None): return None +def get_expected_version(staging=None): + expected_version = get_expected_studio_version(staging) + if expected_version is None: + # Look for latest if expected version is not set in settings + expected_version = get_latest_version( + staging=staging, + remote=True + ) + return expected_version + + def is_current_version_studio_latest(): """Is currently running OpenPype version which is defined by studio. @@ -166,16 +177,13 @@ def is_current_version_studio_latest(): bool: True when is using studio """ output = None - # Skip if is not running from build - if not is_running_from_build(): - return output - - # Skip if build does not support version control - if not op_version_control_available(): - return output - - # Skip if path to folder with zip files is not accessible - if not openpype_path_is_accessible(): + # Skip if is not running from build or build does not support version + # control or path to folder with zip files is not accessible + if ( + not is_running_from_build() + or not op_version_control_available() + or not openpype_path_is_accessible() + ): return output # Get OpenPypeVersion class @@ -183,14 +191,7 @@ def is_current_version_studio_latest(): # Convert current version to OpenPypeVersion object current_version = OpenPypeVersion(version=get_openpype_version()) - staging = is_running_staging() # Get expected version (from settings) - expected_version = get_expected_studio_version(staging) - if expected_version is None: - # Look for latest if expected version is not set in settings - expected_version = get_latest_version( - staging=staging, - remote=True - ) + expected_version = get_expected_version() # Check if current version is expected version return current_version == expected_version diff --git a/openpype/style/data.json b/openpype/style/data.json index b3dffd7c71..6e1b6e822b 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -51,8 +51,10 @@ "border-hover": "rgba(168, 175, 189, .3)", "border-focus": "rgb(92, 173, 214)", - "delete-btn-bg": "rgb(201, 54, 54)", - "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", + "warning-btn-bg": "rgb(201, 54, 54)", + + "warning-btn-bg": "rgb(201, 54, 54)", + "warning-btn-bg-disabled": "rgba(201, 54, 54, 64)", "tab-widget": { "bg": "#21252B", diff --git a/openpype/style/style.css b/openpype/style/style.css index 7f7f30e2bc..65e8d0cb40 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -734,11 +734,11 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:bg-view-hover}; } -#DeleteButton { - background: {color:delete-btn-bg}; +#WarningButton { + background: {color:warning-btn-bg}; } -#DeleteButton:disabled { - background: {color:delete-btn-bg-disabled}; +#WarningButton:disabled { + background: {color:warning-btn-bg-disabled}; } /* Launcher specific stylesheets */ diff --git a/openpype/tools/project_manager/project_manager/style.py b/openpype/tools/project_manager/project_manager/style.py index 9fa7a5520b..980c637bca 100644 --- a/openpype/tools/project_manager/project_manager/style.py +++ b/openpype/tools/project_manager/project_manager/style.py @@ -95,7 +95,7 @@ class ResourceCache: def get_warning_pixmap(cls): src_image = get_warning_image() colors = get_objected_colors() - color_value = colors["delete-btn-bg"] + color_value = colors["warning-btn-bg"] return paint_image_with_color( src_image, diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 4b5aca35ef..e58dcc7d0c 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -369,7 +369,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): cancel_btn = QtWidgets.QPushButton("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") confirm_btn = QtWidgets.QPushButton("Permanently Delete Project", self) - confirm_btn.setObjectName("DeleteButton") + confirm_btn.setObjectName("WarningButton") confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 5af82b2c64..c32cf17e18 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -17,7 +17,9 @@ from openpype.api import ( from openpype.lib import ( get_openpype_execute_args, is_current_version_studio_latest, - is_running_from_build + is_running_from_build, + get_expected_version, + get_openpype_version ) from openpype.modules import TrayModulesManager from openpype import style @@ -32,15 +34,30 @@ from .pype_info_widget import PypeInfoWidget class VersionDialog(QtWidgets.QDialog): + restart_requested = QtCore.Signal() + + _min_width = 400 + _min_height = 130 + def __init__(self, parent=None): super(VersionDialog, self).__init__(parent) - - label_widget = QtWidgets.QLabel( - "Your version does not match to studio version", self + self.setWindowTitle("Wrong OpenPype version") + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowFlags( + self.windowFlags() + | QtCore.Qt.WindowStaysOnTopHint ) + self.setMinimumWidth(self._min_width) + self.setMinimumHeight(self._min_height) + + label_widget = QtWidgets.QLabel(self) + label_widget.setWordWrap(True) + ignore_btn = QtWidgets.QPushButton("Ignore", self) - restart_btn = QtWidgets.QPushButton("Restart and Install", self) + ignore_btn.setObjectName("WarningButton") + restart_btn = QtWidgets.QPushButton("Restart and Change", self) btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) @@ -55,10 +72,22 @@ class VersionDialog(QtWidgets.QDialog): ignore_btn.clicked.connect(self._on_ignore) restart_btn.clicked.connect(self._on_reset) + self._label_widget = label_widget + + self.setStyleSheet(style.load_stylesheet()) + + def update_versions(self, current_version, expected_version): + message = ( + "Your OpenPype version {} does" + " not match to studio version {}" + ).format(str(current_version), str(expected_version)) + self._label_widget.setText(message) + def _on_ignore(self): self.reject() def _on_reset(self): + self.restart_requested.emit() self.accept() @@ -115,9 +144,22 @@ class TrayManager: if self._version_dialog is None: self._version_dialog = VersionDialog() - result = self._version_dialog.exec_() - if result: - self.restart() + self._version_dialog.restart_requested.connect( + self._restart_and_install + ) + + if self._version_dialog.isVisible(): + return + + expected_version = get_expected_version() + current_version = get_openpype_version() + self._version_dialog.update_versions( + current_version, expected_version + ) + self._version_dialog.exec_() + + def _restart_and_install(self): + self.restart() def execute_in_main_thread(self, callback, *args, **kwargs): if isinstance(callback, WrappedCallbackItem): From 644711c9d61f34e83b5f821d833c597b032a37b5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 18:16:23 +0100 Subject: [PATCH 270/395] status action gives information about openpype version --- .../ftrack/scripts/sub_event_status.py | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py b/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py index 004f61338c..3163642e3f 100644 --- a/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py +++ b/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py @@ -16,8 +16,14 @@ from openpype_modules.ftrack.ftrack_server.lib import ( TOPIC_STATUS_SERVER_RESULT ) from openpype.api import Logger +from openpype.lib import ( + is_current_version_studio_latest, + is_running_from_build, + get_expected_version, + get_openpype_version +) -log = Logger().get_logger("Event storer") +log = Logger.get_logger("Event storer") action_identifier = ( "event.server.status" + os.environ["FTRACK_EVENT_SUB_ID"] ) @@ -203,8 +209,57 @@ class StatusFactory: }) return items + def openpype_version_items(self): + items = [] + is_latest = is_current_version_studio_latest() + items.append({ + "type": "label", + "value": "# OpenPype version" + }) + if not is_running_from_build(): + items.append({ + "type": "label", + "value": ( + "OpenPype event server is running from code {}." + ).format(str(get_openpype_version())) + }) + + elif is_latest is None: + items.append({ + "type": "label", + "value": ( + "Can't determine if OpenPype version is outdated" + " {}. OpenPype build version should be updated." + ).format(str(get_openpype_version())) + }) + elif is_latest: + items.append({ + "type": "label", + "value": "OpenPype version is up to date {}.".format( + str(get_openpype_version()) + ) + }) + else: + items.append({ + "type": "label", + "value": ( + "Using outdated OpenPype version {}." + " Expected version is {}." + "
- Please restart event server for automatic" + " updates or update manually." + ).format( + str(get_openpype_version()), + str(get_expected_version()) + ) + }) + + items.append({"type": "label", "value": "---"}) + + return items + def items(self): items = [] + items.extend(self.openpype_version_items()) items.append(self.note_item) items.extend(self.bool_items()) From 4ee86a6ce27f0a56b88926719c61bab308e5c144 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 18:26:13 +0100 Subject: [PATCH 271/395] show tray message when update dialog is ignored --- openpype/tools/tray/pype_tray.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index c32cf17e18..17251b404f 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -35,6 +35,7 @@ from .pype_info_widget import PypeInfoWidget class VersionDialog(QtWidgets.QDialog): restart_requested = QtCore.Signal() + ignore_requested = QtCore.Signal() _min_width = 400 _min_height = 130 @@ -73,9 +74,19 @@ class VersionDialog(QtWidgets.QDialog): restart_btn.clicked.connect(self._on_reset) self._label_widget = label_widget + self._restart_accepted = False self.setStyleSheet(style.load_stylesheet()) + def showEvent(self, event): + super().showEvent(event) + self._restart_accepted = False + + def closeEvent(self, event): + super().closeEvent(event) + if not self._restart_accepted: + self.ignore_requested.emit() + def update_versions(self, current_version, expected_version): message = ( "Your OpenPype version {} does" @@ -87,6 +98,7 @@ class VersionDialog(QtWidgets.QDialog): self.reject() def _on_reset(self): + self._restart_accepted = True self.restart_requested.emit() self.accept() @@ -147,6 +159,9 @@ class TrayManager: self._version_dialog.restart_requested.connect( self._restart_and_install ) + self._version_dialog.ignore_requested.connect( + self._outdated_version_ignored + ) if self._version_dialog.isVisible(): return @@ -161,6 +176,15 @@ class TrayManager: def _restart_and_install(self): self.restart() + def _outdated_version_ignored(self): + self.show_tray_message( + "Outdated OpenPype version", + ( + "Please update your OpenPype as soon as possible." + " All you have to do is to restart tray." + ) + ) + def execute_in_main_thread(self, callback, *args, **kwargs): if isinstance(callback, WrappedCallbackItem): item = callback From f323ae61f02d79703a5809384b6e4e090f48a0e5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 14 Jan 2022 04:13:44 +0100 Subject: [PATCH 272/395] Fix namespace not going back to original namespace when started from inside a namespace --- openpype/hosts/maya/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..f100aee7c0 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -733,7 +733,7 @@ def namespaced(namespace, new=True): str: The namespace that is used during the context """ - original = cmds.namespaceInfo(cur=True) + original = cmds.namespaceInfo(cur=True, absoluteName=True) if new: namespace = avalon.maya.lib.unique_namespace(namespace) cmds.namespace(add=namespace) From 687181e3825373894111f8a6267ad9fd9fe99917 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 Jan 2022 10:58:36 +0100 Subject: [PATCH 273/395] interval of validation can be modified --- .../defaults/system_settings/general.json | 1 + .../schemas/system_schema/schema_general.json | 13 +++++++++++++ openpype/tools/tray/pype_tray.py | 17 ++++++++++++----- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/openpype/settings/defaults/system_settings/general.json b/openpype/settings/defaults/system_settings/general.json index a07152eaf8..7c78de9a5c 100644 --- a/openpype/settings/defaults/system_settings/general.json +++ b/openpype/settings/defaults/system_settings/general.json @@ -4,6 +4,7 @@ "admin_password": "", "production_version": "", "staging_version": "", + "version_check_interval": 5, "environment": { "__environment_keys__": { "global": [] diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index b4c83fc85f..3af3f5ce35 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -47,6 +47,19 @@ { "type": "splitter" }, + { + "type": "label", + "label": "Trigger validation if running OpenPype is using studio defined version each 'n' minutes. Validation happens in OpenPype tray application." + }, + { + "type": "number", + "key": "version_check_interval", + "label": "Version check interval", + "minimum": 0 + }, + { + "type": "splitter" + }, { "key": "environment", "label": "Environment", diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 17251b404f..de1a8577b0 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -108,8 +108,6 @@ class TrayManager: Load submenus, actions, separators and modules into tray's context. """ - _version_check_interval = 5 * 60 * 1000 - def __init__(self, tray_widget, main_window): self.tray_widget = tray_widget self.main_window = main_window @@ -117,7 +115,15 @@ class TrayManager: self.log = Logger.get_logger(self.__class__.__name__) - self.module_settings = get_system_settings()["modules"] + system_settings = get_system_settings() + self.module_settings = system_settings["modules"] + + version_check_interval = system_settings["general"].get( + "version_check_interval" + ) + if version_check_interval is None: + version_check_interval = 5 + self._version_check_interval = version_check_interval * 60 * 1000 self.modules_manager = TrayModulesManager() @@ -247,9 +253,10 @@ class TrayManager: self.main_thread_timer = main_thread_timer version_check_timer = QtCore.QTimer() - version_check_timer.setInterval(self._version_check_interval) version_check_timer.timeout.connect(self._on_version_check_timer) - version_check_timer.start() + if self._version_check_interval > 0: + version_check_timer.setInterval(self._version_check_interval) + version_check_timer.start() self._version_check_timer = version_check_timer # For storing missing settings dialog From 0ebd7881c144a16e98a8923c4f5e2f8ea22a355e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 Jan 2022 11:40:38 +0100 Subject: [PATCH 274/395] fixed reseting from staging --- openpype/tools/tray/pype_tray.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index de1a8577b0..7f78140211 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -18,6 +18,7 @@ from openpype.lib import ( get_openpype_execute_args, is_current_version_studio_latest, is_running_from_build, + is_running_staging, get_expected_version, get_openpype_version ) @@ -349,17 +350,25 @@ class TrayManager: First creates new process with same argument and close current tray. """ args = get_openpype_execute_args() + kwargs = { + "env": dict(os.environ.items()) + } + # Create a copy of sys.argv additional_args = list(sys.argv) # Check last argument from `get_openpype_execute_args` # - when running from code it is the same as first from sys.argv if args[-1] == additional_args[0]: additional_args.pop(0) - args.extend(additional_args) - kwargs = { - "env": dict(os.environ.items()) - } + # Pop OPENPYPE_VERSION + if reset_version: + # Add staging flag if was running from staging + if is_running_staging(): + args.append("--use-staging") + kwargs["env"].pop("OPENPYPE_VERSION", None) + + args.extend(additional_args) if platform.system().lower() == "windows": flags = ( subprocess.CREATE_NEW_PROCESS_GROUP @@ -367,9 +376,6 @@ class TrayManager: ) kwargs["creationflags"] = flags - if reset_version: - kwargs["env"].pop("OPENPYPE_VERSION", None) - subprocess.Popen(args, **kwargs) self.exit() From 4a230b710ea605fce9b9edadb455d0277301032a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 11:53:04 +0100 Subject: [PATCH 275/395] flame: add function to get flame version and root install path --- openpype/hosts/flame/api/__init__.py | 6 +++++- openpype/hosts/flame/api/utils.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index fce59af506..e7590bb36e 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -30,7 +30,9 @@ from .lib import ( get_padding_from_path ) from .utils import ( - setup + setup, + get_flame_version, + get_flame_install_root ) from .pipeline import ( install, @@ -107,6 +109,8 @@ __all__ = [ # utils "setup", + "get_flame_version", + "get_flame_install_root", # menu "FlameMenuProjectConnect", diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index b9899900f5..0e40e40aa7 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -125,3 +125,18 @@ def setup(env=None): _sync_utility_scripts(env) log.info("Flame OpenPype wrapper has been installed") + + +def get_flame_version(): + import flame + + return { + "full": flame.get_version(), + "major": flame.get_version_major(), + "minor": flame.get_version_minor(), + "patch": flame.get_version_patch() + } + + +def get_flame_install_root(): + return "/opt/Autodesk" \ No newline at end of file From cc20a22e3ad70639d58c55e65e68b67e24264fca Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 11:53:35 +0100 Subject: [PATCH 276/395] flame: add function to maintain object duplication --- openpype/hosts/flame/api/__init__.py | 4 +++- openpype/hosts/flame/api/lib.py | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index e7590bb36e..7f516fb11f 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -27,7 +27,8 @@ from .lib import ( get_clips_in_reels, get_reformated_path, get_frame_from_path, - get_padding_from_path + get_padding_from_path, + maintained_object_duplication ) from .utils import ( setup, @@ -93,6 +94,7 @@ __all__ = [ "get_reformated_path", "get_frame_from_path", "get_padding_from_path", + "maintained_object_duplication", # pipeline "install", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index b963a1cb39..800afebf41 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -675,3 +675,26 @@ def get_frame_from_path(path): return found.pop() else: return None + + + +@contextlib.contextmanager +def maintained_object_duplication(item): + """Maintain input item duplication + + Attributes: + item (any flame.PyObject): python api object + + Yield: + duplicate input PyObject type + """ + import flame + # Duplicate the clip to avoid modifying the original clip + duplicate = flame.duplicate(item) + + try: + # do the operation on selected segments + yield duplicate + finally: + # delete the item at the end + flame.delete(duplicate) From f6ab7f2cbaef91afa7ca5a35f3a540c22b7529e6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 12:06:35 +0100 Subject: [PATCH 277/395] flame: adding settings for `ExtractSubsetResources` plugin --- .../defaults/project_settings/flame.json | 12 ++++ .../projects_schema/schema_project_flame.json | 55 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index b6fbdecc95..ed54d631be 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -16,5 +16,17 @@ "handleStart": 10, "handleEnd": 10 } + }, + "publish": { + "ExtractSubsetResources": { + "export_presets_mapping": { + "exr16fpdwaa": { + "ext": "exr", + "xmlPresetDir": "", + "xmlPresetFile": "OpenEXR (16-bit fp DWAA).xml", + "representationTags": [] + } + } + } } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index d713c37620..6ca5fc049d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -119,6 +119,61 @@ ] } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "ExtractSubsetResources", + "label": "Extract Subset Resources", + "is_group": true, + "children": [ + { + "key": "export_presets_mapping", + "label": "Export presets mapping", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "ext", + "label": "Output extension", + "type": "text" + }, + { + "key": "xmlPresetFile", + "label": "XML preset file (with ext)", + "type": "text" + }, + { + "key": "xmlPresetDir", + "label": "XML preset folder (optional)", + "type": "text" + }, + { + "type": "separator" + }, + { + "type": "list", + "key": "representationTags", + "label": "Add representation tags", + "object_type": { + "type": "text", + "multiline": false + } + } + ] + } + } + ] + } + ] } ] } From 0e96a2e3b1d4f0481ec425ecfa0275e885185099 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 12:50:55 +0100 Subject: [PATCH 278/395] flame: update utils modules --- openpype/hosts/flame/api/__init__.py | 6 +- openpype/hosts/flame/api/render_utils.py | 135 +++++++++++++---------- openpype/hosts/flame/api/utils.py | 2 +- 3 files changed, 82 insertions(+), 61 deletions(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 7f516fb11f..656ba11617 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -63,7 +63,8 @@ from .workio import ( work_root ) from .render_utils import ( - export_clip + export_clip, + get_preset_path_by_xml_name ) __all__ = [ @@ -131,5 +132,6 @@ __all__ = [ "work_root", # render utils - "export_clip" + "export_clip", + "get_preset_path_by_xml_name" ] diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py index d2e312785f..1cc94f6548 100644 --- a/openpype/hosts/flame/api/render_utils.py +++ b/openpype/hosts/flame/api/render_utils.py @@ -1,18 +1,15 @@ import os -SHARED_PRESET_PATH = '/opt/Autodesk/shared/export/presets' - -def export_clip(export_path, clip, export_preset, **kwargs): +def export_clip(export_path, clip, preset_path, **kwargs): """Flame exported wrapper Args: export_path (str): exporting directory path clip (PyClip): flame api object - export_preset (str): name of exporting preset xml file + preset_path (str): full export path to xml file Kwargs: - export_type (str)[optional]: name of export type folder thumb_frame_number (int)[optional]: source frame number in_mark (int)[optional]: cut in mark out_mark (int)[optional]: cut out mark @@ -20,8 +17,6 @@ def export_clip(export_path, clip, export_preset, **kwargs): Raises: KeyError: Missing input kwarg `thumb_frame_number` in case `thumbnail` in `export_preset` - KeyError: Missing input kwarg `export_type` - in case of other `export_preset` then `thumbnail` FileExistsError: Missing export preset in shared folder """ import flame @@ -33,11 +28,8 @@ def export_clip(export_path, clip, export_preset, **kwargs): exporter.foreground = True exporter.export_between_marks = True - # Duplicate the clip to avoid modifying the original clip - duplicate_clip = flame.duplicate(clip) - - if export_preset == 'thumbnail': - thumb_frame_number = kwargs.get("thumb_frame_number") + if kwargs.get("thumb_frame_number"): + thumb_frame_number = kwargs["thumb_frame_number"] # make sure it exists in kwargs if not thumb_frame_number: raise KeyError( @@ -46,61 +38,88 @@ def export_clip(export_path, clip, export_preset, **kwargs): in_mark = int(thumb_frame_number) out_mark = int(thumb_frame_number) + 1 - # In case Thumbnail is needed - preset_dir = flame.PyExporter.get_presets_dir( - flame.PyExporter.PresetVisibility.Autodesk, - flame.PyExporter.PresetType.Image_Sequence) - export_preset_path = os.path.join( - preset_dir, "Jpeg", "Jpeg (8-bit).xml") - + elif kwargs.get("in_mark") and kwargs.get("out_mark"): + in_mark = int(kwargs["in_mark"]) + out_mark = int(kwargs["out_mark"]) else: - # In case other output is needed - # get compulsory kwargs - export_type = kwargs.get("export_type") - # make sure it exists in kwargs - if not export_type: - raise KeyError( - "Missing key `export_type` in input kwargs") - - # create full shared preset path - shared_preset_dir = os.path.join( - SHARED_PRESET_PATH, export_type - ) - - # check if export preset is available in shared presets - shared_presets = [ - preset[:-4] for preset in os.listdir(shared_preset_dir)] - if export_preset not in shared_presets: - raise FileExistsError( - "Missing preset file `{}` in `{}`".format( - export_preset, - shared_preset_dir - )) - - export_preset_path = os.path.join( - shared_preset_dir, export_preset + '.xml') - - # check if mark in/out is set in kwargs - if kwargs.get("in_mark") and kwargs.get("out_mark"): - in_mark = int(kwargs["in_mark"]) - out_mark = int(kwargs["out_mark"]) - else: - exporter.export_between_marks = False + exporter.export_between_marks = False try: # set in and out marks if they are available if in_mark and out_mark: - duplicate_clip.in_mark = in_mark - duplicate_clip.out_mark = out_mark + clip.in_mark = in_mark + clip.out_mark = out_mark # export with exporter - exporter.export(duplicate_clip, export_preset_path, export_path) + exporter.export(clip, preset_path, export_path) finally: print('Exported: {} at {}-{}'.format( clip.name.get_value(), - duplicate_clip.in_mark, - duplicate_clip.out_mark + clip.in_mark, + clip.out_mark )) - # delete duplicated clip it is not needed anymore - flame.delete(duplicate_clip) + +def get_preset_path_by_xml_name(xml_preset_name): + def _search_path(root): + output = [] + for root, dirs, files in os.walk(root): + for f in files: + if f != xml_preset_name: + continue + file_path = os.path.join(root, f) + output.append(file_path) + return output + + def _validate_results(results): + if results and len(results) == 1: + return results.pop() + elif results and len(results) > 1: + print(( + "More matching presets for `{}`: /n" + "{}").format(xml_preset_name, results)) + return results.pop() + else: + return None + + from .utils import ( + get_flame_install_root, + get_flame_version + ) + + # get actual flame version and install path + _version = get_flame_version()["full"] + _install_root = get_flame_install_root() + + # search path templates + shared_search_root = "{install_root}/shared/export/presets" + install_search_root = ( + "{install_root}/presets/{version}/export/presets/flame") + + # fill templates + shared_search_root = shared_search_root.format( + install_root=_install_root + ) + install_search_root = install_search_root.format( + install_root=_install_root, + version=_version + ) + + # get search results + shared_results = _search_path(shared_search_root) + installed_results = _search_path(install_search_root) + + # first try to return shared results + shared_preset_path = _validate_results(shared_results) + + if shared_preset_path: + return os.path.dirname(shared_preset_path) + + # then try installed results + installed_preset_path = _validate_results(installed_results) + + if installed_preset_path: + return os.path.dirname(installed_preset_path) + + # if nothing found then return None + return False diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 0e40e40aa7..9939371358 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -139,4 +139,4 @@ def get_flame_version(): def get_flame_install_root(): - return "/opt/Autodesk" \ No newline at end of file + return "/opt/Autodesk" From 183acf4bd3b34dd046401434c67932ad9e8b6050 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 12:51:48 +0100 Subject: [PATCH 279/395] flame: update export plugin with more dynamic preset path abstraction --- .../publish/extract_subset_resources.py | 155 +++++++++++------- 1 file changed, 96 insertions(+), 59 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 6061c80762..3a8fd631d8 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -1,4 +1,5 @@ import os +from copy import deepcopy import pyblish.api import openpype.api from openpype.hosts.flame import api as opfapi @@ -14,25 +15,29 @@ class ExtractSubsetResources(openpype.api.Extractor): families = ["clip"] hosts = ["flame"] + # plugin defaults + default_presets = { + "thumbnail": { + "ext": "jpg", + "xmlPresetFile": "Jpeg (8-bit).xml", + "xmlPresetDir": "", + "representationTags": ["thumbnail"] + }, + "ftrackpreview": { + "ext": "mov", + "xmlPresetFile": "Apple iPad (1920x1080).xml", + "xmlPresetDir": "", + "representationTags": [ + "review", + "delete" + ] + } + } # hide publisher during exporting hide_ui_on_process = True - export_presets_mapping = { - "thumbnail": { - "ext": "jpg", - "uniqueName": "thumbnail" - }, - "OpenEXR (16-bit fp DWAA)_custom": { - "ext": "exr", - "preset_type": "file_sequence", - "uniqueName": "exr16fpdwaa" - }, - "QuickTime (H.264 1080p 8Mbits)_custom": { - "ext": "mov", - "preset_type": "movie_file", - "uniqueName": "ftrackpreview" - } - } + # settings + export_presets_mapping = {} def process(self, instance): # create representation data @@ -53,54 +58,86 @@ class ExtractSubsetResources(openpype.api.Extractor): staging_dir = self.staging_dir(instance) - # loop all preset names and - for preset_name, preset_config in self.export_presets_mapping.items(): - kwargs = {} - unique_name = preset_config["uniqueName"] - preset_type = None + # add default preset type for thumbnail and reviewable video + # update them with settings and overide in case the same + # are found in there + export_presets = deepcopy(self.default_presets) + export_presets.update(self.export_presets_mapping) - # define kwargs based on preset type - if "thumbnail" in preset_name: - kwargs["thumb_frame_number"] = in_mark + ( - source_duration_handles / 2) - else: - preset_type = preset_config["preset_type"] - kwargs.update({ - "in_mark": in_mark, - "out_mark": out_mark, - "export_type": preset_type - }) + # with maintained duplication loop all presets + with opfapi.maintained_object_duplication(clip) as duplclip: + # loop all preset names and + for unique_name, preset_config in export_presets.items(): + kwargs = {} + preset_file = preset_config["xmlPresetFile"] + preset_dir = preset_config["xmlPresetDir"] - export_dir_path = os.path.join( - staging_dir, unique_name - ) - os.makedirs(export_dir_path) + # validate xml preset file is filled + if preset_file == "": + raise ValueError( + ("Check Settings for {} preset: " + "`XML preset file` is not filled").format( + unique_name) + ) - # export - opfapi.export_clip( - export_dir_path, clip, preset_name, **kwargs) + # resolve xml preset dir if not filled + if preset_dir == "": + preset_dir = opfapi.get_preset_path_by_xml_name( + preset_file) - # create representation data - representation_data = { - 'name': unique_name, - 'ext': preset_config["ext"], - "stagingDir": export_dir_path, - } + if not preset_dir: + raise ValueError( + ("Check Settings for {} preset: " + "`XML preset file` {} is not found").format( + unique_name, preset_file) + ) - files = os.listdir(export_dir_path) + # create preset path + preset_path = os.path.join( + preset_dir, preset_file + ) - # add files to represetation but add - # imagesequence as list - if ( - preset_type - and preset_type == "movie_file" - or preset_name == "thumbnail" - ): - representation_data["files"] = files.pop() - else: - representation_data["files"] = files + # define kwargs based on preset type + if "thumbnail" in unique_name: + kwargs["thumb_frame_number"] = in_mark + ( + source_duration_handles / 2) + else: + kwargs.update({ + "in_mark": in_mark, + "out_mark": out_mark + }) - instance.data["representations"].append(representation_data) + export_dir_path = os.path.join( + staging_dir, unique_name + ) + os.makedirs(export_dir_path) - self.log.info("Added representation: {}".format( - representation_data)) + # export + opfapi.export_clip( + export_dir_path, duplclip, preset_path, **kwargs) + + # create representation data + representation_data = { + "name": unique_name, + "outputName": unique_name, + "ext": preset_config["ext"], + "stagingDir": export_dir_path, + "tags": preset_config["representationTags"] + } + + files = os.listdir(export_dir_path) + + # add files to represetation but add + # imagesequence as list + if ( + "movie_file" in preset_path + or unique_name == "thumbnail" + ): + representation_data["files"] = files.pop() + else: + representation_data["files"] = files + + instance.data["representations"].append(representation_data) + + self.log.info("Added representation: {}".format( + representation_data)) From aa39f98ae626b702d02b5b57b786ee22bd0c8252 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 12:57:26 +0100 Subject: [PATCH 280/395] flame: add bool to settings to control if range will be added to repres --- .../hosts/flame/plugins/publish/extract_subset_resources.py | 2 ++ openpype/settings/defaults/project_settings/flame.json | 1 + .../schemas/projects_schema/schema_project_flame.json | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 3a8fd631d8..b2a737cbcb 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -21,12 +21,14 @@ class ExtractSubsetResources(openpype.api.Extractor): "ext": "jpg", "xmlPresetFile": "Jpeg (8-bit).xml", "xmlPresetDir": "", + "representationAddRange": False, "representationTags": ["thumbnail"] }, "ftrackpreview": { "ext": "mov", "xmlPresetFile": "Apple iPad (1920x1080).xml", "xmlPresetDir": "", + "representationAddRange": False, "representationTags": [ "review", "delete" diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index ed54d631be..dfecd8a12e 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -24,6 +24,7 @@ "ext": "exr", "xmlPresetDir": "", "xmlPresetFile": "OpenEXR (16-bit fp DWAA).xml", + "representationAddRange": false, "representationTags": [] } } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index 6ca5fc049d..8ad2b11616 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -159,6 +159,11 @@ { "type": "separator" }, + { + "type": "boolean", + "key": "representationAddRange", + "label": "Add frame range to representation" + }, { "type": "list", "key": "representationTags", From 438d6df439cbec5ebe9da311dbad4a6cda7144d2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 15:56:36 +0100 Subject: [PATCH 281/395] burnins fix bit rate for dnxhd mxf passing metadata to burnins --- openpype/scripts/otio_burnin.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 3fc1412e62..639657d68f 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -157,6 +157,16 @@ def _dnxhd_codec_args(stream_data, source_ffmpeg_cmd): if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-b:v", "-vb", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + output.extend([arg, args[idx + 1]]) + output.extend(["-g", "1"]) return output @@ -716,6 +726,15 @@ def burnins_from_data( ffmpeg_args.extend( get_codec_args(burnin.ffprobe_data, source_ffmpeg_cmd) ) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-metadata", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + ffmpeg_args.extend([arg, args[idx + 1]]) # Use group one (same as `-intra` argument, which is deprecated) ffmpeg_args_str = " ".join(ffmpeg_args) From 1150de03b307105f39d99a6f96ec8cab5a0ccb2b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 11:02:21 +0100 Subject: [PATCH 282/395] format output arguments with anatomy data --- openpype/plugins/publish/extract_review.py | 25 +++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index b6c2e49385..be29c7bf9c 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -187,6 +187,7 @@ class ExtractReview(pyblish.api.InstancePlugin): outputs_per_repres = self._get_outputs_per_representations( instance, profile_outputs ) + fill_data = copy.deepcopy(instance.data["anatomyData"]) for repre, outputs in outputs_per_repres: # Check if input should be preconverted before processing # Store original staging dir (it's value may change) @@ -293,7 +294,7 @@ class ExtractReview(pyblish.api.InstancePlugin): try: # temporary until oiiotool is supported cross platform ffmpeg_args = self._ffmpeg_arguments( - output_def, instance, new_repre, temp_data + output_def, instance, new_repre, temp_data, fill_data ) except ZeroDivisionError: if 'exr' in temp_data["origin_repre"]["ext"]: @@ -446,7 +447,9 @@ class ExtractReview(pyblish.api.InstancePlugin): "handles_are_set": handles_are_set } - def _ffmpeg_arguments(self, output_def, instance, new_repre, temp_data): + def _ffmpeg_arguments( + self, output_def, instance, new_repre, temp_data, fill_data + ): """Prepares ffmpeg arguments for expected extraction. Prepares input and output arguments based on output definition and @@ -472,9 +475,6 @@ class ExtractReview(pyblish.api.InstancePlugin): ffmpeg_input_args = [ value for value in _ffmpeg_input_args if value.strip() ] - ffmpeg_output_args = [ - value for value in _ffmpeg_output_args if value.strip() - ] ffmpeg_video_filters = [ value for value in _ffmpeg_video_filters if value.strip() ] @@ -482,6 +482,21 @@ class ExtractReview(pyblish.api.InstancePlugin): value for value in _ffmpeg_audio_filters if value.strip() ] + ffmpeg_output_args = [] + for value in _ffmpeg_output_args: + value = value.strip() + if not value: + continue + try: + value = value.format(**fill_data) + except Exception: + self.log.warning( + "Failed to format ffmpeg argument: {}".format(value), + exc_info=True + ) + pass + ffmpeg_output_args.append(value) + # Prepare input and output filepaths self.input_output_paths(new_repre, output_def, temp_data) From eed31e543372d3616433aa08d8250993fbc7d0e2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 15:56:36 +0100 Subject: [PATCH 283/395] burnins fix bit rate for dnxhd mxf passing metadata to burnins --- openpype/scripts/otio_burnin.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 15a62ef38e..63a8b064db 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -157,6 +157,16 @@ def _dnxhd_codec_args(stream_data, source_ffmpeg_cmd): if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-b:v", "-vb", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + output.extend([arg, args[idx + 1]]) + output.extend(["-g", "1"]) return output @@ -715,6 +725,15 @@ def burnins_from_data( ffmpeg_args.extend( get_codec_args(burnin.ffprobe_data, source_ffmpeg_cmd) ) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-metadata", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + ffmpeg_args.extend([arg, args[idx + 1]]) # Use group one (same as `-intra` argument, which is deprecated) ffmpeg_args_str = " ".join(ffmpeg_args) From 17578c54471ad931bc48afc82b5aa8ccd4a29908 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 Jan 2022 16:20:05 +0100 Subject: [PATCH 284/395] fix import if 'is_running_staging' --- openpype/lib/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index a2a16bcc00..62d204186d 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -173,6 +173,7 @@ from .openpype_version import ( get_build_version, get_expected_version, is_running_from_build, + is_running_staging, is_current_version_studio_latest ) @@ -309,5 +310,6 @@ __all__ = [ "get_build_version", "get_expected_version", "is_running_from_build", + "is_running_staging", "is_current_version_studio_latest", ] From 26c3ba7e1be4e7a94c3cf8d6869e813dd716bc25 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 17:37:19 +0100 Subject: [PATCH 285/395] flame: add frame ranges to representation --- .../plugins/publish/extract_subset_resources.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index b2a737cbcb..3495309409 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -28,7 +28,7 @@ class ExtractSubsetResources(openpype.api.Extractor): "ext": "mov", "xmlPresetFile": "Apple iPad (1920x1080).xml", "xmlPresetDir": "", - "representationAddRange": False, + "representationAddRange": True, "representationTags": [ "review", "delete" @@ -46,6 +46,9 @@ class ExtractSubsetResources(openpype.api.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] + frame_start = instance.data["frameStart"] + handle_start = instance.data["handleStart"] + frame_start_handle = frame_start - handle_start source_first_frame = instance.data["sourceFirstFrame"] source_start_handles = instance.data["sourceStartH"] source_end_handles = instance.data["sourceEndH"] @@ -139,6 +142,15 @@ class ExtractSubsetResources(openpype.api.Extractor): else: representation_data["files"] = files + # add frame range + if preset_config["representationAddRange"]: + representation_data.update({ + "frameStart": frame_start_handle, + "frameEnd": ( + frame_start_handle + source_duration_handles), + "fps": instance.data["fps"] + }) + instance.data["representations"].append(representation_data) self.log.info("Added representation: {}".format( From cbfb3e734eba83852846b6d047f18bf53c752012 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 14 Jan 2022 04:10:55 +0100 Subject: [PATCH 286/395] Fix not unique group name error (cherry picked from commit f1b7aed767b76bced648b785dc3d40a68b36db7b) --- openpype/hosts/maya/plugins/load/load_reference.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index dd64fd0a16..2cc24f1360 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -63,6 +63,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): if current_namespace != ":": group_name = current_namespace + ":" + group_name + group_name = "|" + group_name + self[:] = new_nodes if attach_to_root: From ce5c70e28d99d1528ab12e75be91c1a219b38aae Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 Jan 2022 17:47:11 +0100 Subject: [PATCH 287/395] change back project manager styles --- openpype/style/data.json | 5 ++--- openpype/style/style.css | 13 +++++++++---- .../tools/project_manager/project_manager/style.py | 2 +- .../project_manager/project_manager/widgets.py | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/openpype/style/data.json b/openpype/style/data.json index 6e1b6e822b..c8adc0674a 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -51,10 +51,9 @@ "border-hover": "rgba(168, 175, 189, .3)", "border-focus": "rgb(92, 173, 214)", - "warning-btn-bg": "rgb(201, 54, 54)", - "warning-btn-bg": "rgb(201, 54, 54)", - "warning-btn-bg-disabled": "rgba(201, 54, 54, 64)", + "delete-btn-bg": "rgb(201, 54, 54)", + "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", "tab-widget": { "bg": "#21252B", diff --git a/openpype/style/style.css b/openpype/style/style.css index 65e8d0cb40..d9b0ff7421 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -734,11 +734,11 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:bg-view-hover}; } -#WarningButton { - background: {color:warning-btn-bg}; +#DeleteButton { + background: {color:delete-btn-bg}; } -#WarningButton:disabled { - background: {color:warning-btn-bg-disabled}; +#DeleteButton:disabled { + background: {color:delete-btn-bg-disabled}; } /* Launcher specific stylesheets */ @@ -1228,6 +1228,11 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: #21252B; } +/* Tray */ +#TrayRestartButton { + background: {color:restart-btn-bg}; +} + /* Globally used names */ #Separator { background: {color:bg-menu-separator}; diff --git a/openpype/tools/project_manager/project_manager/style.py b/openpype/tools/project_manager/project_manager/style.py index 980c637bca..9fa7a5520b 100644 --- a/openpype/tools/project_manager/project_manager/style.py +++ b/openpype/tools/project_manager/project_manager/style.py @@ -95,7 +95,7 @@ class ResourceCache: def get_warning_pixmap(cls): src_image = get_warning_image() colors = get_objected_colors() - color_value = colors["warning-btn-bg"] + color_value = colors["delete-btn-bg"] return paint_image_with_color( src_image, diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index e58dcc7d0c..4b5aca35ef 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -369,7 +369,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): cancel_btn = QtWidgets.QPushButton("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") confirm_btn = QtWidgets.QPushButton("Permanently Delete Project", self) - confirm_btn.setObjectName("WarningButton") + confirm_btn.setObjectName("DeleteButton") confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") From 6e8e1173d8dcfeccbc944b976a1e0504642ea0cf Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 14 Jan 2022 17:42:59 +0100 Subject: [PATCH 288/395] Fix Load VDB to V-Ray for Maya (cherry picked from commit d53db6cd2b02a9b4ac251c70d600b47cc5e2493c) --- .../maya/plugins/load/load_vdb_to_vray.py | 239 ++++++++++++++++-- 1 file changed, 223 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py index 80b453bd13..151731c13c 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py @@ -2,6 +2,72 @@ from avalon import api from openpype.api import get_project_settings import os +from maya import cmds + +# List of 3rd Party Channels Mapping names for VRayVolumeGrid +# See: https://docs.chaosgroup.com/display/VRAY4MAYA/Input +# #Input-3rdPartyChannelsMapping +THIRD_PARTY_CHANNELS = { + 2: "Smoke", + 1: "Temperature", + 10: "Fuel", + 4: "Velocity.x", + 5: "Velocity.y", + 6: "Velocity.z", + 7: "Red", + 8: "Green", + 9: "Blue", + 14: "Wavelet Energy", + 19: "Wavelet.u", + 20: "Wavelet.v", + 21: "Wavelet.w", + # These are not in UI or documentation but V-Ray does seem to set these. + 15: "AdvectionOrigin.x", + 16: "AdvectionOrigin.y", + 17: "AdvectionOrigin.z", + +} + + +def _fix_duplicate_vvg_callbacks(): + """Workaround to kill duplicate VRayVolumeGrids attribute callbacks. + + This fixes a huge lag in Maya on switching 3rd Party Channels Mappings + or to different .vdb file paths because it spams an attribute changed + callback: `vvgUserChannelMappingsUpdateUI`. + + ChaosGroup bug ticket: 154-008-9890 + + Found with: + - Maya 2019.2 on Windows 10 + - V-Ray: V-Ray Next for Maya, update 1 version 4.12.01.00001 + + Bug still present in: + - Maya 2022.1 on Windows 10 + - V-Ray 5 for Maya, Update 2.1 (v5.20.01 from Dec 16 2021) + + """ + # todo(roy): Remove when new V-Ray release fixes duplicate calls + + jobs = cmds.scriptJob(listJobs=True) + + matched = set() + for entry in jobs: + # Remove the number + index, callback = entry.split(":", 1) + callback = callback.strip() + + # Detect whether it is a `vvgUserChannelMappingsUpdateUI` + # attribute change callback + if callback.startswith('"-runOnce" 1 "-attributeChange" "'): + if '"vvgUserChannelMappingsUpdateUI(' in callback: + if callback in matched: + # If we've seen this callback before then + # delete the duplicate callback + cmds.scriptJob(kill=int(index)) + else: + matched.add(callback) + class LoadVDBtoVRay(api.Loader): @@ -14,15 +80,24 @@ class LoadVDBtoVRay(api.Loader): def load(self, context, name, namespace, data): - from maya import cmds import avalon.maya.lib as lib from avalon.maya.pipeline import containerise + assert os.path.exists(self.fname), ( + "Path does not exist: %s" % self.fname + ) + try: family = context["representation"]["context"]["family"] except ValueError: family = "vdbcache" + # Ensure V-ray is loaded with the vrayvolumegrid + if not cmds.pluginInfo("vrayformaya", query=True, loaded=True): + cmds.loadPlugin("vrayformaya") + if not cmds.pluginInfo("vrayvolumegrid", query=True, loaded=True): + cmds.loadPlugin("vrayvolumegrid") + # Check if viewport drawing engine is Open GL Core (compat) render_engine = None compatible = "OpenGLCoreProfileCompat" @@ -30,13 +105,11 @@ class LoadVDBtoVRay(api.Loader): render_engine = cmds.optionVar(query="vp2RenderingEngine") if not render_engine or render_engine != compatible: - raise RuntimeError("Current scene's settings are incompatible." - "See Preferences > Display > Viewport 2.0 to " - "set the render engine to '%s'" % compatible) + self.log.warning("Current scene's settings are incompatible." + "See Preferences > Display > Viewport 2.0 to " + "set the render engine to '%s'" % compatible) asset = context['asset'] - version = context["version"] - asset_name = asset["name"] namespace = namespace or lib.unique_namespace( asset_name + "_", @@ -45,7 +118,7 @@ class LoadVDBtoVRay(api.Loader): ) # Root group - label = "{}:{}".format(namespace, name) + label = "{}:{}_VDB".format(namespace, name) root = cmds.group(name=label, empty=True) settings = get_project_settings(os.environ['AVALON_PROJECT']) @@ -55,20 +128,25 @@ class LoadVDBtoVRay(api.Loader): if c is not None: cmds.setAttr(root + ".useOutlinerColor", 1) cmds.setAttr(root + ".outlinerColor", - (float(c[0])/255), - (float(c[1])/255), - (float(c[2])/255) + float(c[0])/255, + float(c[1])/255, + float(c[2])/255 ) - # Create VR + # Create VRayVolumeGrid grid_node = cmds.createNode("VRayVolumeGrid", - name="{}VVGShape".format(label), + name="{}Shape".format(label), parent=root) - # Set attributes - cmds.setAttr("{}.inFile".format(grid_node), self.fname, type="string") - cmds.setAttr("{}.inReadOffset".format(grid_node), - version["startFrames"]) + # Ensure .currentTime is connected to time1.outTime + cmds.connectAttr("time1.outTime", grid_node + ".currentTime") + + # Set path + self._set_path(grid_node, self.fname, show_preset_popup=True) + + # Lock the shape node so the user can't delete the transform/shape + # as if it was referenced + cmds.lockNode(grid_node, lock=True) nodes = [root, grid_node] self[:] = nodes @@ -79,3 +157,132 @@ class LoadVDBtoVRay(api.Loader): nodes=nodes, context=context, loader=self.__class__.__name__) + + def _set_path(self, grid_node, path, show_preset_popup=True): + + from openpype.hosts.maya.api.lib import attribute_values + from maya import cmds + + def _get_filename_from_folder(path): + # Using the sequence of .vdb files we check the frame range, etc. + # to set the filename with #### padding. + files = sorted(x for x in os.listdir(path) if x.endswith(".vdb")) + if not files: + raise RuntimeError("Couldn't find .vdb files in: %s" % path) + + if len(files) == 1: + # Ensure check for single file is also done in folder + fname = files[0] + else: + # Sequence + from avalon.vendor import clique + # todo: check support for negative frames as input + collections, remainder = clique.assemble(files) + assert len(collections) == 1, ( + "Must find a single image sequence, " + "found: %s" % (collections,) + ) + collection = collections[0] + + fname = collection.format('{head}{{padding}}{tail}') + padding = collection.padding + if padding == 0: + # Clique doesn't provide padding if the frame number never + # starts with a zero and thus has never any visual padding. + # So we fall back to the smallest frame number as padding. + padding = min(len(str(i)) for i in collection.indexes) + + # Supply frame/padding with # signs + padding_str = "#" * padding + fname = fname.format(padding=padding_str) + + return os.path.join(path, fname) + + # The path is either a single file or sequence in a folder so + # we do a quick lookup for our files + if os.path.isfile(path): + path = os.path.dirname(path) + path = _get_filename_from_folder(path) + + # Even when not applying a preset V-Ray will reset the 3rd Party + # Channels Mapping of the VRayVolumeGrid when setting the .inPath + # value. As such we try and preserve the values ourselves. + # Reported as ChaosGroup bug ticket: 154-011-2909  + # todo(roy): Remove when new V-Ray release preserves values + original_user_mapping = cmds.getAttr(grid_node + ".usrchmap") or "" + + # Workaround for V-Ray bug: fix lag on path change, see function + _fix_duplicate_vvg_callbacks() + + # Suppress preset pop-up if we want. + popup_attr = "{0}.inDontOfferPresets".format(grid_node) + popup = {popup_attr: not show_preset_popup} + with attribute_values(popup): + cmds.setAttr(grid_node + ".inPath", path, type="string") + + # Reapply the 3rd Party channels user mapping when no preset popup + # was shown to the user + if not show_preset_popup: + channels = cmds.getAttr(grid_node + ".usrchmapallch").split(";") + channels = set(channels) # optimize lookup + restored_mapping = "" + for entry in original_user_mapping.split(";"): + if not entry: + # Ignore empty entries + continue + + # If 3rd Party Channels selection channel still exists then + # add it again. + index, channel = entry.split(",") + attr = THIRD_PARTY_CHANNELS.get(int(index), + # Fallback for when a mapping + # was set that is not in the + # documentation + "???") + if channel in channels: + restored_mapping += entry + ";" + else: + self.log.warning("Can't preserve '%s' mapping due to " + "missing channel '%s' on node: " + "%s" % (attr, channel, grid_node)) + + if restored_mapping: + cmds.setAttr(grid_node + ".usrchmap", + restored_mapping, + type="string") + + def update(self, container, representation): + + path = api.get_representation_path(representation) + + # Find VRayVolumeGrid + members = cmds.sets(container['objectName'], query=True) + grid_nodes = cmds.ls(members, type="VRayVolumeGrid", long=True) + assert len(grid_nodes) > 0, "This is a bug" + + # Update the VRayVolumeGrid + for grid_node in grid_nodes: + self._set_path(grid_node, path=path, show_preset_popup=False) + + # Update container representation + cmds.setAttr(container["objectName"] + ".representation", + str(representation["_id"]), + type="string") + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + + # Get all members of the avalon container, ensure they are unlocked + # and delete everything + members = cmds.sets(container['objectName'], query=True) + cmds.lockNode(members, lock=False) + cmds.delete([container['objectName']] + members) + + # Clean up the namespace + try: + cmds.namespace(removeNamespace=container['namespace'], + deleteNamespaceContent=True) + except RuntimeError: + pass From a18bdbc418e004bb8d3c0606296d1649872fa74b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 Jan 2022 17:53:56 +0100 Subject: [PATCH 289/395] changed dialog and added restart and update action to tray --- openpype/style/data.json | 1 + openpype/tools/tray/images/gifts.png | Bin 0 -> 8605 bytes openpype/tools/tray/pype_tray.py | 118 +++++++++++++++++++++++---- openpype/tools/utils/__init__.py | 6 +- 4 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 openpype/tools/tray/images/gifts.png diff --git a/openpype/style/data.json b/openpype/style/data.json index c8adc0674a..1db0c732cf 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -51,6 +51,7 @@ "border-hover": "rgba(168, 175, 189, .3)", "border-focus": "rgb(92, 173, 214)", + "restart-btn-bg": "#458056", "delete-btn-bg": "rgb(201, 54, 54)", "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", diff --git a/openpype/tools/tray/images/gifts.png b/openpype/tools/tray/images/gifts.png new file mode 100644 index 0000000000000000000000000000000000000000..57fb3f286312878c641f27362653ede1b7746810 GIT binary patch literal 8605 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 T%VmTC_`iqu)s>1NPv86(+fBse literal 0 HcmV?d00001 diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 7f78140211..0d3e7ae04c 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -29,11 +29,51 @@ from openpype.settings import ( ProjectSettings, DefaultsNotDefined ) -from openpype.tools.utils import WrappedCallbackItem +from openpype.tools.utils import ( + WrappedCallbackItem, + paint_image_with_color +) from .pype_info_widget import PypeInfoWidget +# TODO PixmapLabel should be moved to 'utils' in other future PR so should be +# imported from there +class PixmapLabel(QtWidgets.QLabel): + """Label resizing image to height of font.""" + def __init__(self, pixmap, parent): + super(PixmapLabel, self).__init__(parent) + self._empty_pixmap = QtGui.QPixmap(0, 0) + self._source_pixmap = pixmap + + def set_source_pixmap(self, pixmap): + """Change source image.""" + self._source_pixmap = pixmap + self._set_resized_pix() + + def _get_pix_size(self): + size = self.fontMetrics().height() * 3 + return size, size + + def _set_resized_pix(self): + if self._source_pixmap is None: + self.setPixmap(self._empty_pixmap) + return + width, height = self._get_pix_size() + self.setPixmap( + self._source_pixmap.scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + ) + + def resizeEvent(self, event): + self._set_resized_pix() + super(PixmapLabel, self).resizeEvent(event) + + class VersionDialog(QtWidgets.QDialog): restart_requested = QtCore.Signal() ignore_requested = QtCore.Signal() @@ -43,7 +83,7 @@ class VersionDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(VersionDialog, self).__init__(parent) - self.setWindowTitle("Wrong OpenPype version") + self.setWindowTitle("OpenPype update is needed") icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( @@ -54,12 +94,23 @@ class VersionDialog(QtWidgets.QDialog): self.setMinimumWidth(self._min_width) self.setMinimumHeight(self._min_height) - label_widget = QtWidgets.QLabel(self) + top_widget = QtWidgets.QWidget(self) + + gift_pixmap = self._get_gift_pixmap() + gift_icon_label = PixmapLabel(gift_pixmap, top_widget) + + label_widget = QtWidgets.QLabel(top_widget) label_widget.setWordWrap(True) - ignore_btn = QtWidgets.QPushButton("Ignore", self) - ignore_btn.setObjectName("WarningButton") - restart_btn = QtWidgets.QPushButton("Restart and Change", self) + top_layout = QtWidgets.QHBoxLayout(top_widget) + # top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.setSpacing(10) + top_layout.addWidget(gift_icon_label, 0, QtCore.Qt.AlignCenter) + top_layout.addWidget(label_widget, 1) + + ignore_btn = QtWidgets.QPushButton("Later", self) + restart_btn = QtWidgets.QPushButton("Restart && Update", self) + restart_btn.setObjectName("TrayRestartButton") btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) @@ -67,7 +118,7 @@ class VersionDialog(QtWidgets.QDialog): btns_layout.addWidget(restart_btn, 0) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(label_widget, 0) + layout.addWidget(top_widget, 0) layout.addStretch(1) layout.addLayout(btns_layout, 0) @@ -79,6 +130,21 @@ class VersionDialog(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) + def _get_gift_pixmap(self): + image_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "images", + "gifts.png" + ) + src_image = QtGui.QImage(image_path) + colors = style.get_objected_colors() + color_value = colors["font"] + + return paint_image_with_color( + src_image, + color_value.get_qcolor() + ) + def showEvent(self, event): super().showEvent(event) self._restart_accepted = False @@ -90,8 +156,8 @@ class VersionDialog(QtWidgets.QDialog): def update_versions(self, current_version, expected_version): message = ( - "Your OpenPype version {} does" - " not match to studio version {}" + "Running OpenPype version is {}." + " Your production has been updated to version {}." ).format(str(current_version), str(expected_version)) self._label_widget.setText(message) @@ -113,6 +179,7 @@ class TrayManager: self.tray_widget = tray_widget self.main_window = main_window self.pype_info_widget = None + self._restart_action = None self.log = Logger.get_logger(self.__class__.__name__) @@ -158,7 +225,14 @@ class TrayManager: self.validate_openpype_version() def validate_openpype_version(self): - if is_current_version_studio_latest(): + using_requested = is_current_version_studio_latest() + self._restart_action.setVisible(not using_requested) + if using_requested: + if ( + self._version_dialog is not None + and self._version_dialog.isVisible() + ): + self._version_dialog.close() return if self._version_dialog is None: @@ -170,25 +244,24 @@ class TrayManager: self._outdated_version_ignored ) - if self._version_dialog.isVisible(): - return - expected_version = get_expected_version() current_version = get_openpype_version() self._version_dialog.update_versions( current_version, expected_version ) - self._version_dialog.exec_() + self._version_dialog.show() + self._version_dialog.raise_() + self._version_dialog.activateWindow() def _restart_and_install(self): self.restart() def _outdated_version_ignored(self): self.show_tray_message( - "Outdated OpenPype version", + "OpenPype version is outdated", ( "Please update your OpenPype as soon as possible." - " All you have to do is to restart tray." + " To update, restart OpenPype Tray application." ) ) @@ -341,9 +414,22 @@ class TrayManager: version_action = QtWidgets.QAction(version_string, self.tray_widget) version_action.triggered.connect(self._on_version_action) + + restart_action = QtWidgets.QAction( + "Restart && Update", self.tray_widget + ) + 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() + self._restart_action = restart_action + + def _on_restart_action(self): + self.restart() + def restart(self, reset_version=True): """Restart Tray tool. diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 65025ac358..eb0cb1eef5 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -6,7 +6,10 @@ from .widgets import ( ) from .error_dialog import ErrorMessageBox -from .lib import WrappedCallbackItem +from .lib import ( + WrappedCallbackItem, + paint_image_with_color +) __all__ = ( @@ -18,4 +21,5 @@ __all__ = ( "ErrorMessageBox", "WrappedCallbackItem", + "paint_image_with_color", ) From ea469e213031bedc412a9368e4668e6b0d18bc98 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 17:55:43 +0100 Subject: [PATCH 290/395] flame: fixing extract exporter --- .../plugins/publish/extract_subset_resources.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 3495309409..8bdcf989b6 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -76,6 +76,7 @@ class ExtractSubsetResources(openpype.api.Extractor): kwargs = {} preset_file = preset_config["xmlPresetFile"] preset_dir = preset_config["xmlPresetDir"] + repre_tags = preset_config["representationTags"] # validate xml preset file is filled if preset_file == "": @@ -98,9 +99,9 @@ class ExtractSubsetResources(openpype.api.Extractor): ) # create preset path - preset_path = os.path.join( + preset_path = str(os.path.join( preset_dir, preset_file - ) + )) # define kwargs based on preset type if "thumbnail" in unique_name: @@ -112,9 +113,9 @@ class ExtractSubsetResources(openpype.api.Extractor): "out_mark": out_mark }) - export_dir_path = os.path.join( + export_dir_path = str(os.path.join( staging_dir, unique_name - ) + )) os.makedirs(export_dir_path) # export @@ -127,7 +128,7 @@ class ExtractSubsetResources(openpype.api.Extractor): "outputName": unique_name, "ext": preset_config["ext"], "stagingDir": export_dir_path, - "tags": preset_config["representationTags"] + "tags": repre_tags } files = os.listdir(export_dir_path) @@ -153,5 +154,9 @@ class ExtractSubsetResources(openpype.api.Extractor): instance.data["representations"].append(representation_data) + # add review family if found in tags + if "review" in repre_tags: + instance.data["families"].append("review") + self.log.info("Added representation: {}".format( representation_data)) From 25b54be8727d35a608e64236f1189468ca9fe4ae Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 17:56:16 +0100 Subject: [PATCH 291/395] flame: adding host to some extract plugins --- openpype/plugins/publish/extract_burnin.py | 3 ++- openpype/plugins/publish/extract_otio_audio_tracks.py | 2 +- openpype/plugins/publish/extract_otio_review.py | 2 +- openpype/plugins/publish/extract_otio_trimming_video.py | 2 +- openpype/plugins/publish/extract_review.py | 3 ++- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 459c66ee43..7ff1b24689 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -48,7 +48,8 @@ class ExtractBurnin(openpype.api.Extractor): "tvpaint", "webpublisher", "aftereffects", - "photoshop" + "photoshop", + "flame" # "resolve" ] optional = True diff --git a/openpype/plugins/publish/extract_otio_audio_tracks.py b/openpype/plugins/publish/extract_otio_audio_tracks.py index be0bae5cdc..00c1748cdc 100644 --- a/openpype/plugins/publish/extract_otio_audio_tracks.py +++ b/openpype/plugins/publish/extract_otio_audio_tracks.py @@ -19,7 +19,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): order = pyblish.api.ExtractorOrder - 0.44 label = "Extract OTIO Audio Tracks" - hosts = ["hiero", "resolve"] + hosts = ["hiero", "resolve", "flame"] # FFmpeg tools paths ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") diff --git a/openpype/plugins/publish/extract_otio_review.py b/openpype/plugins/publish/extract_otio_review.py index ed2ba017d5..79d5b2fc8f 100644 --- a/openpype/plugins/publish/extract_otio_review.py +++ b/openpype/plugins/publish/extract_otio_review.py @@ -41,7 +41,7 @@ class ExtractOTIOReview(openpype.api.Extractor): order = api.ExtractorOrder - 0.45 label = "Extract OTIO review" families = ["review"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] # plugin default attributes temp_file_head = "tempFile." diff --git a/openpype/plugins/publish/extract_otio_trimming_video.py b/openpype/plugins/publish/extract_otio_trimming_video.py index 3e2d39c99c..30b57e2c69 100644 --- a/openpype/plugins/publish/extract_otio_trimming_video.py +++ b/openpype/plugins/publish/extract_otio_trimming_video.py @@ -19,7 +19,7 @@ class ExtractOTIOTrimmingVideo(openpype.api.Extractor): order = api.ExtractorOrder label = "Extract OTIO trim longer video" families = ["trim"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, instance): self.staging_dir = self.staging_dir(instance) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index b6c2e49385..b27cca0085 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -51,7 +51,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "tvpaint", "resolve", "webpublisher", - "aftereffects" + "aftereffects", + "flame" ] # Supported extensions From 8cb71742921357aae79e8f66c950d4aa3c1bd8bc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 14 Jan 2022 17:56:37 +0100 Subject: [PATCH 292/395] Cosmetics --- .../hosts/maya/plugins/load/load_vdb_to_vray.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py index 151731c13c..ed561e1131 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py @@ -84,7 +84,7 @@ class LoadVDBtoVRay(api.Loader): from avalon.maya.pipeline import containerise assert os.path.exists(self.fname), ( - "Path does not exist: %s" % self.fname + "Path does not exist: %s" % self.fname ) try: @@ -128,10 +128,9 @@ class LoadVDBtoVRay(api.Loader): if c is not None: cmds.setAttr(root + ".useOutlinerColor", 1) cmds.setAttr(root + ".outlinerColor", - float(c[0])/255, - float(c[1])/255, - float(c[2])/255 - ) + float(c[0]) / 255, + float(c[1]) / 255, + float(c[2]) / 255) # Create VRayVolumeGrid grid_node = cmds.createNode("VRayVolumeGrid", @@ -179,8 +178,8 @@ class LoadVDBtoVRay(api.Loader): # todo: check support for negative frames as input collections, remainder = clique.assemble(files) assert len(collections) == 1, ( - "Must find a single image sequence, " - "found: %s" % (collections,) + "Must find a single image sequence, " + "found: %s" % (collections,) ) collection = collections[0] From abf0d2b6a0c1722a0fdf1a8e14c28da99c553ec9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 14 Jan 2022 17:44:38 +0100 Subject: [PATCH 293/395] Add houdini for validate version (cherry picked from commit bc55371de067c75511fb7f906989cd4f3c2f5aaf) --- openpype/plugins/publish/validate_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/validate_version.py b/openpype/plugins/publish/validate_version.py index e48ce6e3c3..b94152ef2d 100644 --- a/openpype/plugins/publish/validate_version.py +++ b/openpype/plugins/publish/validate_version.py @@ -10,7 +10,7 @@ class ValidateVersion(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder label = "Validate Version" - hosts = ["nuke", "maya", "blender", "standalonepublisher"] + hosts = ["nuke", "maya", "houdini", "blender", "standalonepublisher"] optional = False active = True From 9a3a709149108efa45770a094ac1dacee9730d70 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 14 Jan 2022 17:45:08 +0100 Subject: [PATCH 294/395] Preserve VDB frame numbers on Integrating (cherry picked from commit 09fe8b3540f8a3cc2936d8c07e7bdf72a4690c26) --- openpype/hosts/houdini/plugins/publish/collect_instances.py | 3 +++ openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index ac081ac297..12d118f0cc 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -74,6 +74,9 @@ class CollectInstances(pyblish.api.ContextPlugin): instance = context.create_instance(label) + # Include `families` using `family` data + instance.data["families"] = [instance.data["family"]] + instance[:] = [node] instance.data.update(data) diff --git a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py index 78794acc97..113e1b0bcb 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py +++ b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py @@ -37,5 +37,7 @@ class ExtractVDBCache(openpype.api.Extractor): "ext": "vdb", "files": output, "stagingDir": staging_dir, + "frameStart": instance.data["frameStart"], + "frameEnd": instance.data["frameEnd"], } instance.data["representations"].append(representation) From dfb42dc78c3d8f7d2fa83e52471917315bfa57df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Jan 2022 17:09:17 +0000 Subject: [PATCH 295/395] build(deps): bump follow-redirects from 1.14.4 to 1.14.7 in /website Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.4 to 1.14.7. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.4...v1.14.7) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] --- website/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index 89da2289de..16d2316fc2 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -3983,9 +3983,9 @@ flux@^4.0.1: fbjs "^3.0.0" follow-redirects@^1.0.0, follow-redirects@^1.14.0: - version "1.14.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379" - integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g== + version "1.14.7" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685" + integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ== for-in@^1.0.2: version "1.0.2" From 969dfdc69e2f820e67acf26ca783fef6af56e71c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 14 Jan 2022 19:03:46 +0100 Subject: [PATCH 296/395] add basic support for extended static mesh workflow wip --- .../create/create_unreal_staticmesh.py | 33 +++++- .../hosts/maya/plugins/publish/clean_nodes.py | 27 +++++ .../publish/collect_unreal_staticmesh.py | 20 ++-- .../publish/extract_unreal_staticmesh.py | 28 +++++ .../validate_unreal_staticmesh_naming.py | 109 +++++++++--------- .../defaults/project_settings/global.json | 2 +- .../defaults/project_settings/maya.json | 22 +++- .../schemas/schema_maya_create.json | 36 +++++- .../schemas/schema_maya_publish.json | 25 ++++ 9 files changed, 230 insertions(+), 72 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/clean_nodes.py create mode 100644 openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py diff --git a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py index db1684bbc8..30f024a160 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py @@ -1,11 +1,42 @@ -from openpype.hosts.maya.api import plugin +# -*- coding: utf-8 -*- +"""Creator for Unreal Static Meshes.""" +from openpype.hosts.maya.api import plugin, lib +from avalon.api import CreatorError, Session +from openpype.api import get_project_settings +from maya import cmds # noqa class CreateUnrealStaticMesh(plugin.Creator): + """Unreal Static Meshes with collisions.""" name = "staticMeshMain" label = "Unreal - Static Mesh" family = "unrealStaticMesh" icon = "cube" + dynamic_subset_keys = ["asset"] def __init__(self, *args, **kwargs): + """Constructor.""" super(CreateUnrealStaticMesh, self).__init__(*args, **kwargs) + self._project_settings = get_project_settings( + Session["AVALON_PROJECT"]) + + @classmethod + def get_dynamic_data( + cls, variant, task_name, asset_id, project_name, host_name + ): + dynamic_data = super(CreateUnrealStaticMesh, cls).get_dynamic_data( + variant, task_name, asset_id, project_name, host_name + ) + dynamic_data["asset"] = Session.get("AVALON_ASSET") + + return dynamic_data + + def process(self): + with lib.undo_chunk(): + instance = super(CreateUnrealStaticMesh, self).process() + content = cmds.sets(instance, query=True) + geometry = cmds.sets(name="geometry_SET", empty=True) + collisions = cmds.sets(name="collisions_SET", empty=True) + cmds.sets([geometry, collisions], forceElement=instance) + # todo: Iterate over collision prefixes and add them to correct + # sets. Put rest to the geometry set. diff --git a/openpype/hosts/maya/plugins/publish/clean_nodes.py b/openpype/hosts/maya/plugins/publish/clean_nodes.py new file mode 100644 index 0000000000..e6667b7036 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/clean_nodes.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +"""Cleanup leftover nodes.""" +from maya import cmds # noqa +import pyblish.api + + +class CleanNodesUp(pyblish.api.InstancePlugin): + """Cleans up the staging directory after a successful publish. + + This will also clean published renders and delete their parent directories. + + """ + + order = pyblish.api.IntegratorOrder + 10 + label = "Clean Nodes" + optional = True + active = True + + def process(self, instance): + if not instance.data.get("cleanNodes"): + self.log.info("nothing to clean") + + nodes_to_clean = instance.data.pop("cleanNodes") + self.log.info("Removing {} nodes".format(len(nodes_to_clean))) + for node in nodes_to_clean: + cmds.remove(node) + \ No newline at end of file diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 5ab9643f4b..ad6398041b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -4,25 +4,31 @@ import pyblish.api class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): - """Collect unreal static mesh + """Collect Unreal Static Mesh Ensures always only a single frame is extracted (current frame). This also sets correct FBX options for later extraction. - Note: - This is a workaround so that the `pype.model` family can use the - same pointcache extractor implementation as animation and pointcaches. - This always enforces the "current" frame to be published. - """ order = pyblish.api.CollectorOrder + 0.2 - label = "Collect Model Data" + label = "Collect Unreal Static Meshes" families = ["unrealStaticMesh"] def process(self, instance): # add fbx family to trigger fbx extractor instance.data["families"].append("fbx") + # take the name from instance (without the `S_` prefix) + instance.data["staticMeshCombinedName"] = instance.name[1:] + + geometry_set = [i for i in instance if i == "geometry_SET"] + instance.data["membersToCombine"] = cmds.sets( + geometry_set, query=True) + + collision_set = [i for i in instance if i == "collisions_SET"] + instance.data["collisionMembers"] = cmds.sets( + collision_set, query=True) + # set fbx overrides on instance instance.data["smoothingGroups"] = True instance.data["smoothMesh"] = True diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py new file mode 100644 index 0000000000..fd9cf69612 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +"""Create Unreal Static Mesh data to be extracted as FBX.""" +import openpype.api +import pyblish.api +from maya import cmds # noqa + + +class ExtractUnrealStaticMesh(openpype.api.Extractor): + """Extract FBX from Maya. """ + + order = pyblish.api.ExtractorOrder - 0.1 + label = "Extract Unreal Static Mesh" + families = ["unrealStaticMesh"] + + def process(self, instance): + to_combine = instance.data.get("membersToCombine") + static_mesh_name = instance.data.get("staticMeshCombinedName") + self.log.info( + "merging {] into {}".format( + "+ ".join(to_combine), static_mesh_name)) + cmds.polyUnite( + *to_combine, + n=static_mesh_name) + + if not instance.data.get("cleanNodes"): + instance.data["cleanNodes"] = [] + + instance.data["cleanNodes"].append(static_mesh_name) diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index 99d6cfd4c5..e7df7c8cbb 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -1,18 +1,19 @@ # -*- coding: utf-8 -*- -from maya import cmds +from maya import cmds # noqa import pyblish.api import openpype.api import openpype.hosts.maya.api.action import re -class ValidateUnrealStaticmeshName(pyblish.api.InstancePlugin): +class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): """Validate name of Unreal Static Mesh - Unreals naming convention states that staticMesh sould start with `SM` - prefix - SM_[Name]_## (Eg. SM_sube_01). This plugin also validates other - types of meshes - collision meshes: + Unreals naming convention states that staticMesh should start with `SM` + prefix - SM_[Name]_## (Eg. SM_sube_01).These prefixes can be configured + in Settings UI. This plugin also validates other types of + meshes - collision meshes: UBX_[RenderMeshName]_##: Boxes are created with the Box objects type in @@ -52,69 +53,69 @@ class ValidateUnrealStaticmeshName(pyblish.api.InstancePlugin): families = ["unrealStaticMesh"] label = "Unreal StaticMesh Name" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] - regex_mesh = r"SM_(?P.*)_(\d{2})" - regex_collision = r"((UBX)|(UCP)|(USP)|(UCX))_(?P.*)_(\d{2})" + regex_mesh = r"(?P.*)_(\d{2})" + regex_collision = r"_(?P.*)_(\d{2})" @classmethod def get_invalid(cls, instance): - # find out if supplied transform is group or not - def is_group(groupName): - try: - children = cmds.listRelatives(groupName, children=True) - for child in children: - if not cmds.ls(child, transforms=True): - return False + invalid = [] + + combined_geometry_name = instance.data.get( + "staticMeshCombinedName", None) + if cls.validate_mesh: + # compile regex for testing names + regex_mesh = "{}{}".format( + ("_" + cls.static_mesh_prefix) or "", cls.regex_mesh + ) + sm_r = re.compile(regex_mesh) + if not sm_r.match(combined_geometry_name): + cls.log.error("Mesh doesn't comply with name validation.") return True - except Exception: + + if cls.validate_collision: + collision_set = instance.data.get("collisionMembers", None) + # soft-fail is there are no collision objects + if not collision_set: + cls.log.warning("No collision objects to validate.") return False - invalid = [] - content_instance = instance.data.get("setMembers", None) - if not content_instance: - cls.log.error("Instance has no nodes!") - return True - pass - descendants = cmds.listRelatives(content_instance, - allDescendents=True, - fullPath=True) or [] + regex_collision = "{}{}".format( + "({})_".format( + "|".join("(0}".format(p) for p in cls.collision_prefixes) + ) or "", cls.regex_collision + ) + cl_r = re.compile(regex_collision) - descendants = cmds.ls(descendants, noIntermediate=True, long=True) - trns = cmds.ls(descendants, long=False, type=('transform')) - - # filter out groups - filter = [node for node in trns if not is_group(node)] - - # compile regex for testing names - sm_r = re.compile(cls.regex_mesh) - cl_r = re.compile(cls.regex_collision) - - sm_names = [] - col_names = [] - for obj in filter: - sm_m = sm_r.match(obj) - if sm_m is None: - # test if it matches collision mesh - cl_r = sm_r.match(obj) - if cl_r is None: - cls.log.error("invalid mesh name on: {}".format(obj)) + for obj in collision_set: + cl_m = cl_r.match(obj) + if not cl_m: + cls.log.error("{} is invalid".format(obj)) + invalid.append(obj) + elif cl_m.group("renderName") != combined_geometry_name: + cls.log.error( + "Collision object name doesn't match" + "static mesh name: {} != {}".format( + cl_m.group("renderName"), + combined_geometry_name) + ) invalid.append(obj) - else: - col_names.append((cl_r.group("renderName"), obj)) - else: - sm_names.append(sm_m.group("renderName")) - - for c_mesh in col_names: - if c_mesh[0] not in sm_names: - cls.log.error(("collision name {} doesn't match any " - "static mesh names.").format(obj)) - invalid.append(c_mesh[1]) return invalid def process(self, instance): + # todo: load prefixes from creator settings. + + if not self.validate_mesh and not self.validate_collision: + self.log.info("Validation of both mesh and collision names" + "is disabled.") + return + + if not instance.data.get("collisionMembers", None): + self.log.info("There are no collision objects to validate") + return invalid = self.get_invalid(instance) if invalid: - raise RuntimeError("Model naming is invalid. See log.") + raise RuntimeError("Model naming is invalid. See log.") \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index cff1259c98..2169a62746 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -219,7 +219,7 @@ "hosts": [], "task_types": [], "tasks": [], - "template": "{family}{Variant}" + "template": "{family}{variant}" }, { "families": [ diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index a756071106..67a7b84cdc 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -127,6 +127,13 @@ "enabled": true, "defaults": [ "Main" + ], + "static_mesh_prefix": "S_", + "collision_prefixes": [ + "UBX", + "UCP", + "USP", + "UCX" ] }, "CreateVrayProxy": { @@ -180,6 +187,11 @@ "whitelist_native_plugins": false, "authorized_plugins": [] }, + "ValidateUnrealStaticMeshName": { + "enabled": true, + "validate_mesh": false, + "validate_collision": true + }, "ValidateRenderSettings": { "arnold_render_attributes": [], "vray_render_attributes": [], @@ -197,6 +209,11 @@ "regex": "(.*)_(\\d)*_(?P.*)_(GEO)", "top_level_regex": ".*_GRP" }, + "ValidateModelContent": { + "enabled": true, + "optional": false, + "validate_top_group": true + }, "ValidateTransformNamingSuffix": { "enabled": true, "SUFFIX_NAMING_TABLE": { @@ -281,11 +298,6 @@ "optional": true, "active": true }, - "ValidateModelContent": { - "enabled": true, - "optional": false, - "validate_top_group": true - }, "ValidateNoAnimation": { "enabled": false, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index 088d5d1f96..0544b4bab7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -66,6 +66,38 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CreateUnrealStaticMesh", + "label": "Create Unreal - Static Mesh", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "defaults", + "label": "Default Subsets", + "object_type": "text" + }, + { + "type": "text", + "key": "static_mesh_prefix", + "label": "Static Mesh Prefix" + }, + { + "type": "list", + "key": "collision_prefixes", + "label": "Collision Mesh Prefixes", + "object_type": "text" + } + ] + + }, { "type": "schema_template", "name": "template_create_plugin", @@ -118,10 +150,6 @@ "key": "CreateSetDress", "label": "Create Set Dress" }, - { - "key": "CreateUnrealStaticMesh", - "label": "Create Unreal - Static Mesh" - }, { "key": "CreateVrayProxy", "label": "Create VRay Proxy" diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 7c9a5a6b46..f4a371c6de 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -129,6 +129,31 @@ ] }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateUnrealStaticMeshName", + "label": "Validate Unreal Static Mesh Name", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "validate_mesh", + "label": "Validate mesh Names " + }, + { + "type": "boolean", + "key": "validate_collision", + "label": "Validate collision names" + } + ] + }, + { "type": "dict", "collapsible": true, From 3495ed1b06be855a9551fb6312815ce79cadcd18 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 15 Jan 2022 03:44:00 +0000 Subject: [PATCH 297/395] [Automated] Bump version --- CHANGELOG.md | 20 ++++++++++++++------ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e92c16dc5f..e7cd3cb7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,26 @@ # Changelog -## [3.8.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.8.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) +### 📖 Documentation + +- Slack: Add review to notification message [\#2498](https://github.com/pypeclub/OpenPype/pull/2498) + **🆕 New features** - Flame: OpenTimelineIO Export Modul [\#2398](https://github.com/pypeclub/OpenPype/pull/2398) **🚀 Enhancements** +- General: Be able to use anatomy data in ffmpeg output arguments [\#2525](https://github.com/pypeclub/OpenPype/pull/2525) +- Expose toggle publish plug-in settings for Maya Look Shading Engine Naming [\#2521](https://github.com/pypeclub/OpenPype/pull/2521) - Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510) - TimersManager: Move module one hierarchy higher [\#2501](https://github.com/pypeclub/OpenPype/pull/2501) +- Slack: notifications are sent with Openpype logo and bot name [\#2499](https://github.com/pypeclub/OpenPype/pull/2499) - Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) +- Flame - create publishable clips [\#2495](https://github.com/pypeclub/OpenPype/pull/2495) - Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) - Project Manager: Remove project button cleanup [\#2482](https://github.com/pypeclub/OpenPype/pull/2482) - Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) @@ -23,23 +31,27 @@ - Fix \#2453 Refactor missing \_get\_reference\_node method [\#2455](https://github.com/pypeclub/OpenPype/pull/2455) - Houdini: Remove broken unique name counter [\#2450](https://github.com/pypeclub/OpenPype/pull/2450) - Maya: Improve lib.polyConstraint performance when Select tool is not the active tool context [\#2447](https://github.com/pypeclub/OpenPype/pull/2447) +- General: Validate third party before build [\#2425](https://github.com/pypeclub/OpenPype/pull/2425) - Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) **🐛 Bug fixes** +- Fix published frame content for sequence starting with 0 [\#2513](https://github.com/pypeclub/OpenPype/pull/2513) +- Fix \#2497: reset empty string attributes correctly to "" instead of "None" [\#2506](https://github.com/pypeclub/OpenPype/pull/2506) - General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) - General: PYTHONPATH may break OpenPype dependencies [\#2493](https://github.com/pypeclub/OpenPype/pull/2493) - Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) - General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) - Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) - General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) +- Maya: Validate Shape Zero do not keep fixed geometry vertices selected/active after repair [\#2456](https://github.com/pypeclub/OpenPype/pull/2456) **Merged pull requests:** +- Fix create zip tool - path argument [\#2522](https://github.com/pypeclub/OpenPype/pull/2522) - General: Modules import function output fix [\#2492](https://github.com/pypeclub/OpenPype/pull/2492) - AE: fix hiding of alert window below Publish [\#2491](https://github.com/pypeclub/OpenPype/pull/2491) - Maya: Validate NGONs re-use polyConstraint code from openpype.host.maya.api.lib [\#2458](https://github.com/pypeclub/OpenPype/pull/2458) -- Version handling [\#2363](https://github.com/pypeclub/OpenPype/pull/2363) ## [3.7.0](https://github.com/pypeclub/OpenPype/tree/3.7.0) (2022-01-04) @@ -68,7 +80,6 @@ - Blender 3: Support auto install for new blender version [\#2377](https://github.com/pypeclub/OpenPype/pull/2377) - Maya add render image path to settings [\#2375](https://github.com/pypeclub/OpenPype/pull/2375) - Hiero: python3 compatibility [\#2365](https://github.com/pypeclub/OpenPype/pull/2365) -- Maya: Add is\_static\_image\_plane and is\_in\_all\_views option in imagePlaneLoader [\#2356](https://github.com/pypeclub/OpenPype/pull/2356) **🐛 Bug fixes** @@ -87,8 +98,6 @@ - Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374) - Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) - Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369) -- Tools: Placeholder color [\#2359](https://github.com/pypeclub/OpenPype/pull/2359) -- Houdini: Fix HDA creation [\#2350](https://github.com/pypeclub/OpenPype/pull/2350) **Merged pull requests:** @@ -96,7 +105,6 @@ - Maya: Replaced PATH usage with vendored oiio path for maketx utility [\#2405](https://github.com/pypeclub/OpenPype/pull/2405) - \[Fix\]\[MAYA\] Handle message type attribute within CollectLook [\#2394](https://github.com/pypeclub/OpenPype/pull/2394) - Add validator to check correct version of extension for PS and AE [\#2387](https://github.com/pypeclub/OpenPype/pull/2387) -- Linux : flip updating submodules logic [\#2357](https://github.com/pypeclub/OpenPype/pull/2357) ## [3.6.4](https://github.com/pypeclub/OpenPype/tree/3.6.4) (2021-11-23) diff --git a/openpype/version.py b/openpype/version.py index 1f005d6952..520048bca7 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.8.0-nightly.3" +__version__ = "3.8.0-nightly.4" diff --git a/pyproject.toml b/pyproject.toml index f9155f05a3..598d2b4798 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.8.0-nightly.3" # OpenPype +version = "3.8.0-nightly.4" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From ab97a3266a9bfdb563ae74692656f8c8b86e4f4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Jan 2022 07:05:47 +0000 Subject: [PATCH 298/395] build(deps): bump shelljs from 0.8.4 to 0.8.5 in /website Bumps [shelljs](https://github.com/shelljs/shelljs) from 0.8.4 to 0.8.5. - [Release notes](https://github.com/shelljs/shelljs/releases) - [Changelog](https://github.com/shelljs/shelljs/blob/master/CHANGELOG.md) - [Commits](https://github.com/shelljs/shelljs/compare/v0.8.4...v0.8.5) --- updated-dependencies: - dependency-name: shelljs dependency-type: indirect ... Signed-off-by: dependabot[bot] --- website/yarn.lock | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index 89da2289de..e34f951572 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -2250,9 +2250,9 @@ bail@^1.0.0: integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ== balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== base16@^1.0.0: version "1.0.0" @@ -4136,9 +4136,9 @@ glob-to-regexp@^0.4.1: integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== glob@^7.0.0, glob@^7.0.3, glob@^7.1.3: - version "7.1.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" @@ -4825,6 +4825,13 @@ is-core-module@^2.2.0: dependencies: has "^1.0.3" +is-core-module@^2.8.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211" + integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA== + dependencies: + has "^1.0.3" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -6167,7 +6174,7 @@ path-key@^3.0.0, path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -path-parse@^1.0.6: +path-parse@^1.0.6, path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== @@ -7208,7 +7215,16 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@^1.1.6, resolve@^1.14.2, resolve@^1.3.2: +resolve@^1.1.6: + version "1.21.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f" + integrity sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA== + dependencies: + is-core-module "^2.8.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +resolve@^1.14.2, resolve@^1.3.2: version "1.20.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== @@ -7533,9 +7549,9 @@ shell-quote@1.7.2: integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== shelljs@^0.8.4: - version "0.8.4" - resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2" - integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ== + version "0.8.5" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" + integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== dependencies: glob "^7.0.0" interpret "^1.0.0" @@ -7896,6 +7912,11 @@ supports-color@^7.0.0, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + svg-parser@^2.0.2: version "2.0.4" resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" From a20499eb3ebef7bf5ef2826414ec4416054e388f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Jan 2022 10:55:19 +0100 Subject: [PATCH 299/395] Remove mayalookassigner check to avoid tool initialize on startup --- openpype/hosts/maya/api/customize.py | 33 ++++++++++------------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index 8474262626..eef100ddfc 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -95,29 +95,20 @@ def override_toolbox_ui(): # Create our controls background_color = (0.267, 0.267, 0.267) controls = [] - look_assigner = None - try: - look_assigner = host_tools.get_tool_by_name( - "lookassigner", - parent=pipeline._parent - ) - except Exception: - log.warning("Couldn't create Look assigner window.", exc_info=True) - if look_assigner is not None: - controls.append( - mc.iconTextButton( - "pype_toolbox_lookmanager", - annotation="Look Manager", - label="Look Manager", - image=os.path.join(icons, "lookmanager.png"), - command=host_tools.show_look_assigner, - bgc=background_color, - width=icon_size, - height=icon_size, - parent=parent - ) + controls.append( + mc.iconTextButton( + "pype_toolbox_lookmanager", + annotation="Look Manager", + label="Look Manager", + image=os.path.join(icons, "lookmanager.png"), + command=host_tools.show_look_assigner, + bgc=background_color, + width=icon_size, + height=icon_size, + parent=parent ) + ) controls.append( mc.iconTextButton( From 1bfe3c1fd013e5ebf010cdfef8114cedae18cdf2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Jan 2022 10:56:35 +0100 Subject: [PATCH 300/395] Remove explicit background color - icons are transparent and it works fine without --- openpype/hosts/maya/api/customize.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index eef100ddfc..c7fb042ead 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -93,7 +93,6 @@ def override_toolbox_ui(): return # Create our controls - background_color = (0.267, 0.267, 0.267) controls = [] controls.append( @@ -103,7 +102,6 @@ def override_toolbox_ui(): label="Look Manager", image=os.path.join(icons, "lookmanager.png"), command=host_tools.show_look_assigner, - bgc=background_color, width=icon_size, height=icon_size, parent=parent @@ -119,7 +117,6 @@ def override_toolbox_ui(): command=lambda: host_tools.show_workfiles( parent=pipeline._parent ), - bgc=background_color, width=icon_size, height=icon_size, parent=parent @@ -135,7 +132,6 @@ def override_toolbox_ui(): command=lambda: host_tools.show_loader( parent=pipeline._parent, use_context=True ), - bgc=background_color, width=icon_size, height=icon_size, parent=parent @@ -151,7 +147,6 @@ def override_toolbox_ui(): command=lambda: host_tools.show_scene_inventory( parent=pipeline._parent ), - bgc=background_color, width=icon_size, height=icon_size, parent=parent From 7e41dc49b675e1789fff31dbc9dfc3c5175f4d10 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Jan 2022 11:54:08 +0100 Subject: [PATCH 301/395] Allow to toggle family filters between "include" or "exclude" filtering --- openpype/settings/defaults/project_settings/global.json | 1 + .../projects_schema/schemas/schema_global_tools.json | 8 +++++++- openpype/tools/utils/lib.py | 8 ++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index cff1259c98..c418377682 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -297,6 +297,7 @@ "family_filter_profiles": [ { "hosts": [], + "is_include": true, "task_types": [], "filter_families": [] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json index bb71c9bde6..863ec7f979 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json @@ -267,7 +267,13 @@ "label": "Task types" }, { - "type": "splitter" + "type": "boolean", + "key": "is_include", + "label": "Exclude (OFF) / Include (ON)" + }, + { + "type": "label", + "label": "Include: show selected families by default. Hides others by default.
Exclude: hide selected families by default. Shows others by default." }, { "type": "template", diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 6742df8557..886cdb5186 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -228,6 +228,7 @@ class FamilyConfigCache: self.dbcon = dbcon self.family_configs = {} self._family_filters_set = False + self._family_filters_is_include = True self._require_refresh = True @classmethod @@ -249,7 +250,7 @@ class FamilyConfigCache: "icon": self.default_icon() } if self._family_filters_set: - item["state"] = False + item["state"] = not self._family_filters_is_include return item def refresh(self, force=False): @@ -313,20 +314,23 @@ class FamilyConfigCache: matching_item = filter_profiles(profiles, profiles_filter) families = [] + is_include = True if matching_item: families = matching_item["filter_families"] + is_include = matching_item["is_include"] if not families: return self._family_filters_set = True + self._family_filters_is_include = is_include # Replace icons with a Qt icon we can use in the user interfaces for family in families: family_info = { "name": family, "icon": self.default_icon(), - "state": True + "state": is_include } self.family_configs[family] = family_info From b2f82c35bb3551bb6a301dc473e34f822e496a35 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Jan 2022 12:18:38 +0100 Subject: [PATCH 302/395] Remove dot (.) from end of Loader label to match others --- openpype/hosts/maya/plugins/load/load_audio.py | 2 +- openpype/hosts/maya/plugins/load/load_image_plane.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 73a2a4f448..d5bb495480 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -7,7 +7,7 @@ class AudioLoader(api.Loader): """Specific loader of audio.""" families = ["audio"] - label = "Import audio." + label = "Import audio" representations = ["wav"] icon = "volume-up" color = "orange" diff --git a/openpype/hosts/maya/plugins/load/load_image_plane.py b/openpype/hosts/maya/plugins/load/load_image_plane.py index eea5844e8b..36786115f9 100644 --- a/openpype/hosts/maya/plugins/load/load_image_plane.py +++ b/openpype/hosts/maya/plugins/load/load_image_plane.py @@ -77,7 +77,7 @@ class ImagePlaneLoader(api.Loader): """Specific loader of plate for image planes on selected camera.""" families = ["image", "plate", "render"] - label = "Load imagePlane." + label = "Load imagePlane" representations = ["mov", "exr", "preview", "png"] icon = "image" color = "orange" From 41a2ee812ac6c0a6ff742bfe1490558e7d520363 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Jan 2022 12:21:48 +0100 Subject: [PATCH 303/395] Code cosmetics + fix over-indentation --- openpype/hosts/maya/plugins/load/load_audio.py | 2 +- openpype/hosts/maya/plugins/load/load_gpucache.py | 1 + openpype/hosts/maya/plugins/load/load_image_plane.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index d5bb495480..0611dcc189 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -3,6 +3,7 @@ from avalon.maya.pipeline import containerise from avalon.maya import lib from maya import cmds, mel + class AudioLoader(api.Loader): """Specific loader of audio.""" @@ -12,7 +13,6 @@ class AudioLoader(api.Loader): icon = "volume-up" color = "orange" - def load(self, context, name, namespace, data): start_frame = cmds.playbackOptions(query=True, min=True) diff --git a/openpype/hosts/maya/plugins/load/load_gpucache.py b/openpype/hosts/maya/plugins/load/load_gpucache.py index d0a83b8177..444f98f22e 100644 --- a/openpype/hosts/maya/plugins/load/load_gpucache.py +++ b/openpype/hosts/maya/plugins/load/load_gpucache.py @@ -2,6 +2,7 @@ import os from avalon import api from openpype.api import get_project_settings + class GpuCacheLoader(api.Loader): """Load model Alembic as gpuCache""" diff --git a/openpype/hosts/maya/plugins/load/load_image_plane.py b/openpype/hosts/maya/plugins/load/load_image_plane.py index 36786115f9..0652147324 100644 --- a/openpype/hosts/maya/plugins/load/load_image_plane.py +++ b/openpype/hosts/maya/plugins/load/load_image_plane.py @@ -118,7 +118,7 @@ class ImagePlaneLoader(api.Loader): camera = pm.createNode("camera") if camera is None: - return + return try: camera.displayResolution.set(1) From 107e2e637e955bf1971199c7e490b67e01a97b8d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Jan 2022 13:05:26 +0100 Subject: [PATCH 304/395] Fix typos / cosmetics --- .../plugins/publish/collect_render.py | 2 +- openpype/hosts/blender/api/action.py | 2 +- openpype/hosts/blender/api/lib.py | 2 +- .../hosts/blender/hooks/pre_pyside_install.py | 4 ++-- .../plugins/create/create_animation.py | 2 +- .../blender/plugins/create/create_camera.py | 2 +- .../blender/plugins/create/create_layout.py | 2 +- .../blender/plugins/create/create_model.py | 2 +- .../blender/plugins/create/create_rig.py | 2 +- .../publish/increment_workfile_version.py | 2 +- .../plugins/publish/collect_audio.py | 4 ++-- openpype/hosts/flame/api/lib.py | 2 +- openpype/hosts/flame/api/menu.py | 2 +- .../hosts/flame/api/scripts/wiretap_com.py | 10 ++++---- .../modules/ftrack_lib.py | 2 +- .../modules/panel_app.py | 4 ++-- .../api/utility_scripts/openpype_in_flame.py | 4 ++-- openpype/hosts/flame/api/utils.py | 2 +- openpype/hosts/flame/hooks/pre_flame_setup.py | 2 +- openpype/hosts/flame/otio/flame_export.py | 6 ++--- openpype/hosts/fusion/api/pipeline.py | 2 +- .../hosts/fusion/hooks/pre_fusion_setup.py | 2 +- .../fusion/plugins/load/load_sequence.py | 10 ++++---- .../fusion/plugins/publish/submit_deadline.py | 2 +- .../fusion/scripts/fusion_switch_shot.py | 2 +- .../utility_scripts/__OpenPype_Menu__.py | 2 +- openpype/hosts/harmony/js/README.md | 4 ++-- .../harmony/js/loaders/ImageSequenceLoader.js | 20 ++++++++-------- .../harmony/plugins/load/load_background.py | 16 ++++++------- openpype/hosts/hiero/api/events.py | 4 ++-- openpype/hosts/hiero/api/lib.py | 14 +++++------ openpype/hosts/hiero/api/otio/hiero_export.py | 10 ++++---- openpype/hosts/hiero/api/plugin.py | 24 +++++++++---------- .../Python/StartupUI/PimpMySpreadsheet.py | 2 +- openpype/hosts/hiero/api/tags.py | 2 +- .../hiero/plugins/create/create_shot_clip.py | 2 +- .../publish/precollect_clip_effects.py | 4 ++-- .../plugins/publish/precollect_instances.py | 2 +- .../collect_clip_resolution.py | 2 +- .../publish_old_workflow/precollect_retime.py | 4 ++-- openpype/hosts/maya/api/lib.py | 6 ++--- openpype/hosts/maya/api/menu.json | 10 ++++---- openpype/hosts/maya/api/menu_backup.json | 10 ++++---- openpype/hosts/maya/api/setdress.py | 2 +- .../maya/api/shader_definition_editor.py | 2 +- .../maya/plugins/create/create_render.py | 4 ++-- .../maya/plugins/publish/collect_assembly.py | 2 +- .../maya/plugins/publish/collect_render.py | 2 +- .../maya/plugins/publish/collect_vrayscene.py | 2 +- .../maya/plugins/publish/extract_vrayscene.py | 4 ++-- .../plugins/publish/submit_maya_muster.py | 4 ++-- .../publish/validate_mesh_overlapping_uvs.py | 2 +- .../plugins/publish/validate_rig_contents.py | 2 +- .../validate_unreal_staticmesh_naming.py | 2 +- openpype/hosts/nuke/api/__init__.py | 2 +- openpype/hosts/nuke/api/lib.py | 20 ++++++++-------- openpype/hosts/nuke/api/plugin.py | 4 ++-- openpype/hosts/nuke/api/utils.py | 6 ++--- .../hosts/nuke/plugins/create/create_gizmo.py | 4 ++-- .../hosts/nuke/plugins/load/load_backdrop.py | 2 +- .../nuke/plugins/load/load_camera_abc.py | 2 +- openpype/hosts/nuke/plugins/load/load_clip.py | 4 ++-- .../hosts/nuke/plugins/load/load_effects.py | 4 ++-- .../nuke/plugins/load/load_effects_ip.py | 4 ++-- .../hosts/nuke/plugins/load/load_gizmo.py | 2 +- .../hosts/nuke/plugins/load/load_gizmo_ip.py | 4 ++-- .../hosts/nuke/plugins/load/load_image.py | 2 +- .../hosts/nuke/plugins/load/load_model.py | 2 +- .../nuke/plugins/load/load_script_precomp.py | 4 ++-- .../nuke/plugins/publish/extract_camera.py | 2 +- .../plugins/publish/extract_ouput_node.py | 2 +- .../publish/extract_review_data_mov.py | 2 +- .../publish/increment_script_version.py | 2 +- .../nuke/plugins/publish/remove_ouput_node.py | 2 +- .../nuke/plugins/publish/validate_backdrop.py | 2 +- .../publish/validate_write_deadline_tab.py | 2 +- .../startup/KnobScripter/knob_scripter.py | 8 +++---- .../plugins/load/load_image_from_sequence.py | 2 +- openpype/hosts/resolve/README.markdown | 4 ++-- .../resolve/RESOLVE_API_README_v16.2.0_up.txt | 2 +- openpype/hosts/resolve/api/lib.py | 10 ++++---- openpype/hosts/resolve/api/pipeline.py | 2 +- openpype/hosts/resolve/api/plugin.py | 18 +++++++------- openpype/hosts/resolve/api/testing_utils.py | 4 ++-- .../hosts/resolve/hooks/pre_resolve_setup.py | 2 +- openpype/hosts/resolve/otio/davinci_export.py | 2 +- .../plugins/create/create_shot_clip.py | 2 +- .../utility_scripts/__OpenPype__Menu__.py | 2 +- .../publish/collect_editorial_instances.py | 12 +++++----- .../publish/collect_editorial_resources.py | 16 ++++++------- .../plugins/publish/collect_hierarchy.py | 6 ++--- .../publish/collect_representation_names.py | 2 +- .../plugins/publish/validate_texture_name.py | 2 +- .../hosts/tvpaint/api/communication_server.py | 4 ++-- openpype/hosts/tvpaint/api/lib.py | 2 +- openpype/hosts/tvpaint/api/pipeline.py | 4 ++-- openpype/hosts/tvpaint/api/plugin.py | 2 +- openpype/hosts/tvpaint/lib.py | 6 ++--- .../plugins/publish/collect_instances.py | 2 +- .../plugins/publish/collect_workfile.py | 2 +- .../plugins/publish/extract_sequence.py | 6 ++--- .../publish/increment_workfile_version.py | 2 +- .../tvpaint/plugins/publish/validate_marks.py | 2 +- .../tvpaint_plugin/plugin_code/README.md | 2 +- .../tvpaint_plugin/plugin_code/library.cpp | 4 ++-- openpype/hosts/tvpaint/worker/worker_job.py | 4 ++-- openpype/hosts/unreal/api/lib.py | 2 +- .../load/load_alembic_geometrycache.py | 2 +- .../publish/collect_tvpaint_instances.py | 2 +- .../publish/extract_tvpaint_workfile.py | 4 ++-- .../webserver_service/webpublish_routes.py | 2 +- 111 files changed, 237 insertions(+), 237 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index be024b7e24..6eeb10303c 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -157,7 +157,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): in url Returns: - (list) of absolut urls to rendered file + (list) of absolute urls to rendered file """ start = render_instance.frameStart end = render_instance.frameEnd diff --git a/openpype/hosts/blender/api/action.py b/openpype/hosts/blender/api/action.py index f3426ac3cf..09ef76326e 100644 --- a/openpype/hosts/blender/api/action.py +++ b/openpype/hosts/blender/api/action.py @@ -25,7 +25,7 @@ class SelectInvalidAction(pyblish.api.Action): invalid.extend(invalid_nodes) else: self.log.warning( - "Failed plug-in doens't have any selectable objects." + "Failed plug-in doesn't have any selectable objects." ) bpy.ops.object.select_all(action='DESELECT') diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index fe5d3f93e9..e7210f7e31 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -9,7 +9,7 @@ import addon_utils def load_scripts(paths): """Copy of `load_scripts` from Blender's implementation. - It is possible that whis function will be changed in future and usage will + It is possible that this function will be changed in future and usage will be based on Blender version. """ import bpy_types diff --git a/openpype/hosts/blender/hooks/pre_pyside_install.py b/openpype/hosts/blender/hooks/pre_pyside_install.py index e2a419c8ef..a37f8f0379 100644 --- a/openpype/hosts/blender/hooks/pre_pyside_install.py +++ b/openpype/hosts/blender/hooks/pre_pyside_install.py @@ -21,7 +21,7 @@ class InstallPySideToBlender(PreLaunchHook): platforms = ["windows"] def execute(self): - # Prelaunch hook is not crutial + # Prelaunch hook is not crucial try: self.inner_execute() except Exception: @@ -156,7 +156,7 @@ class InstallPySideToBlender(PreLaunchHook): except pywintypes.error: pass - self.log.warning("Failed to instal PySide2 module to blender.") + self.log.warning("Failed to install PySide2 module to blender.") def is_pyside_installed(self, python_executable): """Check if PySide2 module is in blender's pip list. diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index f7887b7e80..3b4cabe8ec 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -22,7 +22,7 @@ class CreateAnimation(plugin.Creator): ops.execute_in_main_thread(mti) def _process(self): - # Get Instance Containter or create it if it does not exist + # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: instances = bpy.data.collections.new(name=AVALON_INSTANCES) diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 98ccca313c..6fa80b5a5d 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -22,7 +22,7 @@ class CreateCamera(plugin.Creator): ops.execute_in_main_thread(mti) def _process(self): - # Get Instance Containter or create it if it does not exist + # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: instances = bpy.data.collections.new(name=AVALON_INSTANCES) diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index 831261f027..dac12e19b1 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -22,7 +22,7 @@ class CreateLayout(plugin.Creator): ops.execute_in_main_thread(mti) def _process(self): - # Get Instance Containter or create it if it does not exist + # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: instances = bpy.data.collections.new(name=AVALON_INSTANCES) diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index e778f5b74f..903b70033b 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -22,7 +22,7 @@ class CreateModel(plugin.Creator): ops.execute_in_main_thread(mti) def _process(self): - # Get Instance Containter or create it if it does not exist + # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: instances = bpy.data.collections.new(name=AVALON_INSTANCES) diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 2e1c71f570..ec74e279c6 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -22,7 +22,7 @@ class CreateRig(plugin.Creator): ops.execute_in_main_thread(mti) def _process(self): - # Get Instance Containter or create it if it does not exist + # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: instances = bpy.data.collections.new(name=AVALON_INSTANCES) diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index db73842323..b81e1111ea 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -14,7 +14,7 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): def process(self, context): assert all(result["success"] for result in context.data["results"]), ( - "Publishing not succesfull so version is not increased.") + "Publishing not successful so version is not increased.") from openpype.lib import version_up path = context.data["currentFile"] diff --git a/openpype/hosts/celaction/plugins/publish/collect_audio.py b/openpype/hosts/celaction/plugins/publish/collect_audio.py index 8d3c1568e6..80c1c37d7e 100644 --- a/openpype/hosts/celaction/plugins/publish/collect_audio.py +++ b/openpype/hosts/celaction/plugins/publish/collect_audio.py @@ -32,7 +32,7 @@ class AppendCelactionAudio(pyblish.api.ContextPlugin): repr = next((r for r in reprs), None) if not repr: raise "Missing `audioMain` representation" - self.log.info(f"represetation is: {repr}") + self.log.info(f"representation is: {repr}") audio_file = repr.get('data', {}).get('path', "") @@ -56,7 +56,7 @@ class AppendCelactionAudio(pyblish.api.ContextPlugin): representations (list): list for all representations Returns: - dict: subsets with version and representaions in keys + dict: subsets with version and representations in keys """ # Query all subsets for asset diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 96bffab774..2a72d3d88d 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -230,7 +230,7 @@ def maintain_current_timeline(to_timeline, from_timeline=None): project = get_current_project() working_timeline = from_timeline or project.GetCurrentTimeline() - # swith to the input timeline + # switch to the input timeline project.SetCurrentTimeline(to_timeline) try: diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index fef6dbfa35..64277a46eb 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -40,7 +40,7 @@ class _FlameMenuApp(object): self.menu_group_name = menu_group_name self.dynamic_menu_data = {} - # flame module is only avaliable when a + # flame module is only available when a # flame project is loaded and initialized self.flame = None try: diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index d8dc1884cf..bad96373d8 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -37,7 +37,7 @@ class WireTapCom(object): This way we are able to set new project with settings and correct colorspace policy. Also we are able to create new user - or get actuall user with similar name (users are usually cloning + or get actual user with similar name (users are usually cloning their profiles and adding date stamp into suffix). """ @@ -223,7 +223,7 @@ class WireTapCom(object): volumes = [] - # go trough all children and get volume names + # go through all children and get volume names child_obj = WireTapNodeHandle() for child_idx in range(children_num): @@ -263,7 +263,7 @@ class WireTapCom(object): filtered_users = [user for user in used_names if user_name in user] if filtered_users: - # todo: need to find lastly created following regex patern for + # todo: need to find lastly created following regex pattern for # date used in name return filtered_users.pop() @@ -308,7 +308,7 @@ class WireTapCom(object): usernames = [] - # go trough all children and get volume names + # go through all children and get volume names child_obj = WireTapNodeHandle() for child_idx in range(children_num): @@ -355,7 +355,7 @@ class WireTapCom(object): if not requested: raise AttributeError(( "Error: Cannot request number of " - "childrens from the node {}. Make sure your " + "children from the node {}. Make sure your " "wiretap service is running: {}").format( parent_path, parent.lastError()) ) diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py index 26b197ee1d..c2168016c6 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py @@ -234,7 +234,7 @@ class FtrackComponentCreator: ).first() if component_entity: - # overwrite existing members in component enity + # overwrite existing members in component entity # - get data for member from `ftrack.origin` location self._overwrite_members(component_entity, comp_data) diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/panel_app.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/panel_app.py index 9e39147776..648f902872 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/panel_app.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/panel_app.py @@ -304,7 +304,7 @@ class FlameToFtrackPanel(object): self._resolve_project_entity() self._save_ui_state_to_cfg() - # get hanldes from gui input + # get handles from gui input handles = self.handles_input.text() # get frame start from gui input @@ -517,7 +517,7 @@ class FlameToFtrackPanel(object): if self.temp_data_dir: shutil.rmtree(self.temp_data_dir) self.temp_data_dir = None - print("All Temp data were destroied ...") + print("All Temp data were destroyed ...") def close(self): self._save_ui_state_to_cfg() diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py index c5fa881f3c..87e27d4851 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py @@ -16,7 +16,7 @@ def openpype_install(): """ openpype.install() avalon.api.install(opflame) - print("Avalon registred hosts: {}".format( + print("Avalon registered hosts: {}".format( avalon.api.registered_host())) @@ -100,7 +100,7 @@ def app_initialized(parent=None): """ Initialisation of the hook is starting from here -First it needs to test if it can import the flame modul. +First it needs to test if it can import the flame module. This will happen only in case a project has been loaded. Then `app_initialized` will load main Framework which will load all menu objects as apps. diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 201c7d2fac..64b9569f90 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -65,7 +65,7 @@ def _sync_utility_scripts(env=None): if _itm not in remove_black_list: skip = True - # do not skyp if pyc in extension + # do not skip if pyc in extension if not os.path.isdir(_itm) and "pyc" in os.path.splitext(_itm)[-1]: skip = False diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 159fb37410..9deeeda810 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -13,7 +13,7 @@ from pprint import pformat class FlamePrelaunch(PreLaunchHook): """ Flame prelaunch hook - Will make sure flame_script_dirs are coppied to user's folder defined + Will make sure flame_script_dirs are copied to user's folder defined in environment var FLAME_SCRIPT_DIR. """ app_groups = ["flame"] diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index aea1f387e8..1fa9b727d5 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -127,7 +127,7 @@ def create_time_effects(otio_clip, item): # # add otio effect to clip effects # otio_clip.effects.append(otio_effect) - # # loop trought and get all Timewarps + # # loop through and get all Timewarps # for effect in subTrackItems: # if ((track_item not in effect.linkedItems()) # and (len(effect.linkedItems()) > 0)): @@ -615,11 +615,11 @@ def create_otio_timeline(sequence): # Add Gap if needed if itemindex == 0: # if it is first track item at track then add - # it to previouse item + # it to previous item prev_item = segment_data else: - # get previouse item + # get previous item prev_item = segments_ordered[itemindex - 1] log.debug("_ segment_data: {}".format(segment_data)) diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index c721146830..6b16339e53 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -52,7 +52,7 @@ def install(): def uninstall(): - """Uninstall all tha was installed + """Uninstall all that was installed This is where you undo everything that was done in `install()`. That means, removing menus, deregistering families and data diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index a0c16a6700..3a0ef6e370 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -12,7 +12,7 @@ class FusionPrelaunch(PreLaunchHook): app_groups = ["fusion"] def execute(self): - # making sure pyton 3.6 is installed at provided path + # making sure python 3.6 is installed at provided path py36_dir = os.path.normpath(self.launch_context.env.get("PYTHON36", "")) assert os.path.isdir(py36_dir), ( "Python 3.6 is not installed at the provided folder path. Either " diff --git a/openpype/hosts/fusion/plugins/load/load_sequence.py b/openpype/hosts/fusion/plugins/load/load_sequence.py index 24d48fb9da..8f5be75484 100644 --- a/openpype/hosts/fusion/plugins/load/load_sequence.py +++ b/openpype/hosts/fusion/plugins/load/load_sequence.py @@ -185,22 +185,22 @@ class FusionLoadSequence(api.Loader): - We do the same like Fusion - allow fusion to take control. - HoldFirstFrame: Fusion resets this to 0 - - We preverse the value. + - We preserve the value. - HoldLastFrame: Fusion resets this to 0 - - We preverse the value. + - We preserve the value. - Reverse: Fusion resets to disabled if "Loop" is not enabled. - We preserve the value. - Depth: Fusion resets to "Format" - - We preverse the value. + - We preserve the value. - KeyCode: Fusion resets to "" - - We preverse the value. + - We preserve the value. - TimeCodeOffset: Fusion resets to 0 - - We preverse the value. + - We preserve the value. """ diff --git a/openpype/hosts/fusion/plugins/publish/submit_deadline.py b/openpype/hosts/fusion/plugins/publish/submit_deadline.py index 050e558d2e..28671295ab 100644 --- a/openpype/hosts/fusion/plugins/publish/submit_deadline.py +++ b/openpype/hosts/fusion/plugins/publish/submit_deadline.py @@ -124,7 +124,7 @@ class FusionSubmitDeadline(pyblish.api.InstancePlugin): # Include critical variables with submission keys = [ - # TODO: This won't work if the slaves don't have accesss to + # TODO: This won't work if the slaves don't have access to # these paths, such as if slaves are running Linux and the # submitter is on Windows. "PYTHONPATH", diff --git a/openpype/hosts/fusion/scripts/fusion_switch_shot.py b/openpype/hosts/fusion/scripts/fusion_switch_shot.py index 05b577c8ba..efb3cad800 100644 --- a/openpype/hosts/fusion/scripts/fusion_switch_shot.py +++ b/openpype/hosts/fusion/scripts/fusion_switch_shot.py @@ -85,7 +85,7 @@ def _format_filepath(session): new_filename = "{}_{}_slapcomp_v001.comp".format(project, asset) new_filepath = os.path.join(slapcomp_dir, new_filename) - # Create new unqiue filepath + # Create new unique filepath if os.path.exists(new_filepath): new_filepath = pype.version_up(new_filepath) diff --git a/openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py b/openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py index 81df2bc31d..4f804f9bce 100644 --- a/openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py +++ b/openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py @@ -16,7 +16,7 @@ def main(env): # activate resolve from pype avalon.api.install(avalon.fusion) - log.info(f"Avalon registred hosts: {avalon.api.registered_host()}") + log.info(f"Avalon registered hosts: {avalon.api.registered_host()}") menu.launch_openpype_menu() diff --git a/openpype/hosts/harmony/js/README.md b/openpype/hosts/harmony/js/README.md index ca610e49f5..1265a38305 100644 --- a/openpype/hosts/harmony/js/README.md +++ b/openpype/hosts/harmony/js/README.md @@ -2,13 +2,13 @@ ### Development -#### Setting up ESLint as linter for javasript code +#### Setting up ESLint as linter for javascript code You nee [node.js](https://nodejs.org/en/) installed. All you need to do then is to run: ```sh -npm intall +npm install ``` in **js** directory. This will install eslint and all requirements locally. diff --git a/openpype/hosts/harmony/js/loaders/ImageSequenceLoader.js b/openpype/hosts/harmony/js/loaders/ImageSequenceLoader.js index d809c350ab..cf8a9a29ca 100644 --- a/openpype/hosts/harmony/js/loaders/ImageSequenceLoader.js +++ b/openpype/hosts/harmony/js/loaders/ImageSequenceLoader.js @@ -18,11 +18,11 @@ if (typeof $ === 'undefined'){ * @classdesc Image Sequence loader JS code. */ var ImageSequenceLoader = function() { - this.PNGTransparencyMode = 0; // Premultiplied wih Black - this.TGATransparencyMode = 0; // Premultiplied wih Black - this.SGITransparencyMode = 0; // Premultiplied wih Black + this.PNGTransparencyMode = 0; // Premultiplied with Black + this.TGATransparencyMode = 0; // Premultiplied with Black + this.SGITransparencyMode = 0; // Premultiplied with Black this.LayeredPSDTransparencyMode = 1; // Straight - this.FlatPSDTransparencyMode = 2; // Premultiplied wih White + this.FlatPSDTransparencyMode = 2; // Premultiplied with White }; @@ -84,7 +84,7 @@ ImageSequenceLoader.getUniqueColumnName = function(columnPrefix) { * @return {string} Read node name * * @example - * // Agrguments are in following order: + * // Arguments are in following order: * var args = [ * files, // Files in file sequences. * asset, // Asset name. @@ -97,11 +97,11 @@ ImageSequenceLoader.prototype.importFiles = function(args) { MessageLog.trace("ImageSequence:: " + typeof PypeHarmony); MessageLog.trace("ImageSequence $:: " + typeof $); MessageLog.trace("ImageSequence OH:: " + typeof PypeHarmony.OpenHarmony); - var PNGTransparencyMode = 0; // Premultiplied wih Black - var TGATransparencyMode = 0; // Premultiplied wih Black - var SGITransparencyMode = 0; // Premultiplied wih Black + var PNGTransparencyMode = 0; // Premultiplied with Black + var TGATransparencyMode = 0; // Premultiplied with Black + var SGITransparencyMode = 0; // Premultiplied with Black var LayeredPSDTransparencyMode = 1; // Straight - var FlatPSDTransparencyMode = 2; // Premultiplied wih White + var FlatPSDTransparencyMode = 2; // Premultiplied with White var doc = $.scn; var files = args[0]; @@ -224,7 +224,7 @@ ImageSequenceLoader.prototype.importFiles = function(args) { * @return {string} Read node name * * @example - * // Agrguments are in following order: + * // Arguments are in following order: * var args = [ * files, // Files in file sequences * name, // Node name diff --git a/openpype/hosts/harmony/plugins/load/load_background.py b/openpype/hosts/harmony/plugins/load/load_background.py index 946090f6e6..993a09e042 100644 --- a/openpype/hosts/harmony/plugins/load/load_background.py +++ b/openpype/hosts/harmony/plugins/load/load_background.py @@ -13,11 +13,11 @@ copy_files = """function copyFile(srcFilename, dstFilename) } """ -import_files = """var PNGTransparencyMode = 1; //Premultiplied wih Black -var TGATransparencyMode = 0; //Premultiplied wih Black -var SGITransparencyMode = 0; //Premultiplied wih Black +import_files = """var PNGTransparencyMode = 1; //Premultiplied with Black +var TGATransparencyMode = 0; //Premultiplied with Black +var SGITransparencyMode = 0; //Premultiplied with Black var LayeredPSDTransparencyMode = 1; //Straight -var FlatPSDTransparencyMode = 2; //Premultiplied wih White +var FlatPSDTransparencyMode = 2; //Premultiplied with White function getUniqueColumnName( column_prefix ) { @@ -140,11 +140,11 @@ function import_files(args) import_files """ -replace_files = """var PNGTransparencyMode = 1; //Premultiplied wih Black -var TGATransparencyMode = 0; //Premultiplied wih Black -var SGITransparencyMode = 0; //Premultiplied wih Black +replace_files = """var PNGTransparencyMode = 1; //Premultiplied with Black +var TGATransparencyMode = 0; //Premultiplied with Black +var SGITransparencyMode = 0; //Premultiplied with Black var LayeredPSDTransparencyMode = 1; //Straight -var FlatPSDTransparencyMode = 2; //Premultiplied wih White +var FlatPSDTransparencyMode = 2; //Premultiplied with White function replace_files(args) { diff --git a/openpype/hosts/hiero/api/events.py b/openpype/hosts/hiero/api/events.py index 3df095f9e4..7563503593 100644 --- a/openpype/hosts/hiero/api/events.py +++ b/openpype/hosts/hiero/api/events.py @@ -31,7 +31,7 @@ def beforeNewProjectCreated(event): def afterNewProjectCreated(event): log.info("after new project created event...") - # sync avalon data to project properities + # sync avalon data to project properties sync_avalon_data_to_workfile() # add tags from preset @@ -51,7 +51,7 @@ def beforeProjectLoad(event): def afterProjectLoad(event): log.info("after project load event...") - # sync avalon data to project properities + # sync avalon data to project properties sync_avalon_data_to_workfile() # add tags from preset diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 9a22d8cf27..a9467ae5a4 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -299,7 +299,7 @@ def get_track_item_pype_data(track_item): if not tag: return None - # get tag metadata attribut + # get tag metadata attribute tag_data = tag.metadata() # convert tag metadata to normal keys names and values to correct types for k, v in dict(tag_data).items(): @@ -402,7 +402,7 @@ def sync_avalon_data_to_workfile(): try: project.setProjectDirectory(active_project_root) except Exception: - # old way of seting it + # old way of setting it project.setProjectRoot(active_project_root) # get project data from avalon db @@ -614,7 +614,7 @@ def create_nuke_workfile_clips(nuke_workfiles, seq=None): if not seq: seq = hiero.core.Sequence('NewSequences') root.addItem(hiero.core.BinItem(seq)) - # todo will ned to define this better + # todo will need to define this better # track = seq[1] # lazy example to get a destination# track clips_lst = [] for nk in nuke_workfiles: @@ -838,7 +838,7 @@ def apply_colorspace_project(): # remove the TEMP file as we dont need it os.remove(copy_current_file_tmp) - # use the code from bellow for changing xml hrox Attributes + # use the code from below for changing xml hrox Attributes presets.update({"name": os.path.basename(copy_current_file)}) # read HROX in as QDomSocument @@ -874,7 +874,7 @@ def apply_colorspace_clips(): if "default" in clip_colorspace: continue - # check if any colorspace presets for read is mathing + # check if any colorspace presets for read is matching preset_clrsp = None for k in presets: if not bool(re.search(k["regex"], clip_media_source_path)): @@ -931,7 +931,7 @@ def get_sequence_pattern_and_padding(file): Can find file.0001.ext, file.%02d.ext, file.####.ext Return: - string: any matching sequence patern + string: any matching sequence pattern int: padding of sequnce numbering """ foundall = re.findall( @@ -950,7 +950,7 @@ def get_sequence_pattern_and_padding(file): def sync_clip_name_to_data_asset(track_items_list): - # loop trough all selected clips + # loop through all selected clips for track_item in track_items_list: # ignore if parent track is locked or disabled if track_item.parent().isLocked(): diff --git a/openpype/hosts/hiero/api/otio/hiero_export.py b/openpype/hosts/hiero/api/otio/hiero_export.py index abf510403e..1e4088d9c0 100644 --- a/openpype/hosts/hiero/api/otio/hiero_export.py +++ b/openpype/hosts/hiero/api/otio/hiero_export.py @@ -92,7 +92,7 @@ def create_time_effects(otio_clip, track_item): # add otio effect to clip effects otio_clip.effects.append(otio_effect) - # loop trought and get all Timewarps + # loop through and get all Timewarps for effect in subTrackItems: if ((track_item not in effect.linkedItems()) and (len(effect.linkedItems()) > 0)): @@ -388,11 +388,11 @@ def create_otio_timeline(): # Add Gap if needed if itemindex == 0: # if it is first track item at track then add - # it to previouse item + # it to previous item return track_item else: - # get previouse item + # get previous item return track_item.parent().items()[itemindex - 1] # get current timeline @@ -416,11 +416,11 @@ def create_otio_timeline(): # Add Gap if needed if itemindex == 0: # if it is first track item at track then add - # it to previouse item + # it to previous item prev_item = track_item else: - # get previouse item + # get previous item prev_item = track_item.parent().items()[itemindex - 1] # calculate clip frame range difference from each other diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 2bbb1df8c1..3506af2d6a 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -146,7 +146,7 @@ class CreatorWidget(QtWidgets.QDialog): # convert label text to normal capitalized text with spaces label_text = self.camel_case_split(text) - # assign the new text to lable widget + # assign the new text to label widget label = QtWidgets.QLabel(label_text) label.setObjectName("LineLabel") @@ -337,7 +337,7 @@ class SequenceLoader(avalon.Loader): "Sequentially in order" ], default="Original timing", - help="Would you like to place it at orignal timing?" + help="Would you like to place it at original timing?" ) ] @@ -475,7 +475,7 @@ class ClipLoader: def _get_asset_data(self): """ Get all available asset data - joint `data` key with asset.data dict into the representaion + joint `data` key with asset.data dict into the representation """ asset_name = self.context["representation"]["context"]["asset"] @@ -550,7 +550,7 @@ class ClipLoader: (self.timeline_out - self.timeline_in + 1) + self.handle_start + self.handle_end) < self.media_duration) - # if slate is on then remove the slate frame from begining + # if slate is on then remove the slate frame from beginning if slate_on: self.media_duration -= 1 self.handle_start += 1 @@ -634,8 +634,8 @@ class PublishClip: "track": "sequence", } - # parents search patern - parents_search_patern = r"\{([a-z]*?)\}" + # parents search pattern + parents_search_pattern = r"\{([a-z]*?)\}" # default templates for non-ui use rename_default = False @@ -719,7 +719,7 @@ class PublishClip: return self.track_item def _populate_track_item_default_data(self): - """ Populate default formating data from track item. """ + """ Populate default formatting data from track item. """ self.track_item_default_data = { "_folder_": "shots", @@ -814,7 +814,7 @@ class PublishClip: # mark review layer if self.review_track and ( self.review_track not in self.review_track_default): - # if review layer is defined and not the same as defalut + # if review layer is defined and not the same as default self.review_layer = self.review_track # shot num calculate if self.rename_index == 0: @@ -863,7 +863,7 @@ class PublishClip: # in case track name and subset name is the same then add if self.subset_name == self.track_name: hero_data["subset"] = self.subset - # assing data to return hierarchy data to tag + # assign data to return hierarchy data to tag tag_hierarchy_data = hero_data # add data to return data dict @@ -897,7 +897,7 @@ class PublishClip: type ) - # first collect formating data to use for formating template + # first collect formatting data to use for formatting template formating_data = {} for _k, _v in self.hierarchy_data.items(): value = _v["value"].format( @@ -915,9 +915,9 @@ class PublishClip: """ Create parents and return it in list. """ self.parents = [] - patern = re.compile(self.parents_search_patern) + pattern = re.compile(self.parents_search_pattern) - par_split = [(patern.findall(t).pop(), t) + par_split = [(pattern.findall(t).pop(), t) for t in self.hierarchy.split("/")] for type, template in par_split: diff --git a/openpype/hosts/hiero/api/startup/Python/StartupUI/PimpMySpreadsheet.py b/openpype/hosts/hiero/api/startup/Python/StartupUI/PimpMySpreadsheet.py index 39a65045a7..b8dfb07b47 100644 --- a/openpype/hosts/hiero/api/startup/Python/StartupUI/PimpMySpreadsheet.py +++ b/openpype/hosts/hiero/api/startup/Python/StartupUI/PimpMySpreadsheet.py @@ -1,5 +1,5 @@ # PimpMySpreadsheet 1.0, Antony Nasce, 23/05/13. -# Adds custom spreadsheet columns and right-click menu for setting the Shot Status, and Artist Shot Assignement. +# Adds custom spreadsheet columns and right-click menu for setting the Shot Status, and Artist Shot Assignment. # gStatusTags is a global dictionary of key(status)-value(icon) pairs, which can be overridden with custom icons if required # Requires Hiero 1.7v2 or later. # Install Instructions: Copy to ~/.hiero/Python/StartupUI diff --git a/openpype/hosts/hiero/api/tags.py b/openpype/hosts/hiero/api/tags.py index 68f8d35106..fe5c0d5257 100644 --- a/openpype/hosts/hiero/api/tags.py +++ b/openpype/hosts/hiero/api/tags.py @@ -172,7 +172,7 @@ def add_tags_to_workfile(): } } - # loop trough tag data dict and create deep tag structure + # loop through tag data dict and create deep tag structure for _k, _val in nks_pres_tags.items(): # check if key is not decorated with [] so it is defined as bin bin_find = None diff --git a/openpype/hosts/hiero/plugins/create/create_shot_clip.py b/openpype/hosts/hiero/plugins/create/create_shot_clip.py index 0c5bf93a3f..d0c81cffa2 100644 --- a/openpype/hosts/hiero/plugins/create/create_shot_clip.py +++ b/openpype/hosts/hiero/plugins/create/create_shot_clip.py @@ -139,7 +139,7 @@ class CreateShotClip(phiero.Creator): "type": "QComboBox", "label": "Subset Name", "target": "ui", - "toolTip": "chose subset name patern, if is selected, name of track layer will be used", # noqa + "toolTip": "chose subset name pattern, if is selected, name of track layer will be used", # noqa "order": 0}, "subsetFamily": { "value": ["plate", "take"], diff --git a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py b/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py index 80c6abbaef..9ade7603e0 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py @@ -34,7 +34,7 @@ class PreCollectClipEffects(pyblish.api.InstancePlugin): if clip_effect_items: tracks_effect_items[track_index] = clip_effect_items - # process all effects and devide them to instance + # process all effects and divide them to instance for _track_index, sub_track_items in tracks_effect_items.items(): # skip if track index is the same as review track index if review and review_track_index == _track_index: @@ -156,7 +156,7 @@ class PreCollectClipEffects(pyblish.api.InstancePlugin): 'postage_stamp_frame', 'maskChannel', 'export_cc', 'select_cccid', 'mix', 'version', 'matrix'] - # loop trough all knobs and collect not ignored + # loop through all knobs and collect not ignored # and any with any value for knob in node.knobs().keys(): # skip nodes in ignore keys diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index bf3a779ab1..4eac6a008a 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -264,7 +264,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): timeline_range = self.create_otio_time_range_from_timeline_item_data( track_item) - # loop trough audio track items and search for overlaping clip + # loop through audio track items and search for overlapping clip for otio_audio in self.audio_track_items: parent_range = otio_audio.range_in_parent() diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py index fea36d00fb..1d0727d0af 100644 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py +++ b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py @@ -5,7 +5,7 @@ class CollectClipResolution(pyblish.api.InstancePlugin): """Collect clip geometry resolution""" order = pyblish.api.CollectorOrder - 0.1 - label = "Collect Clip Resoluton" + label = "Collect Clip Resolution" hosts = ["hiero"] families = ["clip"] diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_retime.py b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_retime.py index f0e0f1a1a3..2f65a8bd4f 100644 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_retime.py +++ b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_retime.py @@ -52,7 +52,7 @@ class PrecollectRetime(api.InstancePlugin): handle_end )) - # loop withing subtrack items + # loop within subtrack items time_warp_nodes = [] source_in_change = 0 source_out_change = 0 @@ -76,7 +76,7 @@ class PrecollectRetime(api.InstancePlugin): (timeline_in - handle_start), (timeline_out + handle_end) + 1) ] - # calculate differnce + # calculate difference diff_in = (node["lookup"].getValueAt( timeline_in)) - timeline_in diff_out = (node["lookup"].getValueAt( diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..98e1b20132 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -184,7 +184,7 @@ def uv_from_element(element): parent = element.split(".", 1)[0] # Maya is funny in that when the transform of the shape - # of the component elemen has children, the name returned + # of the component element has children, the name returned # by that elementection is the shape. Otherwise, it is # the transform. So lets see what type we're dealing with here. if cmds.nodeType(parent) in supported: @@ -1595,7 +1595,7 @@ def get_container_transforms(container, members=None, root=False): Args: container (dict): the container members (list): optional and convenience argument - root (bool): return highest node in hierachy if True + root (bool): return highest node in hierarchy if True Returns: root (list / str): @@ -2482,7 +2482,7 @@ class shelf(): def _get_render_instances(): """Return all 'render-like' instances. - This returns list of instance sets that needs to receive informations + This returns list of instance sets that needs to receive information about render layer changes. Returns: diff --git a/openpype/hosts/maya/api/menu.json b/openpype/hosts/maya/api/menu.json index bf4d812d33..a2efd5233c 100644 --- a/openpype/hosts/maya/api/menu.json +++ b/openpype/hosts/maya/api/menu.json @@ -506,8 +506,8 @@ "transforms", "local" ], - "title": "# Copy Local Transfroms", - "tooltip": "Copy local transfroms" + "title": "# Copy Local Transforms", + "tooltip": "Copy local transforms" }, { "type": "action", @@ -520,8 +520,8 @@ "transforms", "matrix" ], - "title": "# Copy Matrix Transfroms", - "tooltip": "Copy Matrix transfroms" + "title": "# Copy Matrix Transforms", + "tooltip": "Copy Matrix transforms" }, { "type": "action", @@ -842,7 +842,7 @@ "sourcetype": "file", "tags": ["cleanup", "remove_user_defined_attributes"], "title": "# Remove User Defined Attributes", - "tooltip": "Remove all user-defined attributs from all nodes" + "tooltip": "Remove all user-defined attributes from all nodes" }, { "type": "action", diff --git a/openpype/hosts/maya/api/menu_backup.json b/openpype/hosts/maya/api/menu_backup.json index 731a33a630..e2a558aedc 100644 --- a/openpype/hosts/maya/api/menu_backup.json +++ b/openpype/hosts/maya/api/menu_backup.json @@ -794,8 +794,8 @@ "transforms", "local" ], - "title": "Copy Local Transfroms", - "tooltip": "Copy local transfroms" + "title": "Copy Local Transforms", + "tooltip": "Copy local transforms" }, { "type": "action", @@ -808,8 +808,8 @@ "transforms", "matrix" ], - "title": "Copy Matrix Transfroms", - "tooltip": "Copy Matrix transfroms" + "title": "Copy Matrix Transforms", + "tooltip": "Copy Matrix transforms" }, { "type": "action", @@ -1274,7 +1274,7 @@ "sourcetype": "file", "tags": ["cleanup", "remove_user_defined_attributes"], "title": "Remove User Defined Attributes", - "tooltip": "Remove all user-defined attributs from all nodes" + "tooltip": "Remove all user-defined attributes from all nodes" }, { "type": "action", diff --git a/openpype/hosts/maya/api/setdress.py b/openpype/hosts/maya/api/setdress.py index 3537fa3837..4f826b8fde 100644 --- a/openpype/hosts/maya/api/setdress.py +++ b/openpype/hosts/maya/api/setdress.py @@ -341,7 +341,7 @@ def update_package(set_container, representation): def update_scene(set_container, containers, current_data, new_data, new_file): """Updates the hierarchy, assets and their matrix - Updates the following withing the scene: + Updates the following within the scene: * Setdress hierarchy alembic * Matrix * Parenting diff --git a/openpype/hosts/maya/api/shader_definition_editor.py b/openpype/hosts/maya/api/shader_definition_editor.py index ed425f4718..911db48ac2 100644 --- a/openpype/hosts/maya/api/shader_definition_editor.py +++ b/openpype/hosts/maya/api/shader_definition_editor.py @@ -92,7 +92,7 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): def _write_definition_file(self, content, force=False): """Write content as definition to file in database. - Before file is writen, check is made if its content has not + Before file is written, check is made if its content has not changed. If is changed, warning is issued to user if he wants it to overwrite. Note: GridFs doesn't allow changing file content. You need to delete existing file and create new one. diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 85919d1166..fa5e73f3ed 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -53,8 +53,8 @@ class CreateRender(plugin.Creator): renderer. ass (bool): Submit as ``ass`` file for standalone Arnold renderer. tileRendering (bool): Instance is set to tile rendering mode. We - won't submit actuall render, but we'll make publish job to wait - for Tile Assemly job done and then publish. + won't submit actual render, but we'll make publish job to wait + for Tile Assembly job done and then publish. See Also: https://pype.club/docs/artist_hosts_maya#creating-basic-render-setup diff --git a/openpype/hosts/maya/plugins/publish/collect_assembly.py b/openpype/hosts/maya/plugins/publish/collect_assembly.py index 22af1239b1..313636793b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_assembly.py +++ b/openpype/hosts/maya/plugins/publish/collect_assembly.py @@ -24,7 +24,7 @@ class CollectAssembly(pyblish.api.InstancePlugin): """ order = pyblish.api.CollectorOrder + 0.49 - label = "Assemby" + label = "Assembly" families = ["assembly"] def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index ac1e495f08..cbddb86e53 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -126,7 +126,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): r"^.+:(.*)", layer).group(1) except IndexError: msg = "Invalid layer name in set [ {} ]".format(layer) - self.log.warnig(msg) + self.log.warning(msg) continue self.log.info("processing %s" % layer) diff --git a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py index 7097d7ce9c..e5c182c908 100644 --- a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py @@ -48,7 +48,7 @@ class CollectVrayScene(pyblish.api.InstancePlugin): expected_layer_name = re.search(r"^.+:(.*)", layer).group(1) except IndexError: msg = "Invalid layer name in set [ {} ]".format(layer) - self.log.warnig(msg) + self.log.warning(msg) continue self.log.info("processing %s" % layer) diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py index c9edfc8343..1d7c0fa717 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py @@ -36,7 +36,7 @@ class ExtractVrayscene(openpype.api.Extractor): else: node = vray_settings[0] - # setMembers on vrayscene_layer shoudl contain layer name. + # setMembers on vrayscene_layer should contain layer name. layer_name = instance.data.get("layer") staging_dir = self.staging_dir(instance) @@ -111,7 +111,7 @@ class ExtractVrayscene(openpype.api.Extractor): layer (str): layer name. template (str): token template. start_frame (int, optional): start frame - if set we use - mutliple files export mode. + multiple files export mode. Returns: str: formatted path. diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py index ac3de4114c..f852904580 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py @@ -331,7 +331,7 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): # but dispatcher (Server) and not render clients. Render clients # inherit environment from publisher including PATH, so there's # no problem finding PYPE, but there is now way (as far as I know) - # to set environment dynamically for dispatcher. Therefor this hack. + # to set environment dynamically for dispatcher. Therefore this hack. args = [muster_python, _get_script().replace('\\', '\\\\'), "--paths", @@ -478,7 +478,7 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): # such that proper initialisation happens the same # way as it does on a local machine. # TODO(marcus): This won't work if the slaves don't - # have accesss to these paths, such as if slaves are + # have access to these paths, such as if slaves are # running Linux and the submitter is on Windows. "PYTHONPATH", "PATH", diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py index 57cf0803a4..5ce422239d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py @@ -78,7 +78,7 @@ class GetOverlappingUVs(object): if len(uarray) == 0 or len(varray) == 0: return (False, None, None) - # loop throught all vertices to construct edges/rays + # loop through all vertices to construct edges/rays u = uarray[-1] v = varray[-1] for i in xrange(len(uarray)): # noqa: F821 diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 4a6914ef90..6fe51d7b51 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -9,7 +9,7 @@ class ValidateRigContents(pyblish.api.InstancePlugin): Every rig must contain at least two object sets: "controls_SET" - Set of all animatable controls - "out_SET" - Set of all cachable meshes + "out_SET" - Set of all cacheable meshes """ diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index 99d6cfd4c5..00f1fda2d3 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -10,7 +10,7 @@ import re class ValidateUnrealStaticmeshName(pyblish.api.InstancePlugin): """Validate name of Unreal Static Mesh - Unreals naming convention states that staticMesh sould start with `SM` + Unreals naming convention states that staticMesh should start with `SM` prefix - SM_[Name]_## (Eg. SM_sube_01). This plugin also validates other types of meshes - collision meshes: diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index 1567189ed1..fe30caf3ab 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -54,7 +54,7 @@ def install(): ''' Installing all requarements for Nuke host ''' - # remove all registred callbacks form avalon.nuke + # remove all registered callbacks form avalon.nuke from avalon import pipeline pipeline._registered_event_handlers.clear() diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index e36a5aa5ba..fb66ac1b0b 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -141,7 +141,7 @@ def check_inventory_versions(): max_version = max(versions) # check the available version and do match - # change color of node if not max verion + # change color of node if not max version if version.get("name") not in [max_version]: node["tile_color"].setValue(int("0xd84f20ff", 16)) else: @@ -236,10 +236,10 @@ def get_render_path(node): def format_anatomy(data): - ''' Helping function for formating of anatomy paths + ''' Helping function for formatting of anatomy paths Arguments: - data (dict): dictionary with attributes used for formating + data (dict): dictionary with attributes used for formatting Return: path (str) @@ -462,7 +462,7 @@ def create_write_node(name, data, input=None, prenodes=None, else: now_node.setInput(0, prev_node) - # swith actual node to previous + # switch actual node to previous prev_node = now_node # creating write node @@ -474,7 +474,7 @@ def create_write_node(name, data, input=None, prenodes=None, # connect to previous node now_node.setInput(0, prev_node) - # swith actual node to previous + # switch actual node to previous prev_node = now_node now_node = nuke.createNode("Output", "name Output1") @@ -516,7 +516,7 @@ def create_write_node(name, data, input=None, prenodes=None, GN.addKnob(knob) else: if "___" in _k_name: - # add devider + # add divider GN.addKnob(nuke.Text_Knob("")) else: # add linked knob by _k_name @@ -725,7 +725,7 @@ class WorkfileSettings(object): for i, n in enumerate(copy_inputs): nv.setInput(i, n) - # set coppied knobs + # set copied knobs for k, v in copy_knobs.items(): print(k, v) nv[k].setValue(v) @@ -862,7 +862,7 @@ class WorkfileSettings(object): def set_reads_colorspace(self, read_clrs_inputs): """ Setting colorspace to Read nodes - Looping trought all read nodes and tries to set colorspace based + Looping through all read nodes and tries to set colorspace based on regex rules in presets """ changes = {} @@ -871,7 +871,7 @@ class WorkfileSettings(object): if n.Class() != "Read": continue - # check if any colorspace presets for read is mathing + # check if any colorspace presets for read is matching preset_clrsp = None for input in read_clrs_inputs: @@ -1013,7 +1013,7 @@ class WorkfileSettings(object): def reset_resolution(self): """Set resolution to project resolution.""" - log.info("Reseting resolution") + log.info("Resetting resolution") project = io.find_one({"type": "project"}) asset = api.Session["AVALON_ASSET"] asset = io.find_one({"name": asset, "type": "asset"}) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 82299dd354..ec1d04bc63 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -209,7 +209,7 @@ class ExporterReview(object): nuke_imageio = opnlib.get_nuke_imageio_settings() # TODO: this is only securing backward compatibility lets remove - # this once all projects's anotomy are upated to newer config + # this once all projects's anotomy are updated to newer config if "baking" in nuke_imageio.keys(): return nuke_imageio["baking"]["viewerProcess"] else: @@ -477,7 +477,7 @@ class ExporterReviewMov(ExporterReview): write_node["file_type"].setValue(str(self.ext)) # Knobs `meta_codec` and `mov64_codec` are not available on centos. - # TODO should't this come from settings on outputs? + # TODO shouldn't this come from settings on outputs? try: write_node["meta_codec"].setValue("ap4h") except Exception: diff --git a/openpype/hosts/nuke/api/utils.py b/openpype/hosts/nuke/api/utils.py index e43c11a380..caacdfe3bb 100644 --- a/openpype/hosts/nuke/api/utils.py +++ b/openpype/hosts/nuke/api/utils.py @@ -5,9 +5,9 @@ from openpype.api import resources def set_context_favorites(favorites=None): - """ Addig favorite folders to nuke's browser + """ Adding favorite folders to nuke's browser - Argumets: + Arguments: favorites (dict): couples of {name:path} """ favorites = favorites or {} @@ -51,7 +51,7 @@ def gizmo_is_nuke_default(gizmo): def bake_gizmos_recursively(in_group=nuke.Root()): """Converting a gizmo to group - Argumets: + Arguments: is_group (nuke.Node)[optonal]: group node or all nodes """ # preserve selection after all is done diff --git a/openpype/hosts/nuke/plugins/create/create_gizmo.py b/openpype/hosts/nuke/plugins/create/create_gizmo.py index c59713cff1..a66311cb4b 100644 --- a/openpype/hosts/nuke/plugins/create/create_gizmo.py +++ b/openpype/hosts/nuke/plugins/create/create_gizmo.py @@ -48,7 +48,7 @@ class CreateGizmo(plugin.PypeCreator): gizmo_node["name"].setValue("{}_GZM".format(self.name)) gizmo_node["tile_color"].setValue(int(self.node_color, 16)) - # add sticky node wit guide + # add sticky node with guide with gizmo_node: sticky = nuke.createNode("StickyNote") sticky["label"].setValue( @@ -71,7 +71,7 @@ class CreateGizmo(plugin.PypeCreator): gizmo_node["name"].setValue("{}_GZM".format(self.name)) gizmo_node["tile_color"].setValue(int(self.node_color, 16)) - # add sticky node wit guide + # add sticky node with guide with gizmo_node: sticky = nuke.createNode("StickyNote") sticky["label"].setValue( diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index 9148260e9e..44f7e60782 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -235,7 +235,7 @@ class LoadBackdropNodes(api.Loader): else: GN["tile_color"].setValue(int(self.node_color, 16)) - self.log.info("udated to version: {}".format(version.get("name"))) + self.log.info("updated to version: {}".format(version.get("name"))) return update_container(GN, data_imprint) diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index 377d60e84b..1645e513b4 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -156,7 +156,7 @@ class AlembicCameraLoader(api.Loader): # color node by correct color by actual version self.node_version_color(version, camera_node) - self.log.info("udated to version: {}".format(version.get("name"))) + self.log.info("updated to version: {}".format(version.get("name"))) return update_container(camera_node, data_imprint) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 9ce72c0519..aae7dcdc77 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -270,7 +270,7 @@ class LoadClip(plugin.NukeLoader): read_node, updated_dict ) - self.log.info("udated to version: {}".format(version.get("name"))) + self.log.info("updated to version: {}".format(version.get("name"))) if version_data.get("retime", None): self._make_retimes(read_node, version_data) @@ -302,7 +302,7 @@ class LoadClip(plugin.NukeLoader): self._loader_shift(read_node, start_at_workfile) def _make_retimes(self, parent_node, version_data): - ''' Create all retime and timewarping nodes with coppied animation ''' + ''' Create all retime and timewarping nodes with copied animation ''' speed = version_data.get('speed', 1) time_warp_nodes = version_data.get('timewarps', []) last_node = None diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index 8ba1b6b7c1..cecb61696b 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -253,7 +253,7 @@ class LoadEffects(api.Loader): else: GN["tile_color"].setValue(int("0x3469ffff", 16)) - self.log.info("udated to version: {}".format(version.get("name"))) + self.log.info("updated to version: {}".format(version.get("name"))) def connect_read_node(self, group_node, asset, subset): """ @@ -314,7 +314,7 @@ class LoadEffects(api.Loader): def byteify(self, input): """ Converts unicode strings to strings - It goes trought all dictionary + It goes through all dictionary Arguments: input (dict/str): input diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index d0cab26842..665b3b07d1 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -258,7 +258,7 @@ class LoadEffectsInputProcess(api.Loader): else: GN["tile_color"].setValue(int("0x3469ffff", 16)) - self.log.info("udated to version: {}".format(version.get("name"))) + self.log.info("updated to version: {}".format(version.get("name"))) def connect_active_viewer(self, group_node): """ @@ -331,7 +331,7 @@ class LoadEffectsInputProcess(api.Loader): def byteify(self, input): """ Converts unicode strings to strings - It goes trought all dictionary + It goes through all dictionary Arguments: input (dict/str): input diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index c6228b95f6..28c31c2261 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -149,7 +149,7 @@ class LoadGizmo(api.Loader): else: GN["tile_color"].setValue(int(self.node_color, 16)) - self.log.info("udated to version: {}".format(version.get("name"))) + self.log.info("updated to version: {}".format(version.get("name"))) return update_container(GN, data_imprint) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index 5ca101d6cb..1796b1f992 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -155,7 +155,7 @@ class LoadGizmoInputProcess(api.Loader): else: GN["tile_color"].setValue(int(self.node_color, 16)) - self.log.info("udated to version: {}".format(version.get("name"))) + self.log.info("updated to version: {}".format(version.get("name"))) return update_container(GN, data_imprint) @@ -210,7 +210,7 @@ class LoadGizmoInputProcess(api.Loader): def byteify(self, input): """ Converts unicode strings to strings - It goes trought all dictionary + It goes through all dictionary Arguments: input (dict/str): input diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 02a5b55c18..06c7ecf6ab 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -231,7 +231,7 @@ class LoadImage(api.Loader): node, updated_dict ) - self.log.info("udated to version: {}".format(version.get("name"))) + self.log.info("updated to version: {}".format(version.get("name"))) def remove(self, container): diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 15fa4fa35c..c1241e0383 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -156,7 +156,7 @@ class AlembicModelLoader(api.Loader): # color node by correct color by actual version self.node_version_color(version, model_node) - self.log.info("udated to version: {}".format(version.get("name"))) + self.log.info("updated to version: {}".format(version.get("name"))) return update_container(model_node, data_imprint) diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index 7444dd6e96..94dc70e341 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -67,7 +67,7 @@ class LinkAsGroup(api.Loader): P["useOutput"].setValue(True) with P: - # iterate trough all nodes in group node and find pype writes + # iterate through all nodes in group node and find pype writes writes = [n.name() for n in nuke.allNodes() if n.Class() == "Group" if get_avalon_knob_data(n)] @@ -152,7 +152,7 @@ class LinkAsGroup(api.Loader): else: node["tile_color"].setValue(int("0xff0ff0ff", 16)) - self.log.info("udated to version: {}".format(version.get("name"))) + self.log.info("updated to version: {}".format(version.get("name"))) def remove(self, container): from avalon.nuke import viewer_update_and_undo_stop diff --git a/openpype/hosts/nuke/plugins/publish/extract_camera.py b/openpype/hosts/nuke/plugins/publish/extract_camera.py index bc50dac108..3333da1909 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_camera.py +++ b/openpype/hosts/nuke/plugins/publish/extract_camera.py @@ -113,7 +113,7 @@ class ExtractCamera(openpype.api.Extractor): def bakeCameraWithAxeses(camera_node, output_range): - """ Baking all perent hiearchy of axeses into camera + """ Baking all perent hierarchy of axeses into camera with transposition onto word XYZ coordinance """ bakeFocal = False diff --git a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py index c3a6a3b167..a78424be78 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py +++ b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py @@ -4,7 +4,7 @@ from avalon.nuke import maintained_selection class CreateOutputNode(pyblish.api.ContextPlugin): - """Adding output node for each ouput write node + """Adding output node for each output write node So when latly user will want to Load .nk as LifeGroup or Precomp Nuke will not complain about missing Output node """ diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 32962b57a6..5d53e99e9d 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -49,7 +49,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): # test if family found in context test_families = any([ - # first if exact family set is mathing + # first if exact family set is matching # make sure only interesetion of list is correct bool(set(families).intersection(f_families)), # and if famiies are set at all diff --git a/openpype/hosts/nuke/plugins/publish/increment_script_version.py b/openpype/hosts/nuke/plugins/publish/increment_script_version.py index f55ed21ee2..b854dc0aa1 100644 --- a/openpype/hosts/nuke/plugins/publish/increment_script_version.py +++ b/openpype/hosts/nuke/plugins/publish/increment_script_version.py @@ -15,7 +15,7 @@ class IncrementScriptVersion(pyblish.api.ContextPlugin): def process(self, context): assert all(result["success"] for result in context.data["results"]), ( - "Publishing not succesfull so version is not increased.") + "Publishing not successful so version is not increased.") from openpype.lib import version_up path = context.data["currentFile"] diff --git a/openpype/hosts/nuke/plugins/publish/remove_ouput_node.py b/openpype/hosts/nuke/plugins/publish/remove_ouput_node.py index 12361595fe..fb77e8638c 100644 --- a/openpype/hosts/nuke/plugins/publish/remove_ouput_node.py +++ b/openpype/hosts/nuke/plugins/publish/remove_ouput_node.py @@ -3,7 +3,7 @@ import pyblish.api class RemoveOutputNode(pyblish.api.ContextPlugin): - """Removing output node for each ouput write node + """Removing output node for each output write node """ label = 'Output Node Remove' diff --git a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py index f280ad4af1..ceb70a8c86 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py @@ -48,7 +48,7 @@ class SelectCenterInNodeGraph(pyblish.api.Action): @pyblish.api.log class ValidateBackdrop(pyblish.api.InstancePlugin): """Validate amount of nodes on backdrop node in case user - forgoten to add nodes above the publishing backdrop node""" + forgotten to add nodes above the publishing backdrop node""" order = pyblish.api.ValidatorOrder optional = True diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_deadline_tab.py b/openpype/hosts/nuke/plugins/publish/validate_write_deadline_tab.py index 72fd51a900..5ee93403d0 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_deadline_tab.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_deadline_tab.py @@ -23,7 +23,7 @@ class RepairNukeWriteDeadlineTab(pyblish.api.Action): for instance in instances: group_node = [x for x in instance if x.Class() == "Group"][0] - # Remove exising knobs. + # Remove existing knobs. knob_names = openpype.hosts.nuke.lib.get_deadline_knob_names() for name, knob in group_node.knobs().iteritems(): if name in knob_names: diff --git a/openpype/hosts/nuke/startup/KnobScripter/knob_scripter.py b/openpype/hosts/nuke/startup/KnobScripter/knob_scripter.py index f03067aa4b..368ee64e32 100644 --- a/openpype/hosts/nuke/startup/KnobScripter/knob_scripter.py +++ b/openpype/hosts/nuke/startup/KnobScripter/knob_scripter.py @@ -1,6 +1,6 @@ # ------------------------------------------------- # KnobScripter by Adrian Pueyo -# Complete python sript editor for Nuke +# Complete python script editor for Nuke # adrianpueyo.com, 2016-2019 import string import traceback @@ -2539,7 +2539,7 @@ class KnobScripterTextEdit(QtWidgets.QPlainTextEdit): if self.noSelection: self.cursor.setPosition(self.lastChar) - # check whether the the orignal selection was from top to bottom or vice versa + # check whether the the original selection was from top to bottom or vice versa else: if self.originalPosition == self.firstChar: first = self.lastChar @@ -3012,7 +3012,7 @@ class KnobScripterTextEditMain(KnobScripterTextEdit): return match_key, match_snippet def placeholderToEnd(self, text, placeholder): - '''Returns distance (int) from the first ocurrence of the placeholder, to the end of the string with placeholders removed''' + '''Returns distance (int) from the first occurrence of the placeholder, to the end of the string with placeholders removed''' search = re.search(placeholder, text) if not search: return -1 @@ -3671,7 +3671,7 @@ class KnobScripterPrefs(QtWidgets.QDialog): def updateContext(): ''' - Get the current selection of nodes with their appropiate context + Get the current selection of nodes with their appropriate context Doing this outside the KnobScripter -> forces context update inside groups when needed ''' global knobScripterSelectedNodes diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index 8704627b12..74b612fc72 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -12,7 +12,7 @@ stub = photoshop.stub() class ImageFromSequenceLoader(api.Loader): - """ Load specifing image from sequence + """ Load specific image from sequence Used only as quick load of reference file from a sequence. diff --git a/openpype/hosts/resolve/README.markdown b/openpype/hosts/resolve/README.markdown index 50664fbd21..8c9f72fb0c 100644 --- a/openpype/hosts/resolve/README.markdown +++ b/openpype/hosts/resolve/README.markdown @@ -4,10 +4,10 @@ - add absolute path to ffmpeg into openpype settings ![image](https://user-images.githubusercontent.com/40640033/102630786-43294f00-414d-11eb-98de-f0ae51f62077.png) - install Python 3.6 into `%LOCALAPPDATA%/Programs/Python/Python36` (only respected path by Resolve) -- install OpenTimelineIO for 3.6 `%LOCALAPPDATA%\Programs\Python\Python36\python.exe -m pip install git+https://github.com/PixarAnimationStudios/OpenTimelineIO.git@5aa24fbe89d615448876948fe4b4900455c9a3e8` and move builded files from `%LOCALAPPDATA%/Programs/Python/Python36/Lib/site-packages/opentimelineio/cxx-libs/bin and lib` to `%LOCALAPPDATA%/Programs/Python/Python36/Lib/site-packages/opentimelineio/`. I was building it on Win10 machine with Visual Studio Community 2019 and +- install OpenTimelineIO for 3.6 `%LOCALAPPDATA%\Programs\Python\Python36\python.exe -m pip install git+https://github.com/PixarAnimationStudios/OpenTimelineIO.git@5aa24fbe89d615448876948fe4b4900455c9a3e8` and move built files from `%LOCALAPPDATA%/Programs/Python/Python36/Lib/site-packages/opentimelineio/cxx-libs/bin and lib` to `%LOCALAPPDATA%/Programs/Python/Python36/Lib/site-packages/opentimelineio/`. I was building it on Win10 machine with Visual Studio Community 2019 and ![image](https://user-images.githubusercontent.com/40640033/102792588-ffcb1c80-43a8-11eb-9c6b-bf2114ed578e.png) with installed CMake in PATH. - install PySide2 for 3.6 `%LOCALAPPDATA%\Programs\Python\Python36\python.exe -m pip install PySide2` -- make sure Resovle Fusion (Fusion Tab/menu/Fusion/Fusion Setings) is set to Python 3.6 +- make sure Resolve Fusion (Fusion Tab/menu/Fusion/Fusion Settings) is set to Python 3.6 ![image](https://user-images.githubusercontent.com/40640033/102631545-280b0f00-414e-11eb-89fc-98ac268d209d.png) #### Editorial setup diff --git a/openpype/hosts/resolve/RESOLVE_API_README_v16.2.0_up.txt b/openpype/hosts/resolve/RESOLVE_API_README_v16.2.0_up.txt index a24a053cd7..f1b8b81a71 100644 --- a/openpype/hosts/resolve/RESOLVE_API_README_v16.2.0_up.txt +++ b/openpype/hosts/resolve/RESOLVE_API_README_v16.2.0_up.txt @@ -366,7 +366,7 @@ TimelineItem DeleteTakeByIndex(idx) --> Bool # Deletes a take by index, 1 <= idx <= number of takes. SelectTakeByIndex(idx) --> Bool # Selects a take by index, 1 <= idx <= number of takes. FinalizeTake() --> Bool # Finalizes take selection. - CopyGrades([tgtTimelineItems]) --> Bool # Copies the current grade to all the items in tgtTimelineItems list. Returns True on success and False if any error occured. + CopyGrades([tgtTimelineItems]) --> Bool # Copies the current grade to all the items in tgtTimelineItems list. Returns True on success and False if any error occurred. List and Dict Data Structures diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index aa4b2e7219..22f83c6eed 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -16,7 +16,7 @@ self = sys.modules[__name__] self.project_manager = None self.media_storage = None -# OpenPype sequencial rename variables +# OpenPype sequential rename variables self.rename_index = 0 self.rename_add = 0 @@ -59,7 +59,7 @@ def maintain_current_timeline(to_timeline: object, project = get_current_project() working_timeline = from_timeline or project.GetCurrentTimeline() - # swith to the input timeline + # switch to the input timeline project.SetCurrentTimeline(to_timeline) try: @@ -566,7 +566,7 @@ def create_compound_clip(clip_data, name, folder): mp_in_rc = opentime.RationalTime((ci_l_offset), rate) mp_out_rc = opentime.RationalTime((ci_l_offset + ci_duration - 1), rate) - # get frame in and out for clip swaping + # get frame in and out for clip swapping in_frame = opentime.to_frames(mp_in_rc) out_frame = opentime.to_frames(mp_out_rc) @@ -628,7 +628,7 @@ def create_compound_clip(clip_data, name, folder): def swap_clips(from_clip, to_clip, to_in_frame, to_out_frame): """ - Swaping clips on timeline in timelineItem + Swapping clips on timeline in timelineItem It will add take and activate it to the frame range which is inputted @@ -699,7 +699,7 @@ def get_pype_clip_metadata(clip): def get_clip_attributes(clip): """ - Collect basic atrributes from resolve timeline item + Collect basic attributes from resolve timeline item Args: clip (resolve.TimelineItem): timeline item object diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index ce95cfe02a..8b7e2a6c6a 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -64,7 +64,7 @@ def install(): def uninstall(): - """Uninstall all tha was installed + """Uninstall all that was installed This is where you undo everything that was done in `install()`. That means, removing menus, deregistering families and data diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index f1c55a6180..8612cf82ec 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -133,7 +133,7 @@ class CreatorWidget(QtWidgets.QDialog): # convert label text to normal capitalized text with spaces label_text = self.camel_case_split(text) - # assign the new text to lable widget + # assign the new text to label widget label = QtWidgets.QLabel(label_text) label.setObjectName("LineLabel") @@ -367,7 +367,7 @@ class ClipLoader: def _get_asset_data(self): """ Get all available asset data - joint `data` key with asset.data dict into the representaion + joint `data` key with asset.data dict into the representation """ asset_name = self.context["representation"]["context"]["asset"] @@ -540,8 +540,8 @@ class PublishClip: "track": "sequence", } - # parents search patern - parents_search_patern = r"\{([a-z]*?)\}" + # parents search pattern + parents_search_pattern = r"\{([a-z]*?)\}" # default templates for non-ui use rename_default = False @@ -630,7 +630,7 @@ class PublishClip: return self.timeline_item def _populate_timeline_item_default_data(self): - """ Populate default formating data from track item. """ + """ Populate default formatting data from track item. """ self.timeline_item_default_data = { "_folder_": "shots", @@ -722,7 +722,7 @@ class PublishClip: # mark review layer if self.review_track and ( self.review_track not in self.review_track_default): - # if review layer is defined and not the same as defalut + # if review layer is defined and not the same as default self.review_layer = self.review_track # shot num calculate if self.rename_index == 0: @@ -771,7 +771,7 @@ class PublishClip: # in case track name and subset name is the same then add if self.subset_name == self.track_name: hero_data["subset"] = self.subset - # assing data to return hierarchy data to tag + # assign data to return hierarchy data to tag tag_hierarchy_data = hero_data # add data to return data dict @@ -823,8 +823,8 @@ class PublishClip: """ Create parents and return it in list. """ self.parents = [] - patern = re.compile(self.parents_search_patern) - par_split = [patern.findall(t).pop() + pattern = re.compile(self.parents_search_pattern) + par_split = [pattern.findall(t).pop() for t in self.hierarchy.split("/")] for key in par_split: diff --git a/openpype/hosts/resolve/api/testing_utils.py b/openpype/hosts/resolve/api/testing_utils.py index 98ad6abcf1..4aac66f4b7 100644 --- a/openpype/hosts/resolve/api/testing_utils.py +++ b/openpype/hosts/resolve/api/testing_utils.py @@ -25,12 +25,12 @@ class TestGUI: ui.Button( { "ID": "inputTestSourcesFolder", - "Text": "Select folder with testing medias", + "Text": "Select folder with testing media", "Weight": 1.25, "ToolTip": ( "Chose folder with videos, sequences, " "single images, nested folders with " - "medias" + "media" ), "Flat": False } diff --git a/openpype/hosts/resolve/hooks/pre_resolve_setup.py b/openpype/hosts/resolve/hooks/pre_resolve_setup.py index bcb27e24fc..978e3760fd 100644 --- a/openpype/hosts/resolve/hooks/pre_resolve_setup.py +++ b/openpype/hosts/resolve/hooks/pre_resolve_setup.py @@ -15,7 +15,7 @@ class ResolvePrelaunch(PreLaunchHook): def execute(self): # TODO: add OTIO installation from `openpype/requirements.py` - # making sure pyton 3.6 is installed at provided path + # making sure python 3.6 is installed at provided path py36_dir = os.path.normpath( self.launch_context.env.get("PYTHON36_RESOLVE", "")) assert os.path.isdir(py36_dir), ( diff --git a/openpype/hosts/resolve/otio/davinci_export.py b/openpype/hosts/resolve/otio/davinci_export.py index 2c276d9888..5f11c81fc5 100644 --- a/openpype/hosts/resolve/otio/davinci_export.py +++ b/openpype/hosts/resolve/otio/davinci_export.py @@ -306,7 +306,7 @@ def create_otio_timeline(resolve_project): if index == 0: otio_track.append(clip) else: - # add previouse otio track to timeline + # add previous otio track to timeline otio_timeline.tracks.append(otio_track) # convert track to otio otio_track = create_otio_track( diff --git a/openpype/hosts/resolve/plugins/create/create_shot_clip.py b/openpype/hosts/resolve/plugins/create/create_shot_clip.py index 41fdbf5c61..62d5557a50 100644 --- a/openpype/hosts/resolve/plugins/create/create_shot_clip.py +++ b/openpype/hosts/resolve/plugins/create/create_shot_clip.py @@ -135,7 +135,7 @@ class CreateShotClip(resolve.Creator): "type": "QComboBox", "label": "Subset Name", "target": "ui", - "toolTip": "chose subset name patern, if is selected, name of track layer will be used", # noqa + "toolTip": "chose subset name pattern, if is selected, name of track layer will be used", # noqa "order": 0}, "subsetFamily": { "value": ["plate", "take"], diff --git a/openpype/hosts/resolve/utility_scripts/__OpenPype__Menu__.py b/openpype/hosts/resolve/utility_scripts/__OpenPype__Menu__.py index b1037a9c93..b0cef1838a 100644 --- a/openpype/hosts/resolve/utility_scripts/__OpenPype__Menu__.py +++ b/openpype/hosts/resolve/utility_scripts/__OpenPype__Menu__.py @@ -16,7 +16,7 @@ def main(env): # activate resolve from openpype avalon.install(bmdvr) - log.info(f"Avalon registred hosts: {avalon.registered_host()}") + log.info(f"Avalon registered hosts: {avalon.registered_host()}") bmdvr.launch_pype_menu() diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py index 45c6a264dd..d0d36bb717 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py @@ -83,7 +83,7 @@ class CollectInstances(pyblish.api.InstancePlugin): if isinstance(clip, otio.schema.Gap): continue - # skip all generators like black ampty + # skip all generators like black empty if isinstance( clip.media_reference, otio.schema.GeneratorReference): @@ -142,7 +142,7 @@ class CollectInstances(pyblish.api.InstancePlugin): "item": clip, "clipName": clip_name, - # parent time properities + # parent time properties "trackStartFrame": track_start_frame, "handleStart": handle_start, "handleEnd": handle_end, @@ -180,14 +180,14 @@ class CollectInstances(pyblish.api.InstancePlugin): "families": [] } }) - for subset, properities in self.subsets.items(): - version = properities.get("version") + for subset, properties in self.subsets.items(): + version = properties.get("version") if version == 0: - properities.pop("version") + properties.pop("version") # adding Review-able instance subset_instance_data = deepcopy(instance_data) - subset_instance_data.update(deepcopy(properities)) + subset_instance_data.update(deepcopy(properties)) subset_instance_data.update({ # unique attributes "name": f"{name}_{subset}", diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_resources.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_resources.py index 36bacceb1c..4d7a13fcf2 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_resources.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_resources.py @@ -31,7 +31,7 @@ class CollectInstanceResources(pyblish.api.InstancePlugin): editorial_source_root = instance.data["editorialSourceRoot"] editorial_source_path = instance.data["editorialSourcePath"] - # if `editorial_source_path` then loop trough + # if `editorial_source_path` then loop through if editorial_source_path: # add family if mov or mp4 found which is longer for # cutting `trimming` to enable `ExtractTrimmingVideoAudio` plugin @@ -42,7 +42,7 @@ class CollectInstanceResources(pyblish.api.InstancePlugin): instance.data["families"] += ["trimming"] return - # if template patern in path then fill it with `anatomy_data` + # if template pattern in path then fill it with `anatomy_data` if "{" in editorial_source_root: editorial_source_root = editorial_source_root.format( **anatomy_data) @@ -86,7 +86,7 @@ class CollectInstanceResources(pyblish.api.InstancePlugin): subset_files.update({clip_dir_path: subset_files_items}) # break the loop if correct_clip_dir was captured - # no need to cary on if corect folder was found + # no need to cary on if correct folder was found if correct_clip_dir: break @@ -113,10 +113,10 @@ class CollectInstanceResources(pyblish.api.InstancePlugin): unique_subset_names = list() root_dir = list(subset_files.keys()).pop() files_list = subset_files[root_dir] - search_patern = f"({subset}[A-Za-z0-9]+)(?=[\\._\\s])" + search_pattern = f"({subset}[A-Za-z0-9]+)(?=[\\._\\s])" for _file in files_list: - patern = re.compile(search_patern) - match = patern.findall(_file) + pattern = re.compile(search_pattern) + match = pattern.findall(_file) if not match: continue match_subset = match.pop() @@ -175,7 +175,7 @@ class CollectInstanceResources(pyblish.api.InstancePlugin): instance_data["representations"] = list() collection_head_name = None - # loop trough collections and create representations + # loop through collections and create representations for _collection in collections: ext = _collection.tail[1:] collection_head_name = _collection.head @@ -210,7 +210,7 @@ class CollectInstanceResources(pyblish.api.InstancePlugin): frames.append(frame_start) frames.append(frame_end) - # loop trough reminders and create representations + # loop through reminders and create representations for _reminding_file in remainder: ext = os.path.splitext(_reminding_file)[-1][1:] if ext not in instance_data["extensions"]: diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py index acad98d784..b2735f3428 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py @@ -99,7 +99,7 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin): # in case SP context is set to the same folder if (_index == 0) and ("folder" in parent_key) \ and (parents[-1]["entity_name"] == parent_filled): - self.log.debug(f" skiping : {parent_filled}") + self.log.debug(f" skipping : {parent_filled}") continue # in case first parent is project then start parents from start @@ -119,7 +119,7 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin): # convert hierarchy to string hierarchy = "/".join(hierarchy) - # assing to instance data + # assign to instance data instance.data["hierarchy"] = hierarchy instance.data["parents"] = parents @@ -202,7 +202,7 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin): class CollectHierarchyContext(pyblish.api.ContextPlugin): - '''Collecting Hierarchy from instaces and building + '''Collecting Hierarchy from instances and building context hierarchy tree ''' diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_representation_names.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_representation_names.py index c9063c22ed..82dbba3345 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_representation_names.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_representation_names.py @@ -8,7 +8,7 @@ class CollectRepresentationNames(pyblish.api.InstancePlugin): Sets the representation names for given families based on RegEx filter """ - label = "Collect Representaion Names" + label = "Collect Representation Names" order = pyblish.api.CollectorOrder families = [] hosts = ["standalonepublisher"] diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py index f210be3631..4bafe81020 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py @@ -16,7 +16,7 @@ class ValidateTextureBatchNaming(pyblish.api.InstancePlugin): if isinstance(file_name, list): file_name = file_name[0] - msg = "Couldnt find asset name in '{}'\n".format(file_name) + \ + msg = "Couldn't find asset name in '{}'\n".format(file_name) + \ "File name doesn't follow configured pattern.\n" + \ "Please rename the file." assert "NOT_AVAIL" not in instance.data["asset_build"], msg diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index 6c8aca5445..c8d6d3b458 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -351,7 +351,7 @@ class QtTVPaintRpc(BaseTVPaintRpc): async def scene_inventory_tool(self): """Open Scene Inventory tool. - Funciton can't confirm if tool was opened becauise one part of + Function can't confirm if tool was opened becauise one part of SceneInventory initialization is calling websocket request to host but host can't response because is waiting for response from this call. """ @@ -578,7 +578,7 @@ class BaseCommunicator: # Folder for right windows plugin files source_plugins_dir = os.path.join(plugin_files_path, subfolder) - # Path to libraies (.dll) required for plugin library + # Path to libraries (.dll) required for plugin library # - additional libraries can be copied to TVPaint installation folder # (next to executable) or added to PATH environment variable additional_libs_folder = os.path.join( diff --git a/openpype/hosts/tvpaint/api/lib.py b/openpype/hosts/tvpaint/api/lib.py index 654aff19d8..9e6404e72f 100644 --- a/openpype/hosts/tvpaint/api/lib.py +++ b/openpype/hosts/tvpaint/api/lib.py @@ -159,7 +159,7 @@ def get_layers_data(layer_ids=None, communicator=None): def parse_group_data(data): - """Paser group data collected in 'get_groups_data'.""" + """Parse group data collected in 'get_groups_data'.""" output = [] groups_raw = data.split("\n") for group_raw in groups_raw: diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index e7c5159bbc..6b4632e2f2 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -112,7 +112,7 @@ def containerise( members (list): List of members that were loaded and belongs to the container (layer names). current_containers (list): Preloaded containers. Should be used only - on update/switch when containers were modified durring the process. + on update/switch when containers were modified during the process. Returns: dict: Container data stored to workfile metadata. @@ -166,7 +166,7 @@ def split_metadata_string(text, chunk_length=None): set to global variable `TVPAINT_CHUNK_LENGTH`. Returns: - list: List of strings wil at least one item. + list: List of strings with at least one item. """ if chunk_length is None: chunk_length = TVPAINT_CHUNK_LENGTH diff --git a/openpype/hosts/tvpaint/api/plugin.py b/openpype/hosts/tvpaint/api/plugin.py index e65c25b8d1..af80c9eae2 100644 --- a/openpype/hosts/tvpaint/api/plugin.py +++ b/openpype/hosts/tvpaint/api/plugin.py @@ -35,7 +35,7 @@ class Creator(PypeCreatorMixin, avalon.api.Creator): def are_instances_same(instance_1, instance_2): """Compare instances but skip keys with unique values. - During compare are skiped keys that will be 100% sure + During compare are skipped keys that will be 100% sure different on new instance, like "id". Returns: diff --git a/openpype/hosts/tvpaint/lib.py b/openpype/hosts/tvpaint/lib.py index 513bb2d952..715ebb4a9d 100644 --- a/openpype/hosts/tvpaint/lib.py +++ b/openpype/hosts/tvpaint/lib.py @@ -278,7 +278,7 @@ def _cleanup_out_range_frames(output_idx_by_frame_idx, range_start, range_end): } // Result { - 2: 2, // Redirect to self as is first that refence out range + 2: 2, // Redirect to self as is first that reference out range 3: 2 // Redirect to first redirected frame } ``` @@ -593,7 +593,7 @@ def composite_rendered_layers( transparent_filepaths.add(dst_filepath) continue - # Store first destionation filepath to be used for transparent images + # Store first destination filepath to be used for transparent images if first_dst_filepath is None: first_dst_filepath = dst_filepath @@ -657,7 +657,7 @@ def rename_filepaths_by_frame_start( max(range_end, new_frame_end) ) - # Use differnet ranges based on Mark In and output Frame Start values + # Use different ranges based on Mark In and output Frame Start values # - this is to make sure that filename renaming won't affect files that # are not renamed yet if range_start < new_frame_start: diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index 31d2fd1fd5..9cbfb61550 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -77,7 +77,7 @@ class CollectInstances(pyblish.api.ContextPlugin): # Project name from workfile context project_name = context.data["workfile_context"]["project"] - # Host name from environemnt variable + # Host name from environment variable host_name = os.environ["AVALON_APP"] # Use empty variant value variant = "" diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py index 68ba350a85..89348037d3 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py @@ -35,7 +35,7 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # Project name from workfile context project_name = context.data["workfile_context"]["project"] - # Host name from environemnt variable + # Host name from environment variable host_name = os.environ["AVALON_APP"] # Use empty variant value variant = "" diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index b6b8bd0d9e..729c545545 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -168,7 +168,7 @@ class ExtractSequence(pyblish.api.Extractor): if single_file: repre_files = repre_files[0] - # Extension is harcoded + # Extension is hardcoded # - changing extension would require change code new_repre = { "name": "png", @@ -235,7 +235,7 @@ class ExtractSequence(pyblish.api.Extractor): scene_bg_color (list): Bg color set in scene. Result of george script command `tv_background`. - Retruns: + Returns: tuple: With 2 items first is list of filenames second is path to thumbnail. """ @@ -311,7 +311,7 @@ class ExtractSequence(pyblish.api.Extractor): mark_out (int): On which frame index export will end. layers (list): List of layers to be exported. - Retruns: + Returns: tuple: With 2 items first is list of filenames second is path to thumbnail. """ diff --git a/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py b/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py index c9f2434cef..24d6558168 100644 --- a/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py @@ -15,7 +15,7 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): def process(self, context): assert all(result["success"] for result in context.data["results"]), ( - "Publishing not succesfull so version is not increased.") + "Publishing not successful so version is not increased.") path = context.data["currentFile"] workio.save_file(version_up(path)) diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py index 9d55bb21a9..f45247ceac 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py @@ -44,7 +44,7 @@ class ValidateMarks(pyblish.api.ContextPlugin): handle_start = context.data["handleStart"] handle_end = context.data["handleEnd"] - # Calculate expeted Mark out (Mark In + duration - 1) + # Calculate expected Mark out (Mark In + duration - 1) expected_mark_out = ( scene_mark_in + (frame_end - frame_start) diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/README.md b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/README.md index 03b0a31f51..70a96b2919 100644 --- a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/README.md +++ b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/README.md @@ -3,7 +3,7 @@ README for TVPaint Avalon plugin Introduction ------------ This project is dedicated to integrate Avalon functionality to TVPaint. -This implementaiton is using TVPaint plugin (C/C++) which can communicate with python process. The communication should allow to trigger tools or pipeline functions from TVPaint and accept requests from python process at the same time. +This implementation is using TVPaint plugin (C/C++) which can communicate with python process. The communication should allow to trigger tools or pipeline functions from TVPaint and accept requests from python process at the same time. Current implementation is based on websocket protocol, using json-rpc communication (specification 2.0). Project is in beta stage, tested only on Windows. diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp index a57124084b..bb67715cbd 100644 --- a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp +++ b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp @@ -41,7 +41,7 @@ static struct { nlohmann::json menuItemsById; std::list menuItemsIds; // Messages from server before processing. - // - messages can't be process at the moment of recieve as client is running in thread + // - messages can't be process at the moment of receive as client is running in thread std::queue messages; // Responses to requests mapped by request id std::map responses; @@ -694,7 +694,7 @@ int newMenuItemsProcess(PIFilter* iFilter) { return 1; } /**************************************************************************************/ -// something happenned that needs our attention. +// something happened that needs our attention. // Global variable where current button up data are stored std::string button_up_item_id_str; int FAR PASCAL PI_Msg( PIFilter* iFilter, INTPTR iEvent, INTPTR iReq, INTPTR* iArgs ) diff --git a/openpype/hosts/tvpaint/worker/worker_job.py b/openpype/hosts/tvpaint/worker/worker_job.py index 519d42ce73..1c785ab2ee 100644 --- a/openpype/hosts/tvpaint/worker/worker_job.py +++ b/openpype/hosts/tvpaint/worker/worker_job.py @@ -41,7 +41,7 @@ class BaseCommand: Command also have id which is created on command creation. The idea is that command is just a data container on sender side send - througth server to a worker where is replicated one by one, executed and + through server to a worker where is replicated one by one, executed and result sent back to sender through server. """ @abstractproperty @@ -248,7 +248,7 @@ class ExecuteGeorgeScript(BaseCommand): class CollectSceneData(BaseCommand): - """Helper command which will collect all usefull info about workfile. + """Helper command which will collect all useful info about workfile. Result is dictionary with all layers data, exposure frames by layer ids pre/post behavior of layers by their ids, group information and scene data. diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py index c0fafbb667..61dac46fac 100644 --- a/openpype/hosts/unreal/api/lib.py +++ b/openpype/hosts/unreal/api/lib.py @@ -115,7 +115,7 @@ def _darwin_get_engine_version() -> dict: Returns: dict: version as a key and path as a value. - See Aslo: + See Also: :func:`_win_get_engine_versions`. """ diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py index ad37a7a068..e2023e8b47 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py @@ -98,7 +98,7 @@ class PointCacheAlembicLoader(api.Loader): frame_start = context.get('asset').get('data').get('frameStart') frame_end = context.get('asset').get('data').get('frameEnd') - # If frame start and end are the same, we increse the end frame by + # If frame start and end are the same, we increase the end frame by # one, otherwise Unreal will not import it if frame_start == frame_end: frame_end += 1 diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_tvpaint_instances.py b/openpype/hosts/webpublisher/plugins/publish/collect_tvpaint_instances.py index 976a14e808..92f581be5f 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_tvpaint_instances.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_tvpaint_instances.py @@ -28,7 +28,7 @@ class CollectTVPaintInstances(pyblish.api.ContextPlugin): render_layer_pass_name = "beauty" # Set by settings - # Regex must constain 'layer' and 'variant' groups which are extracted from + # Regex must contain 'layer' and 'variant' groups which are extracted from # name when instances are created layer_name_regex = r"(?PL[0-9]{3}_\w+)_(?P.+)" diff --git a/openpype/hosts/webpublisher/plugins/publish/extract_tvpaint_workfile.py b/openpype/hosts/webpublisher/plugins/publish/extract_tvpaint_workfile.py index 85c8526c83..2142d740a5 100644 --- a/openpype/hosts/webpublisher/plugins/publish/extract_tvpaint_workfile.py +++ b/openpype/hosts/webpublisher/plugins/publish/extract_tvpaint_workfile.py @@ -286,7 +286,7 @@ class ExtractTVPaintSequences(pyblish.api.Extractor): if single_file: repre_files = repre_files[0] - # Extension is harcoded + # Extension is hardcoded # - changing extension would require change code new_repre = { "name": "png", @@ -407,7 +407,7 @@ class ExtractTVPaintSequences(pyblish.api.Extractor): mark_out (int): On which frame index export will end. layers (list): List of layers to be exported. - Retruns: + Returns: tuple: With 2 items first is list of filenames second is path to thumbnail. """ diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py index 30399a6ba7..135a9cd026 100644 --- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py +++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py @@ -335,7 +335,7 @@ class ConfiguredExtensionsEndpoint(_RestApiEndpoint): configured = { "file_exts": set(), "sequence_exts": set(), - # workfiles that could have "Studio Procesing" hardcoded for now + # workfiles that could have "Studio Processing" hardcoded for now "studio_exts": set(["psd", "psb", "tvpp", "tvp"]) } collect_conf = sett["webpublisher"]["publish"]["CollectPublishedFiles"] From 136a163122544a3f011b96758962d7f2c0bc6d1e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Jan 2022 13:16:22 +0100 Subject: [PATCH 305/395] Fix typos in openpype/lib --- openpype/lib/abstract_collect_render.py | 4 ++-- openpype/lib/abstract_submit_deadline.py | 2 +- openpype/lib/anatomy.py | 8 ++++---- openpype/lib/applications.py | 22 +++++++++++----------- openpype/lib/avalon_context.py | 16 ++++++++-------- openpype/lib/editorial.py | 6 +++--- openpype/lib/env_tools.py | 4 ++-- openpype/lib/execute.py | 4 ++-- openpype/lib/git_progress.py | 2 +- openpype/lib/import_utils.py | 2 +- openpype/lib/mongo.py | 2 +- openpype/lib/path_tools.py | 4 ++-- openpype/lib/plugin_tools.py | 4 ++-- openpype/lib/pype_info.py | 2 +- openpype/lib/terminal.py | 2 +- 15 files changed, 42 insertions(+), 42 deletions(-) diff --git a/openpype/lib/abstract_collect_render.py b/openpype/lib/abstract_collect_render.py index 2ac0fe434d..d9c8a0993d 100644 --- a/openpype/lib/abstract_collect_render.py +++ b/openpype/lib/abstract_collect_render.py @@ -49,7 +49,7 @@ class RenderInstance(object): handleStart = attr.ib(default=None) # start frame handleEnd = attr.ib(default=None) # start frame - # for softwares (like Harmony) where frame range cannot be set by DB + # for software (like Harmony) where frame range cannot be set by DB # handles need to be propagated if exist ignoreFrameHandleCheck = attr.ib(default=False) @@ -57,7 +57,7 @@ class RenderInstance(object): # With default values # metadata renderer = attr.ib(default="") # renderer - can be used in Deadline - review = attr.ib(default=False) # genereate review from instance (bool) + review = attr.ib(default=False) # generate review from instance (bool) priority = attr.ib(default=50) # job priority on farm family = attr.ib(default="renderlayer") diff --git a/openpype/lib/abstract_submit_deadline.py b/openpype/lib/abstract_submit_deadline.py index 5b6e1743e0..a0925283ac 100644 --- a/openpype/lib/abstract_submit_deadline.py +++ b/openpype/lib/abstract_submit_deadline.py @@ -485,7 +485,7 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin): def get_aux_files(self): """Return list of auxiliary files for Deadline job. - If needed this should be overriden, otherwise return empty list as + If needed this should be overridden, otherwise return empty list as that field even empty must be present on Deadline submission. Returns: diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 5f7285fe6c..fa81a18ff7 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -125,7 +125,7 @@ class Anatomy: @staticmethod def _prepare_anatomy_data(anatomy_data): - """Prepare anatomy data for futher processing. + """Prepare anatomy data for further processing. Method added to replace `{task}` with `{task[name]}` in templates. """ @@ -722,7 +722,7 @@ class Templates: First is collecting all global keys (keys in top hierarchy where value is not dictionary). All global keys are set for all group keys (keys in top hierarchy where value is dictionary). Value of a key is not - overriden in group if already contain value for the key. + overridden in group if already contain value for the key. In second part all keys with "at" symbol in value are replaced with value of the key afterward "at" symbol from the group. @@ -802,7 +802,7 @@ class Templates: Result: tuple: Contain origin template without missing optional keys and - withoud optional keys identificator ("<" and ">"), information + without optional keys identificator ("<" and ">"), information about missing optional keys and invalid types of optional keys. """ @@ -1628,7 +1628,7 @@ class Roots: This property returns roots for current project or default root values. Warning: Default roots value may cause issues when project use different - roots settings. That may happend when project use multiroot + roots settings. That may happen when project use multiroot templates but default roots miss their keys. """ if self.project_name != self.loaded_project: diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index d0438e12a6..0e1f44391e 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -47,7 +47,7 @@ DEFAULT_ENV_SUBGROUP = "standard" def parse_environments(env_data, env_group=None, platform_name=None): - """Parse environment values from settings byt group and platfrom. + """Parse environment values from settings byt group and platform. Data may contain up to 2 hierarchical levels of dictionaries. At the end of the last level must be string or list. List is joined using platform @@ -261,7 +261,7 @@ class Application: data (dict): Data for the version containing information about executables, variant label or if is enabled. Only required key is `executables`. - group (ApplicationGroup): App group object that created the applicaiton + group (ApplicationGroup): App group object that created the application and under which application belongs. """ @@ -775,7 +775,7 @@ class PostLaunchHook(LaunchHook): class ApplicationLaunchContext: """Context of launching application. - Main purpose of context is to prepare launch arguments and keword arguments + Main purpose of context is to prepare launch arguments and keyword arguments for new process. Most important part of keyword arguments preparations are environment variables. @@ -969,7 +969,7 @@ class ApplicationLaunchContext: hook = klass(self) if not hook.is_valid: self.log.debug( - "Hook is not valid for curent launch context." + "Hook is not valid for current launch context." ) continue @@ -1113,7 +1113,7 @@ class ApplicationLaunchContext: )) # TODO how to handle errors? - # - store to variable to let them accesible? + # - store to variable to let them accessible? try: postlaunch_hook.execute() @@ -1357,11 +1357,11 @@ def apply_project_environments_value( ): """Apply project specific environments on passed environments. - The enviornments are applied on passed `env` argument value so it is not + The environments are applied on passed `env` argument value so it is not required to apply changes back. Args: - project_name (str): Name of project for which environemnts should be + project_name (str): Name of project for which environments should be received. env (dict): Environment values on which project specific environments will be applied. @@ -1391,7 +1391,7 @@ def apply_project_environments_value( def prepare_context_environments(data, env_group=None): - """Modify launch environemnts with context data for launched host. + """Modify launch environments with context data for launched host. Args: data (EnvironmentPrepData): Dictionary where result and intermediate @@ -1463,7 +1463,7 @@ def prepare_context_environments(data, env_group=None): "AVALON_WORKDIR": workdir } log.debug( - "Context environemnts set:\n{}".format( + "Context environments set:\n{}".format( json.dumps(context_env, indent=4) ) ) @@ -1567,7 +1567,7 @@ def should_start_last_workfile( ): """Define if host should start last version workfile if possible. - Default output is `False`. Can be overriden with environment variable + Default output is `False`. Can be overridden with environment variable `AVALON_OPEN_LAST_WORKFILE`, valid values without case sensitivity are `"0", "1", "true", "false", "yes", "no"`. @@ -1617,7 +1617,7 @@ def should_workfile_tool_start( ): """Define if host should start workfile tool at host launch. - Default output is `False`. Can be overriden with environment variable + Default output is `False`. Can be overridden with environment variable `OPENPYPE_WORKFILE_TOOL_ON_START`, valid values without case sensitivity are `"0", "1", "true", "false", "yes", "no"`. diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 8180e416a9..1254580657 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -443,7 +443,7 @@ def get_workfile_template_key( Function is using profiles from project settings to return right template for passet task type and host name. - One of 'project_name' or 'project_settings' must be passed it is preffered + One of 'project_name' or 'project_settings' must be passed it is preferred to pass settings if are already available. Args: @@ -545,7 +545,7 @@ def get_workdir_with_workdir_data( """Fill workdir path from entered data and project's anatomy. It is possible to pass only project's name instead of project's anatomy but - one of them **must** be entered. It is preffered to enter anatomy if is + one of them **must** be entered. It is preferred to enter anatomy if is available as initialization of a new Anatomy object may be time consuming. Args: @@ -582,7 +582,7 @@ def get_workdir_with_workdir_data( ) anatomy_filled = anatomy.format(workdir_data) - # Output is TemplateResult object which contain usefull data + # Output is TemplateResult object which contain useful data return anatomy_filled[template_key]["folder"] @@ -604,7 +604,7 @@ def get_workdir( because workdir template may contain `{app}` key. In `Session` is stored under `AVALON_APP` key. anatomy (Anatomy): Optional argument. Anatomy object is created using - project name from `project_doc`. It is preffered to pass this + project name from `project_doc`. It is preferred to pass this argument as initialization of a new Anatomy object may be time consuming. template_key (str): Key of work templates in anatomy templates. Default @@ -619,7 +619,7 @@ def get_workdir( workdir_data = get_workdir_data( project_doc, asset_doc, task_name, host_name ) - # Output is TemplateResult object which contain usefull data + # Output is TemplateResult object which contain useful data return get_workdir_with_workdir_data( workdir_data, anatomy, template_key=template_key ) @@ -1036,7 +1036,7 @@ class BuildWorkfile: return valid_profiles def _prepare_profile_for_subsets(self, subsets, profiles): - """Select profile for each subset byt it's data. + """Select profile for each subset by it's data. Profiles are filtered for each subset individually. Profile is filtered by subset's family, optionally by name regex and @@ -1197,7 +1197,7 @@ class BuildWorkfile: Representations are tried to load by names defined in configuration. If subset has representation matching representation name each loader is tried to load it until any is successful. If none of them was - successful then next reprensentation name is tried. + successful then next representation name is tried. Subset process loop ends when any representation is loaded or all matching representations were already tried. @@ -1240,7 +1240,7 @@ class BuildWorkfile: print("representations", representations) - # Load ordered reprensentations. + # Load ordered representations. for subset_id, repres in representations_ordered: subset_name = subsets_by_id[subset_id]["name"] diff --git a/openpype/lib/editorial.py b/openpype/lib/editorial.py index 8e8e365bdb..bf868953ea 100644 --- a/openpype/lib/editorial.py +++ b/openpype/lib/editorial.py @@ -116,7 +116,7 @@ def range_from_frames(start, duration, fps): fps (float): frame range Returns: - otio._ot._ot.TimeRange: crated range + otio._ot._ot.TimeRange: created range """ return _ot.TimeRange( @@ -131,7 +131,7 @@ def frames_to_secons(frames, framerate): Args: frames (int): frame - framerate (flaot): frame rate + framerate (float): frame rate Returns: float: second value @@ -257,7 +257,7 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): ((source_range.duration.value - 1) * abs( time_scalar)) + offset_out)) - # calculate available hanles + # calculate available handles if (media_in_trimmed - media_in) < handle_start: handle_start = (media_in_trimmed - media_in) if (media_out - media_out_trimmed) < handle_end: diff --git a/openpype/lib/env_tools.py b/openpype/lib/env_tools.py index ede14e00b2..6521d20f1e 100644 --- a/openpype/lib/env_tools.py +++ b/openpype/lib/env_tools.py @@ -28,11 +28,11 @@ def env_value_to_bool(env_key=None, value=None, default=False): def get_paths_from_environ(env_key=None, env_value=None, return_first=False): - """Return existing paths from specific envirnment variable. + """Return existing paths from specific environment variable. Args: env_key (str): Environment key where should look for paths. - env_value (str): Value of environemnt variable. Argument `env_key` is + env_value (str): Value of environment variable. Argument `env_key` is skipped if this argument is entered. return_first (bool): Return first found value or return list of found paths. `None` or empty list returned if nothing found. diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index f97617d906..6f18a399b4 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -79,7 +79,7 @@ def run_subprocess(*args, **kwargs): Args: *args: Variable length arument list passed to Popen. - **kwargs : Arbitary keyword arguments passed to Popen. Is possible to + **kwargs : Arbitrary keyword arguments passed to Popen. Is possible to pass `logging.Logger` object under "logger" if want to use different than lib's logger. @@ -119,7 +119,7 @@ def run_subprocess(*args, **kwargs): if _stderr: _stderr = _stderr.decode("utf-8") - # Add additional line break if output already containt stdout + # Add additional line break if output already contains stdout if full_output: full_output += "\n" full_output += _stderr diff --git a/openpype/lib/git_progress.py b/openpype/lib/git_progress.py index e9cf9a12e1..331b7b6745 100644 --- a/openpype/lib/git_progress.py +++ b/openpype/lib/git_progress.py @@ -33,7 +33,7 @@ class _GitProgress(git.remote.RemoteProgress): self._t.close() def _detroy_tqdm(self): - """ Used to close tqdm when opration ended. + """ Used to close tqdm when operation ended. """ if self._t is not None: diff --git a/openpype/lib/import_utils.py b/openpype/lib/import_utils.py index 4e72618803..e88c07fca6 100644 --- a/openpype/lib/import_utils.py +++ b/openpype/lib/import_utils.py @@ -14,7 +14,7 @@ def discover_host_vendor_module(module_name): pype_root, "hosts", host, "vendor", main_module) log.debug( - "Importing moduel from host vendor path: `{}`".format(module_path)) + "Importing module from host vendor path: `{}`".format(module_path)) if not os.path.exists(module_path): log.warning( diff --git a/openpype/lib/mongo.py b/openpype/lib/mongo.py index 7e0bd4f796..c08e76c75c 100644 --- a/openpype/lib/mongo.py +++ b/openpype/lib/mongo.py @@ -24,7 +24,7 @@ def _decompose_url(url): validation pass. """ # Use first url from passed url - # - this is beacuse it is possible to pass multiple urls for multiple + # - this is because it is possible to pass multiple urls for multiple # replica sets which would crash on urlparse otherwise # - please don't use comma in username of password url = url.split(",")[0] diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py index 12e9e2db9c..c0b78c5724 100644 --- a/openpype/lib/path_tools.py +++ b/openpype/lib/path_tools.py @@ -116,10 +116,10 @@ def get_last_version_from_path(path_dir, filter): filtred_files = list() # form regex for filtering - patern = r".*".join(filter) + pattern = r".*".join(filter) for file in os.listdir(path_dir): - if not re.findall(patern, file): + if not re.findall(pattern, file): continue filtred_files.append(file) diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 7c66f9760d..183aad939a 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -164,7 +164,7 @@ def prepare_template_data(fill_pairs): """ Prepares formatted data for filling template. - It produces mutliple variants of keys (key, Key, KEY) to control + It produces multiple variants of keys (key, Key, KEY) to control format of filled template. Args: @@ -288,7 +288,7 @@ def set_plugin_attributes_from_settings( if project_name is None: project_name = os.environ.get("AVALON_PROJECT") - # map plugin superclass to preset json. Currenly suppoted is load and + # map plugin superclass to preset json. Currently supported is load and # create (avalon.api.Loader and avalon.api.Creator) plugin_type = None if superclass.__name__.split(".")[-1] in ("Loader", "SubsetLoader"): diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index 33715e369d..378f186f23 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -62,7 +62,7 @@ def is_running_staging(): """Currently used OpenPype is staging version. Returns: - bool: True if openpype version containt 'staging'. + bool: True if openpype version contains 'staging'. """ if "staging" in get_openpype_version(): return True diff --git a/openpype/lib/terminal.py b/openpype/lib/terminal.py index ddc917ac4e..bc0744931a 100644 --- a/openpype/lib/terminal.py +++ b/openpype/lib/terminal.py @@ -130,7 +130,7 @@ class Terminal: def _multiple_replace(text, adict): """Replace multiple tokens defined in dict. - Find and replace all occurances of strings defined in dict is + Find and replace all occurrences of strings defined in dict is supplied string. Args: From b432613e726770d4f21360f8db65ff3936af8429 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sun, 16 Jan 2022 16:13:10 +0100 Subject: [PATCH 306/395] moved implementation from avalon to openpype --- openpype/hosts/aftereffects/api/README.md | 66 + openpype/hosts/aftereffects/api/__init__.py | 167 +-- openpype/hosts/aftereffects/api/extension.zxp | Bin 0 -> 100915 bytes .../hosts/aftereffects/api/extension/.debug | 32 + .../api/extension/CSXS/manifest.xml | 79 ++ .../api/extension/css/boilerplate.css | 327 +++++ .../aftereffects/api/extension/css/styles.css | 51 + .../css/topcoat-desktop-dark.min.css | 1 + .../api/extension/icons/iconDarkNormal.png | Bin 0 -> 18659 bytes .../api/extension/icons/iconDarkRollover.png | Bin 0 -> 18663 bytes .../api/extension/icons/iconDisabled.png | Bin 0 -> 18663 bytes .../api/extension/icons/iconNormal.png | Bin 0 -> 18225 bytes .../api/extension/icons/iconRollover.png | Bin 0 -> 18664 bytes .../aftereffects/api/extension/index.html | 136 ++ .../api/extension/js/libs/CSInterface.js | 1193 +++++++++++++++++ .../api/extension/js/libs/jquery-2.0.2.min.js | 6 + .../api/extension/js/libs/json.js | 530 ++++++++ .../api/extension/js/libs/loglevel.min.js | 2 + .../api/extension/js/libs/wsrpc.js | 393 ++++++ .../api/extension/js/libs/wsrpc.min.js | 1 + .../aftereffects/api/extension/js/main.js | 347 +++++ .../api/extension/js/themeManager.js | 128 ++ .../api/extension/jsx/hostscript.jsx | 723 ++++++++++ .../hosts/aftereffects/api/launch_logic.py | 319 +++++ openpype/hosts/aftereffects/api/lib.py | 71 + openpype/hosts/aftereffects/api/panel.PNG | Bin 0 -> 8756 bytes .../hosts/aftereffects/api/panel_failure.PNG | Bin 0 -> 13568 bytes openpype/hosts/aftereffects/api/pipeline.py | 272 ++++ openpype/hosts/aftereffects/api/plugin.py | 46 + openpype/hosts/aftereffects/api/workio.py | 49 + openpype/hosts/aftereffects/api/ws_stub.py | 605 +++++++++ .../plugins/create/create_local_render.py | 4 - .../plugins/create/create_render.py | 42 +- .../plugins/load/load_background.py | 22 +- .../aftereffects/plugins/load/load_file.py | 19 +- .../plugins/publish/add_publish_highlight.py | 4 +- .../aftereffects/plugins/publish/closeAE.py | 4 +- .../plugins/publish/collect_audio.py | 5 +- .../plugins/publish/collect_current_file.py | 4 +- .../publish/collect_extension_version.py | 10 +- .../plugins/publish/collect_render.py | 6 +- .../plugins/publish/extract_local_render.py | 9 +- .../plugins/publish/extract_save_scene.py | 4 +- .../plugins/publish/increment_workfile.py | 4 +- .../publish/remove_publish_highlight.py | 4 +- .../publish/validate_instance_asset.py | 4 +- .../publish/validate_scene_settings.py | 10 +- openpype/scripts/non_python_host_launch.py | 2 +- 48 files changed, 5523 insertions(+), 178 deletions(-) create mode 100644 openpype/hosts/aftereffects/api/README.md create mode 100644 openpype/hosts/aftereffects/api/extension.zxp create mode 100644 openpype/hosts/aftereffects/api/extension/.debug create mode 100644 openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml create mode 100644 openpype/hosts/aftereffects/api/extension/css/boilerplate.css create mode 100644 openpype/hosts/aftereffects/api/extension/css/styles.css create mode 100644 openpype/hosts/aftereffects/api/extension/css/topcoat-desktop-dark.min.css create mode 100644 openpype/hosts/aftereffects/api/extension/icons/iconDarkNormal.png create mode 100644 openpype/hosts/aftereffects/api/extension/icons/iconDarkRollover.png create mode 100644 openpype/hosts/aftereffects/api/extension/icons/iconDisabled.png create mode 100644 openpype/hosts/aftereffects/api/extension/icons/iconNormal.png create mode 100644 openpype/hosts/aftereffects/api/extension/icons/iconRollover.png create mode 100644 openpype/hosts/aftereffects/api/extension/index.html create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/CSInterface.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/jquery-2.0.2.min.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/json.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/loglevel.min.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/wsrpc.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/wsrpc.min.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/main.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/themeManager.js create mode 100644 openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx create mode 100644 openpype/hosts/aftereffects/api/launch_logic.py create mode 100644 openpype/hosts/aftereffects/api/lib.py create mode 100644 openpype/hosts/aftereffects/api/panel.PNG create mode 100644 openpype/hosts/aftereffects/api/panel_failure.PNG create mode 100644 openpype/hosts/aftereffects/api/pipeline.py create mode 100644 openpype/hosts/aftereffects/api/plugin.py create mode 100644 openpype/hosts/aftereffects/api/workio.py create mode 100644 openpype/hosts/aftereffects/api/ws_stub.py diff --git a/openpype/hosts/aftereffects/api/README.md b/openpype/hosts/aftereffects/api/README.md new file mode 100644 index 0000000000..667324f7a4 --- /dev/null +++ b/openpype/hosts/aftereffects/api/README.md @@ -0,0 +1,66 @@ +# Photoshop Integration + +Requirements: This extension requires use of Javascript engine, which is +available since CC 16.0. +Please check your File>Project Settings>Expressions>Expressions Engine + +## Setup + +The After Effects integration requires two components to work; `extension` and `server`. + +### Extension + +To install the extension download [Extension Manager Command Line tool (ExManCmd)](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#option-2---exmancmd). + +``` +ExManCmd /install {path to avalon-core}\avalon\photoshop\extension.zxp +``` +OR +download [Anastasiy’s Extension Manager](https://install.anastasiy.com/) + +### Server + +The easiest way to get the server and After Effects launch is with: + +``` +python -c ^"import avalon.photoshop;avalon.aftereffects.launch(""c:\Program Files\Adobe\Adobe After Effects 2020\Support Files\AfterFX.exe"")^" +``` + +`avalon.aftereffects.launch` launches the application and server, and also closes the server when After Effects exists. + +## Usage + +The After Effects extension can be found under `Window > Extensions > OpenPype`. Once launched you should be presented with a panel like this: + +![Avalon Panel](panel.PNG "Avalon Panel") + + +## Developing + +### Extension +When developing the extension you can load it [unsigned](https://github.com/Adobe-CEP/CEP-Resources/blob/master/CEP_9.x/Documentation/CEP%209.0%20HTML%20Extension%20Cookbook.md#debugging-unsigned-extensions). + +When signing the extension you can use this [guide](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#package-distribute-install-guide). + +``` +ZXPSignCmd -selfSignedCert NA NA Avalon Avalon-After-Effects avalon extension.p12 +ZXPSignCmd -sign {path to avalon-core}\avalon\aftereffects\extension {path to avalon-core}\avalon\aftereffects\extension.zxp extension.p12 avalon +``` + +### Plugin Examples + +These plugins were made with the [polly config](https://github.com/mindbender-studio/config). To fully integrate and load, you will have to use this config and add `image` to the [integration plugin](https://github.com/mindbender-studio/config/blob/master/polly/plugins/publish/integrate_asset.py). + +Expected deployed extension location on default Windows: +`c:\Program Files (x86)\Common Files\Adobe\CEP\extensions\com.openpype.AE.panel` + +For easier debugging of Javascript: +https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/td-p/10911704?page=1 +Add (optional) --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome +then localhost:8092 + +Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 +## Resources + - https://javascript-tools-guide.readthedocs.io/introduction/index.html + - https://github.com/Adobe-CEP/Getting-Started-guides + - https://github.com/Adobe-CEP/CEP-Resources diff --git a/openpype/hosts/aftereffects/api/__init__.py b/openpype/hosts/aftereffects/api/__init__.py index b1edb91a5c..a7bbd8e604 100644 --- a/openpype/hosts/aftereffects/api/__init__.py +++ b/openpype/hosts/aftereffects/api/__init__.py @@ -1,115 +1,66 @@ -import os -import sys -import logging +"""Public API -from avalon import io -from avalon import api as avalon -from Qt import QtWidgets -from openpype import lib, api -import pyblish.api as pyblish -import openpype.hosts.aftereffects +Anything that isn't defined here is INTERNAL and unreliable for external use. + +""" + +from .launch_logic import ( + get_stub, + stub, +) + +from .pipeline import ( + ls, + Creator, + install, + list_instances, + remove_instance, + containerise +) + +from .workio import ( + file_extensions, + has_unsaved_changes, + save_file, + open_file, + current_file, + work_root, +) + +from .lib import ( + maintained_selection, + get_extension_manifest_path +) + +from .plugin import ( + AfterEffectsLoader +) -log = logging.getLogger("openpype.hosts.aftereffects") +__all__ = [ + # launch_logic + "get_stub", + "stub", + # pipeline + "ls", + "Creator", + "install", + "list_instances", + "remove_instance", + "containerise", -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.aftereffects.__file__)) -PLUGINS_DIR = os.path.join(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") + "file_extensions", + "has_unsaved_changes", + "save_file", + "open_file", + "current_file", + "work_root", + # lib + "maintained_selection", + "get_extension_manifest_path", -def check_inventory(): - if not lib.any_outdated(): - return - - host = pyblish.registered_host() - outdated_containers = [] - for container in host.ls(): - representation = container['representation'] - representation_doc = io.find_one( - { - "_id": io.ObjectId(representation), - "type": "representation" - }, - projection={"parent": True} - ) - if representation_doc and not lib.is_latest(representation_doc): - outdated_containers.append(container) - - # Warn about outdated containers. - print("Starting new QApplication..") - app = QtWidgets.QApplication(sys.argv) - - message_box = QtWidgets.QMessageBox() - message_box.setIcon(QtWidgets.QMessageBox.Warning) - msg = "There are outdated containers in the scene." - message_box.setText(msg) - message_box.exec_() - - # Garbage collect QApplication. - del app - - -def application_launch(): - check_inventory() - - -def install(): - print("Installing Pype config...") - - pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) - - avalon.on("application.launched", application_launch) - - -def uninstall(): - pyblish.deregister_plugin_path(PUBLISH_PATH) - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) - - -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value - - -def get_asset_settings(): - """Get settings on current asset from database. - - Returns: - dict: Scene data. - - """ - asset_data = lib.get_asset()["data"] - fps = asset_data.get("fps") - frame_start = asset_data.get("frameStart") - frame_end = asset_data.get("frameEnd") - handle_start = asset_data.get("handleStart") - handle_end = asset_data.get("handleEnd") - resolution_width = asset_data.get("resolutionWidth") - resolution_height = asset_data.get("resolutionHeight") - duration = (frame_end - frame_start + 1) + handle_start + handle_end - entity_type = asset_data.get("entityType") - - scene_data = { - "fps": fps, - "frameStart": frame_start, - "frameEnd": frame_end, - "handleStart": handle_start, - "handleEnd": handle_end, - "resolutionWidth": resolution_width, - "resolutionHeight": resolution_height, - "duration": duration - } - - return scene_data + # plugin + "AfterEffectsLoader", +] diff --git a/openpype/hosts/aftereffects/api/extension.zxp b/openpype/hosts/aftereffects/api/extension.zxp new file mode 100644 index 0000000000000000000000000000000000000000..35b0c0fc42a731150521187312ef21243882f300 GIT binary patch literal 100915 zcmc$^Q;?=X8!Y&>ZDZQDZQHgnZEM=LZQHhO+taq)z4IUJp0np_Blhgx)LRu%7x_kJ zRK$~+3eq5;r~m)}5^!JBNuaJ5j8;3zdQdf4aq(1 z7@HVaA{rVPFc|m&@CnRJz?d~f1^;;EVhg^tKj>_%c6p6Q>Boc<2KZyq&<@<@D(3jd zBNeDY%18~mV(TV-G0Ro62H)K77 z&Nq22yavG7N(32*(gKNkP%%=#+?m_W%dPZrEk>*ZIj&|uZ^yINBRRPfzDrQmyB{Gpc~+M8iIuWKu^s7obz;T7qe&gl5_~(09K$==?ny@mDEoLznnDma5%l zMaTqf*u##+Znb%TmU57AZI?p~HOp}LNOXLtz_J3aDQsEZj;J-#dSqY4MJ9`pjhv zKDOsRtRb-H>>BdGccZ@*V{k%ln<&bL;57y^L}*{GYCnJRJ)iFKtrhkLS1?tdsz1Of z{9_dQKWqWUjKdZG%jUl=pa2_yv6Bt>wH-i;O5-jdYeC-xi7?66?PS*>PnyUhg z5N&%i4(=$k5aWc#gsk@wpXeRbQ&qnLAgw%hkBoDmduZO1xQEt8rK}=q!MW==9v&Y4 z-M9Tqy`M)E*KA&&x1&Yj=iOeP*=y=_7~P{SXx#jHezUCxm7{hpRvBzF=KdxI7u$3x zO~p|xA2gjo2-OA+YU2QjLF@eV`jd7V4CVsoDp4ZZNzvcwH2duaT{0RZ7y?PDxL@ReiOFVGM)F=+r3-elTtcK zldZr#KMVLItsiIPubp0B>T{a%86Mtb3w}hif<)z@hJBoH(ceF852bq>6C4DwL62)1dOFKSGrq84U^arBh*Pk9RhMf5r-QhRL0t;akd4 z%1daDj}k!cNH2@unX^T~eEOe~Pj3HKD~XOha~m0!Up`lAB&$_!b?uxfQ=`@Rr`Iy2 zlN9{vcC|OA>Y9ySi)wMrX1$qRt6#tdg@<=En-4zHzEvqnAmN9|uO~;ayV@};O{dT- zN^qLfu>=WBL9jInGHvdED=;{<`b&OkFoH+<3#J9t0+q)c{FY#rxF()tjMn`=&TJ>G z-du}D6iVUt31$=o>13R}xju|!Zvt6@lGZTAX$e!o07%W5$|i&k274a~7?uIV;*e#_ z^QOS1X$qZ_qK+3ht$%0d#Hqw@7S)dyP|PKFLc@VJAKnDiAd(0&s?ZjZq90nZZE z3LE5YK8<;|s4?A?un<5jT05Igdn1FzEDJ(amm}~9FK^jn2yc3!>y!CkCmWazgZ%xZn77pT~MG$0sC~>qcZn4g_MBgiqmL_9d%BXXuM@5ynYH zHy$k&j<7h%lR>(X#(68dcqM8)l7M;c;&Eiz@(0nnCKld|mrFFWgcsl_icq3JOgNmZ zD9X!f1r4{k$)VQBy%PcHI-%qV60AM6bkx!uM(5BmM?GE*HrV}(sDl9aDFTL8Y#u^OKsRLq z*0uMCJ!?)OoEM%DA`hyvpV$K%DRf^GIs>v`#0k@)n*`2yf)}V)qj0UzTKwlWCl@R( zZp*~BMiir~Fk{I~smu|zJF~s}yA2YBl{FMLAh*x&Kvplfot>{Nr zYHDSBBUl{;~fc}nz43V)1}SF51D0;YfeASL(UI8v#r zBTPm|uPI!>q(+YTn;gBVkyFiht zL0BAbVWLUL#K&p)W#sjp`svIoMM*WYrBSIwz`9vPbZc!S|FVc^m?8*LRql2wujJj_7Ls^+w_P)Bw>sokmXI z2#d6)NvuU3Nhr^|uSf^pvPpG~*J6`NM`PxbIwc-+v`UZ7%H~QmiJaLMce;PyjP99f zLoSu(ILRbviK=x)YVK`1OYPuxkRz*ykZXjpUc(mzTNZcgoDIVkjjg8|V5MiobS@EC z!5ubA#D*;%yCOjBGV#ygDO8upTuKkJo2LLQ zQSUmgHVvW9|AcG_J}eI7+uZ;%R$U<9oEnL8v@+@mZW76w+gFKH!?&}*GP_0Q#jk={ zSWlZ3oP6x{BnB{6gLMhK&KQCs93yw#5o8K=?AR8c2;e>MoA(aQ)=my-(8)f`h5da{ zGU!6PExl(x2!|q)2Um=gGFNSr*jMkUhvg4`!`Hn1kI`rs4b(9$uqRRfRDtA>A@y7@ z`yW&Byusbde8BR4$vM!hEg|xFKCBno{1q?aay$t|}#uKr)1xBJ)&eW(eZo3QQi5wMi3?T6W=6FF*qk}5SNK?!)%+uF) zCK7~Q3^PnHi|o_shTnd2rS0Z3lf&{g!3$C1&(0)<8rL|6d4ojdPYGr+#6PHzW%(EQ zp2lsRLZ@@vE7a<)ZBx~^zm*VfpgfP~PP807u5w71j6d<>J-J&$@Rx`t*^(V}OiWDO zGE)ueXqYA8BT$ls1_vfMJZNdujPIkDyGehNH$1E*If}c1uC9NU7>F5!U^1^5yA_O@y}4i1#&eZpjy zBwc9Kle;FD9yT(qJg=@mik9IGP0MD@U1MwGg0?O4w_i{6@>1-lE_;l)*irK^ddWl0 zSIZ{csFyo9M`9a0uG%_rY2V*mWpF>`46b=yRYiCq^HM(P@l;1aR#I5{{!xeFaE@5i zRY`fwWiG=hEu(Kbh6?L$p@{5;yD_zxsYFkV788#oyZBQqaEVA&u8Wb7=>$vK_(eS`bFo;TM{&ds3Y=p8oOixKMS=w}zKWMeJlQQDQQ z4g>I+mogoq+toX&FqR|sq+*cQD>7d@z8&0e;CrjO_CblOh4WU4E}aCj6V!MVReQ4F zYbl4U%!F-L?{ZjwF2in=-gJfb56Re+3SnycyNy6jyJ7etE5oK?klJvFG!M9i^BeD; z|Hwk~JMZ*$Go#Mo2u8mFC3^_(<|1=TCd^+RkrN(z^&Nd2R0M{9P> zp-bvWn&lKX_}2*@b*gWM+&h?4$=}4|!}*^77Azh}CfhED$a zcSSKTtFdCHN{4|yvRZCR&|w69f^CIfZ;_asUUw6l{mvE=WipnhAy&&HsJNBg06yf| zQ9+&15L!0FAOL;NIu!NHap#IV6y`kA`(w1J(f2m(W_B%_ruR4yTexaOr>I|f?NfGP z`QrOO&z3Mm+5Fc3%nP7@<^}wJn=PH3J*-Wg{y!cogA@4e0vQm8-AH-{B~??)SbW7A zSZzvduNKOgUj3A#qA!VMBks4YKq3e#v@~uzbG>6vwCVU{torXX^R;OXDPjni?_sll zZslpMCAos1DmZNpPm%pNIwqsdmUv2$rA>#+E!M5gI|hu%@aLJ1gh+)H%NH|>R5(_8 zBpj1#)GI$EJENY+3lD84`f#Cd9FfFtk{ux1k!SKB%9P>=^GL*?0{UAr%Ek2ep<{%a za~$E0RS+jQCT9B30))*Lib0egFSg-D7AbBr#!oq*6?xy~x?UtNqnKJcWs}2s9(_Tp7-Q3;hCrB|DPo=zRhFB^3Pt73IG5I{+k4x z?d*;144r9BOr5O$?P*O69j)kWEN%Zk7RA*stJp%0`&toh?CGW!!?DO~(vD^C4f)c| z#p%(-3ei3i3yHCS#n|Zg-dr14%uZ`aS@XqhMV9KJSjTP~b+oANE#oZP>f+pA101`g z--)4LrUB@ewW2AGjGmR$bdycZR9Qj4_MVC-e_2>&8tI zH;U+_XMwFTgNQa(xEwO~q@-gbuj7rs%)4GEBU%YPA4g!AkVgG;HGGh3Tkz!EV1&$& z?c$>Z?!M+kn09Iu(T&kmW80K?mhC$Ig#I>_f#B3Fz zPB+NXK+9ukUt3MT+GCN_GEs=Yi!4{pnB%Y?Y`^nu>mV{4;L%CyR7ZYV1;t1SLmZZE;#{5pcZCoQm|=$rOU zr$3QaEIqV|xgEn&jLWqxc`V!*EIo7P%A6^>`rYD{M0!$Pu6d5Czo=OjQ@#i*pt7=p z6nz9w6}lD9?Q{aItB8H+^hsAScGPZ6p0B36Ha77hrl(wREuEa~?9K=brD;km5&x(z z+pIqWec;NQ&&z^JSUP$|4jl4mwC9QsJ%saQqU1DNK&QhgEgFR^LmDJzf_-C78LUQ{#mvh}sPqP=#E^5*(iSmhl`L<-TE155ei}Mq(~;zb{vZi~Q(g7`eiQtgcC-e) z;((kI7GJLxKtzLUim{xXA+tNyEl!vIIv0VZs`i!O$lg>Ug&Y-p*5e2pwD6D$Y{Jv9ufa!hVPka{~00gc}sXmKX0&mY&*6KH>e6CUPniLhK zQ+O(>Gon9MzDm>(A7Zwx{Co`#WllE*Xw9{o~%eaeac;K6+^M=tI zWAl#kOV>UFfSf8r{YVZJxNCJmay;VJp>LwTUF$$}Bm;}dBJ5*$oIW?C@2G7SxhpoWYp^>*XZBtqE%`#c(aV;+&+ zrJ(ysNb)8CTQv$-FdvH(8L~PBS8=VO71FU?7tJuAn0|FTO2IPP1Koz4E<}~mrIe`} zMGc_Vc|LTWDrT7X4*Rsk?r{jjc#To4B;@VTk1XLaT1udPo?KUDx#u3+BmA%LkVH=& zf&hu`azl$?M_-THjUH|Wf3nAt+YwZ!VJ_K5&trH+e6k$2BSx|ibS41X5F1Er@2O*s z6_%ceLbM(TbW_|f-_b8_1xYt$3G7TB%NzrXBD45$Zpv)!-WdCy!v{bxutZIu zM6E3?{TOpXn0R?O{UlNV7e3mfKxJg5ef#}RHGh9_GWAzkq95YJmB&fPAIT*U)L6J= zK0wlOG5_8Ne4aY}qHS@#s(+^<7${8v&}Ho~m$;i3s^qj3lfV0cn>h3~*OYs(q-}{r ztxKCqp~PyInL=_hG;ghL75b7ltU0C)8s~)NRWQm zL4-p9scUOt_9%r;s(2@#zJW_zvwvaVx_;ED32XgwHYlVM=jCAh8i}K?z<@7@FkebA ziew@EjIuGrTF?iR7X!mPfme#;GumWUG^sL3qp+U0nCt`v_vC>YG!U=uWafkjN~pI? z6XKb6pE786^)WWf3lN&tDaZsda~4khNGHmHQCZC%S0d#*mBnwc!VtIaLFb}JS3*rJe-KfFb^^TQemeD4Y%3Gh0vQZ*B(HoIz*0hG5Vx^R1TI&k)Nimt zkQb3}cra~m%uJzf04l$<%AOQ7#=})JldnypPOiV~kRESUT0cJBFqJ2?dqazU!~2e<2c1Plcc( zPpzNPNdq|1_41rzaw3=qb@#Iy_g@qhV3n)8psG&pt{uA+i0jJsSX);u+#r@RFvpuZ zrc7Gfu$!C@jTC7+h(VNpQb(-za`ZWrpwe6K2#x9ENnkfE6l@vbBi1t zG*hWu)1P{BSF`?k^1c!5D;C$FToiGXs%rLZuH|>xeNg4Ow@`wdfLp&9xr(yV8K6k( z^D8@1f@TCeJ5@bc42u|*;T`sA<&4+fErZ;t1%fp9iVOVKL=Yr9vqTd>n#OW*|5)+*Not=^X zFjU&U<2EjdfgW|~CPgs0c?|r~r{o`JaK-Cy zwU-CT6M|P$tmF+g(bQR#6hGEuOc03p04w!MDgsR~%)Vg`b$p4=KH)Rl+vWPt>NSyz z%T(=Mr#-$}W#eO1ZiK9EV@F8|jU%-MMqjzNbjL86{aD&@QAfKr8dQ83f!x{sXKl46 zn6qnItk`fx_@rs%0=_1ms-%{sn6FRAw- ziajf0sa50dXx+C3$wZdFCrc|!5V>@zgUJh3Yv6B##I%WL%UW<^IGwDzUgK0fSvz2_qv5q}ZLYWPA&Dm(5cREe@B zpzu^-fKhNdkqde{WHq#BBpKQ0%5-aF*35`|kY?*&b97Fb1boE3*RejQM!P}ivu!h% z@AIB1&nKXF-Tts!_4wxu^7UVwP*dq0Za={7;hzmZM1(5La83yC+x_jXvMy9}MWj8N z04Y-Pz3@#Yc4|26ht4ZLdQ-r1pq!}c*Y<4v@RK*3EDtW#D*+B^j?Hg}%SAonhnS!9W^92EQqS52;;?MOO*@pwX+joO5_`jLJJNSL~#VW#{ z#{-dP5S9Q9KdcN$^Kh_!=@LQAIRosa%ZqOkov6A)Y6#tkm>Z?Pql&j7CO|5KR+AQ|!&>}hK~B4@llT;zmh+J*g4ZO(;{=&iV2jtnv{bZ} zbFfiybFb!gxTlF5lrdwq7WSD{958med&|$(2NoRggGt}#Lu2=IV%Sg4V(8&5m+;sD zh|`V}vpKH=`hCdR64`eW8($In1x08_%~c28S@^T05?|mDUT2}EE#VU+~x%+FF{KIyuw1+gSf^PO|@l?7#OW zrqyJfHW(0j$(i2}DrWlw{>UUo5ekD?rZGZ+NM@wVAygn~RMWO6;7a&KToji_xQ$~7%-ZIp=lem5RB7+ccWbBkC(Sw?;tt?W67i*2I?PY zh7yM^5sDg5qIfD=WFgpxq(?IGo7pv)EIbaI72D;pP{AT!D1K?PaY13{V19aW2>GMi z<$w)A`^5Gk6zL1vH`##c9a0c(n;=4|m~>v~I?jGi{+b$Up8*f=mp29BjpFd}pH%Da zaT`+Y#WC)dhVGPdTYZvftYqiZmY5MYS3Tl}C&Kvc^pXbt%z1&zj3Bid+n=@-5O=~A>jR@pgvgAtlo(%53FX+^C ziKmCr3&v!`UqK*zDEK(uqVa4%6O7930i37`s=&enuVJTkheji%&SxQ9F+5!XFs+gV znguYU`oF`4Pa7r#FjDYZa3c`Ak|#rJ6>gD5ngGvTwiUy~%9rE@S|v+^Y)Z&BBOVBW zXoI{GH&Icx20kB2W4hx2M9hHhy2^e z#%r{!py}k={VEy^orS`5BHT6MpYoV`xohZ~MQyvGqT8Xf887I8?5FMq|h1pb5+CrOjAV(lIAB8cio9HZRF0qteR- zNfT&!Nzjny#Uw;2^Q{%3Br{1nNf0829#DM>k0gsvUUXI;zP|3hbe-rlt7@yxFM=|IBFmV5N%n-2 z>8#Zg5Hi7-SkEkI{vUg24277=U}V>Ae*c&-PYhjizRZG#~@uXRFn6nBme?S$0o47Zul{) zE%!O(zJ35!gf1(zJ3P2S3mz)Y-n~m+m!MP?01+L+U}hf43gExp_uN@g;os+v;up1? z7COJ#{H6r5u_cjB!9b-ZtQq0~6$lc!NI*dh$d|I)!M<#R3JijGk-<5!zu$#%|cnGPX%DqgKAJ(w@JF zhfWXwimX=SH8TvQFj)DQ*Gxd%T+UWo^AI`u&3!ZNydS#Mug6MFQ!Tdy8D+J|)IK!$ zaYQBwABH(mF&>MrY$^J(NS~iAz4gVOErHP8^xa;`0nF_P?|WMplLqQ1y~uzJs)F?H zG^Eq`m$Y{Hot@twG07uV~~qI)+MhsLVBe-_NK!J z&(QU;n@>2Lh4Mdwd0$1HE5c?nv!1JkM-rq4O{EnO!0(J<<7l?mQxz{ z*rb#Q8Ujcjx;38O*fgH-b(J`+7% z!#@)IPJ4zmF9|E|ur26n-%@%Kts5BN<>gGhMrFha5>=fA3FJB2jR*H5Jl_dvP)3Jn zF?|gTla5H5L>`_o>%-5yyQ%UXMz@{m)c>p9%h{y1E(8Y79oi?wwE6P$2OM9mV+ zMMI%u*}5}(Vk2SBB4a>~>a(icTH_3sddpMFir-Kebg)0y1RrvHJon&Z1#8Vx>*ac} zZURrW3bIv}6Cq0QvTp5#;wh6)k`d8TCN6_Z!W9KmUUD)Eo@1S`nX~LH_V9zmhY^QI zqx{TW(P5iTrp}4ZMLyHl=YCbwgJ}wB4*mei^zT(G+m)u05q26m9D5rF?|9OT=B<8A z-gt!ZJI>NHO{4zyt6bKEEFUW?iw<7iO5!r>z1d{1{6*VEA73+BjmSe#nBis6%^{q> z(buP7bJ0n8%{F$~_1YCO^(VKZx02fs$Pt-QGd%V0)4#_FwWg2tn)Q0Ido!4^1F$Es zUEyngi7DyeM$Fc7_RjcZd)s^kzArw>%bylw59ShrD?upb7Q5>0_E6d<$|<_e!!_6l z?v^~{5Q`RnBV{V&oY-GywxDY1%5;`}H~lq|Tv@0x8WkDcdMsV4n6!($M$>Jpv2Xur zH_-Np*PqwpNhT2Q_95^6O;S(ea5LTAD~kgoDbg%)Ol6}4ZK!KHk;h)Zt%NNXlXltt zaQ#Y<6k(P0`%lfLHi>R)Nk+a$|rc=&obO&-Vl!pj72MxIsen!t>?bH&-d*M7dh3(M;oNIXG6HIq?AQr>6f^YW=F>ryTfC!92eMPP`;UMW!$x7OVY3FayAS&7o7%RrOYfJL zL5yT_hy)yDY>NaW93eekZ;Bw_@Yrf-q}8afh}$rhaAT1}HJ1LwM-JI54YktYT=Y>| zE~RpN@+Hz+PhJ}tc#}}Y%6{3uvWeJ`!w>x^I@mKStG1-j1=+PXH(r$whqvx3@>;PP zH@592r`xnqc~c=1Zb+Jw^a3eod9j%r;@sGH-o|9b+1;OU?`bUJymBmMO63U^z%~nZ z5k4{7RoxQ~Q(~KhuVXh*#h!w{-JEC79x?Fey6oGQ^aj2~skpp7Cg|eu2QYQP5%?4A zIr@0S*9q&x-ZS148Y<~xaTOVyM){w)oR=B%z4(GdAJDx5L?fqWdmT56o@BJk{G^`q zwrXPAW4UrNds=*61^2&N2Eob2<)`84D|J@9i%xZlvLQ5}UK(iZAS(JO2+Cxe)Lrj3 z#-al}#Mt4{EkAf2CVo9jOdwD$Lb~2UZE89A`VO=5l*Vx6m?N93f$mvH$h0y0nlYHo z*8(0pj}l#e>sz;3FI$GBwQB_}r)ENee%E@bJ4xH+hV8`PJfjZ5!ALh#LA%shKJSDW zCLcvMJek}sX3qKcPfWtK=pwOb=l1kPW|ud|$$Ua( z`H&n|7E*RovS?D$o##y_+jp0@xPju9jEM}@)@!iyf!3bQSHre|#Ti9x-o^HzaytZz z+?A>qe04G%d^2we6$zOvv^{yeABiQfR-nF)P!@JvoD+jsvV|`KcjRn=5YTrl)x7r!~O9Lb^#Do(O_WP=7oD z2r6g|ggEwqivT314hHmM@PJFdJt%gF(BPARJ{wvT#f!xdiQ*g`x|+WI7PT>&9b2Y< zh!7_(d{7}wK6AMO2Xt`20HOjY@RSXZ8Q5Fnoxg!^%DH)BEg!lGdc34@ou#wy+!kw& z!0tGknj#X|^kxrFD*hpT9RG#%Wt(CU_yLvz2PpVg@L2!;jj4|h43(z!r0!*}dc#%# zrb!u0y6EmWNdm1~{MUK~jAne8?;C?FlQ;ixs7O$P_qdM^ROAJi{w+GDey;#}PjqjE z@7tp_c5p+TYt3;glOw?u#y=BsmT8JT8;O0)f+jJ_d@0l5;Pd z<){6ERcsD_liozMW$!wz-2`;d&;WERIx}1Xgk{TsHYzi@&Vwvh`+IM{PxcRTPPG15 z@JAu{?onq8|NERIi_{#yL>Z(%@Mz*_FOpkNyBL*Yq%AhI3bbRcZElbFo4|*h+wa=i2{Q9F^)1`M&BPb^&*syoX2XRq(yUE{LVjt}w zKbnv}2|pSAcVr(wx&yOTPgW@C*#pOW=_brn>91bI?ntZx)6ammEG*zb67-%Yw+ONU%e71>OT}upI+rByAr*s?H`M4m_r!j z2*VF5PSnM-^G-)}=i^SX&&Kny)$DV?<2@WO*e|5x8J!SC-x$-eXfV*czNC+zlCPZE z$F$x2wHNvN=+9er(5L$NXfUKtzCMS7?ud9n5c~gced2x?L>@PL&l0{QN2qYoP-GIS zS}^{VR0^UaK>(nYAOaQ~fKSFrUIgGiIZQy;4m{x01Q1te^QRwso&Dmyt2vo+F|(=z7?&ai z-fr84N>iJxbj^XaXtFF{e5+pFphTLv$ZES9?@ZepK8&b9&YUMH<;=<043Zm9BrZj&d9N%TT zKk@1wmdNg^W0Uhq=zy&4NGLXB+nMOp27zsN9dGDneehH7_Q+EFj7M6{I!4d( zv$agxSL_n2)vMIX$xYj3SfA{OW~>M;*F3E6Fh_`K=B|7x&c5ts^*6UnsQCIEc+KfK z$gA_Gy2wGv0{-XD6f_>|MejdP>ExfMg#EvGN=kOt)^@I@j{m>8N<0^i)J$IbHV)3hA*+nM5)=E_L`HA zkHe47W1Usb(e|5|DF9Mx2*3l}_a( zZ@9ZQM2^jz|MzDgi={iPSRlFU9@m^(%@<;Eh`I#`g@W1EfyClJf(RN znV;lcrcC^7D!#5UKJ;4F^@{W2yU4w(*7o>a`H%0j#c!WLh4Q*rC>R@V=df4O!)0mx zpk);`wCVU2@sKIKmROoH+*U!x1Ab+LIB&FAiz(%Fo=tP}Y zA^6*5N28~zAYl{0U?1@a20)m(CvHhZ6@ow)u;!3^umNWs1mIqJeqse7EJu;_diA3n zO*VO3Fb0}q(kg=5Yk?oLU2`5oCFlpj>QE&GcLxXBOObPi*_(jNYw`)#z$2i6@4F%z zWCi&1_QhzKn)>buAQKp~9Rzu`vbjn2lCdjEdbA0lMOmJr8-~TkC89Vtdv+IZX<_}k zCvt2cY~mrX1Y<%aeKL9k0u+r-IOe)KZ}jRhwf5+pNcjJC9lPKTXf#k?y-wc=p|KMN zoF0I(&sg-|tA|vfz}{tXHkwKF*<@{p%&?WsZ5YDV<%y%LjT`GP2v-_N7FM+RnP9V{jn)7RY$

CHAj%#mo5y_a_ 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�hK)N(E6#l1nG{B9s?D*3cd$tCARY#B^M-r)20D0^3(DD$Pi4$LzHrS|af)>TZOt%2p3 zpcwY|pPKEVp>IS(MLfwH`4!cGfK%YoL-thMYE%$`%UZBf-P+L_(FVF<9QlJCn#4N< zphk6^mIkMX5z_0?h^xh*ub{!s3Q8khfs)u7P=u)CE`FgwN^>alGCZIQSVjdR9)m{P zlfu#oQk8k-4lI&06Q9`-;r&Cc5h0=-g7{Uc^l_Xmi1dGi6L~T5t0_aFua_#&KCYf_ zNbr;$h{NVA5EQA2lPn$et-wiZQ|$?rStcj|d4=BxmId~UG=bLtF!bq74Mzs4>tlEy z)sa;*&r6IDJt3ovpEdO6RfMJ{^g>Q4nbXT^ds_OI3UQ~J5l}QP4hqe+d>bDOz&t0~ z1yzM#necL5pw+Eavb!n*b4_U;S z;|%bP9>~_gtxMcf;aZ1a@S>^>x5y_Ur|qcg&|xGhO|=rKW0PbJJI{&mPT-g) zhXW%2_S_rtcIU+yG9o1m*KZElP&9_@KxFTf;{cPrJ*lYQ&gu8oEt``8^X}XK|cJRG)N(yp02zOTToR2AzK)&^>MMF%w(AK=R^Wl8{KT1wKVZ2%P6D! zV>%BfAt7!C>j<=&tzWqsy}iu*P^Q4@iiZp*qQqbWHycoiRwEZe9pxjNKNuVYE8W`I zKwPSb-#jk1&IhVE*I!f`yh10Q^=mE3^qUUL#&H%xK2b5OuZeTa-%tS~$`$puh>b&} zzYy>tC0i>gBcG!LZ5nDB%Ak$>H=28?U!CYN#-D2!$)6HVU`#yrJm; znc0)37m$2Nnv6)s%Vvfoad;zZB~YG$6XPoz$2U13p#9s5x%B=gV9~-)-`6gQ93&sb zanW6l555oxAkbTbJ3?PO$A!JSck{e$)Ka-^D2~p+uv1+54D8Q8gkw5f5owt9YG&k3 zO2Cq&_4-@T{;=?&y|PQFawlA8c7liZe$KY73>T)oOKJ#LO#|3PI+{dEu(#*1t79ik?VeRrpe!AhHZ& z$p|xno??MhF!!Dgz`TQb;#K1Gdx+gE?w#;KaN7>4EFCnwO6a$lx<>ag5c|T zXu1uY`cFODUpR!Bp5?5wP5}k!!uQ5hD8P@{9z|6I`;Woh44RYCJLc1tDn(lEno^~okd9S@k|84><1%0FISmRSeJ%Wi@;)U?>BYDUQfpp=G`H$V>5r{ zb}hdBo_sm-XNw~aPY(^gn|amq)yJh9odRrw*C*`m(@uD+nyJP?8GW1SF}%sM+0!l%zNU*>rAf4Bk^C zVw4QE)Gc-i1xs1?NOsVAR&ey%VmVW`~6He`lQ%(P$Bhvj5q3^^@&L`D``kb<1DPmY6V@bSw7d z;$QNG>>|ToDJig$rT9@l?T54#l136z%(z0bNWJ>jOItc6QOxZbgBGA5+7X45X~GMl zH}zBxwlna)rH1NOMk7sU(J*u=T+mK=mtp4mb60lcsiLgw1*aFC4dxP##Is6{bG#@| zX`IEcp(Vz}=~J9iF+>c84ac%p`T>ZG$HXxd^<68Ht6>Cb-sQElq4@`q#0~eiJLW=E z;(eIn~{JsPXA{^<*GHfuBNfmSdu zsOY@e9$MUUzuc};{I~qD!RaMG)~}kQxerV=U>@RDNtCnU&lM|q^M1c&W_cnFoIx+^ z20mzLD}Fn?S#8cSnH87iXv9s%fb^p;ysmBtG+mkgVU@i}ZUNA^|I+0^8Bfgthq=K^ zzfUU&cbIrfkxgDmpAI?8GvNi$Z0>0gZP^#Wmvz^GJ?F|o%@`RR)`xG9$RDy}=FR%- z9&@f}%5C*xh#gd@=gy$g7eE_(bLVu^a;s$a>rBaZZ!`B*g_&q5t{QY0JsMHa?S}8X zOoZjZ^`2Tu`HeggKdt3z*7Q7H!7-X7{ymIBDC%Qd`6IB4rflRS8jbF>wFHcSsAZzX zjCBA2#SVX1Cc~}$=~0S7pb%`t>S?fgZp%p2u-@A3h(9P$)u}oDtHe~kJ5VWAxAZsH zw1^UBv(7+*t*oKaCey`pi^S@NG2BNEU5!t>aKZ`#lJES0-vGr{W=ke&bDkS{l(EY; z^wL;2d_hyFc2+DzR&<+C3-;iA%Gj#>AIpaYJ}ig*#j_01c{6+CsOju^eb~oi8J#7% zTC=1OB9MT`=0X$I-zbjGJM(IPXi6Lb4 z`MgiO%uiSf@}pde7iA-m;d$n|Sou$qfYwx(XGzIjpAMS*dDg~zgfuaOn9D2(hAs=s zvV7`a|Sh8S);!qyC#Xyw* zxoGl(;y?YSE|DJjIs)^~gvdDRKAvrECpoKrRO7PO1o|Fdx_GacDOQ;IppX z^UZr6VqYt+x$6kagfzdCX#c@>Z2R7;I>-FO&-EecA&gxBr$2-hn8=0L*$!zEzJ83D zvSSfCS8(aKU9PyuNXP1y@OWtL5ONG! zxLI-K$k<}SB=P7mTg~3#+%Er4hCk!l6X5{oXdY$JRd>|K4ZfW!twOUx)dmQjCpFgw zlVWrZnuoMv)#>A&IKbCWRbK+?Mz>afbwj;@|Jial`Iza}T9Ft1i(&T% zJ~)U~ES_{*AF^N_U7ZqJQQ(4}DzAB0Y5WlL*a=rv;&7SQiOq^1&gE2(kRQDhFf)>CX zI;csKxufDoW~QkV9gO0`PlC}5rbrmRk7=*%qqA@*GZxg@ln-+1sHh@bZg6`Rw>6H7 zD1P&6w%{ozUb-DHt}(rgj$8?NPXUT}flKVJ_y zZ@*wmmr9k$Xc+7lTNK5HPrTjcgNdt|0RZx&h#M$)fZS`9=kHrkFu0~`e?9!Ot z^xfQW9D1GvZ?;ISILUVNaqPE-u~gb$V{NB}0(4zPT|zq+L^cS9W_xpK8k$^8jX#mt z;6w!b{>3987H&_CCU^uLE$YL*BUA)9f`z1SG?_7XjY+_T?88g`GCs0wA_k37e3mvN zXFOkKvFYKWo{T0`k=f?bDVS1@At`y)68R*#j$JRk!dY<|&PF z0zPgOw&Fu0JVe=V1M&#NBF(nu?eTAdGozWOf=DLM*WI6aX#FWdBtNqQ`&uQ5*V$)l zR*lEZRB5BPOGWM12OT|3`gVSqw0q@!oy=V!h+no!_PM+MF?{U8)JX8O_lB=ODM+FL z=wejQuA(;q<309v>^vR2eQTXJ14wKd##bAzHc@4E%YXP z)^0L`(u=ey`Wkw}NseV1TMnW3u8{n32!U`uLO!`tl(yPzY6P_e?g_O*rzLD4#9n-u zvc~@5Kn9RZ#D-SOZs!(+G;ElHg47d=IEIh!KSoKfU`7{n)U_B0kq#G7XGEi)T)(S? zAEo0f!sZaIcW2w4cE2YGy0>m_R85vp9xR(iLGLLQl@uL3v)}Xu^NbkM1%+oOB)G1O z1XgTAPUr$;Z0gP!{7yEzl!DM5>NWYzdTbTC0nn+KD%t1Fu?fEtvC6?6v7n{0v3n)A z>QeEEj<*+eFBaK6@xVF*ATWURL}1CiSsq_(BAaqsYQ!Bmt7{!$uW zA?c`2zDSc#26Z%F25Cw|i(x~ob3H2i`UMW*iTHd+)^D*@%`kzv1AgnXO>s=8r>C zPPF=Z8|AUa{WP4D#SdkUAlF7|43`y$jICYuU`li^%|I%yp3m{OI&2Lt1oSm1osY>m z0ywRe7HQM*9tXNSz?XrV8^vy}6k0KNl}Cy^QrKX;4LfcO@(1ESrRQNJLGBeWARt&+ zARx^DB|Y0ZSlF7mo7pnhS^bwK=r|2Khb<1I?_LAp#}MMx>MK`9tP)3wv@-EP4*W&5 zp}h_u>4hW|X*RM+d!)<0TdK*%oc68Wb+X&Axov)r<);-WGoIOH39~cy&7AkXx4uCV zW^+C8C99mQ5{%yEch}q#iPYmm*MeK=;GOB6>3O}ZV!>)OgeJAhA{FEhPrnF#`2eh% z3y~}}OoH-YzWq*>3KlaOc!YcXaBS8Dn^FLkN>#JcVZ-OE7s1T-A}U$G_8(fNs}>jh z6>Q6{00Tdre~9eILwZyK=yt^Z>&kn%B#matXSl_0Jf{ecAz3nPgGjTC+HTaqQpQCR zl2R5oEmz)zQvPO-q|K>-Qv!OiF6XxFZP)r2QS^z?)BjH}72jPJ_F~ zy_9sP+^Rt}*+n*aLTIqRWNykmzjceRomGr}Ie2Ika|v&mADql)E*3HBQ2RqT-IGx<&* z$?ACp0Tc@hybN&+gguek9`dveV0M^x;}Nu}JxPXHmoFuC z7Q@NP9_kB+B%z~igHE0K}&d4w9Xx)lCz#9-HtBk$m*bAZD=yRO1GnnYew-W@G(SYg!ofi3s+0#UgogyYR$_cR^%=DeIhr5!d5 z6OmgTiN)&cE)C2GJhdk1A>cemk=wt)r_oNGuOoRk_KXa5!PtI2&!6e91K2&iUa!=i zqCeg3J<$FofVQ@L`;h#z(@R7Er^mn?ye0uVcyiY^41$T7fM&wgF`Hbq^0UZJ(tNxX z?@Y`aQ^|JPXjIo_^tE2^=F3x`Gv;sKhlbYUk@v@`u6?iK*XT$G z&nzck+6=vyl?1!+PQG4sT78STCFs~7iuG%R-{BI3xyUvpFqfAkyc&d-FOk|D>yil7 zMa>_5<_t(SnA$Waa0Q{_@M&}rGRM<3BX~Oc`$E5f$-BEpHk>`B^25vE*1|rEBI`?E zA4dri65<25v)NAiWhrz1`8mP--dS#kMHx-TyD&WiWKS?ZR4{3=3{J$h@~Kt_&_nZqnIRy-`>oH2 z5>}w|xh>hqz99dz=<0M>z8C!~xb<{EKuG_q=z6#~JDM<9yQqq3{L8cWcc6fV6&`m? zObxA&j0_E#3T%`M1ZF?f+7!@F@e<8;DI0^HxUeBLD!5VTjmXBaJKBX#-Fzhn3aRb z!C_N+J}K7db;aS6c-*ZwpkT!5Ev?Cz%cO!twUs^o-tt@AU{1O(rUP{-v0BXj<%oz) zx65SrQcv?1^8ylQLKM)|PQS%D`#Jv}9>yy;u4jv8m|tGJeL zv4oY`*|XPSIAdWvCm{()#f=iE{Wa#t!m>UB9He_cv=9?VwoM0={)TMxf)r$mgESQ( z_)QdxF%|Tc>zlXZi?ikWX(6fT)HI6?RVfas733rVZF7Mq#>)%x$NN)IE0KjmA;!47 zY(q3By;^!I7Gb_6N7z zOpBciG>MEnhfjJx;p8`@@!okk=@;w|Um3fXY3XcrIMi^Lx)g= zH<%g}QRKdeimC>Bf+3wstN6Zc0U-1;21Xthf(k2jo!z60XWQNi|1eltT0Xm#LGN*# zzR)19&EdCV1;d9LARCV^6N6nI(VG zr;0uKhRbY>DG3LOR118hQgY(BYUN_2?gE(}IxbbbTw`&p1#8OD6)HWZS4U$H2|~(W ziA!;k-aR96HB6ZBadds;;Bot*0v06Cil;@i5GW>}YtHr%cb&A)bRS;BW4XPf z8%xPRA6sbG$ykX-iV~lDF1kQsw+NL4y1x#$9C7}v-Y9P~dQJwl`pR{y1?Gcs${J<1 zGw>eGf%#f6+FF>up{F-&r?)Nw_w(1xgYSdoiv^DY-VA;hu!!_#q?u0blb#L>#mBfW zI*EZRt!ze}q_B!^lN6j*WiJ0Mr!W-xj|J!1s`{Q-k_2$}aFI^i8!t)kXE2QAfBM_I zz&^F=I%G0#0xVMayRmW)ysXVWiyrs6py0_Jj!@!_=|W2)M0+JHoUwRVbHlq>l+Q_s zN)gDyu?UC9@u2aVzM#ZqnKSgGRmcH!3N9g{3LZ<_jM*wRuZ$6l@Yjyx`el{)aCHEY zWV=IaVu};mzc%-{SsGVupPli3qOt6$j}0T<83ZM+uCA8iaO)L-#O8aChEXRdUwbrX zfPC`cZ4Km~Oq<-ckT|O>r3f%TF7YLXNT!`b(*+(PzjMxZuv{rZaEqjA`+(&ZpIf?Y zTH4#IEfkayHMa2|vp|H8Lgs(RiL>u2if6mvT3IBmgK@t817c zHUnR;#Z~MUX?K>F&2twxsa-(G!feNm-ztLkNHJIcDgB2qcurfj>Nv2W*|jl7p1LVA!L}7Y zk@S_hBMW3i3@f3g9Hwv5l9r25qAEEyW?{;eVu5A)0yoKsre~Uif8HczPwYd_M?}l> z;-#Zi)`QyRhLG##3o@~^68hwy%^E~VOyn<&b_=9)Iz`dyP5f`|vmfr7I~l9r>y*J! zn3xw2q?xEe$#S_U;dIUjfbyyb3@6*T6(mzbC)tjhNHe7k7KtaF_w<;|304*YGg zn09V;oQw{%IAzkzn&5E)5j;8RhY?+G{HRl?Ir6KQXrHm8@zZLPtLNPE`TMjzvpNFP+T5bImm}?RmaQwPu?s1ST7aWL#A?zvQXAxV`{E2yE3ya6< zr*tGW_FUpCew_E4Wbg%1d0}9d^lMoO;NSRCzD1<+55I(JPICb|wD4h(<{e9N+&74j zsoQzXJag3j66a+pWAU-UfJQ=jsswlB_udy&oVvzI+16C8&!MNoo7xX z!?TP8K}QIk+J|F-n;QejBU2%T&+XOM#tf+yEcb*uDYt~VW7TP)q3Jz@bUEe%JP7Fqn)nmd(MFJvc0t)Q5aX}0x1aAC+A z=W_*yts-bp3PY&6Ke==rCJk$uJ2TDi#=|S{*L(dNNYimgKkmkEUnYBLdzIhPBZ(u) zKORb0bIeLC$8dx9=4s_x+>)FwSYt-6FX2)1b8pg)djZM7+VvauP=t z`ZvBODbdW={$<=fWG57SYg#vY-m^bPV~q3-ckmA;G=r09LEH7E)bQx|pXZrFO~X#* zUYRom?L0zU7;18N4H7rk#fPHiL53jPk$kcOXZMu zXZk9+$RS6Q(7Tq1TO}hDh+6mUPn-qr1bDM^FTXoW&Oto9&omv8Mxw-vU?k3JA|Uow zIPO5(QJI`i_NYy@#K)fJP<%bN6Dx1+?RahkA)zB_ODzvtw%h9tw)QM0Afzuwe9~R`;95mh zg;_a@biQw>+E0Mi^vRjYwQ^-jTL=UVk(edGGG>lnn^8P#{A&d)?|Cjl zUod_T=K7}JAHgqko`*O>I%STmHGq=9WWAo?408)m_R(n9U2ty`yRB7GlyXTATB>n9 zNa;2{SMJ#kzpEATENA%EtRP=#lXvr@IATqFm1;1(09&AXE{v^GsPV0LP|Mc#+*k*ODw3^w>WQ}dmR`6OWXMnl{d1i^5J#)`w zTYmd)EhVd#;>lU*+dIQ3prV(Au1$XN{O0|j=@Xl9pMxgoYZ;X@?i;M+1!Q^$nKQy5 z@?+tS-;Lt~^W#=z{hSVJZ|R1qcWOEc)5%ylGXccdrBB{h{DVgYI?c zFZs*MsX%Kh!0@6OMlBY#(S0}NHsTb=w=(OPaJz4Un_EUGVbVP_sjaT9XEsohWu3^M zc|{-H*ZsA8eXo9xXEF>N`ce>|HsZpNC4K_$vTv&;JRx1;cSV2VY0o?45}JcggOjrP zFVBHXEET$bg|Z4H8+RBux|Kk<6}$btngEEZI}_6{9LwOzsTy!=aC1=}dwIbO~W(T0H*b)eYktIRMB6DiVe)Ppwu(D632&r+q0{h8&2+u<-$~KnTSm4j&mK zK@7J}eW8Ik#x*ZpWGryt)?f67QUSS!jpe@CsA=A}$#82c`j0rO{#r%>2qZp;xl*JR z6^Z=0gE_GyQZB4`mER1E>FR$z1_Tql330I4c|s5D*t35D?n`Gdld&#hMnae_{<6$~P^HAQs7>ThcY>@8Cq2xFVnJ z63lWcjM`fw+2)!mI&ANTOSh+82QD)5i+}6dj*wu9VhE>ktaEVZRc@JJ;wcm_6bbCO*MM3@7w5v#eSmXW^OrRs4>QWXkf14(GaiPXG5rAF9~QNFS0zsoZ= zmWj0$h7=#L;*MOdZU>`$TUrp}RVI@Y($jOsYCi#v-=|ojdAQRPw zM9a*2)r~i5Sr25-wFK6i2uj&Ik`#?pYNEsXxBi}zSd%gdF*6Th@HA0N{1 zrVK()I=u9yrk9Pf%yKz+hF^{w_wM?0Q{qivgN5X%AdxFQJQQ{8v4H~b{^v1{0;0nVSkm%q*rhlXrFzV?A_J6y&O z%Qrxc`Uc;|#<&rB6K|V5z}%IO`#6vUujdpAONFr^^C^h-xHs>c>AJhxT69L46*)%S z*?3BnEw}Z(^!Z!bbrhx%PviTXAX!n5_2j02;OO7@;Ct@}({S)zVy_r|OT)uzY4U!Q zACj_xsW3umUbopx@WP8&prr^Vx1%lA)^4tKxIrh0QO&YxwIYq~n$`*@+7@t@om;@T z^Fhn*!?aoA2gzZ*reW>%a#E|1yY6f9)i~JAOKXs*H^DJLVp1W5siDA_f`5zLJA8pv z*hrPlTzX!fCX9g*fh=}%Z3w}tT>VSGwU+)#|LBkq$rTfuMnIy5QjLuPc!xuyM%Z!d zg8L)e+9}NO|Aj_N-@`3LO+2sXK8WEt2qAhcb0pGWO?o#JwM?C8Bjcw~k&a;6*^30T zMUY+-E79P9j3fBMU;BVbzhnZbKz$E(b$Kc%(Q6&5yuO%<>2z9c(go&t&*UFOcSogB z#;58o2M*p2>!@WxUUqR&WTev(lRfU}+{Aae6GkfUNE|GvajgN1qIPGd z@R$fLF}yBli)~~lMRTMxm{{?AJ8RQ(a$H^||H^!1W$pbYN57J3iW*YWqD@GZt1Ozg z2hUiqDCKfm>^o4fQ5n4TcyTs|kh*%cj&Q*cn_3Ol4NvXaRp;j6#?sQ^lH%>XP2P&e zSAP>#-vHCPtIbHq4!@0?N~Dprw;A-Xm@$^~uZ1dGeqtN~+y2A2S1K10z@LPEMNU3}2DV;{)^Aho z{r;WwL%3;0%va93xyAFsw8g)qZ{mHsyCYGh!A~*G%^SHOzXM99zJ`9{Y|m8+11)Tq z2G`R|tEw{d^ON>|D+23<6y(=#U@UoTP@Jug+oJ3#iL8; ztXv;JbP{7sY5Fm54e z(<4w1mH3e8H-%yM#y8_N(1-3H?MDEL<@@rx1bip^%Co0iASS&$d{A`3MStb;}-p9qe;%G7R$2 z=r6Gtt)#JPACAEdGeJFe#H;>8IFNtF=8dgoqZEZwvt`AF$;sY1Z7kQ69pKNe&uie& zyYnX&_!%Z=o*U?vL^2k4;=}tMi`>W?@89nTuN-)E*)#hj%=vq(%fBc416rd?$lz7$ z8-@+D9r8lUMu;pXk_PDN3c;b$9z2+S*D1j^#yFq12=nX$s9mE(7)NoO84?DPz%drp zql^H%Bq9^~m)@_K$7SMB=tJ+S5u&ZnkyTI+;lNvyh2}s5hgt^?UWxh|%p0;6AS)13 zPR`H^=7F+rBow30O!O~e)jR9rn<*`qln*5Dw&}=JuYC(}qTy8O&{O(b-8PTe4&%zt z)VlSFN`UR-o}T`~GfWf48gEI~7v@x!V6V*ggZhI39ecobx;K*_w@y#kg3KYaPJ{r# z!|}T^GgEYjhlX+Eiv=dLBgJq@22#p{2U1Jsc9JAQ|28Ojl^670zH$sWy49 zK4x*H;^iF&^NM8&mjg}NRM>z^MM?{d?r9?osKaa%=p2JsP-H*=`XjMl*NjQ-K>J=g zFTPE!}4--iTv$&Gu&D-sZfS&ip8dz1zC*mbb}Uuuc!1Q_}l0HVI#ed$6e8^E5>bYsY3LdYk=hEZ+J-sW?zYO&D%{N>aTSYZpIu7 zF0wJ!Kc6&KlW3hoA_OGl&MZRMGo{}yx6B=+%RNj2q|-f6Eab0R_p7p6av3~gf{62Q zyQstn3sa!YoLBI#Whn+G(f(Kk+_49bz(st6G{?4q3S+k!BqRgKj(CDn;^vr|+i<@+ ziCG_jS^3C8+Mm1&$f8ZTaqvkcPTW2U0+vPZo~ZJ}UFbCTCZtL-MO2Im`Oa?q2n_Ud z!W|^)1pk6#k!8p@tFifa^Ks*=$Mx(Z$w!H}IMaK%!tRnGdx#b2Dy2XPh=y*L2D8>j zwi3-qBThkL6p~pa=H;M&Qo{xo{X{6!gLbL~BPBK=&&S632SEl)Tru47H#@xDlFr@Y zhOwvSbr5C2%{f)VFi4KOY9xmdM@YE@GR8mYfXp#USYZCNW68%#SIbedP^`VXDxU?* zv5v~H8^AuW2?vq?+)HjD(+Fsgm@O3~#3v;F3^K|UPc$Pf=Hffo1CwW}zgk2h8^$4X&Pu%LmBIjG8jh(Zx?@!o z*(Py_2jAYggBsN?n)mGn4}{65MT>42WS7Ka<7)Bo1;-!=<_jn#S&)}85pLG1Rw%Tg z269v!1c#v#OGOJOz#ACLPmsO%g~FlEIp5jWXNhMa#`%(e-e;Id5C7ZEMvzCD>GV=J zv7e9|(F?v``N1Qmc-MejN|1csrlzH%x|m!)p=ZS*p=E?&T14!dFmGgu_sQ2$t$a`5 zp+~Z>p>-ySk@T$uW*CW+#L*L689mTs7y*Fy9=FiDajc^0DMB*f!oS1ULX>H!B#sC{ z^tXc#58?Sn!l7L)q7T2@n>}Z$(4YXZzpzYz0&CTTSo^G2wn9=MGJ0f_3Au5?4anzq zK=?;$PSu>cO1|;5VyKATv7s>fLL4#5XrMS6XzZSxvpw&OidO~&i%c{R86un}j^NBp zWG-gJ|BNtgV9^E>zOo8di3qM--*a#M$K65{?qWMpdb(Jb@R$N_ zZv5qCIp}v`+HDPLYhsHcTnH!lKl8VT$!luX-%}weXPGskdp@BQkT#)1`a4evF&T6Z zNLd(4g$-p#=5CJ08(w*2PbmWiSKov;y5tX2p1^kpS9jJU=<xmj)8;LwCI@8@_ z-`1z0ddSRAmQseFp(A~=|{J z4~eTKD`MOu0;;iwn8AU&X!!EIO#ooprm4wVInJ&nn@noJi?cP|F|Ft=YhHSo8WZ2J zzQ3Wz0Ce9+-lK9tBO>>fI~3#8Xb)QVrb~;MH%yAq<5c&LLOqe*aixqut{#T~=n6Cv zzv`dg((Ad89#&fKerc(SI;(_lxLrL)M*hOv;leqvkD`wpd?6+xydzSj^xziU+R#h4^>9sO|(K=_0<)&tDA`Ez$B9?btDkuL2-HMSw zL-S_7Ls}JYx(1-|Co5?wOq!29CenFhq^1APs*f~dUs#L$%Tm_LEwQ?`S+rl1uORj^ z%UKWP5~iSMu6XV6aj7p(KtVvSm(y)VxU^$wbBYd>y)mJsG(okL5 zWJ8IO8S(n=*ifJ23&}H}Q^U3>Qca%_Nve1Yy~$Cr=U2KK#S^*z8*L$)J;XE1_+p3; ze22pC9h3Pr!qRs1Doi>6I;Z|-Xg!}R{29!Qs%Aa_U>0N0{ALydX!fi%liT}tFIFp? z!U3K+OADbTR|CL5ZXh8VbblPL0R;c@_`WYWFdBS4xbPbW3bb@TrOwKJ0`A`aR&Fph zuJi(Io(hm9yuU>X-Ip-!tooXG&b;MGs`(=+$Cvkap=Lc9kcSH5t=x0S*3tb^U*lD_ zOHj^9Jgg?2i91c1B9|$Pw{HellQ@eqYgyLViwum^R|hw7?rLU10eL%`;{Ug2%ObzK zcJ!ZTOBERi2<3lTD_2W1J2QDBdm{@o=l`yOpar1ga>0S*e+sZY7lk86m|*iGD$PYj z;FhLL&-|4qz%r=cPbu*r#Uh=x-{(PYVwjW;R40RZ30#yu{R|PvFE`cdEsKTy7>wfnNZ;Ne$mN^UN zRPZVw9T>3b&mQ3=f>+X(2om6G>kGtB$eyuX4b9s-DsScRCgJc;@HZ@ovye#kjpdTN z4-3N*W1WN=;V|)rmh8<4pk30|-H*YLe+E)I_%OmODc2?ZRrW#F_x@ zNsXs%75$bjwcWt$K>dTM>BltYqsYb2 z>w`!XrB0NqWtVp^y*_7LqE>swSlf9<{> zilXnZM!j7d7SGElgjwrQRvmz*{r)J4HukpgD+(`eRA?^Nea-5WyB(?5Gt{${$#?!)`!?5bo zNaP%_L*|r?Bhd%c<;25qZtrPf36wB<%k+&_ltq4I$rLe_nGBPOEE;nomOX^gsj~~} z{Dr>ok3G2@xpX@NO1n7tRr>2U%P6z5oG))}J`8!3nNYU-eP;>)+VzrKStX7u_vizk z17;u<+xQN+d*O6`vuSBWxT+eR%1mw9xl2Q@63MqAZ=F58QtvRI%oN9Nsa`K~lMEl$ z{DWK?LJ_&|#KZ`Sk#rNAIJPunhVSmW23!-$ zD06Sd|EcXPqvBe!x8FDf3l0H7aCevBu0eylTVss`C&48If?JT_?k>S0H~|7d8rR?w zg1pVmy*Ds3?|)`K%z@@KYq5SjRcH4;Rr~DPW!h4VOo=0;W85(z9`-(1qpOY|{LLR< z;gjz3xni@=WGPXKeXA1^nROx+aCv(Q3|zA&9iUR}Hrtf8flN(FwqR(^_gp%pM>W&!myVBLne5sJ|iigM|ufOfi4BL zuU!Gkdu7#>73z8$(k({JwFTlD zS-XNoyv{<%lAM;QGrgke6gI(*(QNqP)7fjr{X@MAPX}MAlDN_~{jTaw7Od zl;Cw0H#0>6!M8a}>rNY-PkcfZCEEG5eeE4Uq$RqdN?dXN!MIS= z%C+Z^4f{TAMM7;Yn(MHkST=4irFubJmRxEp?%N?r*=60e(f-Z)jnPcgrPF!IEokO; zq0Q6Qj`p3iOkL-ZdUjusj8bn@(k9z7T-ls~hw zeov+EPB9(n z3oHDh7K_@zRc%e2Vk}ZJd+Z{!si38I-y?pVv*82IA~onsIV){d&`L(D;tbs2iG+T! zRw*+bBx>@p@zuq%A*}dmNJ*O>Q-v~3^}SeMU)iVo&%Mge-IH+GlivwN^beBBX$HF8 zat>{1#j>!4thAhaG0RyH-7p_rJSQO%@bYt6Wf_$I`nl3Y5p6`VFROn8q`ms4ER0G{ z_)TMh)6sPQ){azGQ82otR*zXXZn5!?)WgIHWLNm<;H%)QzN`-V_^L;yNh}>!EL5-i z3+pM`Ds9oS$zwgb@fY+*BdRbkVsgSa$Bfj+P|KONvliYn^c?D^F-fkjbRxhCJ|F2n z*4Pfo;ncwr3m_SNj-ikVo?z|QY%P9y(4zlQApRwS-HwDZR052|p~V+nJt2?fN_(L# z==|svov{OAkDIH87|KaqpZ891fs~&>Kh@XB)Co+R7Q|v);u-nim&l zcRT{dNT?V0AF2->!K!7%pBher%S}%meK7nBRrm%I*RvaM#ZX3)t_8Z5s5K_uOifBx zoL^MLT(Xh^Vd)iR8~Yz6w}0qAkm57>8qJ^rAFsiHD8J{)l1xf%(>*x4!)zI#y~vYG zm-lrGrC_-;C$Zx8W=kGTMP3*tSuclIPI4+|UU*&cRYsn=|YxJ$M{8nnm2b%ehO2vf33g$;|9$$h8p*t%OJgdYy99W^hlrR9tZ? zTV(XqcB>fCzm3G~*TJKb46!bA_LURuVV#tk>aUh-ThE`kg|`@lge|v;S^C^oc68ii3<5s+eXHjXh+e8IOT3;ARH57S`<(J-4(i_?L9fDys3Cv z@Y*-;>_4QC3Nt-s!NS{|3B+b`lkg(EWz>;k_bXXyilgEYyJ2wba_F1H_Z9`s+0fN2 z)p^tIXK#Cz)RRdP>~@h)hP}Wglbz_)-}wgH(`{i$3tXgSel5)vNH_v$i4kg&q zB9|^8;|+r|!zp~sn?2@Sd&R9b0slP- zf9+_>(un@uhBg(zErvfbrywzUuz2;0XkOWA7Ns0>q87$iMa}g@;PDT&P}PX5 z`Nr7V$juhK8#?Z<{I6#x%n&kVWnR#w44@2=#UapPb5V%&1tPayD_hZwRoRv)t(vBN z&5^pwx5z8ys$JvdgA|*c0yskZoSu}S=b9?+@S^xmdx&;?A&*dB7xUZhdjXPkv4A;w z4;JKj)YD?z6K{3noQ_>qm_?iIpX%Zd*+--Lc9qJ{hk!jIl?NH|L|71Wx_E+9HcPYL zNAaVndn5k=;mWa?57TrBe@vaM{15koVctiU4MDCcq?aa(tNja4^EDcmzdyqM-ff&o zUN&~Hd6HKJ4)Ib-zZC2wwl3~B0@Sjwvhi%H=r2lyB;@671eZk#$)Ry_Hbpp0L}zzmFLkveuHF+eDet7_v;)JD{{ zHq2MiPzZc?Js@K5@8eZn$8=q#*kmAmlf5)B8>rBKys^rEG{mhJ+Szxq)s(^-NzT@& zgaQnSZ}G#M`TpY7j@ekOYYd@?8+~#Qh!rCxTsXa8uhW=dWXXqP<%w%ys`=NReYh)g z!v2Fd&E^Caqc;Yxx(SvTkf@n!yQvR2nXPAbk_AE$^i#2@Q9%~;>YFON2_thY$L*e_ zBQ$4=X<>Am#Mqnvq1Z7gJRLJ-q|lTb14;aD~qjGm#C zs<84Xx$dXBB17R+nG3WL92NirD+h&`Ov?(OFf^N^ObRmzO7dVhKP<)m123YII!7)IIlX89K`UG==q#C|Ar?8p{lok0p$rH89*=kk)~)m?{PMc`%1xFKqRMBMdiu&0gUCbn6`L^U{dtw4sT`)^oWmo@A^^{O zx$D&)WlUBVf*v!<@YbJ3)}L2m(o_fUN~j{O5y*n};K1<6t4Hv{!S4 zSW-xPAD>$TnUgeb# zbYhv2?syB2kt1Y{t`SLGky zGChlZ^BT*aGTByb7Z+tzRzIeX$XCrAOQ4W6LAG?BR9uX*+rh>8P<_-A>RWpu%514` zV$E@}>rw?VSEviLS23nP_Ks%6Ej)LiGqU8$l1CEqXd%WfWT{PfwWWJ45(8o!e>vZR zJ+MXKO8SHt&DWb={;fz!4E}C+PFG|l4`Z@jtf1gM z=}W^*A6oL`zVw5a57GzNutCgnLPpPr34`Kb5h0j+1 zY^Xd9ln&D;KJhv;LIACHc5S^<>B+Y_rV9B9k|v4Wp=dgiIc{}l#oeUAafGo^CUhK< zayJ*v@k`H=>UBzU z>^ipSmr57n=iUJH`ZsA;)`+HyMKA%OlDUn-0!taD%=WA~)751t zDh~05DvT1F6WQ>HUue0uibA?Z7>58tnj%cF?NEu=(0E^UU-Uy9V?M zT^Wf+j3p^I?3ZTwGELXML)|f{6I=P#pQ=|R+qz9z>TJxIl9;t06f}-Ag@-^;EHmAv7%2Iz&*K^lk=fv9dDz)aoF7dU zES#BddjkW?&el0Eu8`ql>%e2uJKw^4EEOjC_0h&N@taBr!@tf=IB6m%TQ2wcZz+jx zIK#IZnOjRsD`8WseP%+no%Vb}3L21!W+m~hY%HwNIsZ(MTjf~cMzu3Y%8zxd2K*pD z6shw}KuW&`z~S+skF_I10g+DS<$JzKG{zumznh;MnwE5i!Nq#p-@~YSf}b``kjQuG z7_RpIJTRHo-s(x4N2_L`YB7GK%s;5cj*K_Cq&fOy&fg$qK2I1}QmKEz*z0M77?pvB zvL7}U+y&=R{6ge$B*H4XI@vi{^XP?Jhd|pQHS}|(VH===5@lf$ZoS9~9vDo9s-L8lqPd5pb$SRO6kxvi);VU0KL7a95K>{d-p-jRN$-*YT84 zE(N+y!BMovC$R)G5zI|0$&!q>L_Dxs4RfUn{ES4!$3cj3$OC*7w5FA4H@C&q37-B5_aID;fLX;--pDpr>k-I%A zwMt`CkXfzhB$w?*h$*H%54wOqEkk~`!8#mMuZ`BG={FhICZPKs`ec#pYnzoK+wW(d zpv&5Mdy7{Eks~0L1G@SxN~6?IX50j3DWAr|I*gRnPWpKm%(3aYnBzSz32#2Ur+t2C zz7dpQ+t0n&=k22~;YG$)uPBGDxDXihmaNPw^++bo{A*vimhz2Ua``9UY$8{|qJ^*K zMjPQCNgqKR5y9E@3=sF}M5EZAm0mVV>({~bwrs%i@t6z0PQu*RHP`g@IgeA~D?R&I zj~a!eH7*&8xt!+N${3(l%}iyHHS$@79_1IVG`s`ux!myXGw=QbAkr;E-A({-x}Cxz z+zv{7&9;RR?8#dO-nZ7FI(VM8Lh{W|-_N5z)(+`2eWPF7pj}NWm}E|aAW-O{P1p7Z z+q{#ZCiPaW{J$-n#=W@+<{?|&*e65N9K`J(~K|cO(fK| zZ>Qucd|cTTl=h<5jrciq0gj(oMhQueXRg-K2$HsM!ME4@LO~{ML{Z`>P&j1Gboji~ z_$rE4K@w^>Y11LLU1eO?FXsw`VAY1IXe~gI^q!B>cfXs2bp!1u-Qia;G&;7z6S6FM z(o@@bJ+7_!cm;)ouE4z}%cV8UFLb9C2k2|3GFZqQK$NIoh+OR1jGk9Zai#f8bbHf& zvLekcxtYV*3*|Jr%;auo`#%oD-f8*kncA-o-rY zo8ZaSv1BMHi8{7|WvVP_Cmau(4-H(;@Zhd@5|XZ{lKOLNjkyGO!zH-Ae|RZnIB%{b zP=!7keQGn~)zbFUxVc8~l$S1fBe>oU{q|FN=}tP|Q9&_zM7twV=f(`nGkhvvp9>1O z5p?v{lHc0e?hq43XkKfhV_)p`c z<|E#Cl3%CV`^nnKimMy z`qJP}qP?0vxT2a*T5^6`y|kC~8kF)zOs)E$h(Ts|^ONIe#TA}N1l1-OLAP&-c#=lh z#bH(Asct-jmO}wxEybzisEM=Y605Ayl8c{BdUUp?R9HzBln$Q{oOI7b2P|!KW*Afl zQacsB#`wO6`ZO!SiJ2!&SIAG7Tv2YrSM*Sv#=DYLz3)s>p*|kd;z|nj>hB5DL-TfG zP7%#JTKvhkH(z@0X3Nm_eNZp)OPzN%_#?Eb-Zt!doa+#K{%f>wu7+_~{w?BchQ&qi zoYk3Sp%Y}}Amy)WCXJREj$`_Uim%Gf#95{zvxwDRP>{)0$h#X(sF_8@digTwDZstx zya*3!01N0thvE`vpd=K*2+e+ID4gW?p7JbOzZo{o(xs)0l>PaICyk8}ZH6iRvnVHX zZ?-*ggblg@$UlN=;L}$c%V5gnVSK`o0_u;ot!?y-tP7q4WO1BrQrb|d3!1T(*`qnq z&Q)M{*dSI&#f;aL(ksK~ZUbG=9dLGRc5?A^@);mohT+F{m*hLq1+Ieb0<{9!oTk7Z55RU@`USCjJ(=|-@perQsTUmAc=}cL}_8=)=W7XJ=L3Dr+kqVLA7TwIE#xU_TQGF`{)n}{{v z){Pi`Y~&2zgk0D9uv5zox&Dye&c3z`!?A~PZrk&Y?w1M#={au^6yf-Yt2v{3W3=DU z+5_L&j2};}D|vfIcL_L~YX#K5dXo_w;zX+)j3pj9zvhsU8a@3u4>bt0tb<4F2@?+p z(d**P~SlGRQAE=jh^H3joq&9u(B#zffpef?3fVVaw3B%^s>Fs3%7mXUnP z^o3OGq757QD|Gqv)GFp!t3IIA8OZKl8pD+^n7e1`nw2!t@tT&G zT2hkEMBWZMBTQ9iz0i1qUCg(UCghk}T7Z6|P=8)7eMKwt0%0MI2sTCeWUtYD_R_^M ziOuM%r3jsCZfUZOUT21M8;xKt{Na+JY;?3OxSk(nj{Je|@A8T5x~^ z4b)Cbw3r6Fs5c^MOiXiZ87x+@8=#0onADK-+fbNwg*6^ioB>|?bKC@lxeJh!CBnhY zeymBTA-4@EGmTK&B!yiuB4iyZm>|dp#Jh?p|42k8OB1q%{Cg-!%g`PWTOi8rL^->7*g^(={|T&rUciO}B2P8| z0O_3j-PDfx3+4iJFaa95Fq)b<+d$qkni@IT{84=C|BwIGf!NWM4-yvO9&}*ZGXOwL zRa5n057rHTfsAl}U&%X0%XN03G^DLF5G=+6iHxLwGVF}(t<24wU4D&q?_H7KynlUB z|FVo<7Zsfn0I)Iv+W!Za6?weqdljU+R-^#{{J$FpX%pbX-;g_$IHb1}flhWtwoDH8 z4-M{LUE4=bId|G1*bNBw$ph>O_g~un!m0pmZGo#o|4k2wqI^dzsMf>~6|G4qrK(AHv z{Nr{AAQmJG0YCav=PzF%=nsL~o0_>ZS-RX!lkS<$C`sX;rufTr?$(Y#TK4w=^LK;q z{K57c03;jdp)ubr`1rp(3gQETd*B}gTPx##!2Bz(f(l~dK&1u%T2p^_)2|jG2mQu~ zsmj>9m^qmnnV9{NdwH*Q|H}B>5{Bm8x5yt^ko%+tLO zD$)G|^iNs4_kh1G=hx@rpYzgw0e`tvG~+wqU6$HC#9i9feJ}sdxn%b>rTvNc$IP;S z3H+t$pTjlxHHEuD-4EW}FcSBBoPP|s+@s!wM(+E8JL + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml new file mode 100644 index 0000000000..2480089825 --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ./index.html + ./jsx/hostscript.jsx + + + true + + + Panel +

OpenPype + + + 200 + 100 + + + + + + ./icons/iconNormal.png + ./icons/iconRollover.png + ./icons/iconDisabled.png + ./icons/iconDarkNormal.png + ./icons/iconDarkRollover.png + + + + + + \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/css/boilerplate.css b/openpype/hosts/aftereffects/api/extension/css/boilerplate.css new file mode 100644 index 0000000000..d208999b8a --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/css/boilerplate.css @@ -0,0 +1,327 @@ +/* + * HTML5 ✰ Boilerplate + * + * What follows is the result of much research on cross-browser styling. + * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal, + * Kroc Camen, and the H5BP dev community and team. + * + * Detailed information about this CSS: h5bp.com/css + * + * ==|== normalize ========================================================== + */ + + +/* ============================================================================= + HTML5 display definitions + ========================================================================== */ + +article, aside, details, figcaption, figure, footer, header, hgroup, nav, section { display: block; } +audio, canvas, video { display: inline-block; *display: inline; *zoom: 1; } +audio:not([controls]) { display: none; } +[hidden] { display: none; } + + +/* ============================================================================= + Base + ========================================================================== */ + +/* + * 1. Correct text resizing oddly in IE6/7 when body font-size is set using em units + * 2. Force vertical scrollbar in non-IE + * 3. Prevent iOS text size adjust on device orientation change, without disabling user zoom: h5bp.com/g + */ + +html { font-size: 100%; overflow-y: scroll; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } + +body { margin: 0; font-size: 100%; line-height: 1.231; } + +body, button, input, select, textarea { font-family: helvetica, arial,"lucida grande", verdana, "メイリオ", "MS Pゴシック", sans-serif; color: #222; } +/* + * Remove text-shadow in selection highlight: h5bp.com/i + * These selection declarations have to be separate + * Also: hot pink! (or customize the background color to match your design) + */ + +::selection { text-shadow: none; background-color: highlight; color: highlighttext; } + + +/* ============================================================================= + Links + ========================================================================== */ + +a { color: #00e; } +a:visited { color: #551a8b; } +a:hover { color: #06e; } +a:focus { outline: thin dotted; } + +/* Improve readability when focused and hovered in all browsers: h5bp.com/h */ +a:hover, a:active { outline: 0; } + + +/* ============================================================================= + Typography + ========================================================================== */ + +abbr[title] { border-bottom: 1px dotted; } + +b, strong { font-weight: bold; } + +blockquote { margin: 1em 40px; } + +dfn { font-style: italic; } + +hr { display: block; height: 1px; border: 0; border-top: 1px solid #ccc; margin: 1em 0; padding: 0; } + +ins { background: #ff9; color: #000; text-decoration: none; } + +mark { background: #ff0; color: #000; font-style: italic; font-weight: bold; } + +/* Redeclare monospace font family: h5bp.com/j */ +pre, code, kbd, samp { font-family: monospace, serif; _font-family: 'courier new', monospace; font-size: 1em; } + +/* Improve readability of pre-formatted text in all browsers */ +pre { white-space: pre; white-space: pre-wrap; word-wrap: break-word; } + +q { quotes: none; } +q:before, q:after { content: ""; content: none; } + +small { font-size: 85%; } + +/* Position subscript and superscript content without affecting line-height: h5bp.com/k */ +sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } +sup { top: -0.5em; } +sub { bottom: -0.25em; } + + +/* ============================================================================= + Lists + ========================================================================== */ + +ul, ol { margin: 1em 0; padding: 0 0 0 40px; } +dd { margin: 0 0 0 40px; } +nav ul, nav ol { list-style: none; list-style-image: none; margin: 0; padding: 0; } + + +/* ============================================================================= + Embedded content + ========================================================================== */ + +/* + * 1. Improve image quality when scaled in IE7: h5bp.com/d + * 2. Remove the gap between images and borders on image containers: h5bp.com/e + */ + +img { border: 0; -ms-interpolation-mode: bicubic; vertical-align: middle; } + +/* + * Correct overflow not hidden in IE9 + */ + +svg:not(:root) { overflow: hidden; } + + +/* ============================================================================= + Figures + ========================================================================== */ + +figure { margin: 0; } + + +/* ============================================================================= + Forms + ========================================================================== */ + +form { margin: 0; } +fieldset { border: 0; margin: 0; padding: 0; } + +/* Indicate that 'label' will shift focus to the associated form element */ +label { cursor: pointer; } + +/* + * 1. Correct color not inheriting in IE6/7/8/9 + * 2. Correct alignment displayed oddly in IE6/7 + */ + +legend { border: 0; *margin-left: -7px; padding: 0; } + +/* + * 1. Correct font-size not inheriting in all browsers + * 2. Remove margins in FF3/4 S5 Chrome + * 3. Define consistent vertical alignment display in all browsers + */ + +button, input, select, textarea { font-size: 100%; margin: 0; vertical-align: baseline; *vertical-align: middle; } + +/* + * 1. Define line-height as normal to match FF3/4 (set using !important in the UA stylesheet) + */ + +button, input { line-height: normal; } + +/* + * 1. Display hand cursor for clickable form elements + * 2. Allow styling of clickable form elements in iOS + * 3. Correct inner spacing displayed oddly in IE7 (doesn't effect IE6) + */ + +button, input[type="button"], input[type="reset"], input[type="submit"] { cursor: pointer; -webkit-appearance: button; *overflow: visible; } + +/* + * Consistent box sizing and appearance + */ + +input[type="checkbox"], input[type="radio"] { box-sizing: border-box; padding: 0; } +input[type="search"] { -webkit-appearance: textfield; -moz-box-sizing: content-box; -webkit-box-sizing: content-box; box-sizing: content-box; } +input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; } + +/* + * Remove inner padding and border in FF3/4: h5bp.com/l + */ + +button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; } + +/* + * 1. Remove default vertical scrollbar in IE6/7/8/9 + * 2. Allow only vertical resizing + */ + +textarea { overflow: auto; vertical-align: top; resize: vertical; } + +/* Colors for form validity */ +input:valid, textarea:valid { } +input:invalid, textarea:invalid { background-color: #f0dddd; } + + +/* ============================================================================= + Tables + ========================================================================== */ + +table { border-collapse: collapse; border-spacing: 0; } +td { vertical-align: top; } + + +/* ==|== primary styles ===================================================== + Author: + ========================================================================== */ + +/* ==|== media queries ====================================================== + PLACEHOLDER Media Queries for Responsive Design. + These override the primary ('mobile first') styles + Modify as content requires. + ========================================================================== */ + +@media only screen and (min-width: 480px) { + /* Style adjustments for viewports 480px and over go here */ + +} + +@media only screen and (min-width: 768px) { + /* Style adjustments for viewports 768px and over go here */ + +} + + + +/* ==|== non-semantic helper classes ======================================== + Please define your styles before this section. + ========================================================================== */ + +/* For image replacement */ +.ir { display: block; border: 0; text-indent: -999em; overflow: hidden; background-color: transparent; background-repeat: no-repeat; text-align: left; direction: ltr; } +.ir br { display: none; } + +/* Hide from both screenreaders and browsers: h5bp.com/u */ +.hidden { display: none !important; visibility: hidden; } + +/* Hide only visually, but have it available for screenreaders: h5bp.com/v */ +.visuallyhidden { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; } + +/* Extends the .visuallyhidden class to allow the element to be focusable when navigated to via the keyboard: h5bp.com/p */ +.visuallyhidden.focusable:active, .visuallyhidden.focusable:focus { clip: auto; height: auto; margin: 0; overflow: visible; position: static; width: auto; } + +/* Hide visually and from screenreaders, but maintain layout */ +.invisible { visibility: hidden; } + +/* Contain floats: h5bp.com/q */ +.clearfix:before, .clearfix:after { content: ""; display: table; } +.clearfix:after { clear: both; } +.clearfix { *zoom: 1; } + + + +/* ==|== print styles ======================================================= + Print styles. + Inlined to avoid required HTTP connection: h5bp.com/r + ========================================================================== */ + +@media print { + * { background: transparent !important; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } /* Black prints faster: h5bp.com/s */ + a, a:visited { text-decoration: underline; } + a[href]:after { content: " (" attr(href) ")"; } + abbr[title]:after { content: " (" attr(title) ")"; } + .ir a:after, a[href^="javascript:"]:after, a[href^="#"]:after { content: ""; } /* Don't show links for images, or javascript/internal links */ + pre, blockquote { border: 1px solid #999; page-break-inside: avoid; } + table { display: table-header-group; } /* h5bp.com/t */ + tr, img { page-break-inside: avoid; } + img { max-width: 100% !important; } + @page { margin: 0.5cm; } + p, h2, h3 { orphans: 3; widows: 3; } + h2, h3 { page-break-after: avoid; } +} + +/* reflow reset for -webkit-margin-before: 1em */ +p { margin: 0; } + +html { + overflow-y: auto; + background-color: transparent; + height: 100%; +} + +body { + background: #fff; + font: normal 100%; + position: relative; + height: 100%; +} + +body, div, img, p, button, input, select, textarea { + box-sizing: border-box; +} + +.image { + display: block; +} + +input { + cursor: default; + display: block; +} + +input[type=button] { + background-color: #e5e9e8; + border: 1px solid #9daca9; + border-radius: 4px; + box-shadow: inset 0 1px #fff; + font: inherit; + letter-spacing: inherit; + text-indent: inherit; + color: inherit; +} + +input[type=button]:hover { + background-color: #eff1f1; +} + +input[type=button]:active { + background-color: #d2d6d6; + border: 1px solid #9daca9; + box-shadow: inset 0 1px rgba(0,0,0,0.1); +} + +/* Reset anchor styles to an unstyled default to be in parity with design surface. It + is presumed that most link styles in real-world designs are custom (non-default). */ +a, a:visited, a:hover, a:active { + color: inherit; + text-decoration: inherit; +} \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/css/styles.css b/openpype/hosts/aftereffects/api/extension/css/styles.css new file mode 100644 index 0000000000..c9cf2b93ac --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/css/styles.css @@ -0,0 +1,51 @@ +/*Your styles*/ + + body { + margin: 10px; +} + + +#content { + margin-right:auto; + margin-left:auto; + vertical-align:middle; + width:100%; +} + + +#btn_test{ + width: 100%; +} + + + + +/* +Those classes will be edited at runtime with values specified +by the settings of the CC application +*/ +.hostFontColor{} +.hostFontFamily{} +.hostFontSize{} + +/*font family, color and size*/ +.hostFont{} +/*background color*/ +.hostBgd{} +/*lighter background color*/ +.hostBgdLight{} +/*darker background color*/ +.hostBgdDark{} +/*background color and font*/ +.hostElt{} + + +.hostButton{ + border:1px solid; + border-radius:2px; + height:20px; + vertical-align:bottom; + font-family:inherit; + color:inherit; + font-size:inherit; +} \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/css/topcoat-desktop-dark.min.css b/openpype/hosts/aftereffects/api/extension/css/topcoat-desktop-dark.min.css new file mode 100644 index 0000000000..6b479def43 --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/css/topcoat-desktop-dark.min.css @@ -0,0 +1 @@ +.button-bar{display:table;table-layout:fixed;white-space:nowrap;margin:0;padding:0}.button-bar__item{display:table-cell;width:auto;border-radius:0}.button-bar__item>input{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.button-bar__button{border-radius:inherit}.button-bar__item:disabled{opacity:.3;cursor:default;pointer-events:none}.button,.topcoat-button,.topcoat-button--quiet,.topcoat-button--large,.topcoat-button--large--quiet,.topcoat-button--cta,.topcoat-button--large--cta,.topcoat-button-bar__button,.topcoat-button-bar__button--large{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.button--disabled,.topcoat-button:disabled,.topcoat-button--quiet:disabled,.topcoat-button--large:disabled,.topcoat-button--large--quiet:disabled,.topcoat-button--cta:disabled,.topcoat-button--large--cta:disabled,.topcoat-button-bar__button:disabled,.topcoat-button-bar__button--large:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-button,.topcoat-button--quiet,.topcoat-button--large,.topcoat-button--large--quiet,.topcoat-button--cta,.topcoat-button--large--cta,.topcoat-button-bar__button,.topcoat-button-bar__button--large{padding:0 .563rem;font-size:12px;line-height:1.313rem;letter-spacing:0;color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);vertical-align:top;background-color:#595b5b;box-shadow:inset 0 1px #737373;border:1px solid #333434;border-radius:4px}.topcoat-button:hover,.topcoat-button--quiet:hover,.topcoat-button--large:hover,.topcoat-button--large--quiet:hover,.topcoat-button-bar__button:hover,.topcoat-button-bar__button--large:hover{background-color:#626465}.topcoat-button:focus,.topcoat-button--quiet:focus,.topcoat-button--quiet:hover:focus,.topcoat-button--large:focus,.topcoat-button--large--quiet:focus,.topcoat-button--large--quiet:hover:focus,.topcoat-button--cta:focus,.topcoat-button--large--cta:focus,.topcoat-button-bar__button:focus,.topcoat-button-bar__button--large:focus{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1;outline:0}.topcoat-button:active,.topcoat-button--large:active,.topcoat-button-bar__button:active,.topcoat-button-bar__button--large:active,:checked+.topcoat-button-bar__button{border:1px solid #333434;background-color:#3f4041;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-button--quiet:hover,.topcoat-button--large--quiet:hover{text-shadow:0 -1px rgba(0,0,0,.69);border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-button--quiet:active,.topcoat-button--quiet:focus:active,.topcoat-button--large--quiet:active,.topcoat-button--large--quiet:focus:active{color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);background-color:#3f4041;border:1px solid #333434;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-button--large,.topcoat-button--large--quiet,.topcoat-button-bar__button--large{font-size:.875rem;font-weight:600;line-height:1.688rem;padding:0 .875rem}.topcoat-button--large--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-button--cta,.topcoat-button--large--cta{border:1px solid #134f7f;background-color:#288edf;box-shadow:inset 0 1px rgba(255,255,255,.36);color:#fff;font-weight:500;text-shadow:0 -1px rgba(0,0,0,.36)}.topcoat-button--cta:hover,.topcoat-button--large--cta:hover{background-color:#4ca1e4}.topcoat-button--cta:active,.topcoat-button--large--cta:active{background-color:#1e7dc8;box-shadow:inset 0 1px rgba(0,0,0,.12)}.topcoat-button--large--cta{font-size:.875rem;font-weight:600;line-height:1.688rem;padding:0 .875rem}.button-bar,.topcoat-button-bar{display:table;table-layout:fixed;white-space:nowrap;margin:0;padding:0}.button-bar__item,.topcoat-button-bar__item{display:table-cell;width:auto;border-radius:0}.button-bar__item>input,.topcoat-button-bar__item>input{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.button-bar__button{border-radius:inherit}.button-bar__item:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-button-bar>.topcoat-button-bar__item:first-child{border-top-left-radius:4px;border-bottom-left-radius:4px}.topcoat-button-bar>.topcoat-button-bar__item:last-child{border-top-right-radius:4px;border-bottom-right-radius:4px}.topcoat-button-bar__item:first-child>.topcoat-button-bar__button,.topcoat-button-bar__item:first-child>.topcoat-button-bar__button--large{border-right:0}.topcoat-button-bar__item:last-child>.topcoat-button-bar__button,.topcoat-button-bar__item:last-child>.topcoat-button-bar__button--large{border-left:0}.topcoat-button-bar__button{border-radius:inherit}.topcoat-button-bar__button:focus,.topcoat-button-bar__button--large:focus{z-index:1}.topcoat-button-bar__button--large{border-radius:inherit}.button{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.button--disabled{opacity:.3;cursor:default;pointer-events:none}.button,.topcoat-button,.topcoat-button--quiet,.topcoat-button--large,.topcoat-button--large--quiet,.topcoat-button--cta,.topcoat-button--large--cta{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.button--disabled,.topcoat-button:disabled,.topcoat-button--quiet:disabled,.topcoat-button--large:disabled,.topcoat-button--large--quiet:disabled,.topcoat-button--cta:disabled,.topcoat-button--large--cta:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-button,.topcoat-button--quiet,.topcoat-button--large,.topcoat-button--large--quiet,.topcoat-button--cta,.topcoat-button--large--cta{padding:0 .563rem;font-size:12px;line-height:1.313rem;letter-spacing:0;color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);vertical-align:top;background-color:#595b5b;box-shadow:inset 0 1px #737373;border:1px solid #333434;border-radius:4px}.topcoat-button:hover,.topcoat-button--quiet:hover,.topcoat-button--large:hover,.topcoat-button--large--quiet:hover{background-color:#626465}.topcoat-button:focus,.topcoat-button--quiet:focus,.topcoat-button--quiet:hover:focus,.topcoat-button--large:focus,.topcoat-button--large--quiet:focus,.topcoat-button--large--quiet:hover:focus,.topcoat-button--cta:focus,.topcoat-button--large--cta:focus{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1;outline:0}.topcoat-button:active,.topcoat-button--large:active{border:1px solid #333434;background-color:#3f4041;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-button--quiet:hover,.topcoat-button--large--quiet:hover{text-shadow:0 -1px rgba(0,0,0,.69);border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-button--quiet:active,.topcoat-button--quiet:focus:active,.topcoat-button--large--quiet:active,.topcoat-button--large--quiet:focus:active{color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);background-color:#3f4041;border:1px solid #333434;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-button--large,.topcoat-button--large--quiet{font-size:.875rem;font-weight:600;line-height:1.688rem;padding:0 .875rem}.topcoat-button--large--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-button--cta,.topcoat-button--large--cta{border:1px solid #134f7f;background-color:#288edf;box-shadow:inset 0 1px rgba(255,255,255,.36);color:#fff;font-weight:500;text-shadow:0 -1px rgba(0,0,0,.36)}.topcoat-button--cta:hover,.topcoat-button--large--cta:hover{background-color:#4ca1e4}.topcoat-button--cta:active,.topcoat-button--large--cta:active{background-color:#1e7dc8;box-shadow:inset 0 1px rgba(0,0,0,.12)}.topcoat-button--large--cta{font-size:.875rem;font-weight:600;line-height:1.688rem;padding:0 .875rem}input[type=checkbox]{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.checkbox{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.checkbox__label{position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.checkbox--disabled{opacity:.3;cursor:default;pointer-events:none}.checkbox:before,.checkbox:after{content:'';position:absolute}.checkbox:before{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}input[type=checkbox]{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.checkbox,.topcoat-checkbox__checkmark{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.checkbox__label,.topcoat-checkbox{position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.checkbox--disabled,input[type=checkbox]:disabled+.topcoat-checkbox__checkmark{opacity:.3;cursor:default;pointer-events:none}.checkbox:before,.checkbox:after,.topcoat-checkbox__checkmark:before,.topcoat-checkbox__checkmark:after{content:'';position:absolute}.checkbox:before,.topcoat-checkbox__checkmark:before{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.topcoat-checkbox__checkmark{height:1rem}input[type=checkbox]{height:1rem;width:1rem;margin-top:0;margin-right:-1rem;margin-bottom:-1rem;margin-left:0}input[type=checkbox]:checked+.topcoat-checkbox__checkmark:after{opacity:1}.topcoat-checkbox{line-height:1rem}.topcoat-checkbox__checkmark:before{width:1rem;height:1rem;background:#595b5b;border:1px solid #333434;border-radius:3px;box-shadow:inset 0 1px #737373}.topcoat-checkbox__checkmark{width:1rem;height:1rem}.topcoat-checkbox__checkmark:after{top:2px;left:1px;opacity:0;width:14px;height:4px;background:transparent;border:7px solid #c6c8c8;border-width:3px;border-top:0;border-right:0;border-radius:1px;-webkit-transform:rotate(-50deg);-ms-transform:rotate(-50deg);transform:rotate(-50deg)}input[type=checkbox]:focus+.topcoat-checkbox__checkmark:before{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1}input[type=checkbox]:active+.topcoat-checkbox__checkmark:before{border:1px solid #333434;background-color:#3f4041;box-shadow:inset 0 1px rgba(0,0,0,.05)}input[type=checkbox]:disabled:active+.topcoat-checkbox__checkmark:before{border:1px solid #333434;background:#595b5b;box-shadow:inset 0 1px #737373}.button,.topcoat-icon-button,.topcoat-icon-button--quiet,.topcoat-icon-button--large,.topcoat-icon-button--large--quiet{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.button--disabled,.topcoat-icon-button:disabled,.topcoat-icon-button--quiet:disabled,.topcoat-icon-button--large:disabled,.topcoat-icon-button--large--quiet:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-icon-button,.topcoat-icon-button--quiet,.topcoat-icon-button--large,.topcoat-icon-button--large--quiet{padding:0 .25rem;line-height:1.313rem;letter-spacing:0;color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);vertical-align:baseline;background-color:#595b5b;box-shadow:inset 0 1px #737373;border:1px solid #333434;border-radius:4px}.topcoat-icon-button:hover,.topcoat-icon-button--quiet:hover,.topcoat-icon-button--large:hover,.topcoat-icon-button--large--quiet:hover{background-color:#626465}.topcoat-icon-button:focus,.topcoat-icon-button--quiet:focus,.topcoat-icon-button--quiet:hover:focus,.topcoat-icon-button--large:focus,.topcoat-icon-button--large--quiet:focus,.topcoat-icon-button--large--quiet:hover:focus{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1;outline:0}.topcoat-icon-button:active,.topcoat-icon-button--large:active{border:1px solid #333434;background-color:#3f4041;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-icon-button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-icon-button--quiet:hover,.topcoat-icon-button--large--quiet:hover{text-shadow:0 -1px rgba(0,0,0,.69);border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-icon-button--quiet:active,.topcoat-icon-button--quiet:focus:active,.topcoat-icon-button--large--quiet:active,.topcoat-icon-button--large--quiet:focus:active{color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);background-color:#3f4041;border:1px solid #333434;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-icon-button--large,.topcoat-icon-button--large--quiet{width:1.688rem;height:1.688rem;line-height:1.688rem}.topcoat-icon-button--large--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-icon,.topcoat-icon--large{position:relative;display:inline-block;vertical-align:top;overflow:hidden;width:.81406rem;height:.81406rem;vertical-align:middle;top:-1px}.topcoat-icon--large{width:1.06344rem;height:1.06344rem;top:-2px}.input{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0}.input:disabled{opacity:.3;cursor:default;pointer-events:none}.list{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:auto;-webkit-overflow-scrolling:touch}.list__header{margin:0}.list__container{padding:0;margin:0;list-style-type:none}.list__item{margin:0;padding:0}.navigation-bar{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;white-space:nowrap;overflow:hidden;word-spacing:0;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.navigation-bar__item{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0}.navigation-bar__title{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.notification{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.notification,.topcoat-notification{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.topcoat-notification{padding:.15em .5em .2em;border-radius:2px;background-color:#ec514e;color:#fff}input[type=radio]{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.radio-button{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.radio-button__label{position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.radio-button:before,.radio-button:after{content:'';position:absolute;border-radius:100%}.radio-button:after{top:50%;left:50%;-webkit-transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%);transform:translate(-50%,-50%)}.radio-button:before{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.radio-button--disabled{opacity:.3;cursor:default;pointer-events:none}input[type=radio]{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.radio-button,.topcoat-radio-button__checkmark{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.radio-button__label,.topcoat-radio-button{position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.radio-button:before,.radio-button:after,.topcoat-radio-button__checkmark:before,.topcoat-radio-button__checkmark:after{content:'';position:absolute;border-radius:100%}.radio-button:after,.topcoat-radio-button__checkmark:after{top:50%;left:50%;-webkit-transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%);transform:translate(-50%,-50%)}.radio-button:before,.topcoat-radio-button__checkmark:before{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.radio-button--disabled,input[type=radio]:disabled+.topcoat-radio-button__checkmark{opacity:.3;cursor:default;pointer-events:none}input[type=radio]{height:1.063rem;width:1.063rem;margin-top:0;margin-right:-1.063rem;margin-bottom:-1.063rem;margin-left:0}input[type=radio]:checked+.topcoat-radio-button__checkmark:after{opacity:1}.topcoat-radio-button{color:#c6c8c8;line-height:1.063rem}.topcoat-radio-button__checkmark:before{width:1.063rem;height:1.063rem;background:#595b5b;border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-radio-button__checkmark{position:relative;width:1.063rem;height:1.063rem}.topcoat-radio-button__checkmark:after{opacity:0;width:.313rem;height:.313rem;background:#c6c8c8;border:1px solid rgba(0,0,0,.05);box-shadow:0 1px rgba(255,255,255,.1);-webkit-transform:none;-ms-transform:none;transform:none;top:.313rem;left:.313rem}input[type=radio]:focus+.topcoat-radio-button__checkmark:before{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1}input[type=radio]:active+.topcoat-radio-button__checkmark:before{border:1px solid #333434;background-color:#3f4041;box-shadow:inset 0 1px rgba(0,0,0,.05)}input[type=radio]:disabled:active+.topcoat-radio-button__checkmark:before{border:1px solid #333434;background:#595b5b;box-shadow:inset 0 1px #737373}.range{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0;-webkit-appearance:none}.range__thumb{cursor:pointer}.range__thumb--webkit{cursor:pointer;-webkit-appearance:none}.range:disabled{opacity:.3;cursor:default;pointer-events:none}.range,.topcoat-range{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0;-webkit-appearance:none}.range__thumb,.topcoat-range::-moz-range-thumb{cursor:pointer}.range__thumb--webkit,.topcoat-range::-webkit-slider-thumb{cursor:pointer;-webkit-appearance:none}.range:disabled,.topcoat-range:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-range{border-radius:4px;border:1px solid #333434;background-color:#454646;height:.5rem;border-radius:15px}.topcoat-range::-moz-range-track{border-radius:4px;border:1px solid #333434;background-color:#454646;height:.5rem;border-radius:15px}.topcoat-range::-webkit-slider-thumb{height:1.313rem;width:.75rem;background-color:#595b5b;border:1px solid #333434;border-radius:4px;box-shadow:inset 0 1px #737373}.topcoat-range::-moz-range-thumb{height:1.313rem;width:.75rem;background-color:#595b5b;border:1px solid #333434;border-radius:4px;box-shadow:inset 0 1px #737373}.topcoat-range:focus::-webkit-slider-thumb{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1}.topcoat-range:focus::-moz-range-thumb{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1}.topcoat-range:active::-webkit-slider-thumb{border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-range:active::-moz-range-thumb{border:1px solid #333434;box-shadow:inset 0 1px #737373}.search-input{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0;-webkit-appearance:none}input[type=search]::-webkit-search-cancel-button{-webkit-appearance:none}.search-input:disabled{opacity:.3;cursor:default;pointer-events:none}.search-input,.topcoat-search-input,.topcoat-search-input--large{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0;-webkit-appearance:none}input[type=search]::-webkit-search-cancel-button{-webkit-appearance:none}.search-input:disabled,.topcoat-search-input:disabled,.topcoat-search-input--large:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-search-input,.topcoat-search-input--large{line-height:1.313rem;height:1.313rem;font-size:12px;border:1px solid #333434;background-color:#454646;box-shadow:inset 0 1px 0 rgba(0,0,0,.23);color:#c6c8c8;padding:0 0 0 1.3rem;border-radius:15px;background-image:url(../img/search.svg);background-position:1rem center;background-repeat:no-repeat;background-size:12px}.topcoat-search-input:focus,.topcoat-search-input--large:focus{background-color:#595b5b;color:#fff;border:1px solid #0036ff;box-shadow:inset 0 1px 0 rgba(0,0,0,.23),0 0 0 2px #6fb5f1}.topcoat-search-input::-webkit-search-cancel-button,.topcoat-search-input::-webkit-search-decoration,.topcoat-search-input--large::-webkit-search-cancel-button,.topcoat-search-input--large::-webkit-search-decoration{margin-right:5px}.topcoat-search-input:focus::-webkit-input-placeholder,.topcoat-search-input:focus::-webkit-input-placeholder{color:#c6c8c8}.topcoat-search-input:disabled::-webkit-input-placeholder{color:#fff}.topcoat-search-input:disabled::-moz-placeholder{color:#fff}.topcoat-search-input:disabled:-ms-input-placeholder{color:#fff}.topcoat-search-input--large{line-height:1.688rem;height:1.688rem;font-size:.875rem;font-weight:400;padding:0 0 0 1.8rem;border-radius:25px;background-position:1.2rem center;background-size:.875rem}.topcoat-search-input--large:disabled{color:#fff}.topcoat-search-input--large:disabled::-webkit-input-placeholder{color:#fff}.topcoat-search-input--large:disabled::-moz-placeholder{color:#fff}.topcoat-search-input--large:disabled:-ms-input-placeholder{color:#fff}.switch{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.switch__input{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.switch__toggle{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.switch__toggle:before,.switch__toggle:after{content:'';position:absolute;z-index:-1;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.switch--disabled{opacity:.3;cursor:default;pointer-events:none}.switch,.topcoat-switch{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.switch__input,.topcoat-switch__input{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.switch__toggle,.topcoat-switch__toggle{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.switch__toggle:before,.switch__toggle:after,.topcoat-switch__toggle:before,.topcoat-switch__toggle:after{content:'';position:absolute;z-index:-1;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.switch--disabled,.topcoat-switch__input:disabled+.topcoat-switch__toggle{opacity:.3;cursor:default;pointer-events:none}.topcoat-switch{font-size:12px;padding:0 .563rem;border-radius:4px;border:1px solid #333434;overflow:hidden;width:3.5rem}.topcoat-switch__toggle:before,.topcoat-switch__toggle:after{top:-1px;width:2.6rem}.topcoat-switch__toggle:before{content:'ON';color:#288edf;background-color:#3f4041;right:.8rem;padding-left:.75rem}.topcoat-switch__toggle{line-height:1.313rem;height:1.313rem;width:1rem;border-radius:4px;color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);background-color:#595b5b;border:1px solid #333434;margin-left:-.6rem;margin-bottom:-1px;margin-top:-1px;box-shadow:inset 0 1px #737373;-webkit-transition:margin-left .05s ease-in-out;transition:margin-left .05s ease-in-out}.topcoat-switch__toggle:after{content:'OFF';background-color:#3f4041;left:.8rem;padding-left:.6rem}.topcoat-switch__input:checked+.topcoat-switch__toggle{margin-left:1.85rem}.topcoat-switch__input:active+.topcoat-switch__toggle{border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-switch__input:focus+.topcoat-switch__toggle{border:1px solid #0036ff;box-shadow:0 0 0 2px #6fb5f1}.topcoat-switch__input:disabled+.topcoat-switch__toggle:after,.topcoat-switch__input:disabled+.topcoat-switch__toggle:before{background:transparent}.button,.topcoat-tab-bar__button{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.button--disabled,.topcoat-tab-bar__button:disabled{opacity:.3;cursor:default;pointer-events:none}.button-bar,.topcoat-tab-bar{display:table;table-layout:fixed;white-space:nowrap;margin:0;padding:0}.button-bar__item,.topcoat-tab-bar__item{display:table-cell;width:auto;border-radius:0}.button-bar__item>input,.topcoat-tab-bar__item>input{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.button-bar__button{border-radius:inherit}.button-bar__item:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-tab-bar__button{padding:0 .563rem;height:1.313rem;line-height:1.313rem;letter-spacing:0;color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);vertical-align:top;background-color:#595b5b;box-shadow:inset 0 1px #737373;border-top:1px solid #333434}.topcoat-tab-bar__button:active,.topcoat-tab-bar__button--large:active,:checked+.topcoat-tab-bar__button{color:#288edf;background-color:#3f4041;box-shadow:inset 0 0 1px rgba(0,0,0,.05)}.topcoat-tab-bar__button:focus,.topcoat-tab-bar__button--large:focus{z-index:1;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1;outline:0}.input,.topcoat-text-input,.topcoat-text-input--large{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0}.input:disabled,.topcoat-text-input:disabled,.topcoat-text-input--large:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-text-input,.topcoat-text-input--large{line-height:1.313rem;font-size:12px;letter-spacing:0;padding:0 .563rem;border:1px solid #333434;border-radius:4px;background-color:#454646;box-shadow:inset 0 1px rgba(0,0,0,.05);color:#c6c8c8;vertical-align:top}.topcoat-text-input:focus,.topcoat-text-input--large:focus{background-color:#595b5b;color:#fff;border:1px solid #0036ff;box-shadow:0 0 0 2px #6fb5f1}.topcoat-text-input:disabled::-webkit-input-placeholder{color:#fff}.topcoat-text-input:disabled::-moz-placeholder{color:#fff}.topcoat-text-input:disabled:-ms-input-placeholder{color:#fff}.topcoat-text-input:invalid{border:1px solid #ec514e}.topcoat-text-input--large{line-height:1.688rem;font-size:.875rem}.topcoat-text-input--large:disabled{color:#fff}.topcoat-text-input--large:disabled::-webkit-input-placeholder{color:#fff}.topcoat-text-input--large:disabled::-moz-placeholder{color:#fff}.topcoat-text-input--large:disabled:-ms-input-placeholder{color:#fff}.topcoat-text-input--large:invalid{border:1px solid #ec514e}.textarea{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;vertical-align:top;resize:none;outline:0}.textarea:disabled{opacity:.3;cursor:default;pointer-events:none}.textarea,.topcoat-textarea,.topcoat-textarea--large{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;vertical-align:top;resize:none;outline:0}.textarea:disabled,.topcoat-textarea:disabled,.topcoat-textarea--large:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-textarea,.topcoat-textarea--large{padding:1rem;font-size:1rem;font-weight:400;border-radius:4px;line-height:1.313rem;border:1px solid #333434;background-color:#454646;box-shadow:inset 0 1px rgba(0,0,0,.05);color:#c6c8c8;letter-spacing:0}.topcoat-textarea:focus,.topcoat-textarea--large:focus{background-color:#595b5b;color:#fff;border:1px solid #0036ff;box-shadow:0 0 0 2px #6fb5f1}.topcoat-textarea:disabled::-webkit-input-placeholder{color:#fff}.topcoat-textarea:disabled::-moz-placeholder{color:#fff}.topcoat-textarea:disabled:-ms-input-placeholder{color:#fff}.topcoat-textarea--large{font-size:1.3rem;line-height:1.688rem}.topcoat-textarea--large:disabled{color:#fff}.topcoat-textarea--large:disabled::-webkit-input-placeholder{color:#fff}.topcoat-textarea--large:disabled::-moz-placeholder{color:#fff}.topcoat-textarea--large:disabled:-ms-input-placeholder{color:#fff}@font-face{font-family:"Source Sans";src:url(../font/SourceSansPro-Regular.otf)}@font-face{font-family:"Source Sans";src:url(../font/SourceSansPro-Light.otf);font-weight:200}@font-face{font-family:"Source Sans";src:url(../font/SourceSansPro-Semibold.otf);font-weight:600}body{margin:0;padding:0;background:#4b4d4e;color:#000;font:16px "Source Sans",helvetica,arial,sans-serif;font-weight:400}:focus{outline-color:transparent;outline-style:none}.topcoat-icon--menu-stack{background:url(../img/hamburger_light.svg) no-repeat;background-size:cover}.quarter{width:25%}.half{width:50%}.three-quarters{width:75%}.third{width:33.333%}.two-thirds{width:66.666%}.full{width:100%}.left{text-align:left}.center{text-align:center}.right{text-align:right}.reset-ui{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden} \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/icons/iconDarkNormal.png b/openpype/hosts/aftereffects/api/extension/icons/iconDarkNormal.png new file mode 100644 index 0000000000000000000000000000000000000000..b8652a85b842b12d45e495c1d496590da0e26f01 GIT binary patch literal 18659 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/aftereffects/api/extension/icons/iconDarkRollover.png b/openpype/hosts/aftereffects/api/extension/icons/iconDarkRollover.png new file mode 100644 index 0000000000000000000000000000000000000000..49edd7ca278ed7d0047effd37242f2bed1e9be27 GIT binary patch literal 18663 zcmeI32T)Vn*2fQu^r8qh#2bo$NGb`C7(xkEK%_}fEFq0ZNhDE78um3*#x7S|hoXniawQDS- zC6-7403dB;Y3jiHhE2Z3M0lSrg)8-VU*c>_R}KJ3&Yye>0m;V}0f2-ZlSo{<)`P`m zaXeUTh!v3tVf(V^OfMP$_&23GGMpTH7Z?n+4w_g;gdDYIIVgxh983;|$j7K_DT~it z9ie>UnSy=UJTo(4#hUbp*@q5=L@C&7OC6ASD%z=h@=(~Rhy!;<+IODvd|W)#T|0i| z385!@AT_^Uv{Xzg-crXN6C!oqWZAqIVb!;)+B;!Jp%Q9rV6Ip`RhcuUDg+D%bw?mw z7}#=`;@$zQg#ueNc6UDjLQ;p5b_xL-jw&k%rN;n}`LqaAz+)AVUBB*t89+h+@^tH4 zSl}rXKv=oZErE+yfzrCUl6iob6o9ag*tY@@-U)1}QC0N^_9g-H<{w<}?^ou|?a|_q zO3K3*s$tDSH;6&EiMqJJmTA^mEnF$DOP)neHiDH0BrQgTqGewU+yj907zN&J2gdyC zWOD23bPpBCY=*wRDKeqr?%pxpRiDK+0)TdIaKpGZJby{ZDv=QH@srE@g*`S&rf(lQ zNY9fsx&oy4Hn|M2C)t>tI9&MbSyyN0)77_3HjwYQ>>6j3Z!UEi^ZFc&A07Jm;>Jt0 zP}Igy3*q4x<@W|{&Md7zBEHwXr90YU^s3~sGTFN9xx9ILQ zQM*-cGS@_c8nV=P%ZlthCYs^37nQWOz#keZp-b&O!eiMouyW^XG6rnfzsO=~WtzpI z;=;x?V)Mz$O%X!w2S}=i#1pP<+A|tnY=Jx)nrCnDQ0fY?@SNU~z2+w+?^C(Rk9jsK*-CCElD<{HBT+iL6)ka=QWAziO=3^U#_7Nd(Qf@`t^CAZ!L01A@`FkwN)R> zMbC%N-5Ei=rKctrNknNB9-d`&>9kX$T%&oTkArHj_W2aF|Q^^j`&z*3ZhlyTq zl5?WK;e!4CbMw*Lld-j73#_cqQ$Rl=*; zgML!d;R<2Z5e&`6DT_oGF&6tRx{zpdcJYIQi>sXwddN44JxQ`jEN7BdfmV=0%pHli z)p6=^idt}|^O&($6-1Ja5mr$#)nyN;eekNZdAjjJrm>M>8W~AoxJ5{M!{TS16uE+Y};cZl0O( z3Gt*X%PjjWi#H{@xqTU_XG_eATt`A>jH_2`xM^50qU+|Q_BcLse4ZhZvB1^<_c0fi z>3K%+M3JfAO`nYZ#IzfmLegwl8La^&J%26T1s`)7^ZqO2qlFYnBwtERq*h`O@6we4%$ z{$vN+!m}&RuCiHWQ(b$n_GInjBut6{@(HRZr6=V{O54>f>;2bj7o01&MzhN6Tz}TJ zzo6J>-+HX;8Itq*jrry|sX5CZ7v&UX%*!ZT6T2oW?J=X`;`@Tz1?R6WzS#9tq&c_1 zyI>cY;l7Y@wKBJ&Kei-x*Y#ch8Z!`;-v3M{OKH7QFz2$bJZ>GXHM#Cszu~UR!T4S{ zgH%7a{;5t!!Th@`ZZT6GQyy3_7BUJsVFM8dBdzz`g(VPRDT-CkYf3s-b3bm|*dEyU zF*PMWrM&N4^M!uherEsvmgD!($5*7e>TvE?)DU0TcQqJF9*;wKwq*y~5$!@5S9(<8 z6nKnF&XbE*&-i5yk>4&Pd?NTB?;v#G^;%J_uAA@am0qp6de^Rkm9}|b8*#Vl1a%^C zq5;SVSuVtw9T{pIx?$GHaA1(~%|nK`X44nA$#?gCn_H>oyD0|fWc{oRT zs3);5rj9AIZ*B0a$M;3a-QArrP0-=udPeciAMMt8mUh?GjQ^0Hovg=yvL`gq-F8|@p||1$NZWXH~h ziSU|@?0^$><%9b&7MzF4Y2)IJY$Dd^AAM&$SL@t@sO2B#`z=2t5m2sF&QO6j=r%+= zCAw)XTl2wE%Qo=HpE1Rl0JTb;GumB+do|7exzKOc zM&7~3XQi#I?YozMr}?nnvA4%3`sB*x+K&{R3Ac8EDv`QNvbSRM@Y@2KB{~%5tPEc# zl8wK1pg_y}kzH1wOG0jNiF%-|XXvJ~JA2Bm2zkDdbe8Dax9d&IlfI31B)Dta74l!< zsfq-J$OF0Z%?lG0c0bi@@$XXH&$@FnspaV9^I5kFsrB?mdT)0tG;M=Sd}cy-f{c&! zv!=_^ZR;-gef(tB3{R-lY%U5bAQqmwiN97%%eY-vyUNh%w9}x|UrxIHjnA);pA7pn z^egTx{)GPXQS*l4p)tbk8_mu_&bE)}k7y%SBl$yyxsjQ@!|@~D8*|opZ|kd`-8g$p zR8^+nN#VmsN{t3i4$+TSzOj(0{^Q1Y>RUgkw63)Czm8SCRArk6boLI3w+$_m#x?DT z+L_%h`rwZpN(?3VR+Hop$>u4J$s-%wH(a;7M`(Ebx;1drqWKTmV`#iY`&s$+?AYo`wB^C0BORhk6y$^y;1umIS z$v_}$sB-9I+k)r8z8Yh7cZ;6+Y4qn5+ZAsfH0m>cMtr{ik>Q5%mZxu9c5f~9OCM-x zu^1BB7gj$W^D$bMI43$O`fj*%_?t9+0v7wy(08nNG=o@AtyfQ5IDT^cZtlVb@ZjM` zZ=TdOBsQE#_>d5L(>v(Q*z3|ai!U@+l~*mhaob-9xoL3SNMD&>i=WclQ*E+#ok5V@ zniC$MFMV{ppD_}J zY#bc!=jR9W(}A&k8E_;Pi-jXla1;v4>jC8i_;AVoP#=!!w8&Q-QyPcj%VcwzEFZ|E zF4>*6ook??GC9z<*Oz&Dv%d}G!}-FFM-lE%X2X#%1pGHawzmAkyuE)J$>Eyq;2p`A znf-D=j#B`e26v!wSlfLmG_xHvAFk@J4uVSgro-Ou>ovuPN`ce7Xx_XK4)5TRzdQu? z*E^7J;XfS4H}`L6&t=lTFU}9Klb+v((fpae37hmxhfSrz^a|m5`G+I=(#TwvuM>;q zMVQXA@Apr<+b2z1GKJ|gc|WxR4+r=EzS!TTXr^Q?jWC&vP$UA1LOSs>5v_+q>a9fZ zvJx>vWV$Cv!&Fiu(icm`Ea;o9}3ON zl)#%ijLD?pbkTIGJ_<{NV%)J5C>l-Ihw9T2C@9jMiq*xS$aD&tIyK23!+(-CWl^?I zX3-aEDo+}TMWZMfG#N_MrSYUuybpJ>I|7Q)L%LJ+-SufSI{mBkjPReN?R=TMGDP;8 z(P#4L|F=W@LguHTESVf$@&wFCDqd<$6&gp{wjW!+I=q-u1%plY<7tZ$KD!k9lV;NPP>tv@6D_ha~Z(0uqM{It|m zuiuMsSahx**_UR_;N`^sxDlr%e~bZZ!A?57aTeH`6YCw8yxD&KXj%W9Qr-d*@wv`puUU- zYMv1_d8ITB)sc5mPb2WA25tiPUd!~Sd67(+ywf>n@+5`U{SpIq{4&t>cY!k;UjnDo zX~w1ZRBfMba-E;Ns5* zSZfyaHSxApA`MF8+K#UI8u; z5dNkB7k@q=uK*Va2!B(6i$5QbSAYuyguf}k#h(wzE5HQ;!rv6&;?D=<72pB^;cp6X z@#h2b3UGmd@HYjx`11jI1-L*!_?rS;{P}>q0$d;<{7nHa{(L}Q0WJ^_{-yvIe?B0u z02c@df0HjRiSLi;(tLQ&=KAp-!dG!Tat7fb9qX zc)bMxaESo0kac8pr8xl1(zP-*cJgof^O>Qgv;9)hchL>>$S`^R#0teFjZ33Mlq0xe z;?MV5)~rzW@L(SB$Ub4L$wjT*qpNz_*jQ|dN?2ofxL#sKSh?{gO!JEX$jR27ZEcid z__3~Sg*^kXp|p{rn-B8x^E>pr_fnG8i^c7dVn=psYD42VX#}@&vt!lRW0fmL%j`x= z{emhrf^OridhXwEf2ZA3Chb-YSucz2=Pts!N!}c*M76d2-oh_JY=hR5b7W}*UFRpp;GG6O2#wkzYd7L(jcAH1`% zaSp81cz;dil>@FkC^oLt;yuu}ebd?IisDmUrJQRA`$bA9*DFQ~v)?t7cll67!c;M% zzNF2R8vl2v_e7_!l=F9ar&eCSML#cjtNyh^du7x0Q2uYnKfHwa?>-&pwOg)O!@=K9 z4!>{(K0)|xlzeNFFzcXvYrHVrTmr6MCK*#MY4tAX^t&LrAOnZtK)E1{!|+zQ(bqX6AZBjMI z3oTYMue(0sK^m!azSon*B}&~JH8TwC@1U2Fv(~)IL9`9pi=Oc=vu)SwZ}yITq}BFL zD&ImJJe(@>qCYFw~fIK!kIcY=uEMB$&R%UBVb4|AF`8Pm0*IfVr literal 0 HcmV?d00001 diff --git a/openpype/hosts/aftereffects/api/extension/icons/iconDisabled.png b/openpype/hosts/aftereffects/api/extension/icons/iconDisabled.png new file mode 100644 index 0000000000000000000000000000000000000000..49edd7ca278ed7d0047effd37242f2bed1e9be27 GIT binary patch literal 18663 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/aftereffects/api/extension/icons/iconNormal.png b/openpype/hosts/aftereffects/api/extension/icons/iconNormal.png new file mode 100644 index 0000000000000000000000000000000000000000..199326f2eac3fee033a163b7e3fe395057746c4c GIT binary patch literal 18225 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/aftereffects/api/extension/icons/iconRollover.png b/openpype/hosts/aftereffects/api/extension/icons/iconRollover.png new file mode 100644 index 0000000000000000000000000000000000000000..ff62645798592fa733626e43741031a57d34c61a GIT binary patch literal 18664 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/aftereffects/api/extension/index.html b/openpype/hosts/aftereffects/api/extension/index.html new file mode 100644 index 0000000000..9e39bf1acc --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/index.html @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/js/libs/CSInterface.js b/openpype/hosts/aftereffects/api/extension/js/libs/CSInterface.js new file mode 100644 index 0000000000..4239391efd --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/js/libs/CSInterface.js @@ -0,0 +1,1193 @@ +/************************************************************************************************** +* +* ADOBE SYSTEMS INCORPORATED +* Copyright 2013 Adobe Systems Incorporated +* All Rights Reserved. +* +* NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the +* terms of the Adobe license agreement accompanying it. If you have received this file from a +* source other than Adobe, then your use, modification, or distribution of it requires the prior +* written permission of Adobe. +* +**************************************************************************************************/ + +/** CSInterface - v8.0.0 */ + +/** + * Stores constants for the window types supported by the CSXS infrastructure. + */ +function CSXSWindowType() +{ +} + +/** Constant for the CSXS window type Panel. */ +CSXSWindowType._PANEL = "Panel"; + +/** Constant for the CSXS window type Modeless. */ +CSXSWindowType._MODELESS = "Modeless"; + +/** Constant for the CSXS window type ModalDialog. */ +CSXSWindowType._MODAL_DIALOG = "ModalDialog"; + +/** EvalScript error message */ +EvalScript_ErrMessage = "EvalScript error."; + +/** + * @class Version + * Defines a version number with major, minor, micro, and special + * components. The major, minor and micro values are numeric; the special + * value can be any string. + * + * @param major The major version component, a positive integer up to nine digits long. + * @param minor The minor version component, a positive integer up to nine digits long. + * @param micro The micro version component, a positive integer up to nine digits long. + * @param special The special version component, an arbitrary string. + * + * @return A new \c Version object. + */ +function Version(major, minor, micro, special) +{ + this.major = major; + this.minor = minor; + this.micro = micro; + this.special = special; +} + +/** + * The maximum value allowed for a numeric version component. + * This reflects the maximum value allowed in PlugPlug and the manifest schema. + */ +Version.MAX_NUM = 999999999; + +/** + * @class VersionBound + * Defines a boundary for a version range, which associates a \c Version object + * with a flag for whether it is an inclusive or exclusive boundary. + * + * @param version The \c #Version object. + * @param inclusive True if this boundary is inclusive, false if it is exclusive. + * + * @return A new \c VersionBound object. + */ +function VersionBound(version, inclusive) +{ + this.version = version; + this.inclusive = inclusive; +} + +/** + * @class VersionRange + * Defines a range of versions using a lower boundary and optional upper boundary. + * + * @param lowerBound The \c #VersionBound object. + * @param upperBound The \c #VersionBound object, or null for a range with no upper boundary. + * + * @return A new \c VersionRange object. + */ +function VersionRange(lowerBound, upperBound) +{ + this.lowerBound = lowerBound; + this.upperBound = upperBound; +} + +/** + * @class Runtime + * Represents a runtime related to the CEP infrastructure. + * Extensions can declare dependencies on particular + * CEP runtime versions in the extension manifest. + * + * @param name The runtime name. + * @param version A \c #VersionRange object that defines a range of valid versions. + * + * @return A new \c Runtime object. + */ +function Runtime(name, versionRange) +{ + this.name = name; + this.versionRange = versionRange; +} + +/** +* @class Extension +* Encapsulates a CEP-based extension to an Adobe application. +* +* @param id The unique identifier of this extension. +* @param name The localizable display name of this extension. +* @param mainPath The path of the "index.html" file. +* @param basePath The base path of this extension. +* @param windowType The window type of the main window of this extension. + Valid values are defined by \c #CSXSWindowType. +* @param width The default width in pixels of the main window of this extension. +* @param height The default height in pixels of the main window of this extension. +* @param minWidth The minimum width in pixels of the main window of this extension. +* @param minHeight The minimum height in pixels of the main window of this extension. +* @param maxWidth The maximum width in pixels of the main window of this extension. +* @param maxHeight The maximum height in pixels of the main window of this extension. +* @param defaultExtensionDataXml The extension data contained in the default \c ExtensionDispatchInfo section of the extension manifest. +* @param specialExtensionDataXml The extension data contained in the application-specific \c ExtensionDispatchInfo section of the extension manifest. +* @param requiredRuntimeList An array of \c Runtime objects for runtimes required by this extension. +* @param isAutoVisible True if this extension is visible on loading. +* @param isPluginExtension True if this extension has been deployed in the Plugins folder of the host application. +* +* @return A new \c Extension object. +*/ +function Extension(id, name, mainPath, basePath, windowType, width, height, minWidth, minHeight, maxWidth, maxHeight, + defaultExtensionDataXml, specialExtensionDataXml, requiredRuntimeList, isAutoVisible, isPluginExtension) +{ + this.id = id; + this.name = name; + this.mainPath = mainPath; + this.basePath = basePath; + this.windowType = windowType; + this.width = width; + this.height = height; + this.minWidth = minWidth; + this.minHeight = minHeight; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + this.defaultExtensionDataXml = defaultExtensionDataXml; + this.specialExtensionDataXml = specialExtensionDataXml; + this.requiredRuntimeList = requiredRuntimeList; + this.isAutoVisible = isAutoVisible; + this.isPluginExtension = isPluginExtension; +} + +/** + * @class CSEvent + * A standard JavaScript event, the base class for CEP events. + * + * @param type The name of the event type. + * @param scope The scope of event, can be "GLOBAL" or "APPLICATION". + * @param appId The unique identifier of the application that generated the event. + * @param extensionId The unique identifier of the extension that generated the event. + * + * @return A new \c CSEvent object + */ +function CSEvent(type, scope, appId, extensionId) +{ + this.type = type; + this.scope = scope; + this.appId = appId; + this.extensionId = extensionId; +} + +/** Event-specific data. */ +CSEvent.prototype.data = ""; + +/** + * @class SystemPath + * Stores operating-system-specific location constants for use in the + * \c #CSInterface.getSystemPath() method. + * @return A new \c SystemPath object. + */ +function SystemPath() +{ +} + +/** The path to user data. */ +SystemPath.USER_DATA = "userData"; + +/** The path to common files for Adobe applications. */ +SystemPath.COMMON_FILES = "commonFiles"; + +/** The path to the user's default document folder. */ +SystemPath.MY_DOCUMENTS = "myDocuments"; + +/** @deprecated. Use \c #SystemPath.Extension. */ +SystemPath.APPLICATION = "application"; + +/** The path to current extension. */ +SystemPath.EXTENSION = "extension"; + +/** The path to hosting application's executable. */ +SystemPath.HOST_APPLICATION = "hostApplication"; + +/** + * @class ColorType + * Stores color-type constants. + */ +function ColorType() +{ +} + +/** RGB color type. */ +ColorType.RGB = "rgb"; + +/** Gradient color type. */ +ColorType.GRADIENT = "gradient"; + +/** Null color type. */ +ColorType.NONE = "none"; + +/** + * @class RGBColor + * Stores an RGB color with red, green, blue, and alpha values. + * All values are in the range [0.0 to 255.0]. Invalid numeric values are + * converted to numbers within this range. + * + * @param red The red value, in the range [0.0 to 255.0]. + * @param green The green value, in the range [0.0 to 255.0]. + * @param blue The blue value, in the range [0.0 to 255.0]. + * @param alpha The alpha (transparency) value, in the range [0.0 to 255.0]. + * The default, 255.0, means that the color is fully opaque. + * + * @return A new RGBColor object. + */ +function RGBColor(red, green, blue, alpha) +{ + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; +} + +/** + * @class Direction + * A point value in which the y component is 0 and the x component + * is positive or negative for a right or left direction, + * or the x component is 0 and the y component is positive or negative for + * an up or down direction. + * + * @param x The horizontal component of the point. + * @param y The vertical component of the point. + * + * @return A new \c Direction object. + */ +function Direction(x, y) +{ + this.x = x; + this.y = y; +} + +/** + * @class GradientStop + * Stores gradient stop information. + * + * @param offset The offset of the gradient stop, in the range [0.0 to 1.0]. + * @param rgbColor The color of the gradient at this point, an \c #RGBColor object. + * + * @return GradientStop object. + */ +function GradientStop(offset, rgbColor) +{ + this.offset = offset; + this.rgbColor = rgbColor; +} + +/** + * @class GradientColor + * Stores gradient color information. + * + * @param type The gradient type, must be "linear". + * @param direction A \c #Direction object for the direction of the gradient + (up, down, right, or left). + * @param numStops The number of stops in the gradient. + * @param gradientStopList An array of \c #GradientStop objects. + * + * @return A new \c GradientColor object. + */ +function GradientColor(type, direction, numStops, arrGradientStop) +{ + this.type = type; + this.direction = direction; + this.numStops = numStops; + this.arrGradientStop = arrGradientStop; +} + +/** + * @class UIColor + * Stores color information, including the type, anti-alias level, and specific color + * values in a color object of an appropriate type. + * + * @param type The color type, 1 for "rgb" and 2 for "gradient". + The supplied color object must correspond to this type. + * @param antialiasLevel The anti-alias level constant. + * @param color A \c #RGBColor or \c #GradientColor object containing specific color information. + * + * @return A new \c UIColor object. + */ +function UIColor(type, antialiasLevel, color) +{ + this.type = type; + this.antialiasLevel = antialiasLevel; + this.color = color; +} + +/** + * @class AppSkinInfo + * Stores window-skin properties, such as color and font. All color parameter values are \c #UIColor objects except that systemHighlightColor is \c #RGBColor object. + * + * @param baseFontFamily The base font family of the application. + * @param baseFontSize The base font size of the application. + * @param appBarBackgroundColor The application bar background color. + * @param panelBackgroundColor The background color of the extension panel. + * @param appBarBackgroundColorSRGB The application bar background color, as sRGB. + * @param panelBackgroundColorSRGB The background color of the extension panel, as sRGB. + * @param systemHighlightColor The highlight color of the extension panel, if provided by the host application. Otherwise, the operating-system highlight color. + * + * @return AppSkinInfo object. + */ +function AppSkinInfo(baseFontFamily, baseFontSize, appBarBackgroundColor, panelBackgroundColor, appBarBackgroundColorSRGB, panelBackgroundColorSRGB, systemHighlightColor) +{ + this.baseFontFamily = baseFontFamily; + this.baseFontSize = baseFontSize; + this.appBarBackgroundColor = appBarBackgroundColor; + this.panelBackgroundColor = panelBackgroundColor; + this.appBarBackgroundColorSRGB = appBarBackgroundColorSRGB; + this.panelBackgroundColorSRGB = panelBackgroundColorSRGB; + this.systemHighlightColor = systemHighlightColor; +} + +/** + * @class HostEnvironment + * Stores information about the environment in which the extension is loaded. + * + * @param appName The application's name. + * @param appVersion The application's version. + * @param appLocale The application's current license locale. + * @param appUILocale The application's current UI locale. + * @param appId The application's unique identifier. + * @param isAppOnline True if the application is currently online. + * @param appSkinInfo An \c #AppSkinInfo object containing the application's default color and font styles. + * + * @return A new \c HostEnvironment object. + */ +function HostEnvironment(appName, appVersion, appLocale, appUILocale, appId, isAppOnline, appSkinInfo) +{ + this.appName = appName; + this.appVersion = appVersion; + this.appLocale = appLocale; + this.appUILocale = appUILocale; + this.appId = appId; + this.isAppOnline = isAppOnline; + this.appSkinInfo = appSkinInfo; +} + +/** + * @class HostCapabilities + * Stores information about the host capabilities. + * + * @param EXTENDED_PANEL_MENU True if the application supports panel menu. + * @param EXTENDED_PANEL_ICONS True if the application supports panel icon. + * @param DELEGATE_APE_ENGINE True if the application supports delegated APE engine. + * @param SUPPORT_HTML_EXTENSIONS True if the application supports HTML extensions. + * @param DISABLE_FLASH_EXTENSIONS True if the application disables FLASH extensions. + * + * @return A new \c HostCapabilities object. + */ +function HostCapabilities(EXTENDED_PANEL_MENU, EXTENDED_PANEL_ICONS, DELEGATE_APE_ENGINE, SUPPORT_HTML_EXTENSIONS, DISABLE_FLASH_EXTENSIONS) +{ + this.EXTENDED_PANEL_MENU = EXTENDED_PANEL_MENU; + this.EXTENDED_PANEL_ICONS = EXTENDED_PANEL_ICONS; + this.DELEGATE_APE_ENGINE = DELEGATE_APE_ENGINE; + this.SUPPORT_HTML_EXTENSIONS = SUPPORT_HTML_EXTENSIONS; + this.DISABLE_FLASH_EXTENSIONS = DISABLE_FLASH_EXTENSIONS; // Since 5.0.0 +} + +/** + * @class ApiVersion + * Stores current api version. + * + * Since 4.2.0 + * + * @param major The major version + * @param minor The minor version. + * @param micro The micro version. + * + * @return ApiVersion object. + */ +function ApiVersion(major, minor, micro) +{ + this.major = major; + this.minor = minor; + this.micro = micro; +} + +/** + * @class MenuItemStatus + * Stores flyout menu item status + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function MenuItemStatus(menuItemLabel, enabled, checked) +{ + this.menuItemLabel = menuItemLabel; + this.enabled = enabled; + this.checked = checked; +} + +/** + * @class ContextMenuItemStatus + * Stores the status of the context menu item. + * + * Since 5.2.0 + * + * @param menuItemID The menu item id. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function ContextMenuItemStatus(menuItemID, enabled, checked) +{ + this.menuItemID = menuItemID; + this.enabled = enabled; + this.checked = checked; +} +//------------------------------ CSInterface ---------------------------------- + +/** + * @class CSInterface + * This is the entry point to the CEP extensibility infrastructure. + * Instantiate this object and use it to: + *
    + *
  • Access information about the host application in which an extension is running
  • + *
  • Launch an extension
  • + *
  • Register interest in event notifications, and dispatch events
  • + *
+ * + * @return A new \c CSInterface object + */ +function CSInterface() +{ +} + +/** + * User can add this event listener to handle native application theme color changes. + * Callback function gives extensions ability to fine-tune their theme color after the + * global theme color has been changed. + * The callback function should be like below: + * + * @example + * // event is a CSEvent object, but user can ignore it. + * function OnAppThemeColorChanged(event) + * { + * // Should get a latest HostEnvironment object from application. + * var skinInfo = JSON.parse(window.__adobe_cep__.getHostEnvironment()).appSkinInfo; + * // Gets the style information such as color info from the skinInfo, + * // and redraw all UI controls of your extension according to the style info. + * } + */ +CSInterface.THEME_COLOR_CHANGED_EVENT = "com.adobe.csxs.events.ThemeColorChanged"; + +/** The host environment data object. */ +CSInterface.prototype.hostEnvironment = window.__adobe_cep__ ? JSON.parse(window.__adobe_cep__.getHostEnvironment()) : null; + +/** Retrieves information about the host environment in which the + * extension is currently running. + * + * @return A \c #HostEnvironment object. + */ +CSInterface.prototype.getHostEnvironment = function() +{ + this.hostEnvironment = JSON.parse(window.__adobe_cep__.getHostEnvironment()); + return this.hostEnvironment; +}; + +/** Closes this extension. */ +CSInterface.prototype.closeExtension = function() +{ + window.__adobe_cep__.closeExtension(); +}; + +/** + * Retrieves a path for which a constant is defined in the system. + * + * @param pathType The path-type constant defined in \c #SystemPath , + * + * @return The platform-specific system path string. + */ +CSInterface.prototype.getSystemPath = function(pathType) +{ + var path = decodeURI(window.__adobe_cep__.getSystemPath(pathType)); + var OSVersion = this.getOSInformation(); + if (OSVersion.indexOf("Windows") >= 0) + { + path = path.replace("file:///", ""); + } + else if (OSVersion.indexOf("Mac") >= 0) + { + path = path.replace("file://", ""); + } + return path; +}; + +/** + * Evaluates a JavaScript script, which can use the JavaScript DOM + * of the host application. + * + * @param script The JavaScript script. + * @param callback Optional. A callback function that receives the result of execution. + * If execution fails, the callback function receives the error message \c EvalScript_ErrMessage. + */ +CSInterface.prototype.evalScript = function(script, callback) +{ + if(callback === null || callback === undefined) + { + callback = function(result){}; + } + window.__adobe_cep__.evalScript(script, callback); +}; + +/** + * Retrieves the unique identifier of the application. + * in which the extension is currently running. + * + * @return The unique ID string. + */ +CSInterface.prototype.getApplicationID = function() +{ + var appId = this.hostEnvironment.appId; + return appId; +}; + +/** + * Retrieves host capability information for the application + * in which the extension is currently running. + * + * @return A \c #HostCapabilities object. + */ +CSInterface.prototype.getHostCapabilities = function() +{ + var hostCapabilities = JSON.parse(window.__adobe_cep__.getHostCapabilities() ); + return hostCapabilities; +}; + +/** + * Triggers a CEP event programmatically. Yoy can use it to dispatch + * an event of a predefined type, or of a type you have defined. + * + * @param event A \c CSEvent object. + */ +CSInterface.prototype.dispatchEvent = function(event) +{ + if (typeof event.data == "object") + { + event.data = JSON.stringify(event.data); + } + + window.__adobe_cep__.dispatchEvent(event); +}; + +/** + * Registers an interest in a CEP event of a particular type, and + * assigns an event handler. + * The event infrastructure notifies your extension when events of this type occur, + * passing the event object to the registered handler function. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.addEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.addEventListener(type, listener, obj); +}; + +/** + * Removes a registered event listener. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method that was registered. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.removeEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.removeEventListener(type, listener, obj); +}; + +/** + * Loads and launches another extension, or activates the extension if it is already loaded. + * + * @param extensionId The extension's unique identifier. + * @param startupParams Not currently used, pass "". + * + * @example + * To launch the extension "help" with ID "HLP" from this extension, call: + * requestOpenExtension("HLP", ""); + * + */ +CSInterface.prototype.requestOpenExtension = function(extensionId, params) +{ + window.__adobe_cep__.requestOpenExtension(extensionId, params); +}; + +/** + * Retrieves the list of extensions currently loaded in the current host application. + * The extension list is initialized once, and remains the same during the lifetime + * of the CEP session. + * + * @param extensionIds Optional, an array of unique identifiers for extensions of interest. + * If omitted, retrieves data for all extensions. + * + * @return Zero or more \c #Extension objects. + */ +CSInterface.prototype.getExtensions = function(extensionIds) +{ + var extensionIdsStr = JSON.stringify(extensionIds); + var extensionsStr = window.__adobe_cep__.getExtensions(extensionIdsStr); + + var extensions = JSON.parse(extensionsStr); + return extensions; +}; + +/** + * Retrieves network-related preferences. + * + * @return A JavaScript object containing network preferences. + */ +CSInterface.prototype.getNetworkPreferences = function() +{ + var result = window.__adobe_cep__.getNetworkPreferences(); + var networkPre = JSON.parse(result); + + return networkPre; +}; + +/** + * Initializes the resource bundle for this extension with property values + * for the current application and locale. + * To support multiple locales, you must define a property file for each locale, + * containing keyed display-string values for that locale. + * See localization documentation for Extension Builder and related products. + * + * Keys can be in the + * form key.value="localized string", for use in HTML text elements. + * For example, in this input element, the localized \c key.value string is displayed + * instead of the empty \c value string: + * + * + * + * @return An object containing the resource bundle information. + */ +CSInterface.prototype.initResourceBundle = function() +{ + var resourceBundle = JSON.parse(window.__adobe_cep__.initResourceBundle()); + var resElms = document.querySelectorAll('[data-locale]'); + for (var n = 0; n < resElms.length; n++) + { + var resEl = resElms[n]; + // Get the resource key from the element. + var resKey = resEl.getAttribute('data-locale'); + if (resKey) + { + // Get all the resources that start with the key. + for (var key in resourceBundle) + { + if (key.indexOf(resKey) === 0) + { + var resValue = resourceBundle[key]; + if (key.length == resKey.length) + { + resEl.innerHTML = resValue; + } + else if ('.' == key.charAt(resKey.length)) + { + var attrKey = key.substring(resKey.length + 1); + resEl[attrKey] = resValue; + } + } + } + } + } + return resourceBundle; +}; + +/** + * Writes installation information to a file. + * + * @return The file path. + */ +CSInterface.prototype.dumpInstallationInfo = function() +{ + return window.__adobe_cep__.dumpInstallationInfo(); +}; + +/** + * Retrieves version information for the current Operating System, + * See http://www.useragentstring.com/pages/Chrome/ for Chrome \c navigator.userAgent values. + * + * @return A string containing the OS version, or "unknown Operation System". + * If user customizes the User Agent by setting CEF command parameter "--user-agent", only + * "Mac OS X" or "Windows" will be returned. + */ +CSInterface.prototype.getOSInformation = function() +{ + var userAgent = navigator.userAgent; + + if ((navigator.platform == "Win32") || (navigator.platform == "Windows")) + { + var winVersion = "Windows"; + var winBit = ""; + if (userAgent.indexOf("Windows") > -1) + { + if (userAgent.indexOf("Windows NT 5.0") > -1) + { + winVersion = "Windows 2000"; + } + else if (userAgent.indexOf("Windows NT 5.1") > -1) + { + winVersion = "Windows XP"; + } + else if (userAgent.indexOf("Windows NT 5.2") > -1) + { + winVersion = "Windows Server 2003"; + } + else if (userAgent.indexOf("Windows NT 6.0") > -1) + { + winVersion = "Windows Vista"; + } + else if (userAgent.indexOf("Windows NT 6.1") > -1) + { + winVersion = "Windows 7"; + } + else if (userAgent.indexOf("Windows NT 6.2") > -1) + { + winVersion = "Windows 8"; + } + else if (userAgent.indexOf("Windows NT 6.3") > -1) + { + winVersion = "Windows 8.1"; + } + else if (userAgent.indexOf("Windows NT 10") > -1) + { + winVersion = "Windows 10"; + } + + if (userAgent.indexOf("WOW64") > -1 || userAgent.indexOf("Win64") > -1) + { + winBit = " 64-bit"; + } + else + { + winBit = " 32-bit"; + } + } + + return winVersion + winBit; + } + else if ((navigator.platform == "MacIntel") || (navigator.platform == "Macintosh")) + { + var result = "Mac OS X"; + + if (userAgent.indexOf("Mac OS X") > -1) + { + result = userAgent.substring(userAgent.indexOf("Mac OS X"), userAgent.indexOf(")")); + result = result.replace(/_/g, "."); + } + + return result; + } + + return "Unknown Operation System"; +}; + +/** + * Opens a page in the default system browser. + * + * Since 4.2.0 + * + * @param url The URL of the page/file to open, or the email address. + * Must use HTTP/HTTPS/file/mailto protocol. For example: + * "http://www.adobe.com" + * "https://github.com" + * "file:///C:/log.txt" + * "mailto:test@adobe.com" + * + * @return One of these error codes:\n + *
    \n + *
  • NO_ERROR - 0
  • \n + *
  • ERR_UNKNOWN - 1
  • \n + *
  • ERR_INVALID_PARAMS - 2
  • \n + *
  • ERR_INVALID_URL - 201
  • \n + *
\n + */ +CSInterface.prototype.openURLInDefaultBrowser = function(url) +{ + return cep.util.openURLInDefaultBrowser(url); +}; + +/** + * Retrieves extension ID. + * + * Since 4.2.0 + * + * @return extension ID. + */ +CSInterface.prototype.getExtensionID = function() +{ + return window.__adobe_cep__.getExtensionId(); +}; + +/** + * Retrieves the scale factor of screen. + * On Windows platform, the value of scale factor might be different from operating system's scale factor, + * since host application may use its self-defined scale factor. + * + * Since 4.2.0 + * + * @return One of the following float number. + *
    \n + *
  • -1.0 when error occurs
  • \n + *
  • 1.0 means normal screen
  • \n + *
  • >1.0 means HiDPI screen
  • \n + *
\n + */ +CSInterface.prototype.getScaleFactor = function() +{ + return window.__adobe_cep__.getScaleFactor(); +}; + +/** + * Set a handler to detect any changes of scale factor. This only works on Mac. + * + * Since 4.2.0 + * + * @param handler The function to be called when scale factor is changed. + * + */ +CSInterface.prototype.setScaleFactorChangedHandler = function(handler) +{ + window.__adobe_cep__.setScaleFactorChangedHandler(handler); +}; + +/** + * Retrieves current API version. + * + * Since 4.2.0 + * + * @return ApiVersion object. + * + */ +CSInterface.prototype.getCurrentApiVersion = function() +{ + var apiVersion = JSON.parse(window.__adobe_cep__.getCurrentApiVersion()); + return apiVersion; +}; + +/** + * Set panel flyout menu by an XML. + * + * Since 5.2.0 + * + * Register a callback function for "com.adobe.csxs.events.flyoutMenuClicked" to get notified when a + * menu item is clicked. + * The "data" attribute of event is an object which contains "menuId" and "menuName" attributes. + * + * Register callback functions for "com.adobe.csxs.events.flyoutMenuOpened" and "com.adobe.csxs.events.flyoutMenuClosed" + * respectively to get notified when flyout menu is opened or closed. + * + * @param menu A XML string which describes menu structure. + * An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setPanelFlyoutMenu = function(menu) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeSync("setPanelFlyoutMenu", menu); +}; + +/** + * Updates a menu item in the extension window's flyout menu, by setting the enabled + * and selection status. + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + * + * @return false when the host application does not support this functionality (HostCapabilities.EXTENDED_PANEL_MENU is false). + * Fails silently if menu label is invalid. + * + * @see HostCapabilities.EXTENDED_PANEL_MENU + */ +CSInterface.prototype.updatePanelMenuItem = function(menuItemLabel, enabled, checked) +{ + var ret = false; + if (this.getHostCapabilities().EXTENDED_PANEL_MENU) + { + var itemStatus = new MenuItemStatus(menuItemLabel, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updatePanelMenuItem", JSON.stringify(itemStatus)); + } + return ret; +}; + + +/** + * Set context menu by XML string. + * + * Since 5.2.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item name is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + https://developer.chrome.com/extensions/contextMenus + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + * + * @param menu A XML string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setContextMenu = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenu", menu, callback); +}; + +/** + * Set context menu by JSON string. + * + * Since 6.0.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item label is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + https://developer.chrome.com/extensions/contextMenus + * + * @param menu A JSON string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu JSON: + * + * { + * "menu": [ + * { + * "id": "menuItemId1", + * "label": "testExample1", + * "enabled": true, + * "checkable": true, + * "checked": false, + * "icon": "./image/small_16X16.png" + * }, + * { + * "id": "menuItemId2", + * "label": "testExample2", + * "menu": [ + * { + * "id": "menuItemId2-1", + * "label": "testExample2-1", + * "menu": [ + * { + * "id": "menuItemId2-1-1", + * "label": "testExample2-1-1", + * "enabled": false, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "id": "menuItemId2-2", + * "label": "testExample2-2", + * "enabled": true, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "label": "---" + * }, + * { + * "id": "menuItemId3", + * "label": "testExample3", + * "enabled": false, + * "checkable": true, + * "checked": false + * } + * ] + * } + * + */ +CSInterface.prototype.setContextMenuByJSON = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenuByJSON", menu, callback); +}; + +/** + * Updates a context menu item by setting the enabled and selection status. + * + * Since 5.2.0 + * + * @param menuItemID The menu item ID. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + */ +CSInterface.prototype.updateContextMenuItem = function(menuItemID, enabled, checked) +{ + var itemStatus = new ContextMenuItemStatus(menuItemID, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updateContextMenuItem", JSON.stringify(itemStatus)); +}; + +/** + * Get the visibility status of an extension window. + * + * Since 6.0.0 + * + * @return true if the extension window is visible; false if the extension window is hidden. + */ +CSInterface.prototype.isWindowVisible = function() +{ + return window.__adobe_cep__.invokeSync("isWindowVisible", ""); +}; + +/** + * Resize extension's content to the specified dimensions. + * 1. Works with modal and modeless extensions in all Adobe products. + * 2. Extension's manifest min/max size constraints apply and take precedence. + * 3. For panel extensions + * 3.1 This works in all Adobe products except: + * * Premiere Pro + * * Prelude + * * After Effects + * 3.2 When the panel is in certain states (especially when being docked), + * it will not change to the desired dimensions even when the + * specified size satisfies min/max constraints. + * + * Since 6.0.0 + * + * @param width The new width + * @param height The new height + */ +CSInterface.prototype.resizeContent = function(width, height) +{ + window.__adobe_cep__.resizeContent(width, height); +}; + +/** + * Register the invalid certificate callback for an extension. + * This callback will be triggered when the extension tries to access the web site that contains the invalid certificate on the main frame. + * But if the extension does not call this function and tries to access the web site containing the invalid certificate, a default error page will be shown. + * + * Since 6.1.0 + * + * @param callback the callback function + */ +CSInterface.prototype.registerInvalidCertificateCallback = function(callback) +{ + return window.__adobe_cep__.registerInvalidCertificateCallback(callback); +}; + +/** + * Register an interest in some key events to prevent them from being sent to the host application. + * + * This function works with modeless extensions and panel extensions. + * Generally all the key events will be sent to the host application for these two extensions if the current focused element + * is not text input or dropdown, + * If you want to intercept some key events and want them to be handled in the extension, please call this function + * in advance to prevent them being sent to the host application. + * + * Since 6.1.0 + * + * @param keyEventsInterest A JSON string describing those key events you are interested in. A null object or + an empty string will lead to removing the interest + * + * This JSON string should be an array, each object has following keys: + * + * keyCode: [Required] represents an OS system dependent virtual key code identifying + * the unmodified value of the pressed key. + * ctrlKey: [optional] a Boolean that indicates if the control key was pressed (true) or not (false) when the event occurred. + * altKey: [optional] a Boolean that indicates if the alt key was pressed (true) or not (false) when the event occurred. + * shiftKey: [optional] a Boolean that indicates if the shift key was pressed (true) or not (false) when the event occurred. + * metaKey: [optional] (Mac Only) a Boolean that indicates if the Meta key was pressed (true) or not (false) when the event occurred. + * On Macintosh keyboards, this is the command key. To detect Windows key on Windows, please use keyCode instead. + * An example JSON string: + * + * [ + * { + * "keyCode": 48 + * }, + * { + * "keyCode": 123, + * "ctrlKey": true + * }, + * { + * "keyCode": 123, + * "ctrlKey": true, + * "metaKey": true + * } + * ] + * + */ +CSInterface.prototype.registerKeyEventsInterest = function(keyEventsInterest) +{ + return window.__adobe_cep__.registerKeyEventsInterest(keyEventsInterest); +}; + +/** + * Set the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @param title The window title. + */ +CSInterface.prototype.setWindowTitle = function(title) +{ + window.__adobe_cep__.invokeSync("setWindowTitle", title); +}; + +/** + * Get the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @return The window title. + */ +CSInterface.prototype.getWindowTitle = function() +{ + return window.__adobe_cep__.invokeSync("getWindowTitle", ""); +}; diff --git a/openpype/hosts/aftereffects/api/extension/js/libs/jquery-2.0.2.min.js b/openpype/hosts/aftereffects/api/extension/js/libs/jquery-2.0.2.min.js new file mode 100644 index 0000000000..73e5218d21 --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/js/libs/jquery-2.0.2.min.js @@ -0,0 +1,6 @@ +/*! jQuery v2.0.2 | (c) 2005, 2013 jQuery Foundation, Inc. | jquery.org/license +//@ sourceMappingURL=jquery-2.0.2.min.map +*/ +(function(e,undefined){var t,n,r=typeof undefined,i=e.location,o=e.document,s=o.documentElement,a=e.jQuery,u=e.$,l={},c=[],p="2.0.2",f=c.concat,h=c.push,d=c.slice,g=c.indexOf,m=l.toString,y=l.hasOwnProperty,v=p.trim,x=function(e,n){return new x.fn.init(e,n,t)},b=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,w=/\S+/g,T=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,C=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,k=/^-ms-/,N=/-([\da-z])/gi,E=function(e,t){return t.toUpperCase()},S=function(){o.removeEventListener("DOMContentLoaded",S,!1),e.removeEventListener("load",S,!1),x.ready()};x.fn=x.prototype={jquery:p,constructor:x,init:function(e,t,n){var r,i;if(!e)return this;if("string"==typeof e){if(r="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:T.exec(e),!r||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof x?t[0]:t,x.merge(this,x.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:o,!0)),C.test(r[1])&&x.isPlainObject(t))for(r in t)x.isFunction(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return i=o.getElementById(r[2]),i&&i.parentNode&&(this.length=1,this[0]=i),this.context=o,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):x.isFunction(e)?n.ready(e):(e.selector!==undefined&&(this.selector=e.selector,this.context=e.context),x.makeArray(e,this))},selector:"",length:0,toArray:function(){return d.call(this)},get:function(e){return null==e?this.toArray():0>e?this[this.length+e]:this[e]},pushStack:function(e){var t=x.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e,t){return x.each(this,e,t)},ready:function(e){return x.ready.promise().done(e),this},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},map:function(e){return this.pushStack(x.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:h,sort:[].sort,splice:[].splice},x.fn.init.prototype=x.fn,x.extend=x.fn.extend=function(){var e,t,n,r,i,o,s=arguments[0]||{},a=1,u=arguments.length,l=!1;for("boolean"==typeof s&&(l=s,s=arguments[1]||{},a=2),"object"==typeof s||x.isFunction(s)||(s={}),u===a&&(s=this,--a);u>a;a++)if(null!=(e=arguments[a]))for(t in e)n=s[t],r=e[t],s!==r&&(l&&r&&(x.isPlainObject(r)||(i=x.isArray(r)))?(i?(i=!1,o=n&&x.isArray(n)?n:[]):o=n&&x.isPlainObject(n)?n:{},s[t]=x.extend(l,o,r)):r!==undefined&&(s[t]=r));return s},x.extend({expando:"jQuery"+(p+Math.random()).replace(/\D/g,""),noConflict:function(t){return e.$===x&&(e.$=u),t&&e.jQuery===x&&(e.jQuery=a),x},isReady:!1,readyWait:1,holdReady:function(e){e?x.readyWait++:x.ready(!0)},ready:function(e){(e===!0?--x.readyWait:x.isReady)||(x.isReady=!0,e!==!0&&--x.readyWait>0||(n.resolveWith(o,[x]),x.fn.trigger&&x(o).trigger("ready").off("ready")))},isFunction:function(e){return"function"===x.type(e)},isArray:Array.isArray,isWindow:function(e){return null!=e&&e===e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[m.call(e)]||"object":typeof e},isPlainObject:function(e){if("object"!==x.type(e)||e.nodeType||x.isWindow(e))return!1;try{if(e.constructor&&!y.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(t){return!1}return!0},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw Error(e)},parseHTML:function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||o;var r=C.exec(e),i=!n&&[];return r?[t.createElement(r[1])]:(r=x.buildFragment([e],t,i),i&&x(i).remove(),x.merge([],r.childNodes))},parseJSON:JSON.parse,parseXML:function(e){var t,n;if(!e||"string"!=typeof e)return null;try{n=new DOMParser,t=n.parseFromString(e,"text/xml")}catch(r){t=undefined}return(!t||t.getElementsByTagName("parsererror").length)&&x.error("Invalid XML: "+e),t},noop:function(){},globalEval:function(e){var t,n=eval;e=x.trim(e),e&&(1===e.indexOf("use strict")?(t=o.createElement("script"),t.text=e,o.head.appendChild(t).parentNode.removeChild(t)):n(e))},camelCase:function(e){return e.replace(k,"ms-").replace(N,E)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t,n){var r,i=0,o=e.length,s=j(e);if(n){if(s){for(;o>i;i++)if(r=t.apply(e[i],n),r===!1)break}else for(i in e)if(r=t.apply(e[i],n),r===!1)break}else if(s){for(;o>i;i++)if(r=t.call(e[i],i,e[i]),r===!1)break}else for(i in e)if(r=t.call(e[i],i,e[i]),r===!1)break;return e},trim:function(e){return null==e?"":v.call(e)},makeArray:function(e,t){var n=t||[];return null!=e&&(j(Object(e))?x.merge(n,"string"==typeof e?[e]:e):h.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:g.call(t,e,n)},merge:function(e,t){var n=t.length,r=e.length,i=0;if("number"==typeof n)for(;n>i;i++)e[r++]=t[i];else while(t[i]!==undefined)e[r++]=t[i++];return e.length=r,e},grep:function(e,t,n){var r,i=[],o=0,s=e.length;for(n=!!n;s>o;o++)r=!!t(e[o],o),n!==r&&i.push(e[o]);return i},map:function(e,t,n){var r,i=0,o=e.length,s=j(e),a=[];if(s)for(;o>i;i++)r=t(e[i],i,n),null!=r&&(a[a.length]=r);else for(i in e)r=t(e[i],i,n),null!=r&&(a[a.length]=r);return f.apply([],a)},guid:1,proxy:function(e,t){var n,r,i;return"string"==typeof t&&(n=e[t],t=e,e=n),x.isFunction(e)?(r=d.call(arguments,2),i=function(){return e.apply(t||this,r.concat(d.call(arguments)))},i.guid=e.guid=e.guid||x.guid++,i):undefined},access:function(e,t,n,r,i,o,s){var a=0,u=e.length,l=null==n;if("object"===x.type(n)){i=!0;for(a in n)x.access(e,t,a,n[a],!0,o,s)}else if(r!==undefined&&(i=!0,x.isFunction(r)||(s=!0),l&&(s?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(x(e),n)})),t))for(;u>a;a++)t(e[a],n,s?r:r.call(e[a],a,t(e[a],n)));return i?e:l?t.call(e):u?t(e[0],n):o},now:Date.now,swap:function(e,t,n,r){var i,o,s={};for(o in t)s[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=s[o];return i}}),x.ready.promise=function(t){return n||(n=x.Deferred(),"complete"===o.readyState?setTimeout(x.ready):(o.addEventListener("DOMContentLoaded",S,!1),e.addEventListener("load",S,!1))),n.promise(t)},x.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){l["[object "+t+"]"]=t.toLowerCase()});function j(e){var t=e.length,n=x.type(e);return x.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}t=x(o),function(e,undefined){var t,n,r,i,o,s,a,u,l,c,p,f,h,d,g,m,y,v="sizzle"+-new Date,b=e.document,w=0,T=0,C=at(),k=at(),N=at(),E=!1,S=function(){return 0},j=typeof undefined,D=1<<31,A={}.hasOwnProperty,L=[],H=L.pop,q=L.push,O=L.push,F=L.slice,P=L.indexOf||function(e){var t=0,n=this.length;for(;n>t;t++)if(this[t]===e)return t;return-1},R="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",W="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",$=W.replace("w","w#"),B="\\["+M+"*("+W+")"+M+"*(?:([*^$|!~]?=)"+M+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+$+")|)|)"+M+"*\\]",I=":("+W+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+B.replace(3,8)+")*)|.*)\\)|)",z=RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),_=RegExp("^"+M+"*,"+M+"*"),X=RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=RegExp(M+"*[+~]"),Y=RegExp("="+M+"*([^\\]'\"]*)"+M+"*\\]","g"),V=RegExp(I),G=RegExp("^"+$+"$"),J={ID:RegExp("^#("+W+")"),CLASS:RegExp("^\\.("+W+")"),TAG:RegExp("^("+W.replace("w","w*")+")"),ATTR:RegExp("^"+B),PSEUDO:RegExp("^"+I),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:RegExp("^(?:"+R+")$","i"),needsContext:RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Q=/^[^{]+\{\s*\[native \w/,K=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,Z=/^(?:input|select|textarea|button)$/i,et=/^h\d$/i,tt=/'|\\/g,nt=RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),rt=function(e,t,n){var r="0x"+t-65536;return r!==r||n?t:0>r?String.fromCharCode(r+65536):String.fromCharCode(55296|r>>10,56320|1023&r)};try{O.apply(L=F.call(b.childNodes),b.childNodes),L[b.childNodes.length].nodeType}catch(it){O={apply:L.length?function(e,t){q.apply(e,F.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function ot(e,t,r,i){var o,s,a,u,l,f,g,m,x,w;if((t?t.ownerDocument||t:b)!==p&&c(t),t=t||p,r=r||[],!e||"string"!=typeof e)return r;if(1!==(u=t.nodeType)&&9!==u)return[];if(h&&!i){if(o=K.exec(e))if(a=o[1]){if(9===u){if(s=t.getElementById(a),!s||!s.parentNode)return r;if(s.id===a)return r.push(s),r}else if(t.ownerDocument&&(s=t.ownerDocument.getElementById(a))&&y(t,s)&&s.id===a)return r.push(s),r}else{if(o[2])return O.apply(r,t.getElementsByTagName(e)),r;if((a=o[3])&&n.getElementsByClassName&&t.getElementsByClassName)return O.apply(r,t.getElementsByClassName(a)),r}if(n.qsa&&(!d||!d.test(e))){if(m=g=v,x=t,w=9===u&&e,1===u&&"object"!==t.nodeName.toLowerCase()){f=vt(e),(g=t.getAttribute("id"))?m=g.replace(tt,"\\$&"):t.setAttribute("id",m),m="[id='"+m+"'] ",l=f.length;while(l--)f[l]=m+xt(f[l]);x=U.test(e)&&t.parentNode||t,w=f.join(",")}if(w)try{return O.apply(r,x.querySelectorAll(w)),r}catch(T){}finally{g||t.removeAttribute("id")}}}return St(e.replace(z,"$1"),t,r,i)}function st(e){return Q.test(e+"")}function at(){var e=[];function t(n,r){return e.push(n+=" ")>i.cacheLength&&delete t[e.shift()],t[n]=r}return t}function ut(e){return e[v]=!0,e}function lt(e){var t=p.createElement("div");try{return!!e(t)}catch(n){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function ct(e,t,n){e=e.split("|");var r,o=e.length,s=n?null:t;while(o--)(r=i.attrHandle[e[o]])&&r!==t||(i.attrHandle[e[o]]=s)}function pt(e,t){var n=e.getAttributeNode(t);return n&&n.specified?n.value:e[t]===!0?t.toLowerCase():null}function ft(e,t){return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}function ht(e){return"input"===e.nodeName.toLowerCase()?e.defaultValue:undefined}function dt(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&(~t.sourceIndex||D)-(~e.sourceIndex||D);if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function gt(e){return function(t){var n=t.nodeName.toLowerCase();return"input"===n&&t.type===e}}function mt(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function yt(e){return ut(function(t){return t=+t,ut(function(n,r){var i,o=e([],n.length,t),s=o.length;while(s--)n[i=o[s]]&&(n[i]=!(r[i]=n[i]))})})}s=ot.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?"HTML"!==t.nodeName:!1},n=ot.support={},c=ot.setDocument=function(e){var t=e?e.ownerDocument||e:b,r=t.parentWindow;return t!==p&&9===t.nodeType&&t.documentElement?(p=t,f=t.documentElement,h=!s(t),r&&r.frameElement&&r.attachEvent("onbeforeunload",function(){c()}),n.attributes=lt(function(e){return e.innerHTML="",ct("type|href|height|width",ft,"#"===e.firstChild.getAttribute("href")),ct(R,pt,null==e.getAttribute("disabled")),e.className="i",!e.getAttribute("className")}),n.input=lt(function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")}),ct("value",ht,n.attributes&&n.input),n.getElementsByTagName=lt(function(e){return e.appendChild(t.createComment("")),!e.getElementsByTagName("*").length}),n.getElementsByClassName=lt(function(e){return e.innerHTML="
",e.firstChild.className="i",2===e.getElementsByClassName("i").length}),n.getById=lt(function(e){return f.appendChild(e).id=v,!t.getElementsByName||!t.getElementsByName(v).length}),n.getById?(i.find.ID=function(e,t){if(typeof t.getElementById!==j&&h){var n=t.getElementById(e);return n&&n.parentNode?[n]:[]}},i.filter.ID=function(e){var t=e.replace(nt,rt);return function(e){return e.getAttribute("id")===t}}):(delete i.find.ID,i.filter.ID=function(e){var t=e.replace(nt,rt);return function(e){var n=typeof e.getAttributeNode!==j&&e.getAttributeNode("id");return n&&n.value===t}}),i.find.TAG=n.getElementsByTagName?function(e,t){return typeof t.getElementsByTagName!==j?t.getElementsByTagName(e):undefined}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},i.find.CLASS=n.getElementsByClassName&&function(e,t){return typeof t.getElementsByClassName!==j&&h?t.getElementsByClassName(e):undefined},g=[],d=[],(n.qsa=st(t.querySelectorAll))&&(lt(function(e){e.innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll(":checked").length||d.push(":checked")}),lt(function(e){var n=t.createElement("input");n.setAttribute("type","hidden"),e.appendChild(n).setAttribute("t",""),e.querySelectorAll("[t^='']").length&&d.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll(":enabled").length||d.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),d.push(",.*:")})),(n.matchesSelector=st(m=f.webkitMatchesSelector||f.mozMatchesSelector||f.oMatchesSelector||f.msMatchesSelector))&<(function(e){n.disconnectedMatch=m.call(e,"div"),m.call(e,"[s!='']:x"),g.push("!=",I)}),d=d.length&&RegExp(d.join("|")),g=g.length&&RegExp(g.join("|")),y=st(f.contains)||f.compareDocumentPosition?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},n.sortDetached=lt(function(e){return 1&e.compareDocumentPosition(t.createElement("div"))}),S=f.compareDocumentPosition?function(e,r){if(e===r)return E=!0,0;var i=r.compareDocumentPosition&&e.compareDocumentPosition&&e.compareDocumentPosition(r);return i?1&i||!n.sortDetached&&r.compareDocumentPosition(e)===i?e===t||y(b,e)?-1:r===t||y(b,r)?1:l?P.call(l,e)-P.call(l,r):0:4&i?-1:1:e.compareDocumentPosition?-1:1}:function(e,n){var r,i=0,o=e.parentNode,s=n.parentNode,a=[e],u=[n];if(e===n)return E=!0,0;if(!o||!s)return e===t?-1:n===t?1:o?-1:s?1:l?P.call(l,e)-P.call(l,n):0;if(o===s)return dt(e,n);r=e;while(r=r.parentNode)a.unshift(r);r=n;while(r=r.parentNode)u.unshift(r);while(a[i]===u[i])i++;return i?dt(a[i],u[i]):a[i]===b?-1:u[i]===b?1:0},t):p},ot.matches=function(e,t){return ot(e,null,null,t)},ot.matchesSelector=function(e,t){if((e.ownerDocument||e)!==p&&c(e),t=t.replace(Y,"='$1']"),!(!n.matchesSelector||!h||g&&g.test(t)||d&&d.test(t)))try{var r=m.call(e,t);if(r||n.disconnectedMatch||e.document&&11!==e.document.nodeType)return r}catch(i){}return ot(t,p,null,[e]).length>0},ot.contains=function(e,t){return(e.ownerDocument||e)!==p&&c(e),y(e,t)},ot.attr=function(e,t){(e.ownerDocument||e)!==p&&c(e);var r=i.attrHandle[t.toLowerCase()],o=r&&A.call(i.attrHandle,t.toLowerCase())?r(e,t,!h):undefined;return o===undefined?n.attributes||!h?e.getAttribute(t):(o=e.getAttributeNode(t))&&o.specified?o.value:null:o},ot.error=function(e){throw Error("Syntax error, unrecognized expression: "+e)},ot.uniqueSort=function(e){var t,r=[],i=0,o=0;if(E=!n.detectDuplicates,l=!n.sortStable&&e.slice(0),e.sort(S),E){while(t=e[o++])t===e[o]&&(i=r.push(o));while(i--)e.splice(r[i],1)}return e},o=ot.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=o(e)}else if(3===i||4===i)return e.nodeValue}else for(;t=e[r];r++)n+=o(t);return n},i=ot.selectors={cacheLength:50,createPseudo:ut,match:J,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(nt,rt),e[3]=(e[4]||e[5]||"").replace(nt,rt),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||ot.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&ot.error(e[0]),e},PSEUDO:function(e){var t,n=!e[5]&&e[2];return J.CHILD.test(e[0])?null:(e[3]&&e[4]!==undefined?e[2]=e[4]:n&&V.test(n)&&(t=vt(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(nt,rt).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=C[e+" "];return t||(t=RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&C(e,function(e){return t.test("string"==typeof e.className&&e.className||typeof e.getAttribute!==j&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=ot.attr(r,e);return null==i?"!="===t:t?(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i+" ").indexOf(n)>-1:"|="===t?i===n||i.slice(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),s="last"!==e.slice(-4),a="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,p,f,h,d,g=o!==s?"nextSibling":"previousSibling",m=t.parentNode,y=a&&t.nodeName.toLowerCase(),x=!u&&!a;if(m){if(o){while(g){p=t;while(p=p[g])if(a?p.nodeName.toLowerCase()===y:1===p.nodeType)return!1;d=g="only"===e&&!d&&"nextSibling"}return!0}if(d=[s?m.firstChild:m.lastChild],s&&x){c=m[v]||(m[v]={}),l=c[e]||[],h=l[0]===w&&l[1],f=l[0]===w&&l[2],p=h&&m.childNodes[h];while(p=++h&&p&&p[g]||(f=h=0)||d.pop())if(1===p.nodeType&&++f&&p===t){c[e]=[w,h,f];break}}else if(x&&(l=(t[v]||(t[v]={}))[e])&&l[0]===w)f=l[1];else while(p=++h&&p&&p[g]||(f=h=0)||d.pop())if((a?p.nodeName.toLowerCase()===y:1===p.nodeType)&&++f&&(x&&((p[v]||(p[v]={}))[e]=[w,f]),p===t))break;return f-=i,f===r||0===f%r&&f/r>=0}}},PSEUDO:function(e,t){var n,r=i.pseudos[e]||i.setFilters[e.toLowerCase()]||ot.error("unsupported pseudo: "+e);return r[v]?r(t):r.length>1?(n=[e,e,"",t],i.setFilters.hasOwnProperty(e.toLowerCase())?ut(function(e,n){var i,o=r(e,t),s=o.length;while(s--)i=P.call(e,o[s]),e[i]=!(n[i]=o[s])}):function(e){return r(e,0,n)}):r}},pseudos:{not:ut(function(e){var t=[],n=[],r=a(e.replace(z,"$1"));return r[v]?ut(function(e,t,n,i){var o,s=r(e,null,i,[]),a=e.length;while(a--)(o=s[a])&&(e[a]=!(t[a]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),!n.pop()}}),has:ut(function(e){return function(t){return ot(e,t).length>0}}),contains:ut(function(e){return function(t){return(t.textContent||t.innerText||o(t)).indexOf(e)>-1}}),lang:ut(function(e){return G.test(e||"")||ot.error("unsupported lang: "+e),e=e.replace(nt,rt).toLowerCase(),function(t){var n;do if(n=h?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return n=n.toLowerCase(),n===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===f},focus:function(e){return e===p.activeElement&&(!p.hasFocus||p.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeName>"@"||3===e.nodeType||4===e.nodeType)return!1;return!0},parent:function(e){return!i.pseudos.empty(e)},header:function(e){return et.test(e.nodeName)},input:function(e){return Z.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||t.toLowerCase()===e.type)},first:yt(function(){return[0]}),last:yt(function(e,t){return[t-1]}),eq:yt(function(e,t,n){return[0>n?n+t:n]}),even:yt(function(e,t){var n=0;for(;t>n;n+=2)e.push(n);return e}),odd:yt(function(e,t){var n=1;for(;t>n;n+=2)e.push(n);return e}),lt:yt(function(e,t,n){var r=0>n?n+t:n;for(;--r>=0;)e.push(r);return e}),gt:yt(function(e,t,n){var r=0>n?n+t:n;for(;t>++r;)e.push(r);return e})}};for(t in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})i.pseudos[t]=gt(t);for(t in{submit:!0,reset:!0})i.pseudos[t]=mt(t);function vt(e,t){var n,r,o,s,a,u,l,c=k[e+" "];if(c)return t?0:c.slice(0);a=e,u=[],l=i.preFilter;while(a){(!n||(r=_.exec(a)))&&(r&&(a=a.slice(r[0].length)||a),u.push(o=[])),n=!1,(r=X.exec(a))&&(n=r.shift(),o.push({value:n,type:r[0].replace(z," ")}),a=a.slice(n.length));for(s in i.filter)!(r=J[s].exec(a))||l[s]&&!(r=l[s](r))||(n=r.shift(),o.push({value:n,type:s,matches:r}),a=a.slice(n.length));if(!n)break}return t?a.length:a?ot.error(e):k(e,u).slice(0)}function xt(e){var t=0,n=e.length,r="";for(;n>t;t++)r+=e[t].value;return r}function bt(e,t,n){var i=t.dir,o=n&&"parentNode"===i,s=T++;return t.first?function(t,n,r){while(t=t[i])if(1===t.nodeType||o)return e(t,n,r)}:function(t,n,a){var u,l,c,p=w+" "+s;if(a){while(t=t[i])if((1===t.nodeType||o)&&e(t,n,a))return!0}else while(t=t[i])if(1===t.nodeType||o)if(c=t[v]||(t[v]={}),(l=c[i])&&l[0]===p){if((u=l[1])===!0||u===r)return u===!0}else if(l=c[i]=[p],l[1]=e(t,n,a)||r,l[1]===!0)return!0}}function wt(e){return e.length>1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function Tt(e,t,n,r,i){var o,s=[],a=0,u=e.length,l=null!=t;for(;u>a;a++)(o=e[a])&&(!n||n(o,r,i))&&(s.push(o),l&&t.push(a));return s}function Ct(e,t,n,r,i,o){return r&&!r[v]&&(r=Ct(r)),i&&!i[v]&&(i=Ct(i,o)),ut(function(o,s,a,u){var l,c,p,f=[],h=[],d=s.length,g=o||Et(t||"*",a.nodeType?[a]:a,[]),m=!e||!o&&t?g:Tt(g,f,e,a,u),y=n?i||(o?e:d||r)?[]:s:m;if(n&&n(m,y,a,u),r){l=Tt(y,h),r(l,[],a,u),c=l.length;while(c--)(p=l[c])&&(y[h[c]]=!(m[h[c]]=p))}if(o){if(i||e){if(i){l=[],c=y.length;while(c--)(p=y[c])&&l.push(m[c]=p);i(null,y=[],l,u)}c=y.length;while(c--)(p=y[c])&&(l=i?P.call(o,p):f[c])>-1&&(o[l]=!(s[l]=p))}}else y=Tt(y===s?y.splice(d,y.length):y),i?i(null,s,y,u):O.apply(s,y)})}function kt(e){var t,n,r,o=e.length,s=i.relative[e[0].type],a=s||i.relative[" "],l=s?1:0,c=bt(function(e){return e===t},a,!0),p=bt(function(e){return P.call(t,e)>-1},a,!0),f=[function(e,n,r){return!s&&(r||n!==u)||((t=n).nodeType?c(e,n,r):p(e,n,r))}];for(;o>l;l++)if(n=i.relative[e[l].type])f=[bt(wt(f),n)];else{if(n=i.filter[e[l].type].apply(null,e[l].matches),n[v]){for(r=++l;o>r;r++)if(i.relative[e[r].type])break;return Ct(l>1&&wt(f),l>1&&xt(e.slice(0,l-1).concat({value:" "===e[l-2].type?"*":""})).replace(z,"$1"),n,r>l&&kt(e.slice(l,r)),o>r&&kt(e=e.slice(r)),o>r&&xt(e))}f.push(n)}return wt(f)}function Nt(e,t){var n=0,o=t.length>0,s=e.length>0,a=function(a,l,c,f,h){var d,g,m,y=[],v=0,x="0",b=a&&[],T=null!=h,C=u,k=a||s&&i.find.TAG("*",h&&l.parentNode||l),N=w+=null==C?1:Math.random()||.1;for(T&&(u=l!==p&&l,r=n);null!=(d=k[x]);x++){if(s&&d){g=0;while(m=e[g++])if(m(d,l,c)){f.push(d);break}T&&(w=N,r=++n)}o&&((d=!m&&d)&&v--,a&&b.push(d))}if(v+=x,o&&x!==v){g=0;while(m=t[g++])m(b,y,l,c);if(a){if(v>0)while(x--)b[x]||y[x]||(y[x]=H.call(f));y=Tt(y)}O.apply(f,y),T&&!a&&y.length>0&&v+t.length>1&&ot.uniqueSort(f)}return T&&(w=N,u=C),b};return o?ut(a):a}a=ot.compile=function(e,t){var n,r=[],i=[],o=N[e+" "];if(!o){t||(t=vt(e)),n=t.length;while(n--)o=kt(t[n]),o[v]?r.push(o):i.push(o);o=N(e,Nt(i,r))}return o};function Et(e,t,n){var r=0,i=t.length;for(;i>r;r++)ot(e,t[r],n);return n}function St(e,t,r,o){var s,u,l,c,p,f=vt(e);if(!o&&1===f.length){if(u=f[0]=f[0].slice(0),u.length>2&&"ID"===(l=u[0]).type&&n.getById&&9===t.nodeType&&h&&i.relative[u[1].type]){if(t=(i.find.ID(l.matches[0].replace(nt,rt),t)||[])[0],!t)return r;e=e.slice(u.shift().value.length)}s=J.needsContext.test(e)?0:u.length;while(s--){if(l=u[s],i.relative[c=l.type])break;if((p=i.find[c])&&(o=p(l.matches[0].replace(nt,rt),U.test(u[0].type)&&t.parentNode||t))){if(u.splice(s,1),e=o.length&&xt(u),!e)return O.apply(r,o),r;break}}}return a(e,f)(o,t,!h,r,U.test(e)),r}i.pseudos.nth=i.pseudos.eq;function jt(){}jt.prototype=i.filters=i.pseudos,i.setFilters=new jt,n.sortStable=v.split("").sort(S).join("")===v,c(),[0,0].sort(S),n.detectDuplicates=E,x.find=ot,x.expr=ot.selectors,x.expr[":"]=x.expr.pseudos,x.unique=ot.uniqueSort,x.text=ot.getText,x.isXMLDoc=ot.isXML,x.contains=ot.contains}(e);var D={};function A(e){var t=D[e]={};return x.each(e.match(w)||[],function(e,n){t[n]=!0}),t}x.Callbacks=function(e){e="string"==typeof e?D[e]||A(e):x.extend({},e);var t,n,r,i,o,s,a=[],u=!e.once&&[],l=function(p){for(t=e.memory&&p,n=!0,s=i||0,i=0,o=a.length,r=!0;a&&o>s;s++)if(a[s].apply(p[0],p[1])===!1&&e.stopOnFalse){t=!1;break}r=!1,a&&(u?u.length&&l(u.shift()):t?a=[]:c.disable())},c={add:function(){if(a){var n=a.length;(function s(t){x.each(t,function(t,n){var r=x.type(n);"function"===r?e.unique&&c.has(n)||a.push(n):n&&n.length&&"string"!==r&&s(n)})})(arguments),r?o=a.length:t&&(i=n,l(t))}return this},remove:function(){return a&&x.each(arguments,function(e,t){var n;while((n=x.inArray(t,a,n))>-1)a.splice(n,1),r&&(o>=n&&o--,s>=n&&s--)}),this},has:function(e){return e?x.inArray(e,a)>-1:!(!a||!a.length)},empty:function(){return a=[],o=0,this},disable:function(){return a=u=t=undefined,this},disabled:function(){return!a},lock:function(){return u=undefined,t||c.disable(),this},locked:function(){return!u},fireWith:function(e,t){return t=t||[],t=[e,t.slice?t.slice():t],!a||n&&!u||(r?u.push(t):l(t)),this},fire:function(){return c.fireWith(this,arguments),this},fired:function(){return!!n}};return c},x.extend({Deferred:function(e){var t=[["resolve","done",x.Callbacks("once memory"),"resolved"],["reject","fail",x.Callbacks("once memory"),"rejected"],["notify","progress",x.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return x.Deferred(function(n){x.each(t,function(t,o){var s=o[0],a=x.isFunction(e[t])&&e[t];i[o[1]](function(){var e=a&&a.apply(this,arguments);e&&x.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[s+"With"](this===r?n.promise():this,a?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?x.extend(e,r):r}},i={};return r.pipe=r.then,x.each(t,function(e,o){var s=o[2],a=o[3];r[o[1]]=s.add,a&&s.add(function(){n=a},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=s.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t=0,n=d.call(arguments),r=n.length,i=1!==r||e&&x.isFunction(e.promise)?r:0,o=1===i?e:x.Deferred(),s=function(e,t,n){return function(r){t[e]=this,n[e]=arguments.length>1?d.call(arguments):r,n===a?o.notifyWith(t,n):--i||o.resolveWith(t,n)}},a,u,l;if(r>1)for(a=Array(r),u=Array(r),l=Array(r);r>t;t++)n[t]&&x.isFunction(n[t].promise)?n[t].promise().done(s(t,l,n)).fail(o.reject).progress(s(t,u,a)):--i;return i||o.resolveWith(l,n),o.promise()}}),x.support=function(t){var n=o.createElement("input"),r=o.createDocumentFragment(),i=o.createElement("div"),s=o.createElement("select"),a=s.appendChild(o.createElement("option"));return n.type?(n.type="checkbox",t.checkOn=""!==n.value,t.optSelected=a.selected,t.reliableMarginRight=!0,t.boxSizingReliable=!0,t.pixelPosition=!1,n.checked=!0,t.noCloneChecked=n.cloneNode(!0).checked,s.disabled=!0,t.optDisabled=!a.disabled,n=o.createElement("input"),n.value="t",n.type="radio",t.radioValue="t"===n.value,n.setAttribute("checked","t"),n.setAttribute("name","t"),r.appendChild(n),t.checkClone=r.cloneNode(!0).cloneNode(!0).lastChild.checked,t.focusinBubbles="onfocusin"in e,i.style.backgroundClip="content-box",i.cloneNode(!0).style.backgroundClip="",t.clearCloneStyle="content-box"===i.style.backgroundClip,x(function(){var n,r,s="padding:0;margin:0;border:0;display:block;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box",a=o.getElementsByTagName("body")[0];a&&(n=o.createElement("div"),n.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",a.appendChild(n).appendChild(i),i.innerHTML="",i.style.cssText="-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%",x.swap(a,null!=a.style.zoom?{zoom:1}:{},function(){t.boxSizing=4===i.offsetWidth}),e.getComputedStyle&&(t.pixelPosition="1%"!==(e.getComputedStyle(i,null)||{}).top,t.boxSizingReliable="4px"===(e.getComputedStyle(i,null)||{width:"4px"}).width,r=i.appendChild(o.createElement("div")),r.style.cssText=i.style.cssText=s,r.style.marginRight=r.style.width="0",i.style.width="1px",t.reliableMarginRight=!parseFloat((e.getComputedStyle(r,null)||{}).marginRight)),a.removeChild(n))}),t):t}({});var L,H,q=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,O=/([A-Z])/g;function F(){Object.defineProperty(this.cache={},0,{get:function(){return{}}}),this.expando=x.expando+Math.random()}F.uid=1,F.accepts=function(e){return e.nodeType?1===e.nodeType||9===e.nodeType:!0},F.prototype={key:function(e){if(!F.accepts(e))return 0;var t={},n=e[this.expando];if(!n){n=F.uid++;try{t[this.expando]={value:n},Object.defineProperties(e,t)}catch(r){t[this.expando]=n,x.extend(e,t)}}return this.cache[n]||(this.cache[n]={}),n},set:function(e,t,n){var r,i=this.key(e),o=this.cache[i];if("string"==typeof t)o[t]=n;else if(x.isEmptyObject(o))x.extend(this.cache[i],t);else for(r in t)o[r]=t[r];return o},get:function(e,t){var n=this.cache[this.key(e)];return t===undefined?n:n[t]},access:function(e,t,n){return t===undefined||t&&"string"==typeof t&&n===undefined?this.get(e,t):(this.set(e,t,n),n!==undefined?n:t)},remove:function(e,t){var n,r,i,o=this.key(e),s=this.cache[o];if(t===undefined)this.cache[o]={};else{x.isArray(t)?r=t.concat(t.map(x.camelCase)):(i=x.camelCase(t),t in s?r=[t,i]:(r=i,r=r in s?[r]:r.match(w)||[])),n=r.length;while(n--)delete s[r[n]]}},hasData:function(e){return!x.isEmptyObject(this.cache[e[this.expando]]||{})},discard:function(e){e[this.expando]&&delete this.cache[e[this.expando]]}},L=new F,H=new F,x.extend({acceptData:F.accepts,hasData:function(e){return L.hasData(e)||H.hasData(e)},data:function(e,t,n){return L.access(e,t,n)},removeData:function(e,t){L.remove(e,t)},_data:function(e,t,n){return H.access(e,t,n)},_removeData:function(e,t){H.remove(e,t)}}),x.fn.extend({data:function(e,t){var n,r,i=this[0],o=0,s=null;if(e===undefined){if(this.length&&(s=L.get(i),1===i.nodeType&&!H.get(i,"hasDataAttrs"))){for(n=i.attributes;n.length>o;o++)r=n[o].name,0===r.indexOf("data-")&&(r=x.camelCase(r.slice(5)),P(i,r,s[r]));H.set(i,"hasDataAttrs",!0)}return s}return"object"==typeof e?this.each(function(){L.set(this,e)}):x.access(this,function(t){var n,r=x.camelCase(e);if(i&&t===undefined){if(n=L.get(i,e),n!==undefined)return n;if(n=L.get(i,r),n!==undefined)return n;if(n=P(i,r,undefined),n!==undefined)return n}else this.each(function(){var n=L.get(this,r);L.set(this,r,t),-1!==e.indexOf("-")&&n!==undefined&&L.set(this,e,t)})},null,t,arguments.length>1,null,!0)},removeData:function(e){return this.each(function(){L.remove(this,e)})}});function P(e,t,n){var r;if(n===undefined&&1===e.nodeType)if(r="data-"+t.replace(O,"-$1").toLowerCase(),n=e.getAttribute(r),"string"==typeof n){try{n="true"===n?!0:"false"===n?!1:"null"===n?null:+n+""===n?+n:q.test(n)?JSON.parse(n):n}catch(i){}L.set(e,t,n)}else n=undefined;return n}x.extend({queue:function(e,t,n){var r;return e?(t=(t||"fx")+"queue",r=H.get(e,t),n&&(!r||x.isArray(n)?r=H.access(e,t,x.makeArray(n)):r.push(n)),r||[]):undefined},dequeue:function(e,t){t=t||"fx";var n=x.queue(e,t),r=n.length,i=n.shift(),o=x._queueHooks(e,t),s=function(){x.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,s,o)),!r&&o&&o.empty.fire() +},_queueHooks:function(e,t){var n=t+"queueHooks";return H.get(e,n)||H.access(e,n,{empty:x.Callbacks("once memory").add(function(){H.remove(e,[t+"queue",n])})})}}),x.fn.extend({queue:function(e,t){var n=2;return"string"!=typeof e&&(t=e,e="fx",n--),n>arguments.length?x.queue(this[0],e):t===undefined?this:this.each(function(){var n=x.queue(this,e,t);x._queueHooks(this,e),"fx"===e&&"inprogress"!==n[0]&&x.dequeue(this,e)})},dequeue:function(e){return this.each(function(){x.dequeue(this,e)})},delay:function(e,t){return e=x.fx?x.fx.speeds[e]||e:e,t=t||"fx",this.queue(t,function(t,n){var r=setTimeout(t,e);n.stop=function(){clearTimeout(r)}})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,t){var n,r=1,i=x.Deferred(),o=this,s=this.length,a=function(){--r||i.resolveWith(o,[o])};"string"!=typeof e&&(t=e,e=undefined),e=e||"fx";while(s--)n=H.get(o[s],e+"queueHooks"),n&&n.empty&&(r++,n.empty.add(a));return a(),i.promise(t)}});var R,M,W=/[\t\r\n\f]/g,$=/\r/g,B=/^(?:input|select|textarea|button)$/i;x.fn.extend({attr:function(e,t){return x.access(this,x.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){x.removeAttr(this,e)})},prop:function(e,t){return x.access(this,x.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[x.propFix[e]||e]})},addClass:function(e){var t,n,r,i,o,s=0,a=this.length,u="string"==typeof e&&e;if(x.isFunction(e))return this.each(function(t){x(this).addClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];a>s;s++)if(n=this[s],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(W," "):" ")){o=0;while(i=t[o++])0>r.indexOf(" "+i+" ")&&(r+=i+" ");n.className=x.trim(r)}return this},removeClass:function(e){var t,n,r,i,o,s=0,a=this.length,u=0===arguments.length||"string"==typeof e&&e;if(x.isFunction(e))return this.each(function(t){x(this).removeClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];a>s;s++)if(n=this[s],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(W," "):"")){o=0;while(i=t[o++])while(r.indexOf(" "+i+" ")>=0)r=r.replace(" "+i+" "," ");n.className=e?x.trim(r):""}return this},toggleClass:function(e,t){var n=typeof e,i="boolean"==typeof t;return x.isFunction(e)?this.each(function(n){x(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if("string"===n){var o,s=0,a=x(this),u=t,l=e.match(w)||[];while(o=l[s++])u=i?u:!a.hasClass(o),a[u?"addClass":"removeClass"](o)}else(n===r||"boolean"===n)&&(this.className&&H.set(this,"__className__",this.className),this.className=this.className||e===!1?"":H.get(this,"__className__")||"")})},hasClass:function(e){var t=" "+e+" ",n=0,r=this.length;for(;r>n;n++)if(1===this[n].nodeType&&(" "+this[n].className+" ").replace(W," ").indexOf(t)>=0)return!0;return!1},val:function(e){var t,n,r,i=this[0];{if(arguments.length)return r=x.isFunction(e),this.each(function(n){var i;1===this.nodeType&&(i=r?e.call(this,n,x(this).val()):e,null==i?i="":"number"==typeof i?i+="":x.isArray(i)&&(i=x.map(i,function(e){return null==e?"":e+""})),t=x.valHooks[this.type]||x.valHooks[this.nodeName.toLowerCase()],t&&"set"in t&&t.set(this,i,"value")!==undefined||(this.value=i))});if(i)return t=x.valHooks[i.type]||x.valHooks[i.nodeName.toLowerCase()],t&&"get"in t&&(n=t.get(i,"value"))!==undefined?n:(n=i.value,"string"==typeof n?n.replace($,""):null==n?"":n)}}}),x.extend({valHooks:{option:{get:function(e){var t=e.attributes.value;return!t||t.specified?e.value:e.text}},select:{get:function(e){var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||0>i,s=o?null:[],a=o?i+1:r.length,u=0>i?a:o?i:0;for(;a>u;u++)if(n=r[u],!(!n.selected&&u!==i||(x.support.optDisabled?n.disabled:null!==n.getAttribute("disabled"))||n.parentNode.disabled&&x.nodeName(n.parentNode,"optgroup"))){if(t=x(n).val(),o)return t;s.push(t)}return s},set:function(e,t){var n,r,i=e.options,o=x.makeArray(t),s=i.length;while(s--)r=i[s],(r.selected=x.inArray(x(r).val(),o)>=0)&&(n=!0);return n||(e.selectedIndex=-1),o}}},attr:function(e,t,n){var i,o,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return typeof e.getAttribute===r?x.prop(e,t,n):(1===s&&x.isXMLDoc(e)||(t=t.toLowerCase(),i=x.attrHooks[t]||(x.expr.match.bool.test(t)?M:R)),n===undefined?i&&"get"in i&&null!==(o=i.get(e,t))?o:(o=x.find.attr(e,t),null==o?undefined:o):null!==n?i&&"set"in i&&(o=i.set(e,n,t))!==undefined?o:(e.setAttribute(t,n+""),n):(x.removeAttr(e,t),undefined))},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(w);if(o&&1===e.nodeType)while(n=o[i++])r=x.propFix[n]||n,x.expr.match.bool.test(n)&&(e[r]=!1),e.removeAttribute(n)},attrHooks:{type:{set:function(e,t){if(!x.support.radioValue&&"radio"===t&&x.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},propFix:{"for":"htmlFor","class":"className"},prop:function(e,t,n){var r,i,o,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return o=1!==s||!x.isXMLDoc(e),o&&(t=x.propFix[t]||t,i=x.propHooks[t]),n!==undefined?i&&"set"in i&&(r=i.set(e,n,t))!==undefined?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){return e.hasAttribute("tabindex")||B.test(e.nodeName)||e.href?e.tabIndex:-1}}}}),M={set:function(e,t,n){return t===!1?x.removeAttr(e,n):e.setAttribute(n,n),n}},x.each(x.expr.match.bool.source.match(/\w+/g),function(e,t){var n=x.expr.attrHandle[t]||x.find.attr;x.expr.attrHandle[t]=function(e,t,r){var i=x.expr.attrHandle[t],o=r?undefined:(x.expr.attrHandle[t]=undefined)!=n(e,t,r)?t.toLowerCase():null;return x.expr.attrHandle[t]=i,o}}),x.support.optSelected||(x.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null}}),x.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){x.propFix[this.toLowerCase()]=this}),x.each(["radio","checkbox"],function(){x.valHooks[this]={set:function(e,t){return x.isArray(t)?e.checked=x.inArray(x(e).val(),t)>=0:undefined}},x.support.checkOn||(x.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})});var I=/^key/,z=/^(?:mouse|contextmenu)|click/,_=/^(?:focusinfocus|focusoutblur)$/,X=/^([^.]*)(?:\.(.+)|)$/;function U(){return!0}function Y(){return!1}function V(){try{return o.activeElement}catch(e){}}x.event={global:{},add:function(e,t,n,i,o){var s,a,u,l,c,p,f,h,d,g,m,y=H.get(e);if(y){n.handler&&(s=n,n=s.handler,o=s.selector),n.guid||(n.guid=x.guid++),(l=y.events)||(l=y.events={}),(a=y.handle)||(a=y.handle=function(e){return typeof x===r||e&&x.event.triggered===e.type?undefined:x.event.dispatch.apply(a.elem,arguments)},a.elem=e),t=(t||"").match(w)||[""],c=t.length;while(c--)u=X.exec(t[c])||[],d=m=u[1],g=(u[2]||"").split(".").sort(),d&&(f=x.event.special[d]||{},d=(o?f.delegateType:f.bindType)||d,f=x.event.special[d]||{},p=x.extend({type:d,origType:m,data:i,handler:n,guid:n.guid,selector:o,needsContext:o&&x.expr.match.needsContext.test(o),namespace:g.join(".")},s),(h=l[d])||(h=l[d]=[],h.delegateCount=0,f.setup&&f.setup.call(e,i,g,a)!==!1||e.addEventListener&&e.addEventListener(d,a,!1)),f.add&&(f.add.call(e,p),p.handler.guid||(p.handler.guid=n.guid)),o?h.splice(h.delegateCount++,0,p):h.push(p),x.event.global[d]=!0);e=null}},remove:function(e,t,n,r,i){var o,s,a,u,l,c,p,f,h,d,g,m=H.hasData(e)&&H.get(e);if(m&&(u=m.events)){t=(t||"").match(w)||[""],l=t.length;while(l--)if(a=X.exec(t[l])||[],h=g=a[1],d=(a[2]||"").split(".").sort(),h){p=x.event.special[h]||{},h=(r?p.delegateType:p.bindType)||h,f=u[h]||[],a=a[2]&&RegExp("(^|\\.)"+d.join("\\.(?:.*\\.|)")+"(\\.|$)"),s=o=f.length;while(o--)c=f[o],!i&&g!==c.origType||n&&n.guid!==c.guid||a&&!a.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(f.splice(o,1),c.selector&&f.delegateCount--,p.remove&&p.remove.call(e,c));s&&!f.length&&(p.teardown&&p.teardown.call(e,d,m.handle)!==!1||x.removeEvent(e,h,m.handle),delete u[h])}else for(h in u)x.event.remove(e,h+t[l],n,r,!0);x.isEmptyObject(u)&&(delete m.handle,H.remove(e,"events"))}},trigger:function(t,n,r,i){var s,a,u,l,c,p,f,h=[r||o],d=y.call(t,"type")?t.type:t,g=y.call(t,"namespace")?t.namespace.split("."):[];if(a=u=r=r||o,3!==r.nodeType&&8!==r.nodeType&&!_.test(d+x.event.triggered)&&(d.indexOf(".")>=0&&(g=d.split("."),d=g.shift(),g.sort()),c=0>d.indexOf(":")&&"on"+d,t=t[x.expando]?t:new x.Event(d,"object"==typeof t&&t),t.isTrigger=i?2:3,t.namespace=g.join("."),t.namespace_re=t.namespace?RegExp("(^|\\.)"+g.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=undefined,t.target||(t.target=r),n=null==n?[t]:x.makeArray(n,[t]),f=x.event.special[d]||{},i||!f.trigger||f.trigger.apply(r,n)!==!1)){if(!i&&!f.noBubble&&!x.isWindow(r)){for(l=f.delegateType||d,_.test(l+d)||(a=a.parentNode);a;a=a.parentNode)h.push(a),u=a;u===(r.ownerDocument||o)&&h.push(u.defaultView||u.parentWindow||e)}s=0;while((a=h[s++])&&!t.isPropagationStopped())t.type=s>1?l:f.bindType||d,p=(H.get(a,"events")||{})[t.type]&&H.get(a,"handle"),p&&p.apply(a,n),p=c&&a[c],p&&x.acceptData(a)&&p.apply&&p.apply(a,n)===!1&&t.preventDefault();return t.type=d,i||t.isDefaultPrevented()||f._default&&f._default.apply(h.pop(),n)!==!1||!x.acceptData(r)||c&&x.isFunction(r[d])&&!x.isWindow(r)&&(u=r[c],u&&(r[c]=null),x.event.triggered=d,r[d](),x.event.triggered=undefined,u&&(r[c]=u)),t.result}},dispatch:function(e){e=x.event.fix(e);var t,n,r,i,o,s=[],a=d.call(arguments),u=(H.get(this,"events")||{})[e.type]||[],l=x.event.special[e.type]||{};if(a[0]=e,e.delegateTarget=this,!l.preDispatch||l.preDispatch.call(this,e)!==!1){s=x.event.handlers.call(this,e,u),t=0;while((i=s[t++])&&!e.isPropagationStopped()){e.currentTarget=i.elem,n=0;while((o=i.handlers[n++])&&!e.isImmediatePropagationStopped())(!e.namespace_re||e.namespace_re.test(o.namespace))&&(e.handleObj=o,e.data=o.data,r=((x.event.special[o.origType]||{}).handle||o.handler).apply(i.elem,a),r!==undefined&&(e.result=r)===!1&&(e.preventDefault(),e.stopPropagation()))}return l.postDispatch&&l.postDispatch.call(this,e),e.result}},handlers:function(e,t){var n,r,i,o,s=[],a=t.delegateCount,u=e.target;if(a&&u.nodeType&&(!e.button||"click"!==e.type))for(;u!==this;u=u.parentNode||this)if(u.disabled!==!0||"click"!==e.type){for(r=[],n=0;a>n;n++)o=t[n],i=o.selector+" ",r[i]===undefined&&(r[i]=o.needsContext?x(i,this).index(u)>=0:x.find(i,this,null,[u]).length),r[i]&&r.push(o);r.length&&s.push({elem:u,handlers:r})}return t.length>a&&s.push({elem:this,handlers:t.slice(a)}),s},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(e,t){return null==e.which&&(e.which=null!=t.charCode?t.charCode:t.keyCode),e}},mouseHooks:{props:"button buttons clientX clientY offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(e,t){var n,r,i,s=t.button;return null==e.pageX&&null!=t.clientX&&(n=e.target.ownerDocument||o,r=n.documentElement,i=n.body,e.pageX=t.clientX+(r&&r.scrollLeft||i&&i.scrollLeft||0)-(r&&r.clientLeft||i&&i.clientLeft||0),e.pageY=t.clientY+(r&&r.scrollTop||i&&i.scrollTop||0)-(r&&r.clientTop||i&&i.clientTop||0)),e.which||s===undefined||(e.which=1&s?1:2&s?3:4&s?2:0),e}},fix:function(e){if(e[x.expando])return e;var t,n,r,i=e.type,s=e,a=this.fixHooks[i];a||(this.fixHooks[i]=a=z.test(i)?this.mouseHooks:I.test(i)?this.keyHooks:{}),r=a.props?this.props.concat(a.props):this.props,e=new x.Event(s),t=r.length;while(t--)n=r[t],e[n]=s[n];return e.target||(e.target=o),3===e.target.nodeType&&(e.target=e.target.parentNode),a.filter?a.filter(e,s):e},special:{load:{noBubble:!0},focus:{trigger:function(){return this!==V()&&this.focus?(this.focus(),!1):undefined},delegateType:"focusin"},blur:{trigger:function(){return this===V()&&this.blur?(this.blur(),!1):undefined},delegateType:"focusout"},click:{trigger:function(){return"checkbox"===this.type&&this.click&&x.nodeName(this,"input")?(this.click(),!1):undefined},_default:function(e){return x.nodeName(e.target,"a")}},beforeunload:{postDispatch:function(e){e.result!==undefined&&(e.originalEvent.returnValue=e.result)}}},simulate:function(e,t,n,r){var i=x.extend(new x.Event,n,{type:e,isSimulated:!0,originalEvent:{}});r?x.event.trigger(i,null,t):x.event.dispatch.call(t,i),i.isDefaultPrevented()&&n.preventDefault()}},x.removeEvent=function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n,!1)},x.Event=function(e,t){return this instanceof x.Event?(e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||e.getPreventDefault&&e.getPreventDefault()?U:Y):this.type=e,t&&x.extend(this,t),this.timeStamp=e&&e.timeStamp||x.now(),this[x.expando]=!0,undefined):new x.Event(e,t)},x.Event.prototype={isDefaultPrevented:Y,isPropagationStopped:Y,isImmediatePropagationStopped:Y,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=U,e&&e.preventDefault&&e.preventDefault()},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=U,e&&e.stopPropagation&&e.stopPropagation()},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=U,this.stopPropagation()}},x.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(e,t){x.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj;return(!i||i!==r&&!x.contains(r,i))&&(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),x.support.focusinBubbles||x.each({focus:"focusin",blur:"focusout"},function(e,t){var n=0,r=function(e){x.event.simulate(t,e.target,x.event.fix(e),!0)};x.event.special[t]={setup:function(){0===n++&&o.addEventListener(e,r,!0)},teardown:function(){0===--n&&o.removeEventListener(e,r,!0)}}}),x.fn.extend({on:function(e,t,n,r,i){var o,s;if("object"==typeof e){"string"!=typeof t&&(n=n||t,t=undefined);for(s in e)this.on(s,t,n,e[s],i);return this}if(null==n&&null==r?(r=t,n=t=undefined):null==r&&("string"==typeof t?(r=n,n=undefined):(r=n,n=t,t=undefined)),r===!1)r=Y;else if(!r)return this;return 1===i&&(o=r,r=function(e){return x().off(e),o.apply(this,arguments)},r.guid=o.guid||(o.guid=x.guid++)),this.each(function(){x.event.add(this,e,r,n,t)})},one:function(e,t,n,r){return this.on(e,t,n,r,1)},off:function(e,t,n){var r,i;if(e&&e.preventDefault&&e.handleObj)return r=e.handleObj,x(e.delegateTarget).off(r.namespace?r.origType+"."+r.namespace:r.origType,r.selector,r.handler),this;if("object"==typeof e){for(i in e)this.off(i,t,e[i]);return this}return(t===!1||"function"==typeof t)&&(n=t,t=undefined),n===!1&&(n=Y),this.each(function(){x.event.remove(this,e,n,t)})},trigger:function(e,t){return this.each(function(){x.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];return n?x.event.trigger(e,t,n,!0):undefined}});var G=/^.[^:#\[\.,]*$/,J=/^(?:parents|prev(?:Until|All))/,Q=x.expr.match.needsContext,K={children:!0,contents:!0,next:!0,prev:!0};x.fn.extend({find:function(e){var t,n=[],r=this,i=r.length;if("string"!=typeof e)return this.pushStack(x(e).filter(function(){for(t=0;i>t;t++)if(x.contains(r[t],this))return!0}));for(t=0;i>t;t++)x.find(e,r[t],n);return n=this.pushStack(i>1?x.unique(n):n),n.selector=this.selector?this.selector+" "+e:e,n},has:function(e){var t=x(e,this),n=t.length;return this.filter(function(){var e=0;for(;n>e;e++)if(x.contains(this,t[e]))return!0})},not:function(e){return this.pushStack(et(this,e||[],!0))},filter:function(e){return this.pushStack(et(this,e||[],!1))},is:function(e){return!!et(this,"string"==typeof e&&Q.test(e)?x(e):e||[],!1).length},closest:function(e,t){var n,r=0,i=this.length,o=[],s=Q.test(e)||"string"!=typeof e?x(e,t||this.context):0;for(;i>r;r++)for(n=this[r];n&&n!==t;n=n.parentNode)if(11>n.nodeType&&(s?s.index(n)>-1:1===n.nodeType&&x.find.matchesSelector(n,e))){n=o.push(n);break}return this.pushStack(o.length>1?x.unique(o):o)},index:function(e){return e?"string"==typeof e?g.call(x(e),this[0]):g.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){var n="string"==typeof e?x(e,t):x.makeArray(e&&e.nodeType?[e]:e),r=x.merge(this.get(),n);return this.pushStack(x.unique(r))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}});function Z(e,t){while((e=e[t])&&1!==e.nodeType);return e}x.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return x.dir(e,"parentNode")},parentsUntil:function(e,t,n){return x.dir(e,"parentNode",n)},next:function(e){return Z(e,"nextSibling")},prev:function(e){return Z(e,"previousSibling")},nextAll:function(e){return x.dir(e,"nextSibling")},prevAll:function(e){return x.dir(e,"previousSibling")},nextUntil:function(e,t,n){return x.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return x.dir(e,"previousSibling",n)},siblings:function(e){return x.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return x.sibling(e.firstChild)},contents:function(e){return e.contentDocument||x.merge([],e.childNodes)}},function(e,t){x.fn[e]=function(n,r){var i=x.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=x.filter(r,i)),this.length>1&&(K[e]||x.unique(i),J.test(e)&&i.reverse()),this.pushStack(i)}}),x.extend({filter:function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?x.find.matchesSelector(r,e)?[r]:[]:x.find.matches(e,x.grep(t,function(e){return 1===e.nodeType}))},dir:function(e,t,n){var r=[],i=n!==undefined;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&x(e).is(n))break;r.push(e)}return r},sibling:function(e,t){var n=[];for(;e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}});function et(e,t,n){if(x.isFunction(t))return x.grep(e,function(e,r){return!!t.call(e,r,e)!==n});if(t.nodeType)return x.grep(e,function(e){return e===t!==n});if("string"==typeof t){if(G.test(t))return x.filter(t,e,n);t=x.filter(t,e)}return x.grep(e,function(e){return g.call(t,e)>=0!==n})}var tt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,nt=/<([\w:]+)/,rt=/<|&#?\w+;/,it=/<(?:script|style|link)/i,ot=/^(?:checkbox|radio)$/i,st=/checked\s*(?:[^=]|=\s*.checked.)/i,at=/^$|\/(?:java|ecma)script/i,ut=/^true\/(.*)/,lt=/^\s*\s*$/g,ct={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ct.optgroup=ct.option,ct.tbody=ct.tfoot=ct.colgroup=ct.caption=ct.thead,ct.th=ct.td,x.fn.extend({text:function(e){return x.access(this,function(e){return e===undefined?x.text(this):this.empty().append((this[0]&&this[0].ownerDocument||o).createTextNode(e))},null,e,arguments.length)},append:function(){return this.domManip(arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=pt(this,e);t.appendChild(e)}})},prepend:function(){return this.domManip(arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=pt(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return this.domManip(arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return this.domManip(arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},remove:function(e,t){var n,r=e?x.filter(e,this):this,i=0;for(;null!=(n=r[i]);i++)t||1!==n.nodeType||x.cleanData(mt(n)),n.parentNode&&(t&&x.contains(n.ownerDocument,n)&&dt(mt(n,"script")),n.parentNode.removeChild(n));return this},empty:function(){var e,t=0;for(;null!=(e=this[t]);t++)1===e.nodeType&&(x.cleanData(mt(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null==e?!1:e,t=null==t?e:t,this.map(function(){return x.clone(this,e,t)})},html:function(e){return x.access(this,function(e){var t=this[0]||{},n=0,r=this.length;if(e===undefined&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!it.test(e)&&!ct[(nt.exec(e)||["",""])[1].toLowerCase()]){e=e.replace(tt,"<$1>");try{for(;r>n;n++)t=this[n]||{},1===t.nodeType&&(x.cleanData(mt(t,!1)),t.innerHTML=e);t=0}catch(i){}}t&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(){var e=x.map(this,function(e){return[e.nextSibling,e.parentNode]}),t=0;return this.domManip(arguments,function(n){var r=e[t++],i=e[t++];i&&(r&&r.parentNode!==i&&(r=this.nextSibling),x(this).remove(),i.insertBefore(n,r))},!0),t?this:this.remove()},detach:function(e){return this.remove(e,!0)},domManip:function(e,t,n){e=f.apply([],e);var r,i,o,s,a,u,l=0,c=this.length,p=this,h=c-1,d=e[0],g=x.isFunction(d);if(g||!(1>=c||"string"!=typeof d||x.support.checkClone)&&st.test(d))return this.each(function(r){var i=p.eq(r);g&&(e[0]=d.call(this,r,i.html())),i.domManip(e,t,n)});if(c&&(r=x.buildFragment(e,this[0].ownerDocument,!1,!n&&this),i=r.firstChild,1===r.childNodes.length&&(r=i),i)){for(o=x.map(mt(r,"script"),ft),s=o.length;c>l;l++)a=r,l!==h&&(a=x.clone(a,!0,!0),s&&x.merge(o,mt(a,"script"))),t.call(this[l],a,l);if(s)for(u=o[o.length-1].ownerDocument,x.map(o,ht),l=0;s>l;l++)a=o[l],at.test(a.type||"")&&!H.access(a,"globalEval")&&x.contains(u,a)&&(a.src?x._evalUrl(a.src):x.globalEval(a.textContent.replace(lt,"")))}return this}}),x.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){x.fn[e]=function(e){var n,r=[],i=x(e),o=i.length-1,s=0;for(;o>=s;s++)n=s===o?this:this.clone(!0),x(i[s])[t](n),h.apply(r,n.get());return this.pushStack(r)}}),x.extend({clone:function(e,t,n){var r,i,o,s,a=e.cloneNode(!0),u=x.contains(e.ownerDocument,e);if(!(x.support.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||x.isXMLDoc(e)))for(s=mt(a),o=mt(e),r=0,i=o.length;i>r;r++)yt(o[r],s[r]);if(t)if(n)for(o=o||mt(e),s=s||mt(a),r=0,i=o.length;i>r;r++)gt(o[r],s[r]);else gt(e,a);return s=mt(a,"script"),s.length>0&&dt(s,!u&&mt(e,"script")),a},buildFragment:function(e,t,n,r){var i,o,s,a,u,l,c=0,p=e.length,f=t.createDocumentFragment(),h=[];for(;p>c;c++)if(i=e[c],i||0===i)if("object"===x.type(i))x.merge(h,i.nodeType?[i]:i);else if(rt.test(i)){o=o||f.appendChild(t.createElement("div")),s=(nt.exec(i)||["",""])[1].toLowerCase(),a=ct[s]||ct._default,o.innerHTML=a[1]+i.replace(tt,"<$1>")+a[2],l=a[0];while(l--)o=o.firstChild;x.merge(h,o.childNodes),o=f.firstChild,o.textContent=""}else h.push(t.createTextNode(i));f.textContent="",c=0;while(i=h[c++])if((!r||-1===x.inArray(i,r))&&(u=x.contains(i.ownerDocument,i),o=mt(f.appendChild(i),"script"),u&&dt(o),n)){l=0;while(i=o[l++])at.test(i.type||"")&&n.push(i)}return f},cleanData:function(e){var t,n,r,i,o,s,a=x.event.special,u=0;for(;(n=e[u])!==undefined;u++){if(F.accepts(n)&&(o=n[H.expando],o&&(t=H.cache[o]))){if(r=Object.keys(t.events||{}),r.length)for(s=0;(i=r[s])!==undefined;s++)a[i]?x.event.remove(n,i):x.removeEvent(n,i,t.handle);H.cache[o]&&delete H.cache[o]}delete L.cache[n[L.expando]]}},_evalUrl:function(e){return x.ajax({url:e,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0})}});function pt(e,t){return x.nodeName(e,"table")&&x.nodeName(1===t.nodeType?t:t.firstChild,"tr")?e.getElementsByTagName("tbody")[0]||e.appendChild(e.ownerDocument.createElement("tbody")):e}function ft(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function ht(e){var t=ut.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function dt(e,t){var n=e.length,r=0;for(;n>r;r++)H.set(e[r],"globalEval",!t||H.get(t[r],"globalEval"))}function gt(e,t){var n,r,i,o,s,a,u,l;if(1===t.nodeType){if(H.hasData(e)&&(o=H.access(e),s=H.set(t,o),l=o.events)){delete s.handle,s.events={};for(i in l)for(n=0,r=l[i].length;r>n;n++)x.event.add(t,i,l[i][n])}L.hasData(e)&&(a=L.access(e),u=x.extend({},a),L.set(t,u))}}function mt(e,t){var n=e.getElementsByTagName?e.getElementsByTagName(t||"*"):e.querySelectorAll?e.querySelectorAll(t||"*"):[];return t===undefined||t&&x.nodeName(e,t)?x.merge([e],n):n}function yt(e,t){var n=t.nodeName.toLowerCase();"input"===n&&ot.test(e.type)?t.checked=e.checked:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}x.fn.extend({wrapAll:function(e){var t;return x.isFunction(e)?this.each(function(t){x(this).wrapAll(e.call(this,t))}):(this[0]&&(t=x(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this)},wrapInner:function(e){return x.isFunction(e)?this.each(function(t){x(this).wrapInner(e.call(this,t))}):this.each(function(){var t=x(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=x.isFunction(e);return this.each(function(n){x(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){x.nodeName(this,"body")||x(this).replaceWith(this.childNodes)}).end()}});var vt,xt,bt=/^(none|table(?!-c[ea]).+)/,wt=/^margin/,Tt=RegExp("^("+b+")(.*)$","i"),Ct=RegExp("^("+b+")(?!px)[a-z%]+$","i"),kt=RegExp("^([+-])=("+b+")","i"),Nt={BODY:"block"},Et={position:"absolute",visibility:"hidden",display:"block"},St={letterSpacing:0,fontWeight:400},jt=["Top","Right","Bottom","Left"],Dt=["Webkit","O","Moz","ms"];function At(e,t){if(t in e)return t;var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=Dt.length;while(i--)if(t=Dt[i]+n,t in e)return t;return r}function Lt(e,t){return e=t||e,"none"===x.css(e,"display")||!x.contains(e.ownerDocument,e)}function Ht(t){return e.getComputedStyle(t,null)}function qt(e,t){var n,r,i,o=[],s=0,a=e.length;for(;a>s;s++)r=e[s],r.style&&(o[s]=H.get(r,"olddisplay"),n=r.style.display,t?(o[s]||"none"!==n||(r.style.display=""),""===r.style.display&&Lt(r)&&(o[s]=H.access(r,"olddisplay",Rt(r.nodeName)))):o[s]||(i=Lt(r),(n&&"none"!==n||!i)&&H.set(r,"olddisplay",i?n:x.css(r,"display"))));for(s=0;a>s;s++)r=e[s],r.style&&(t&&"none"!==r.style.display&&""!==r.style.display||(r.style.display=t?o[s]||"":"none"));return e}x.fn.extend({css:function(e,t){return x.access(this,function(e,t,n){var r,i,o={},s=0;if(x.isArray(t)){for(r=Ht(e),i=t.length;i>s;s++)o[t[s]]=x.css(e,t[s],!1,r);return o}return n!==undefined?x.style(e,t,n):x.css(e,t)},e,t,arguments.length>1)},show:function(){return qt(this,!0)},hide:function(){return qt(this)},toggle:function(e){var t="boolean"==typeof e;return this.each(function(){(t?e:Lt(this))?x(this).show():x(this).hide()})}}),x.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=vt(e,"opacity");return""===n?"1":n}}}},cssNumber:{columnCount:!0,fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":"cssFloat"},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,s,a=x.camelCase(t),u=e.style;return t=x.cssProps[a]||(x.cssProps[a]=At(u,a)),s=x.cssHooks[t]||x.cssHooks[a],n===undefined?s&&"get"in s&&(i=s.get(e,!1,r))!==undefined?i:u[t]:(o=typeof n,"string"===o&&(i=kt.exec(n))&&(n=(i[1]+1)*i[2]+parseFloat(x.css(e,t)),o="number"),null==n||"number"===o&&isNaN(n)||("number"!==o||x.cssNumber[a]||(n+="px"),x.support.clearCloneStyle||""!==n||0!==t.indexOf("background")||(u[t]="inherit"),s&&"set"in s&&(n=s.set(e,n,r))===undefined||(u[t]=n)),undefined)}},css:function(e,t,n,r){var i,o,s,a=x.camelCase(t);return t=x.cssProps[a]||(x.cssProps[a]=At(e.style,a)),s=x.cssHooks[t]||x.cssHooks[a],s&&"get"in s&&(i=s.get(e,!0,n)),i===undefined&&(i=vt(e,t,r)),"normal"===i&&t in St&&(i=St[t]),""===n||n?(o=parseFloat(i),n===!0||x.isNumeric(o)?o||0:i):i}}),vt=function(e,t,n){var r,i,o,s=n||Ht(e),a=s?s.getPropertyValue(t)||s[t]:undefined,u=e.style;return s&&(""!==a||x.contains(e.ownerDocument,e)||(a=x.style(e,t)),Ct.test(a)&&wt.test(t)&&(r=u.width,i=u.minWidth,o=u.maxWidth,u.minWidth=u.maxWidth=u.width=a,a=s.width,u.width=r,u.minWidth=i,u.maxWidth=o)),a};function Ot(e,t,n){var r=Tt.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function Ft(e,t,n,r,i){var o=n===(r?"border":"content")?4:"width"===t?1:0,s=0;for(;4>o;o+=2)"margin"===n&&(s+=x.css(e,n+jt[o],!0,i)),r?("content"===n&&(s-=x.css(e,"padding"+jt[o],!0,i)),"margin"!==n&&(s-=x.css(e,"border"+jt[o]+"Width",!0,i))):(s+=x.css(e,"padding"+jt[o],!0,i),"padding"!==n&&(s+=x.css(e,"border"+jt[o]+"Width",!0,i)));return s}function Pt(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=Ht(e),s=x.support.boxSizing&&"border-box"===x.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=vt(e,t,o),(0>i||null==i)&&(i=e.style[t]),Ct.test(i))return i;r=s&&(x.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+Ft(e,t,n||(s?"border":"content"),r,o)+"px"}function Rt(e){var t=o,n=Nt[e];return n||(n=Mt(e,t),"none"!==n&&n||(xt=(xt||x("