From dfe666e61fe3eaeb7a1e69dcdacb0645b6092ab1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 3 Oct 2020 01:15:19 +0200 Subject: [PATCH 001/131] renamed `load_json` to `load_json_file` --- pype/settings/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index 96c3829388..129a218dba 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -59,7 +59,7 @@ def default_settings(): return _DEFAULT_SETTINGS -def load_json(fpath): +def load_json_file(fpath): # Load json data with open(fpath, "r") as opened_file: lines = opened_file.read().splitlines() From 9683208f0ca217ab17a300412bd10fa8977923c3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 3 Oct 2020 01:15:52 +0200 Subject: [PATCH 002/131] simplified `load_json_file` as jsons are expected to be saved with gui --- pype/settings/lib.py | 41 +---------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index 129a218dba..57f90ff0f7 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -61,48 +61,9 @@ def default_settings(): def load_json_file(fpath): # Load json data - with open(fpath, "r") as opened_file: - lines = opened_file.read().splitlines() - - # prepare json string - standard_json = "" - for line in lines: - # Remove all whitespace on both sides - line = line.strip() - - # Skip blank lines - if len(line) == 0: - continue - - standard_json += line - - # Check if has extra commas - extra_comma = False - if ",]" in standard_json or ",}" in standard_json: - extra_comma = True - standard_json = standard_json.replace(",]", "]") - standard_json = standard_json.replace(",}", "}") - - if extra_comma: - log.error("Extra comma in json file: \"{}\"".format(fpath)) - - # return empty dict if file is empty - if standard_json == "": - return {} - - # Try to parse string - try: - return json.loads(standard_json) - - except json.decoder.JSONDecodeError: - # Return empty dict if it is first time that decode error happened - return {} - - # Repreduce the exact same exception but traceback contains better - # information about position of error in the loaded json try: with open(fpath, "r") as opened_file: - json.load(opened_file) + return json.load(opened_file) except json.decoder.JSONDecodeError: log.warning( From 11766b71a58b65067db9902bfd81e08092d321b1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 3 Oct 2020 01:16:41 +0200 Subject: [PATCH 003/131] changed `load_json` to `load_json_file` in code --- pype/settings/lib.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index 57f90ff0f7..6d36101194 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -153,25 +153,25 @@ def load_jsons_from_dir(path, *args, **kwargs): def studio_system_settings(): if os.path.exists(SYSTEM_SETTINGS_PATH): - return load_json(SYSTEM_SETTINGS_PATH) + return load_json_file(SYSTEM_SETTINGS_PATH) return {} def studio_environments(): if os.path.exists(ENVIRONMENTS_PATH): - return load_json(ENVIRONMENTS_PATH) + return load_json_file(ENVIRONMENTS_PATH) return {} def studio_project_settings(): if os.path.exists(PROJECT_SETTINGS_PATH): - return load_json(PROJECT_SETTINGS_PATH) + return load_json_file(PROJECT_SETTINGS_PATH) return {} def studio_project_anatomy(): if os.path.exists(PROJECT_ANATOMY_PATH): - return load_json(PROJECT_ANATOMY_PATH) + return load_json_file(PROJECT_ANATOMY_PATH) return {} @@ -198,7 +198,7 @@ def project_settings_overrides(project_name): path_to_json = path_to_project_overrides(project_name) if not os.path.exists(path_to_json): return {} - return load_json(path_to_json) + return load_json_file(path_to_json) def project_anatomy_overrides(project_name): @@ -208,7 +208,7 @@ def project_anatomy_overrides(project_name): path_to_json = path_to_project_anatomy(project_name) if not os.path.exists(path_to_json): return {} - return load_json(path_to_json) + return load_json_file(path_to_json) def merge_overrides(global_dict, override_dict): From f44cb798ff3a639d2cd3b6945a5530f71c350ee1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 3 Oct 2020 01:17:08 +0200 Subject: [PATCH 004/131] moved `load_jsons_from_dir` --- pype/settings/lib.py | 76 ++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index 6d36101194..fb76bdc2b3 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -74,6 +74,44 @@ def load_json_file(fpath): return {} +def load_jsons_from_dir(path, *args, **kwargs): + output = {} + + path = os.path.normpath(path) + if not os.path.exists(path): + # TODO warning + return output + + sub_keys = list(kwargs.pop("subkeys", args)) + for sub_key in tuple(sub_keys): + _path = os.path.join(path, sub_key) + if not os.path.exists(_path): + break + + path = _path + sub_keys.pop(0) + + base_len = len(path) + 1 + for base, _directories, filenames in os.walk(path): + base_items_str = base[base_len:] + if not base_items_str: + base_items = [] + else: + base_items = base_items_str.split(os.path.sep) + + for filename in filenames: + basename, ext = os.path.splitext(filename) + if ext == ".json": + full_path = os.path.join(base, filename) + value = load_json_file(full_path) + dict_keys = base_items + [basename] + output = subkey_merge(output, value, dict_keys) + + for sub_key in sub_keys: + output = output[sub_key] + return output + + def find_environments(data): if not data or not isinstance(data, dict): return @@ -113,44 +151,6 @@ def subkey_merge(_dict, value, keys): return _dict -def load_jsons_from_dir(path, *args, **kwargs): - output = {} - - path = os.path.normpath(path) - if not os.path.exists(path): - # TODO warning - return output - - sub_keys = list(kwargs.pop("subkeys", args)) - for sub_key in tuple(sub_keys): - _path = os.path.join(path, sub_key) - if not os.path.exists(_path): - break - - path = _path - sub_keys.pop(0) - - base_len = len(path) + 1 - for base, _directories, filenames in os.walk(path): - base_items_str = base[base_len:] - if not base_items_str: - base_items = [] - else: - base_items = base_items_str.split(os.path.sep) - - for filename in filenames: - basename, ext = os.path.splitext(filename) - if ext == ".json": - full_path = os.path.join(base, filename) - value = load_json(full_path) - dict_keys = base_items + [basename] - output = subkey_merge(output, value, dict_keys) - - for sub_key in sub_keys: - output = output[sub_key] - return output - - def studio_system_settings(): if os.path.exists(SYSTEM_SETTINGS_PATH): return load_json_file(SYSTEM_SETTINGS_PATH) From 62c157ab2e4e1de4d64b7715d46812e18289b55c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 3 Oct 2020 01:19:17 +0200 Subject: [PATCH 005/131] path to project settings returns path to studio overrides if project name is None --- pype/settings/lib.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index fb76bdc2b3..ca134d0611 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -175,7 +175,9 @@ def studio_project_anatomy(): return {} -def path_to_project_overrides(project_name): +def path_to_project_settings(project_name): + if not project_name: + return PROJECT_SETTINGS_PATH return os.path.join( STUDIO_OVERRIDES_PATH, project_name, @@ -184,6 +186,8 @@ def path_to_project_overrides(project_name): def path_to_project_anatomy(project_name): + if not project_name: + return PROJECT_ANATOMY_PATH return os.path.join( STUDIO_OVERRIDES_PATH, project_name, @@ -195,7 +199,7 @@ def project_settings_overrides(project_name): if not project_name: return {} - path_to_json = path_to_project_overrides(project_name) + path_to_json = path_to_project_settings(project_name) if not os.path.exists(path_to_json): return {} return load_json_file(path_to_json) From 85844ba4cfdce91844c3211d13ad22ac0c6d77ed Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 3 Oct 2020 01:19:36 +0200 Subject: [PATCH 006/131] added functions for saving settings --- pype/settings/lib.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index ca134d0611..0e6aa65adc 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -195,6 +195,38 @@ def path_to_project_anatomy(project_name): ) +def save_studio_settings(data): + dirpath = os.path.dirname(SYSTEM_SETTINGS_PATH) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + print("Saving studio overrides") + with open(SYSTEM_SETTINGS_PATH, "w") as file_stream: + json.dump(data, file_stream, indent=4) + + +def save_project_settings(project_name, overrides): + project_overrides_json_path = path_to_project_settings(project_name) + dirpath = os.path.dirname(project_overrides_json_path) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + print("Saving overrides of project \"{}\"".format(project_name)) + with open(project_overrides_json_path, "w") as file_stream: + json.dump(overrides, file_stream, indent=4) + + +def save_project_anatomy(project_name, anatomy_data): + project_anatomy_json_path = path_to_project_anatomy(project_name) + dirpath = os.path.dirname(project_anatomy_json_path) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + print("Saving anatomy of project \"{}\"".format(project_name)) + with open(project_anatomy_json_path, "w") as file_stream: + json.dump(anatomy_data, file_stream, indent=4) + + def project_settings_overrides(project_name): if not project_name: return {} From 2dea080841f2fdffdebec9b64742dfb8c5740a7b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 3 Oct 2020 01:19:44 +0200 Subject: [PATCH 007/131] STUDIO_OVERRIDES_PATH is safer --- pype/settings/lib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index 0e6aa65adc..abb704f033 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -13,7 +13,7 @@ M_ENVIRONMENT_KEY = "__environment_keys__" M_POP_KEY = "__pop_key__" # Folder where studio overrides are stored -STUDIO_OVERRIDES_PATH = os.environ["PYPE_PROJECT_CONFIGS"] +STUDIO_OVERRIDES_PATH = os.getenv("PYPE_PROJECT_CONFIGS") # File where studio's system overrides are stored SYSTEM_SETTINGS_KEY = "system_settings" @@ -70,7 +70,6 @@ def load_json_file(fpath): "File has invalid json format \"{}\"".format(fpath), exc_info=True ) - return {} From d8b6733b86e03d36c8d8192bcdfe829dfb2c6a5e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 3 Oct 2020 01:20:01 +0200 Subject: [PATCH 008/131] using saving functions in settings tool --- pype/tools/settings/settings/widgets/base.py | 130 +++++-------------- 1 file changed, 31 insertions(+), 99 deletions(-) diff --git a/pype/tools/settings/settings/widgets/base.py b/pype/tools/settings/settings/widgets/base.py index 7cbe7c2f6f..5feab765e5 100644 --- a/pype/tools/settings/settings/widgets/base.py +++ b/pype/tools/settings/settings/widgets/base.py @@ -3,11 +3,8 @@ import json from Qt import QtWidgets, QtCore, QtGui from pype.settings.lib import ( SYSTEM_SETTINGS_KEY, - SYSTEM_SETTINGS_PATH, PROJECT_SETTINGS_KEY, - PROJECT_SETTINGS_PATH, PROJECT_ANATOMY_KEY, - PROJECT_ANATOMY_PATH, DEFAULTS_DIR, @@ -21,8 +18,9 @@ from pype.settings.lib import ( project_settings_overrides, project_anatomy_overrides, - path_to_project_overrides, - path_to_project_anatomy + save_studio_settings, + save_project_settings, + save_project_anatomy ) from .widgets import UnsavedChangesDialog from . import lib @@ -183,13 +181,7 @@ class SystemWidget(QtWidgets.QWidget): values = lib.convert_gui_data_to_overrides(_data.get("system", {})) - dirpath = os.path.dirname(SYSTEM_SETTINGS_PATH) - if not os.path.exists(dirpath): - os.makedirs(dirpath) - - print("Saving data to:", SYSTEM_SETTINGS_PATH) - with open(SYSTEM_SETTINGS_PATH, "w") as file_stream: - json.dump(values, file_stream, indent=4) + save_studio_settings(values) self._update_values() @@ -621,29 +613,25 @@ class ProjectWidget(QtWidgets.QWidget): if item.child_invalid: has_invalid = True - if has_invalid: - invalid_items = [] - for item in self.input_fields: - invalid_items.extend(item.get_invalid()) - msg_box = QtWidgets.QMessageBox( - QtWidgets.QMessageBox.Warning, - "Invalid input", - "There is invalid value in one of inputs." - " Please lead red color and fix them." - ) - msg_box.setStandardButtons(QtWidgets.QMessageBox.Ok) - msg_box.exec_() + if not has_invalid: + return self._save_overrides() - first_invalid_item = invalid_items[0] - self.scroll_widget.ensureWidgetVisible(first_invalid_item) - if first_invalid_item.isVisible(): - first_invalid_item.setFocus(True) - return + invalid_items = [] + for item in self.input_fields: + invalid_items.extend(item.get_invalid()) + msg_box = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Warning, + "Invalid input", + "There is invalid value in one of inputs." + " Please lead red color and fix them." + ) + msg_box.setStandardButtons(QtWidgets.QMessageBox.Ok) + msg_box.exec_() - if self.project_name is None: - self._save_studio_overrides() - else: - self._save_overrides() + first_invalid_item = invalid_items[0] + self.scroll_widget.ensureWidgetVisible(first_invalid_item) + if first_invalid_item.isVisible(): + first_invalid_item.setFocus(True) def _on_refresh(self): self.reset() @@ -665,75 +653,19 @@ class ProjectWidget(QtWidgets.QWidget): ) # Saving overrides data - project_overrides_data = output_data.get( - PROJECT_SETTINGS_KEY, {} - ) - project_overrides_json_path = path_to_project_overrides( - self.project_name - ) - dirpath = os.path.dirname(project_overrides_json_path) - if not os.path.exists(dirpath): - os.makedirs(dirpath) - - print("Saving data to:", project_overrides_json_path) - with open(project_overrides_json_path, "w") as file_stream: - json.dump(project_overrides_data, file_stream, indent=4) + project_overrides_data = output_data.get(PROJECT_SETTINGS_KEY, {}) + save_project_settings(self.project_name, project_overrides_data) # Saving anatomy data - project_anatomy_data = output_data.get( - PROJECT_ANATOMY_KEY, {} - ) - project_anatomy_json_path = path_to_project_anatomy( - self.project_name - ) - dirpath = os.path.dirname(project_anatomy_json_path) - if not os.path.exists(dirpath): - os.makedirs(dirpath) + project_anatomy_data = output_data.get(PROJECT_ANATOMY_KEY, {}) + save_project_anatomy(self.project_name, project_anatomy_data) - print("Saving data to:", project_anatomy_json_path) - with open(project_anatomy_json_path, "w") as file_stream: - json.dump(project_anatomy_data, file_stream, indent=4) - - # Refill values with overrides - self._on_project_change() - - def _save_studio_overrides(self): - data = {} - for input_field in self.input_fields: - value, is_group = input_field.studio_overrides() - if value is not lib.NOT_SET: - data.update(value) - - output_data = lib.convert_gui_data_to_overrides( - data.get("project", {}) - ) - - # Project overrides data - project_overrides_data = output_data.get( - PROJECT_SETTINGS_KEY, {} - ) - dirpath = os.path.dirname(PROJECT_SETTINGS_PATH) - if not os.path.exists(dirpath): - os.makedirs(dirpath) - - print("Saving data to:", PROJECT_SETTINGS_PATH) - with open(PROJECT_SETTINGS_PATH, "w") as file_stream: - json.dump(project_overrides_data, file_stream, indent=4) - - # Project Anatomy data - project_anatomy_data = output_data.get( - PROJECT_ANATOMY_KEY, {} - ) - dirpath = os.path.dirname(PROJECT_ANATOMY_PATH) - if not os.path.exists(dirpath): - os.makedirs(dirpath) - - print("Saving data to:", PROJECT_ANATOMY_PATH) - with open(PROJECT_ANATOMY_PATH, "w") as file_stream: - json.dump(project_anatomy_data, file_stream, indent=4) - - # Update saved values - self._update_values() + if self.project_name: + # Refill values with overrides + self._on_project_change() + else: + # Update saved values + self._update_values() def _update_values(self): self.ignore_value_changes = True From 4ed67bed349a4f73a574507599542d934c83fc52 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 3 Oct 2020 01:31:02 +0200 Subject: [PATCH 009/131] make sure settings.lib import won't crash if PYPE_PROJECT_CONFIGS is not set --- pype/settings/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index abb704f033..a29d71ac4d 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -13,7 +13,7 @@ M_ENVIRONMENT_KEY = "__environment_keys__" M_POP_KEY = "__pop_key__" # Folder where studio overrides are stored -STUDIO_OVERRIDES_PATH = os.getenv("PYPE_PROJECT_CONFIGS") +STUDIO_OVERRIDES_PATH = os.getenv("PYPE_PROJECT_CONFIGS") or "" # File where studio's system overrides are stored SYSTEM_SETTINGS_KEY = "system_settings" From 84554e0568de6aceda745c34594d0af938de3399 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 10:35:03 +0200 Subject: [PATCH 010/131] fixed saving of project settings --- pype/tools/settings/settings/widgets/base.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pype/tools/settings/settings/widgets/base.py b/pype/tools/settings/settings/widgets/base.py index 5feab765e5..e342d375f5 100644 --- a/pype/tools/settings/settings/widgets/base.py +++ b/pype/tools/settings/settings/widgets/base.py @@ -643,8 +643,12 @@ class ProjectWidget(QtWidgets.QWidget): def _save_overrides(self): data = {} + studio_overrides = bool(self.project_name is None) for item in self.input_fields: - value, is_group = item.overrides() + if studio_overrides: + value, is_group = item.studio_overrides() + else: + value, is_group = item.overrides() if value is not lib.NOT_SET: data.update(value) @@ -670,7 +674,7 @@ class ProjectWidget(QtWidgets.QWidget): def _update_values(self): self.ignore_value_changes = True - default_values = default_values = lib.convert_data_to_gui_data( + default_values = lib.convert_data_to_gui_data( {"project": default_settings()} ) for input_field in self.input_fields: From 15021cbb94b14981cedb3739dabf9e3aaf609850 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 11:24:05 +0200 Subject: [PATCH 011/131] safer work with default settings --- pype/settings/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index a29d71ac4d..0c4f21e7ee 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -278,13 +278,13 @@ def apply_overrides(source_data, override_data): def system_settings(): - default_values = default_settings()[SYSTEM_SETTINGS_KEY] + default_values = copy.deepcopy(default_settings()[SYSTEM_SETTINGS_KEY]) studio_values = studio_system_settings() return apply_overrides(default_values, studio_values) def project_settings(project_name): - default_values = default_settings()[PROJECT_SETTINGS_KEY] + default_values = copy.deepcopy(default_settings()[PROJECT_SETTINGS_KEY]) studio_values = studio_project_settings() studio_overrides = apply_overrides(default_values, studio_values) From f7db222c7679700e247875e0490ec6d3e7d70de7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 11:24:27 +0200 Subject: [PATCH 012/131] print more specific data --- pype/settings/lib.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index 0c4f21e7ee..a8ae0bb753 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -199,7 +199,9 @@ def save_studio_settings(data): if not os.path.exists(dirpath): os.makedirs(dirpath) - print("Saving studio overrides") + print("Saving studio overrides. Output path: {}".format( + SYSTEM_SETTINGS_PATH + )) with open(SYSTEM_SETTINGS_PATH, "w") as file_stream: json.dump(data, file_stream, indent=4) @@ -210,7 +212,9 @@ def save_project_settings(project_name, overrides): if not os.path.exists(dirpath): os.makedirs(dirpath) - print("Saving overrides of project \"{}\"".format(project_name)) + print("Saving overrides of project \"{}\". Output path: {}".format( + project_name, project_overrides_json_path + )) with open(project_overrides_json_path, "w") as file_stream: json.dump(overrides, file_stream, indent=4) @@ -221,7 +225,9 @@ def save_project_anatomy(project_name, anatomy_data): if not os.path.exists(dirpath): os.makedirs(dirpath) - print("Saving anatomy of project \"{}\"".format(project_name)) + print("Saving anatomy of project \"{}\". Output path: {}".format( + project_name, project_anatomy_json_path + )) with open(project_anatomy_json_path, "w") as file_stream: json.dump(anatomy_data, file_stream, indent=4) From fe6e3ad1ea2b50ce2648ac877ceab3e2fd5b4365 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 11:24:39 +0200 Subject: [PATCH 013/131] added few dostrings --- pype/settings/lib.py | 101 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index a8ae0bb753..b82e4a651b 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -74,6 +74,35 @@ def load_json_file(fpath): def load_jsons_from_dir(path, *args, **kwargs): + """Load all json files with content from entered path. + + Enterd path hiearchy: + |_ folder1 + | |_ data1.json + |_ folder2 + |_ subfolder1 + |_ data2.json + + Will result in: + ```javascript + { + "folder1": { + "data1": "CONTENT OF FILE" + }, + "folder2": { + "data1": { + "subfolder1": "CONTENT OF FILE" + } + } + } + ``` + + Args: + path (str): Path to folder where jsons should be. + + Returns: + dict: loaded data + """ output = {} path = os.path.normpath(path) @@ -112,6 +141,15 @@ def load_jsons_from_dir(path, *args, **kwargs): def find_environments(data): + """ Find environemnt values from system settings by it's metadata. + + Args: + data(dict): System settings data or dictionary which may contain + environments metadata. + + Returns: + dict: Key as Environment key and value for `acre` module. + """ if not data or not isinstance(data, dict): return @@ -151,24 +189,28 @@ def subkey_merge(_dict, value, keys): def studio_system_settings(): + """Studio overrides of system settings.""" if os.path.exists(SYSTEM_SETTINGS_PATH): return load_json_file(SYSTEM_SETTINGS_PATH) return {} def studio_environments(): + """Environment values from defaults.""" if os.path.exists(ENVIRONMENTS_PATH): return load_json_file(ENVIRONMENTS_PATH) return {} def studio_project_settings(): + """Studio overrides of default project settings.""" if os.path.exists(PROJECT_SETTINGS_PATH): return load_json_file(PROJECT_SETTINGS_PATH) return {} def studio_project_anatomy(): + """Studio overrides of default project anatomy data.""" if os.path.exists(PROJECT_ANATOMY_PATH): return load_json_file(PROJECT_ANATOMY_PATH) return {} @@ -195,6 +237,14 @@ def path_to_project_anatomy(project_name): def save_studio_settings(data): + """Save studio overrides of system settings. + + Saving must corespond with loading. For loading should be used function + `studio_system_settings`. + + Args: + data(dict): Data of studio overrides with override metadata. + """ dirpath = os.path.dirname(SYSTEM_SETTINGS_PATH) if not os.path.exists(dirpath): os.makedirs(dirpath) @@ -207,6 +257,17 @@ def save_studio_settings(data): def save_project_settings(project_name, overrides): + """Save studio overrides of project settings. + + Data are saved for specific project or as defaults for all projects. + Saving must corespond with loading. For loading should be used functions + `project_settings_overrides` and `studio_project_settings`. + + Args: + project_name(str, null): Project name for which overrides are + or None for global settings. + data(dict): Data of project overrides with override metadata. + """ project_overrides_json_path = path_to_project_settings(project_name) dirpath = os.path.dirname(project_overrides_json_path) if not os.path.exists(dirpath): @@ -220,6 +281,17 @@ def save_project_settings(project_name, overrides): def save_project_anatomy(project_name, anatomy_data): + """Save studio overrides of project anatomy. + + Data are saved for specific project or as defaults for all projects. + Saving must corespond with loading. For loading should be used functions + `project_anatomy_overrides` and `studio_project_anatomy`. + + Args: + project_name(str, null): Project name for which overrides are + or None for global settings. + data(dict): Data of project overrides with override metadata. + """ project_anatomy_json_path = path_to_project_anatomy(project_name) dirpath = os.path.dirname(project_anatomy_json_path) if not os.path.exists(dirpath): @@ -233,6 +305,14 @@ def save_project_anatomy(project_name, anatomy_data): def project_settings_overrides(project_name): + """Studio overrides of project settings for specific project. + + Args: + project_name(str): Name of project for which data should be loaded. + + Returns: + dict: Only overrides for entered project, may be empty dictionary. + """ if not project_name: return {} @@ -243,6 +323,14 @@ def project_settings_overrides(project_name): def project_anatomy_overrides(project_name): + """Studio overrides of project anatomy for specific project. + + Args: + project_name(str): Name of project for which data should be loaded. + + Returns: + dict: Only overrides for entered project, may be empty dictionary. + """ if not project_name: return {} @@ -253,6 +341,7 @@ def project_anatomy_overrides(project_name): def merge_overrides(global_dict, override_dict): + """Merge override data to source data by metadata stored in.""" if M_OVERRIDEN_KEY in override_dict: overriden_keys = set(override_dict.pop(M_OVERRIDEN_KEY)) else: @@ -262,10 +351,7 @@ def merge_overrides(global_dict, override_dict): if value == M_POP_KEY: global_dict.pop(key) - elif ( - key in overriden_keys - or key not in global_dict - ): + elif (key in overriden_keys or key not in global_dict): global_dict[key] = value elif isinstance(value, dict) and isinstance(global_dict[key], dict): @@ -284,12 +370,14 @@ def apply_overrides(source_data, override_data): def system_settings(): + """System settings with applied studio overrides.""" default_values = copy.deepcopy(default_settings()[SYSTEM_SETTINGS_KEY]) studio_values = studio_system_settings() return apply_overrides(default_values, studio_values) def project_settings(project_name): + """Project settings with applied studio and project overrides.""" default_values = copy.deepcopy(default_settings()[PROJECT_SETTINGS_KEY]) studio_values = studio_project_settings() @@ -301,6 +389,11 @@ def project_settings(project_name): def environments(): + """Environments from defaults and extracted from system settings. + + Returns: + dict: Output should be ready for `acre` module. + """ envs = copy.deepcopy(default_settings()[ENVIRONMENTS_KEY]) envs_from_system_settings = find_environments(system_settings()) for env_group_key, values in envs_from_system_settings.items(): From df0597799402898c14ed0f14e6c1dbb97b3a15e3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 12:00:10 +0200 Subject: [PATCH 014/131] modified docstring in save functions to be more clear --- pype/settings/lib.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index b82e4a651b..2afdff1dd0 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -239,8 +239,9 @@ def path_to_project_anatomy(project_name): def save_studio_settings(data): """Save studio overrides of system settings. - Saving must corespond with loading. For loading should be used function - `studio_system_settings`. + Do not use to store whole system settings data with defaults but only it's + overrides with metadata defining how overrides should be applied in load + function. For loading should be used function `studio_system_settings`. Args: data(dict): Data of studio overrides with override metadata. @@ -260,8 +261,12 @@ def save_project_settings(project_name, overrides): """Save studio overrides of project settings. Data are saved for specific project or as defaults for all projects. - Saving must corespond with loading. For loading should be used functions - `project_settings_overrides` and `studio_project_settings`. + + Do not use to store whole project settings data with defaults but only it's + overrides with metadata defining how overrides should be applied in load + function. For loading should be used functions `studio_project_settings` + for global project settings and `project_settings_overrides` for + project specific settings. Args: project_name(str, null): Project name for which overrides are @@ -283,9 +288,11 @@ def save_project_settings(project_name, overrides): def save_project_anatomy(project_name, anatomy_data): """Save studio overrides of project anatomy. - Data are saved for specific project or as defaults for all projects. - Saving must corespond with loading. For loading should be used functions - `project_anatomy_overrides` and `studio_project_anatomy`. + Do not use to store whole project anatomy data with defaults but only it's + overrides with metadata defining how overrides should be applied in load + function. For loading should be used functions `studio_project_anatomy` + for global project settings and `project_anatomy_overrides` for + project specific settings. Args: project_name(str, null): Project name for which overrides are From 2082e4bb6ed626067665678bda25bb93a5a32774 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 5 Oct 2020 15:46:36 +0200 Subject: [PATCH 015/131] feat(nuke): improving render knobs --- pype/hosts/nuke/lib.py | 8 ++--- .../plugins/nuke/publish/collect_instances.py | 30 +++++++++++-------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/pype/hosts/nuke/lib.py b/pype/hosts/nuke/lib.py index 19a0784327..d896bfe1ef 100644 --- a/pype/hosts/nuke/lib.py +++ b/pype/hosts/nuke/lib.py @@ -431,13 +431,9 @@ def add_rendering_knobs(node): node (obj): with added knobs ''' if "render" not in node.knobs(): - knob = nuke.Boolean_Knob("render", "Render") + knob = nuke.Enumeration_Knob("render", "Render", [ + "Do Not Render", "Locally", "On Farm"]) knob.setFlag(0x1000) - knob.setValue(False) - node.addKnob(knob) - if "render_farm" not in node.knobs(): - knob = nuke.Boolean_Knob("render_farm", "Render on Farm") - knob.setValue(False) node.addKnob(knob) return node diff --git a/pype/plugins/nuke/publish/collect_instances.py b/pype/plugins/nuke/publish/collect_instances.py index 9085e12bd8..f1e7f2bdde 100644 --- a/pype/plugins/nuke/publish/collect_instances.py +++ b/pype/plugins/nuke/publish/collect_instances.py @@ -76,19 +76,23 @@ class CollectNukeInstances(pyblish.api.ContextPlugin): if node.Class() == "Group": # only alter families for render family if "write" in families_ak: - - if node["render"].value(): - self.log.info("flagged for render") - add_family = "{}.local".format("render") - # dealing with local/farm rendering - if node["render_farm"].value(): - self.log.info("adding render farm family") - add_family = "{}.farm".format("render") - instance.data["transfer"] = False - families.append(add_family) - if "render" in families: - families.remove("render") - family = "write" + target = node["render"].value() + if target == "Do Not Render": + # Local rendering + self.log.info("flagged for no render") + families.append("render") + elif target == "Locally": + # Local rendering + self.log.info("flagged for local render") + families.append("{}.local".format("render")) + elif target == "On Farm": + # Farm rendering + self.log.info("flagged for farm render") + instance.data["transfer"] = False + families.append("{}.farm".format("render")) + if "render" in families: + families.remove("render") + family = "write" node.begin() for i in nuke.allNodes(): From 40c4b512bbbf2d916f840887b1018b9224a88e79 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 20:17:27 +0200 Subject: [PATCH 016/131] modified first docstring by comments --- pype/settings/lib.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index 2afdff1dd0..c2c4d3a363 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -74,9 +74,12 @@ def load_json_file(fpath): def load_jsons_from_dir(path, *args, **kwargs): - """Load all json files with content from entered path. + """Load all .json files with content from entered folder path. - Enterd path hiearchy: + Data are loaded recursively from a directory and recreate the + hierarchy as a dictionary. + + Entered path hiearchy: |_ folder1 | |_ data1.json |_ folder2 @@ -98,10 +101,10 @@ def load_jsons_from_dir(path, *args, **kwargs): ``` Args: - path (str): Path to folder where jsons should be. + path (str): Path to the root folder where the json hierarchy starts. Returns: - dict: loaded data + dict: Loaded data. """ output = {} From 22869a1507b62c24122e6621b8f195d49fee4132 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 22:51:23 +0200 Subject: [PATCH 017/131] add role_list attribute to BaseHandler --- pype/modules/ftrack/lib/ftrack_base_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pype/modules/ftrack/lib/ftrack_base_handler.py b/pype/modules/ftrack/lib/ftrack_base_handler.py index d322fbaf23..96a8150399 100644 --- a/pype/modules/ftrack/lib/ftrack_base_handler.py +++ b/pype/modules/ftrack/lib/ftrack_base_handler.py @@ -35,6 +35,7 @@ class BaseHandler(object): type = 'No-type' ignore_me = False preactions = [] + role_list = [] def __init__(self, session, plugins_presets=None): '''Expects a ftrack_api.Session instance''' From 14e192e72c838809b30b5a7920dcd438d7275126 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 22:52:16 +0200 Subject: [PATCH 018/131] extracted rolecheck from _preregister to separate method --- .../modules/ftrack/lib/ftrack_base_handler.py | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/pype/modules/ftrack/lib/ftrack_base_handler.py b/pype/modules/ftrack/lib/ftrack_base_handler.py index 96a8150399..c711896c19 100644 --- a/pype/modules/ftrack/lib/ftrack_base_handler.py +++ b/pype/modules/ftrack/lib/ftrack_base_handler.py @@ -149,20 +149,27 @@ class BaseHandler(object): def reset_session(self): self.session.reset() + def _register_role_check(self): + if not self.role_list or not isinstance(self.role_list, (list, tuple)): + return + + user_entity = self.session.query( + "User where username is \"{}\"".format(self.session.api_user) + ).one() + available = False + lowercase_rolelist = [ + role_name.lower() + for role_name in self.role_list + ] + for role in user_entity["user_security_roles"]: + if role["security_role"]["name"].lower() in lowercase_rolelist: + available = True + break + if available is False: + raise MissingPermision + def _preregister(self): - if hasattr(self, "role_list") and len(self.role_list) > 0: - username = self.session.api_user - user = self.session.query( - 'User where username is "{}"'.format(username) - ).one() - available = False - lowercase_rolelist = [x.lower() for x in self.role_list] - for role in user['user_security_roles']: - if role['security_role']['name'].lower() in lowercase_rolelist: - available = True - break - if available is False: - raise MissingPermision + self._register_role_check() # Custom validations result = self.preregister() From 378022003c9d6465b1704c9bb9f7ffd541f4373b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 22:52:42 +0200 Subject: [PATCH 019/131] modified preregister result check --- pype/modules/ftrack/lib/ftrack_base_handler.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pype/modules/ftrack/lib/ftrack_base_handler.py b/pype/modules/ftrack/lib/ftrack_base_handler.py index c711896c19..e928f2fb88 100644 --- a/pype/modules/ftrack/lib/ftrack_base_handler.py +++ b/pype/modules/ftrack/lib/ftrack_base_handler.py @@ -180,12 +180,11 @@ class BaseHandler(object): ).format(self.__class__.__name__)) return - if result is True: - return - msg = None - if isinstance(result, str): - msg = result - raise PreregisterException(msg) + if result is not True: + msg = None + if isinstance(result, str): + msg = result + raise PreregisterException(msg) def preregister(self): ''' From b96fc36aedc8a239eacbb47ea4d3d92398f0d362 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 23:02:05 +0200 Subject: [PATCH 020/131] implemented ServerAction with modified discovery and register methods --- .../ftrack/lib/ftrack_action_handler.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/pype/modules/ftrack/lib/ftrack_action_handler.py b/pype/modules/ftrack/lib/ftrack_action_handler.py index 76c8e41411..a550d9e7d3 100644 --- a/pype/modules/ftrack/lib/ftrack_action_handler.py +++ b/pype/modules/ftrack/lib/ftrack_action_handler.py @@ -195,3 +195,82 @@ class BaseAction(BaseHandler): ).format(str(type(result)))) return result + + +class ServerAction(BaseAction): + """Action class meant to be used on event server. + + Unlike the `BaseAction` roles are not checked on register but on discover. + For the same reason register is modified to not filter topics by username. + """ + + def __init__(self, *args, **kwargs): + if not self.role_list: + self.role_list = set() + else: + self.role_list = set( + role_name.lower() + for role_name in self.role_list + ) + super(ServerAction, self).__init__(*args, **kwargs) + + def _register_role_check(self): + # Skip register role check. + return + + def _discover(self, event): + """Check user discover availability.""" + if not self._check_user_discover(event): + return + return super(ServerAction, self)._discover(event) + + def _check_user_discover(self, event): + """Should be action discovered by user trying to show actions.""" + if not self.role_list: + return True + + user_entity = self._get_user_entity(event) + if not user_entity: + return False + + for role in user_entity["user_security_roles"]: + lowered_role = role["security_role"]["name"].lower() + if lowered_role in self.role_list: + return True + return False + + def _get_user_entity(self, event): + """Query user entity from event.""" + not_set = object() + + # Check if user is already stored in event data + user_entity = event["data"].get("user_entity", not_set) + if user_entity is not_set: + # Query user entity from event + user_info = event.get("source", {}).get("user", {}) + user_id = user_info.get("id") + username = user_info.get("username") + if user_id: + user_entity = self.session.query( + "User where id is {}".format(user_id) + ).first() + if not user_entity and username: + user_entity = self.session.query( + "User where username is {}".format(username) + ).first() + event["data"]["user_entity"] = user_entity + + return user_entity + + def register(self): + """Register subcription to Ftrack event hub.""" + self.session.event_hub.subscribe( + "topic=ftrack.action.discover", + self._discover, + priority=self.priority + ) + + launch_subscription = ( + "topic=ftrack.action.launch and data.actionIdentifier={0}" + ).format(self.identifier) + self.session.event_hub.subscribe(launch_subscription, self._launch) From ef5b917ff32d546186d2e35da6c3fa859d9ce876 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 23:02:23 +0200 Subject: [PATCH 021/131] extracted ServerAction to ftrack.lib --- pype/modules/ftrack/lib/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pype/modules/ftrack/lib/__init__.py b/pype/modules/ftrack/lib/__init__.py index d8e9c7a11c..a52e73d10f 100644 --- a/pype/modules/ftrack/lib/__init__.py +++ b/pype/modules/ftrack/lib/__init__.py @@ -2,7 +2,7 @@ from . import avalon_sync from . import credentials from .ftrack_base_handler import BaseHandler from .ftrack_event_handler import BaseEvent -from .ftrack_action_handler import BaseAction, statics_icon +from .ftrack_action_handler import BaseAction, ServerAction, statics_icon from .ftrack_app_handler import AppAction __all__ = ( @@ -11,6 +11,7 @@ __all__ = ( "BaseHandler", "BaseEvent", "BaseAction", + "ServerAction", "statics_icon", "AppAction" ) From 7bc975c5aa2fbad5248a06dbf7b9b87769b3d575 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 23:05:35 +0200 Subject: [PATCH 022/131] simplified server actions with new ServerAction --- .../action_push_frame_values_to_task.py | 42 ++----------------- .../ftrack/events/action_sync_to_avalon.py | 36 ++-------------- 2 files changed, 6 insertions(+), 72 deletions(-) diff --git a/pype/modules/ftrack/events/action_push_frame_values_to_task.py b/pype/modules/ftrack/events/action_push_frame_values_to_task.py index a55c1e46a6..3a538b57eb 100644 --- a/pype/modules/ftrack/events/action_push_frame_values_to_task.py +++ b/pype/modules/ftrack/events/action_push_frame_values_to_task.py @@ -1,10 +1,10 @@ import json import collections import ftrack_api -from pype.modules.ftrack.lib import BaseAction +from pype.modules.ftrack.lib import ServerAction -class PushFrameValuesToTaskAction(BaseAction): +class PushFrameValuesToTaskAction(ServerAction): """Action for testing purpose or as base for new actions.""" # Ignore event handler by default @@ -34,50 +34,14 @@ class PushFrameValuesToTaskAction(BaseAction): "frameStart": "fstart", "frameEnd": "fend" } - discover_role_list = {"Pypeclub", "Administrator", "Project Manager"} - - def register(self): - modified_role_names = set() - for role_name in self.discover_role_list: - modified_role_names.add(role_name.lower()) - self.discover_role_list = modified_role_names - - self.session.event_hub.subscribe( - "topic=ftrack.action.discover", - self._discover, - priority=self.priority - ) - - launch_subscription = ( - "topic=ftrack.action.launch and data.actionIdentifier={0}" - ).format(self.identifier) - self.session.event_hub.subscribe(launch_subscription, self._launch) + role_list = {"Pypeclub", "Administrator", "Project Manager"} def discover(self, session, entities, event): """ Validation """ # Check if selection is valid - valid_selection = False for ent in event["data"]["selection"]: # Ignore entities that are not tasks or projects if ent["entityType"].lower() == "show": - valid_selection = True - break - - if not valid_selection: - return False - - # Get user and check his roles - user_id = event.get("source", {}).get("user", {}).get("id") - if not user_id: - return False - - user = session.query("User where id is \"{}\"".format(user_id)).first() - if not user: - return False - - for role in user["user_security_roles"]: - lowered_role = role["security_role"]["name"].lower() - if lowered_role in self.discover_role_list: return True return False diff --git a/pype/modules/ftrack/events/action_sync_to_avalon.py b/pype/modules/ftrack/events/action_sync_to_avalon.py index 4e119228c3..7192afeeb6 100644 --- a/pype/modules/ftrack/events/action_sync_to_avalon.py +++ b/pype/modules/ftrack/events/action_sync_to_avalon.py @@ -1,11 +1,11 @@ import time import traceback -from pype.modules.ftrack import BaseAction +from pype.modules.ftrack import ServerAction from pype.modules.ftrack.lib.avalon_sync import SyncEntitiesFactory -class SyncToAvalonServer(BaseAction): +class SyncToAvalonServer(ServerAction): """ Synchronizing data action - from Ftrack to Avalon DB @@ -36,48 +36,18 @@ class SyncToAvalonServer(BaseAction): variant = "- Sync To Avalon (Server)" #: Action description. description = "Send data from Ftrack to Avalon" + role_list = {"Pypeclub", "Administrator", "Project Manager"} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.entities_factory = SyncEntitiesFactory(self.log, self.session) - def register(self): - self.session.event_hub.subscribe( - "topic=ftrack.action.discover", - self._discover, - priority=self.priority - ) - - launch_subscription = ( - "topic=ftrack.action.launch and data.actionIdentifier={0}" - ).format(self.identifier) - self.session.event_hub.subscribe(launch_subscription, self._launch) - def discover(self, session, entities, event): """ Validation """ # Check if selection is valid - valid_selection = False for ent in event["data"]["selection"]: # Ignore entities that are not tasks or projects if ent["entityType"].lower() in ["show", "task"]: - valid_selection = True - break - - if not valid_selection: - return False - - # Get user and check his roles - user_id = event.get("source", {}).get("user", {}).get("id") - if not user_id: - return False - - user = session.query("User where id is \"{}\"".format(user_id)).first() - if not user: - return False - - role_list = ["Pypeclub", "Administrator", "Project Manager"] - for role in user["user_security_roles"]: - if role["security_role"]["name"] in role_list: return True return False From 2f3d164447f50cc3fd901371eba022b12772c0f9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 5 Oct 2020 23:10:13 +0200 Subject: [PATCH 023/131] extracted ServerAction to ftrack module level --- pype/modules/ftrack/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pype/modules/ftrack/__init__.py b/pype/modules/ftrack/__init__.py index fad771f084..a1f0b00ce0 100644 --- a/pype/modules/ftrack/__init__.py +++ b/pype/modules/ftrack/__init__.py @@ -1,6 +1,6 @@ from . import ftrack_server from .ftrack_server import FtrackServer, check_ftrack_url -from .lib import BaseHandler, BaseEvent, BaseAction +from .lib import BaseHandler, BaseEvent, BaseAction, ServerAction __all__ = ( "ftrack_server", @@ -8,5 +8,6 @@ __all__ = ( "check_ftrack_url", "BaseHandler", "BaseEvent", - "BaseAction" + "BaseAction", + "ServerAction" ) From d0b6b6526c32b9b8f3c9afe00f0cb69f221242af Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 6 Oct 2020 12:37:05 +0200 Subject: [PATCH 024/131] make accessing data more efficient --- pype/plugins/harmony/publish/collect_scene.py | 53 ++++++++++++++++++ .../plugins/harmony/publish/extract_render.py | 55 ++++++++----------- .../harmony/publish/extract_template.py | 4 +- .../harmony/publish/extract_workfile.py | 22 +++++--- .../publish/validate_scene_settings.py | 35 +++++++----- 5 files changed, 114 insertions(+), 55 deletions(-) create mode 100644 pype/plugins/harmony/publish/collect_scene.py diff --git a/pype/plugins/harmony/publish/collect_scene.py b/pype/plugins/harmony/publish/collect_scene.py new file mode 100644 index 0000000000..c9a4c31234 --- /dev/null +++ b/pype/plugins/harmony/publish/collect_scene.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""Collect scene data.""" +import os + +import pyblish.api +from avalon import harmony + + +class CollectScene(pyblish.api.ContextPlugin): + """Collect basic scene information.""" + + label = "Scene Data" + order = pyblish.api.CollectorOrder + hosts = ["harmony"] + + def process(self, context): + + sig = harmony.signature() + func = """function %s() + { + return [ + about.getApplicationPath(), + scene.currentProjectPath(), + scene.currentScene(), + scene.getFrameRate(), + scene.getStartFrame(), + scene.getStopFrame(), + sound.getSoundtrackAll().path(), + scene.defaultResolutionX(), + scene.defaultResolutionY() + ] + } + %s + """ % (sig, sig) + result = harmony.send( + {"function": func, "args": []} + )["result"] + + context.data["applicationPath"] = result[0] + context.data["scenePath"] = os.path.join( + result[1], result[2] + ".xstage") + context.data["frameRate"] = result[3] + context.data["frameStart"] = result[4] + context.data["frameEnd"] = result[5] + context.data["audioPath"] = result[6] + context.data["resolutionWidth"] = result[7] + context.data["resolutionHeight"] = result[8] + + all_nodes = harmony.send( + {"function": "node.subNodes", "args": ["Top"]} + )["result"] + + context.data["all_nodes"] = all_nodes diff --git a/pype/plugins/harmony/publish/extract_render.py b/pype/plugins/harmony/publish/extract_render.py index 70dceb9ca2..8467f6710e 100644 --- a/pype/plugins/harmony/publish/extract_render.py +++ b/pype/plugins/harmony/publish/extract_render.py @@ -21,42 +21,30 @@ class ExtractRender(pyblish.api.InstancePlugin): def process(self, instance): # Collect scene data. - func = """function func(write_node) - { - return [ - about.getApplicationPath(), - scene.currentProjectPath(), - scene.currentScene(), - scene.getFrameRate(), - scene.getStartFrame(), - scene.getStopFrame(), - sound.getSoundtrackAll().path() - ] - } - func - """ - result = harmony.send( - {"function": func, "args": [instance[0]]} - )["result"] - application_path = result[0] - scene_path = os.path.join(result[1], result[2] + ".xstage") - frame_rate = result[3] - frame_start = result[4] - frame_end = result[5] - audio_path = result[6] - if audio_path: + + application_path = instance.context.data.get("applicationPath") + scene_path = instance.context.data.get("scenePath") + frame_rate = instance.context.data.get("frameRate") + frame_start = instance.context.data.get("frameStart") + frame_end = instance.context.data.get("frameEnd") + audio_path = instance.context.data.get("audioPath") + + if audio_path and os.path.exists(audio_path): + self.log.info(f"Using audio from {audio_path}") instance.data["audio"] = [{"filename": audio_path}] + instance.data["fps"] = frame_rate # Set output path to temp folder. path = tempfile.mkdtemp() - func = """function func(args) + sig = harmony.signature() + func = """function %s(args) { node.setTextAttr(args[0], "DRAWING_NAME", 1, args[1]); } - func - """ - result = harmony.send( + %s + """ % (sig, sig) + harmony.send( { "function": func, "args": [instance[0], path + "/" + instance.data["name"]] @@ -66,6 +54,7 @@ class ExtractRender(pyblish.api.InstancePlugin): # Execute rendering. Ignoring error cause Harmony returns error code # always. + self.log.info(f"running [ {application_path} -batch {scene_path}") proc = subprocess.Popen( [application_path, "-batch", scene_path], stdout=subprocess.PIPE, @@ -73,12 +62,16 @@ class ExtractRender(pyblish.api.InstancePlugin): stdin=subprocess.PIPE ) output, error = proc.communicate() + self.log.info("Click on the line below to see more details.") self.log.info(output.decode("utf-8")) # Collect rendered files. - self.log.debug(path) + self.log.debug(f"collecting from: {path}") files = os.listdir(path) - self.log.debug(files) + assert files, ( + "No rendered files found, render failed." + ) + self.log.debug(f"files there: {files}") collections, remainder = clique.assemble(files, minimum_items=1) assert not remainder, ( "There should not be a remainder for {0}: {1}".format( @@ -89,7 +82,7 @@ class ExtractRender(pyblish.api.InstancePlugin): if len(collections) > 1: for col in collections: if len(list(col)) > 1: - collection = col + collection = col else: collection = collections[0] diff --git a/pype/plugins/harmony/publish/extract_template.py b/pype/plugins/harmony/publish/extract_template.py index 1ba0befc54..9b1b423b88 100644 --- a/pype/plugins/harmony/publish/extract_template.py +++ b/pype/plugins/harmony/publish/extract_template.py @@ -30,9 +30,7 @@ class ExtractTemplate(pype.api.Extractor): unique_backdrops = [backdrops[x] for x in set(backdrops.keys())] # Get non-connected nodes within backdrops. - all_nodes = avalon.harmony.send( - {"function": "node.subNodes", "args": ["Top"]} - )["result"] + all_nodes = instance.context.data.get("all_nodes") for node in [x for x in all_nodes if x not in dependencies]: within_unique_backdrops = bool( [x for x in self.get_backdrops(node) if x in unique_backdrops] diff --git a/pype/plugins/harmony/publish/extract_workfile.py b/pype/plugins/harmony/publish/extract_workfile.py index 304b70e293..1a0183803e 100644 --- a/pype/plugins/harmony/publish/extract_workfile.py +++ b/pype/plugins/harmony/publish/extract_workfile.py @@ -1,8 +1,11 @@ +# -*- coding: utf-8 -*- +"""Extract work file.""" import os import shutil +from zipfile import ZipFile import pype.api -import avalon.harmony +from avalon import harmony import pype.hosts.harmony @@ -14,13 +17,12 @@ class ExtractWorkfile(pype.api.Extractor): families = ["workfile"] def process(self, instance): + """Plugin entry point.""" # Export template. - backdrops = avalon.harmony.send( + backdrops = harmony.send( {"function": "Backdrop.backdrops", "args": ["Top"]} )["result"] - nodes = avalon.harmony.send( - {"function": "node.subNodes", "args": ["Top"]} - )["result"] + nodes = instance.context.data.get("all_nodes") staging_dir = self.staging_dir(instance) filepath = os.path.join(staging_dir, "{}.tpl".format(instance.name)) @@ -29,15 +31,19 @@ class ExtractWorkfile(pype.api.Extractor): # Prep representation. os.chdir(staging_dir) shutil.make_archive( - "{}".format(instance.name), + f"{instance.name}", "zip", - os.path.join(staging_dir, "{}.tpl".format(instance.name)) + os.path.join(staging_dir, f"{instance.name}.tpl") ) + # Check if archive is ok + with ZipFile(os.path.basename("f{instance.name}.zip")) as zr: + if zr.testzip() is not None: + raise Exception("File archive is corrupted.") representation = { "name": "tpl", "ext": "zip", - "files": "{}.zip".format(instance.name), + "files": f"{instance.name}.zip", "stagingDir": staging_dir } instance.data["representations"] = [representation] diff --git a/pype/plugins/harmony/publish/validate_scene_settings.py b/pype/plugins/harmony/publish/validate_scene_settings.py index d7895804bd..34db37de6d 100644 --- a/pype/plugins/harmony/publish/validate_scene_settings.py +++ b/pype/plugins/harmony/publish/validate_scene_settings.py @@ -1,8 +1,11 @@ +# -*- coding: utf-8 -*- +"""Validate scene settings.""" +import os import json import pyblish.api -import avalon.harmony +from avalon import harmony import pype.hosts.harmony @@ -14,9 +17,17 @@ class ValidateSceneSettingsRepair(pyblish.api.Action): on = "failed" def process(self, context, plugin): + """Repair action entry point.""" pype.hosts.harmony.set_scene_settings( pype.hosts.harmony.get_asset_settings() ) + if not os.patch.exists(context.data["scenePath"]): + self.log.info("correcting scene name") + scene_dir = os.path.dirname(context.data["currentFile"]) + scene_path = os.path.join( + scene_dir, os.path.basename(scene_dir) + ".xstage" + ) + harmony.save_scene_as(scene_path) class ValidateSceneSettings(pyblish.api.InstancePlugin): @@ -31,6 +42,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): frame_check_filter = ["_ch_", "_pr_", "_intd_", "_extd_"] def process(self, instance): + """Plugin entry point.""" expected_settings = pype.hosts.harmony.get_asset_settings() self.log.info(expected_settings) @@ -46,19 +58,13 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): for string in self.frame_check_filter): expected_settings.pop("frameEnd") - func = """function func() - { - return { - "fps": scene.getFrameRate(), - "frameStart": scene.getStartFrame(), - "frameEnd": scene.getStopFrame(), - "resolutionWidth": scene.defaultResolutionX(), - "resolutionHeight": scene.defaultResolutionY() - }; + current_settings = { + "fps": instance.context.data.get("frameRate"), + "frameStart": instance.context.data.get("frameStart"), + "frameEnd": instance.context.data.get("frameEnd"), + "resolutionWidth": instance.context.data.get("resolutionWidth"), + "resolutionHeight": instance.context.data.get("resolutionHeight"), } - func - """ - current_settings = avalon.harmony.send({"function": func})["result"] invalid_settings = [] for key, value in expected_settings.items(): @@ -73,3 +79,6 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): json.dumps(invalid_settings, sort_keys=True, indent=4) ) assert not invalid_settings, msg + assert os.path.exists(instance.context.data.get("scenePath")), ( + "Scene file not found (saved under wrong name)" + ) From ce24026f2b99c599f573f719a4e20916e3bd5913 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 6 Oct 2020 16:10:55 +0200 Subject: [PATCH 025/131] Fix - renaming or deleting tasks in Ftrack wasn't synced Part for manual syncing through Action --- pype/modules/ftrack/lib/avalon_sync.py | 89 +++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/pype/modules/ftrack/lib/avalon_sync.py b/pype/modules/ftrack/lib/avalon_sync.py index 28114c7fdc..7ff5283d6a 100644 --- a/pype/modules/ftrack/lib/avalon_sync.py +++ b/pype/modules/ftrack/lib/avalon_sync.py @@ -24,9 +24,9 @@ log = Logger().get_logger(__name__) # Current schemas for avalon types EntitySchemas = { - "project": "avalon-core:project-2.1", - "asset": "avalon-core:asset-3.0", - "config": "avalon-core:config-1.1" + "project": "pype:project-2.1", + "asset": "pype:asset-3.0", + "config": "pype:config-1.1" } # Group name of custom attributes @@ -103,15 +103,40 @@ def get_pype_attr(session, split_hierarchical=True): return custom_attributes -def from_dict_to_set(data): +def from_dict_to_set(data, is_project): """ Converts 'data' into $set part of MongoDB update command. + Sets new or modified keys. + Tasks are updated completely, not per task. (Eg. change in any of the + tasks results in full update of "tasks" from Ftrack. Args: - data: (dictionary) - up-to-date data from Ftrack + data (dictionary): up-to-date data from Ftrack + is_project (boolean): true for project Returns: (dictionary) - { "$set" : "{..}"} """ + not_set = object() + task_changes = not_set + if ( + is_project + and "config" in data + and "tasks" in data["config"] + ): + task_changes = data["config"].pop("tasks") + task_changes_key = "config.tasks" + if not data["config"]: + data.pop("config") + elif ( + not is_project + and "data" in data + and "tasks" in data["data"] + ): + task_changes = data["data"].pop("tasks") + task_changes_key = "data.tasks" + if not data["data"]: + data.pop("data") + result = {"$set": {}} dict_queue = queue.Queue() dict_queue.put((None, data)) @@ -128,6 +153,9 @@ def from_dict_to_set(data): result["$set"][new_key] = value continue dict_queue.put((new_key, value)) + + if task_changes is not not_set and task_changes_key: + result["$set"][task_changes_key] = task_changes return result @@ -659,7 +687,7 @@ class SyncEntitiesFactory: # Tasks must be checked too for task in entity_dict["tasks"].items(): task_name, task = task - passed = task_name + passed = task_names.get(task_name) if passed is None: passed = check_regex( task_name, "task", schema_patterns=_schema_patterns @@ -731,7 +759,7 @@ class SyncEntitiesFactory: for id in ids: if id not in self.entities_dict: continue - self.entities_dict[id]["tasks"].remove(name) + self.entities_dict[id]["tasks"].pop(name) ent_path = self.get_ent_path(id) self.log.warning(failed_regex_msg.format( "/".join([ent_path, name]) @@ -1680,6 +1708,18 @@ class SyncEntitiesFactory: self.updates[avalon_id] ) + # double check changes in tasks, some task could be renamed or + # deleted in Ftrack - not captured otherwise + final_entity = self.entities_dict[ftrack_id]["final_entity"] + if final_entity["data"].get("tasks", {}) != \ + avalon_entity["data"].get("tasks", {}): + if "data" not in self.updates[avalon_id]: + self.updates[avalon_id]["data"] = {} + + self.updates[avalon_id]["data"]["tasks"] = ( + final_entity["data"]["tasks"] + ) + def synchronize(self): self.log.debug("* Synchronization begins") avalon_project_id = self.ftrack_avalon_mapper.get(self.ft_project_id) @@ -2027,15 +2067,20 @@ class SyncEntitiesFactory: self._changeability_by_mongo_id[mongo_id] = is_changeable def update_entities(self): + """ + Runs changes converted to "$set" queries in bulk. + """ mongo_changes_bulk = [] for mongo_id, changes in self.updates.items(): - filter = {"_id": ObjectId(mongo_id)} - change_data = from_dict_to_set(changes) + mongo_id = ObjectId(mongo_id) + is_project = mongo_id == self.avalon_project_id + change_data = from_dict_to_set(changes, is_project) + + filter = {"_id": mongo_id} mongo_changes_bulk.append(UpdateOne(filter, change_data)) if not mongo_changes_bulk: # TODO LOG return - log.debug("mongo_changes_bulk:: {}".format(mongo_changes_bulk)) self.dbcon.bulk_write(mongo_changes_bulk) def reload_parents(self, hierarchy_changing_ids): @@ -2107,6 +2152,18 @@ class SyncEntitiesFactory: ) def compare_dict(self, dict_new, dict_old, _ignore_keys=[]): + """ + Recursively compares and list changes between dictionaries + 'dict_new' and 'dict_old'. + Keys in '_ignore_keys' are skipped and not compared. + Args: + dict_new (dictionary): + dict_old (dictionary): + _ignore_keys (list): + + Returns: + (dictionary) of new or updated keys and theirs values + """ # _ignore_keys may be used for keys nested dict like"data.visualParent" changes = {} ignore_keys = [] @@ -2148,6 +2205,18 @@ class SyncEntitiesFactory: return changes def merge_dicts(self, dict_new, dict_old): + """ + Apply all new or updated keys from 'dict_new' on 'dict_old'. + Recursively. + Doesn't recognise that 'dict_new' doesn't contain some keys + anymore. + Args: + dict_new (dictionary): from Ftrack most likely + dict_old (dictionary): current in DB + + Returns: + (dictionary) of applied changes to original dictionary + """ for key, value in dict_new.items(): if key not in dict_old: dict_old[key] = value From 437147d865f400491e9e11d9d47572b06f4dd660 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 6 Oct 2020 16:27:19 +0200 Subject: [PATCH 026/131] fps and style issues --- pype/plugins/harmony/publish/collect_scene.py | 2 +- pype/plugins/harmony/publish/extract_template.py | 2 +- pype/plugins/harmony/publish/extract_workfile.py | 2 +- pype/plugins/harmony/publish/validate_audio.py | 6 +++++- pype/plugins/harmony/publish/validate_scene_settings.py | 9 ++++++++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/pype/plugins/harmony/publish/collect_scene.py b/pype/plugins/harmony/publish/collect_scene.py index c9a4c31234..e24e327afc 100644 --- a/pype/plugins/harmony/publish/collect_scene.py +++ b/pype/plugins/harmony/publish/collect_scene.py @@ -50,4 +50,4 @@ class CollectScene(pyblish.api.ContextPlugin): {"function": "node.subNodes", "args": ["Top"]} )["result"] - context.data["all_nodes"] = all_nodes + context.data["allNodes"] = all_nodes diff --git a/pype/plugins/harmony/publish/extract_template.py b/pype/plugins/harmony/publish/extract_template.py index 9b1b423b88..eaebbbcd37 100644 --- a/pype/plugins/harmony/publish/extract_template.py +++ b/pype/plugins/harmony/publish/extract_template.py @@ -30,7 +30,7 @@ class ExtractTemplate(pype.api.Extractor): unique_backdrops = [backdrops[x] for x in set(backdrops.keys())] # Get non-connected nodes within backdrops. - all_nodes = instance.context.data.get("all_nodes") + all_nodes = instance.context.data.get("allNodes") for node in [x for x in all_nodes if x not in dependencies]: within_unique_backdrops = bool( [x for x in self.get_backdrops(node) if x in unique_backdrops] diff --git a/pype/plugins/harmony/publish/extract_workfile.py b/pype/plugins/harmony/publish/extract_workfile.py index 1a0183803e..41d7868857 100644 --- a/pype/plugins/harmony/publish/extract_workfile.py +++ b/pype/plugins/harmony/publish/extract_workfile.py @@ -22,7 +22,7 @@ class ExtractWorkfile(pype.api.Extractor): backdrops = harmony.send( {"function": "Backdrop.backdrops", "args": ["Top"]} )["result"] - nodes = instance.context.data.get("all_nodes") + nodes = instance.context.data.get("allNodes") staging_dir = self.staging_dir(instance) filepath = os.path.join(staging_dir, "{}.tpl".format(instance.name)) diff --git a/pype/plugins/harmony/publish/validate_audio.py b/pype/plugins/harmony/publish/validate_audio.py index ba113e7610..898ad8ae70 100644 --- a/pype/plugins/harmony/publish/validate_audio.py +++ b/pype/plugins/harmony/publish/validate_audio.py @@ -8,7 +8,11 @@ import pype.hosts.harmony class ValidateAudio(pyblish.api.InstancePlugin): - """Ensures that there is an audio file in the scene. If you are sure that you want to send render without audio, you can disable this validator before clicking on "publish" """ + """Ensures that there is an audio file in the scene. + + If you are sure that you want to send render without audio, you can + disable this validator before clicking on "publish" + """ order = pyblish.api.ValidatorOrder label = "Validate Audio" diff --git a/pype/plugins/harmony/publish/validate_scene_settings.py b/pype/plugins/harmony/publish/validate_scene_settings.py index 34db37de6d..70e6f47721 100644 --- a/pype/plugins/harmony/publish/validate_scene_settings.py +++ b/pype/plugins/harmony/publish/validate_scene_settings.py @@ -58,8 +58,15 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): for string in self.frame_check_filter): expected_settings.pop("frameEnd") + # handle case where ftrack uses only two decimal places + # 23.976023976023978 vs. 23.98 + fps = instance.context.data.get("frameRate") + if isinstance(instance.context.data.get("frameRate"), float): + fps = float( + "{:.2f}".format(instance.context.data.get("frameRate"))) + current_settings = { - "fps": instance.context.data.get("frameRate"), + "fps": fps, "frameStart": instance.context.data.get("frameStart"), "frameEnd": instance.context.data.get("frameEnd"), "resolutionWidth": instance.context.data.get("resolutionWidth"), From c08a6adc570162045ee9008177df4712a57ef5ad Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Oct 2020 15:35:22 +0200 Subject: [PATCH 027/131] Fix - renaming or deleting tasks in Ftrack wasn't synced Part for syncing through Event server --- .../ftrack/events/action_sync_to_avalon.py | 2 +- .../ftrack/events/event_sync_to_avalon.py | 82 ++++++++++++++----- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/pype/modules/ftrack/events/action_sync_to_avalon.py b/pype/modules/ftrack/events/action_sync_to_avalon.py index 4e119228c3..01db815413 100644 --- a/pype/modules/ftrack/events/action_sync_to_avalon.py +++ b/pype/modules/ftrack/events/action_sync_to_avalon.py @@ -15,7 +15,7 @@ class SyncToAvalonServer(BaseAction): - Data(dictionary): - VisualParent(ObjectId) - Avalon Id of parent asset - Parents(array of string) - All parent names except project - - Tasks(array of string) - Tasks on asset + - Tasks(dictionary of dictionaries) - Tasks on asset - FtrackId(string) - entityType(string) - entity's type on Ftrack * All Custom attributes in group 'Avalon' diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index d0c9ea2e96..b96741626c 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -472,6 +472,16 @@ class SyncToAvalonEvent(BaseEvent): return filtered_updates def get_ent_path(self, ftrack_id): + """ + Looks for entity in FTrack with 'ftrack_id'. If found returns + concatenated paths from its 'link' elemenent's names. Describes + location of entity in tree. + Args: + ftrack_id (string): entityId of FTrack entity + + Returns: + (string) - example : "/test_project/assets/my_asset" + """ entity = self.ftrack_ents_by_id.get(ftrack_id) if not entity: entity = self.process_session.query( @@ -491,7 +501,7 @@ class SyncToAvalonEvent(BaseEvent): self.process_session.commit() except Exception: self.set_process_session(session) - + self.log.debug("start launch") # Reset object values for each launch self.reset_variables() self._cur_event = event @@ -502,7 +512,7 @@ class SyncToAvalonEvent(BaseEvent): "move": {}, "add": {} } - + self.log.debug("event_data:: {}".format(event["data"])) entities_info = event["data"]["entities"] found_actions = set() for ent_info in entities_info: @@ -741,8 +751,9 @@ class SyncToAvalonEvent(BaseEvent): avalon_ent["data"]["tasks"] ) - if removed_name in self.task_changes_by_avalon_id[mongo_id]: - self.task_changes_by_avalon_id[mongo_id].remove( + if removed_name in self.task_changes_by_avalon_id[mongo_id].\ + keys(): + self.task_changes_by_avalon_id[mongo_id].pop( removed_name ) @@ -1068,11 +1079,13 @@ class SyncToAvalonEvent(BaseEvent): ) # Tasks - tasks = [] + tasks = {} for child in ftrack_ent["children"]: if child.entity_type.lower() != "task": continue - tasks.append(child["name"]) + self.log.debug("child:: {}".format(child)) + task_type = self._get_task_type(child['entityId']) + tasks[child["name"]] = {"type": task_type} # Visual Parent vis_par = None @@ -1423,8 +1436,8 @@ class SyncToAvalonEvent(BaseEvent): avalon_ent["data"]["tasks"] ) - if old_name in self.task_changes_by_avalon_id[mongo_id]: - self.task_changes_by_avalon_id[mongo_id].remove(old_name) + if old_name in self.task_changes_by_avalon_id[mongo_id].keys(): + self.task_changes_by_avalon_id[mongo_id].pop(old_name) else: parent_ftrack_ent = self.ftrack_ents_by_id.get(parent_id) if not parent_ftrack_ent: @@ -1442,17 +1455,21 @@ class SyncToAvalonEvent(BaseEvent): continue child_names.append(child["name"]) - tasks = [task for task in ( - self.task_changes_by_avalon_id[mongo_id] - )] - for task in tasks: - if task not in child_names: - self.task_changes_by_avalon_id[mongo_id].remove( - task + tasks = copy.deepcopy( + self.task_changes_by_avalon_id[mongo_id].keys() + ) + + for task_name in tasks: + if task_name not in child_names: + self.task_changes_by_avalon_id[mongo_id].pop( + task_name ) - if new_name not in self.task_changes_by_avalon_id[mongo_id]: - self.task_changes_by_avalon_id[mongo_id].append(new_name) + task_type = self._get_task_type(ent_info['entityId']) + if new_name not in self.task_changes_by_avalon_id[mongo_id].keys(): + self.task_changes_by_avalon_id[mongo_id][new_name] = { + "type": task_type + } # not_found are not processed since all not found are # not found because they are not synchronizable @@ -1688,8 +1705,12 @@ class SyncToAvalonEvent(BaseEvent): self.regex_failed.append(ent_info["entityId"]) continue - if new_name not in self.task_changes_by_avalon_id[mongo_id]: - self.task_changes_by_avalon_id[mongo_id].append(new_name) + task_type = self._get_task_type(ent_info['entityId']) + if new_name not in \ + self.task_changes_by_avalon_id[mongo_id].keys(): + self.task_changes_by_avalon_id[mongo_id][new_name] = { + "type": task_type + } def _mongo_id_configuration( self, @@ -2293,7 +2314,9 @@ class SyncToAvalonEvent(BaseEvent): mongo_changes_bulk = [] for mongo_id, changes in self.updates.items(): filter = {"_id": mongo_id} - change_data = avalon_sync.from_dict_to_set(changes) + avalon_ent = self.avalon_ents_by_id[mongo_id] + is_project = avalon_ent["type"] == "project" + change_data = avalon_sync.from_dict_to_set(changes, is_project) mongo_changes_bulk.append(UpdateOne(filter, change_data)) if not mongo_changes_bulk: @@ -2477,6 +2500,25 @@ class SyncToAvalonEvent(BaseEvent): ) return True + def _get_task_type(self, entityId): + """ + Returns task type ('Props', 'Art') from Task 'entityId' + Args: + entityId (string): entityId of Task + + Returns: + (string) - None if Task not found + """ + task_type = None + entity = self.process_session.query( + self.entities_query_by_id.format( + self.cur_project["id"], entityId + ) + ).first() + if entity: + task_type = entity["type"]["name"] + return task_type + def register(session, plugins_presets): '''Register plugin. Called when used as an plugin.''' From 3932b19ea94637d2023acde3a039b59db5c4e5a7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 7 Oct 2020 17:52:36 +0200 Subject: [PATCH 028/131] fix(nuke): improving names of render targets --- pype/hosts/nuke/lib.py | 14 +++++++++----- pype/plugins/nuke/publish/collect_instances.py | 6 +++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/pype/hosts/nuke/lib.py b/pype/hosts/nuke/lib.py index d896bfe1ef..8fd84b8555 100644 --- a/pype/hosts/nuke/lib.py +++ b/pype/hosts/nuke/lib.py @@ -389,24 +389,28 @@ def create_write_node(name, data, input=None, prenodes=None, review=True): # imprinting group node avalon.nuke.imprint(GN, data["avalon"]) - divider = nuke.Text_Knob('') - GN.addKnob(divider) + # add divider + GN.addKnob(nuke.Text_Knob('')) add_rendering_knobs(GN) if review: add_review_knob(GN) + # add divider + GN.addKnob(nuke.Text_Knob('')) + # Add linked knobs. linked_knob_names = ["Render", "use_limit", "first", "last"] for name in linked_knob_names: link = nuke.Link_Knob(name) link.makeLink(write_node.name(), name) link.setName(name) + link.setFlag(0x1000) GN.addKnob(link) - divider = nuke.Text_Knob('') - GN.addKnob(divider) + # add divider + GN.addKnob(nuke.Text_Knob('')) # adding write to read button add_button_write_to_read(GN) @@ -432,7 +436,7 @@ def add_rendering_knobs(node): ''' if "render" not in node.knobs(): knob = nuke.Enumeration_Knob("render", "Render", [ - "Do Not Render", "Locally", "On Farm"]) + "Use existing frames", "Local", "On farm"]) knob.setFlag(0x1000) node.addKnob(knob) return node diff --git a/pype/plugins/nuke/publish/collect_instances.py b/pype/plugins/nuke/publish/collect_instances.py index f1e7f2bdde..069c8d6c22 100644 --- a/pype/plugins/nuke/publish/collect_instances.py +++ b/pype/plugins/nuke/publish/collect_instances.py @@ -77,15 +77,15 @@ class CollectNukeInstances(pyblish.api.ContextPlugin): # only alter families for render family if "write" in families_ak: target = node["render"].value() - if target == "Do Not Render": + if target == "Use existing frames": # Local rendering self.log.info("flagged for no render") families.append("render") - elif target == "Locally": + elif target == "Local": # Local rendering self.log.info("flagged for local render") families.append("{}.local".format("render")) - elif target == "On Farm": + elif target == "On farm": # Farm rendering self.log.info("flagged for farm render") instance.data["transfer"] = False From f086088a9e710a3f2b377004a96ecaf20fcdaa3e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 7 Oct 2020 18:03:09 +0200 Subject: [PATCH 029/131] fix(hiero): plate thumbnail correct frame from source range --- pype/plugins/hiero/publish/collect_plates.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pype/plugins/hiero/publish/collect_plates.py b/pype/plugins/hiero/publish/collect_plates.py index 4d1cc36a92..34ea622414 100644 --- a/pype/plugins/hiero/publish/collect_plates.py +++ b/pype/plugins/hiero/publish/collect_plates.py @@ -192,11 +192,12 @@ class CollectPlatesData(api.InstancePlugin): instance.data["representations"].append( plates_mov_representation) - thumb_frame = instance.data["clipInH"] + ( - (instance.data["clipOutH"] - instance.data["clipInH"]) / 2) + thumb_frame = instance.data["sourceInH"] + ( + (instance.data["sourceOutH"] - instance.data["sourceInH"]) / 2) thumb_file = "{}_{}{}".format(head, thumb_frame, ".png") thumb_path = os.path.join(staging_dir, thumb_file) - + self.log.debug("__ thumb_path: `{}`, frame: `{}`".format( + thumb_path, thumb_frame)) thumbnail = item.thumbnail(thumb_frame).save( thumb_path, format='png' From 6d30b2e852df3da0e3377dcdb2d8f70c6bc81a8c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 7 Oct 2020 18:17:09 +0200 Subject: [PATCH 030/131] fix(hiero): review thumbnail two variation of frame identification --- pype/plugins/hiero/publish/collect_reviews.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pype/plugins/hiero/publish/collect_reviews.py b/pype/plugins/hiero/publish/collect_reviews.py index edb81aed8a..a444d57d6b 100644 --- a/pype/plugins/hiero/publish/collect_reviews.py +++ b/pype/plugins/hiero/publish/collect_reviews.py @@ -142,8 +142,15 @@ class CollectReviews(api.InstancePlugin): staging_dir = os.path.dirname( source_path) - thumb_frame = instance.data["clipInH"] + ( - (instance.data["clipOutH"] - instance.data["clipInH"]) / 2) + media_duration = instance.data.get("mediaDuration") + clip_duration_h = instance.data.get("clipDurationH") + + if media_duration > clip_duration_h: + thumb_frame = instance.data["clipInH"] + ( + (instance.data["clipOutH"] - instance.data["clipInH"]) / 2) + elif media_duration <= clip_duration_h: + thumb_frame = instance.data["sourceIn"] + ( + (instance.data["sourceOut"] - instance.data["sourceIn"]) / 2) thumb_file = "{}_{}{}".format(head, thumb_frame, ".png") thumb_path = os.path.join(staging_dir, thumb_file) self.log.debug("__ thumb_path: {}".format(thumb_path)) From b4a0b1d2589321bc3389e5d849a3fba1cf3e1776 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 7 Oct 2020 18:53:08 +0200 Subject: [PATCH 031/131] change tasks to dictionary --- pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py | 7 +++---- pype/plugins/global/publish/extract_hierarchy_avalon.py | 4 ++-- pype/plugins/hiero/publish/collect_shots.py | 2 +- pype/plugins/hiero/publish/collect_tag_tasks.py | 4 ++-- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py b/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py index 2ee0898711..e4496138bb 100644 --- a/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py +++ b/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py @@ -142,11 +142,10 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): existing_tasks.append(child['name'].lower()) # existing_tasks.append(child['type']['name']) - for task in tasks: - task_name = next(iter(task)) - task_type = task[task_name]["type"] + for task_name in tasks: + task_type = tasks[task_name]["type"] if task_name.lower() in existing_tasks: - print("Task {} already exists".format(task)) + print("Task {} already exists".format(task_name)) continue tasks_to_create.append((task_name, task_type)) diff --git a/pype/plugins/global/publish/extract_hierarchy_avalon.py b/pype/plugins/global/publish/extract_hierarchy_avalon.py index 64df672709..72cd935c2d 100644 --- a/pype/plugins/global/publish/extract_hierarchy_avalon.py +++ b/pype/plugins/global/publish/extract_hierarchy_avalon.py @@ -102,11 +102,11 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): new_tasks = data.pop("tasks", {}) if "tasks" not in cur_entity_data and not new_tasks: continue - for task in new_tasks: + for task_name in new_tasks: task_name = next(iter(task)) if task_name in cur_entity_data["tasks"].keys(): continue - cur_entity_data["tasks"][task_name] = task[task_name] + cur_entity_data["tasks"][task_name] = new_tasks[task_name] cur_entity_data.update(data) data = cur_entity_data else: diff --git a/pype/plugins/hiero/publish/collect_shots.py b/pype/plugins/hiero/publish/collect_shots.py index 6f83e08fbe..ed658a19a9 100644 --- a/pype/plugins/hiero/publish/collect_shots.py +++ b/pype/plugins/hiero/publish/collect_shots.py @@ -43,7 +43,7 @@ class CollectShots(api.InstancePlugin): "{} - {} - tasks:{} - assetbuilds:{} - comments:{}".format( data["asset"], data["subset"], - [task.keys()[0] for task in data["tasks"]], + [task for task in data["tasks"]], [x["name"] for x in data.get("assetbuilds", [])], len(data.get("comments", [])) ) diff --git a/pype/plugins/hiero/publish/collect_tag_tasks.py b/pype/plugins/hiero/publish/collect_tag_tasks.py index dbcf5e5260..ae4578bbf7 100644 --- a/pype/plugins/hiero/publish/collect_tag_tasks.py +++ b/pype/plugins/hiero/publish/collect_tag_tasks.py @@ -13,7 +13,7 @@ class CollectClipTagTasks(api.InstancePlugin): # gets tags tags = instance.data["tags"] - tasks = list() + tasks = dict() for t in tags: t_metadata = dict(t["metadata"]) t_family = t_metadata.get("tag.family", "") @@ -22,7 +22,7 @@ class CollectClipTagTasks(api.InstancePlugin): if "task" in t_family: t_task_name = t_metadata.get("tag.label", "") t_task_type = t_metadata.get("tag.type", "") - tasks.append({t_task_name: {"type": t_task_type}}) + tasks[t_task_name] = {"type": t_task_type} instance.data["tasks"] = tasks From d67402331a82f4561fa377cebe4f07d0c67ae64f Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 7 Oct 2020 19:12:32 +0200 Subject: [PATCH 032/131] remove forgotten line --- pype/plugins/global/publish/extract_hierarchy_avalon.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pype/plugins/global/publish/extract_hierarchy_avalon.py b/pype/plugins/global/publish/extract_hierarchy_avalon.py index 72cd935c2d..74751c6807 100644 --- a/pype/plugins/global/publish/extract_hierarchy_avalon.py +++ b/pype/plugins/global/publish/extract_hierarchy_avalon.py @@ -103,7 +103,6 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): if "tasks" not in cur_entity_data and not new_tasks: continue for task_name in new_tasks: - task_name = next(iter(task)) if task_name in cur_entity_data["tasks"].keys(): continue cur_entity_data["tasks"][task_name] = new_tasks[task_name] From d6e86f852a09be5630d2933498fbaf676ad42a10 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Oct 2020 20:39:29 +0200 Subject: [PATCH 033/131] Fix - change of task type wasn't propagated Rewrite of approach, simplified, in any task change is noticed, parents of changed tasks are captured, all task entities are pulled from Ftrack and Task dictionaries are recreated from scratch. --- .../ftrack/events/event_sync_to_avalon.py | 473 +++++++++--------- 1 file changed, 249 insertions(+), 224 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index b96741626c..f3ef38b868 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -40,6 +40,12 @@ class SyncToAvalonEvent(BaseEvent): "select id, name, parent_id, link, custom_attributes from TypedContext" " where project_id is \"{}\" and id in ({})" ) + + # useful for getting all tasks for asset + entities_query_by_parent_id = ( + "select id, name, parent_id, link, custom_attributes from TypedContext" + " where project_id is \"{}\" and parent_id in ({})" + ) entities_name_query_by_name = ( "select id, name from TypedContext" " where project_id is \"{}\" and name in ({})" @@ -313,9 +319,6 @@ class SyncToAvalonEvent(BaseEvent): if self._avalon_archived_by_id is not None: self._avalon_archived_by_id[mongo_id] = entity - if mongo_id in self.task_changes_by_avalon_id: - self.task_changes_by_avalon_id.pop(mongo_id) - def _bubble_changeability(self, unchangeable_ids): unchangeable_queue = queue.Queue() for entity_id in unchangeable_ids: @@ -383,8 +386,6 @@ class SyncToAvalonEvent(BaseEvent): self._avalon_archived_by_id = None self._avalon_archived_by_name = None - self.task_changes_by_avalon_id = {} - self._avalon_custom_attributes = None self._ent_types_by_name = None @@ -398,6 +399,10 @@ class SyncToAvalonEvent(BaseEvent): self.ftrack_updated = {} self.ftrack_removed = {} + # set of ftrack ids with modified tasks + # handled separately by full wipeout and replace from FTrack + self.modified_tasks_ftrackids = set() + self.moved_in_avalon = [] self.renamed_in_avalon = [] self.hier_cust_attrs_changes = collections.defaultdict(list) @@ -496,12 +501,24 @@ class SyncToAvalonEvent(BaseEvent): return "/".join([ent["name"] for ent in entity["link"]]) def launch(self, session, event): + """ + Main entry port for synchronization. + Goes through event (can contain multiple changes) and decides if + the event is interesting for us (interest_entTypes). + It separates changes into add|remove|update. + All task changes are handled together by refresh from Ftrack. + Args: + session (object): session to Ftrack + event (dictionary): event content + + Returns: + (boolean or None) + """ # Try to commit and if any error happen then recreate session try: self.process_session.commit() except Exception: self.set_process_session(session) - self.log.debug("start launch") # Reset object values for each launch self.reset_variables() self._cur_event = event @@ -512,7 +529,7 @@ class SyncToAvalonEvent(BaseEvent): "move": {}, "add": {} } - self.log.debug("event_data:: {}".format(event["data"])) + entities_info = event["data"]["entities"] found_actions = set() for ent_info in entities_info: @@ -537,26 +554,45 @@ class SyncToAvalonEvent(BaseEvent): continue ftrack_id = ftrack_id[0] + # task modified, collect parent id of task, handle separately + if entityType.lower() == 'task': + self.modified_tasks_ftrackids.add(ent_info["parentId"]) + if action == "move": ent_keys = ent_info["keys"] - # Seprate update info from move action + # Separate update info from move action if len(ent_keys) > 1: _ent_info = ent_info.copy() for ent_key in ent_keys: if ent_key == "parent_id": + # task parents modified, collect both + if entityType.lower() == 'task': + self.modified_tasks_ftrackids.add( + ent_info["changes"]["new"]) + self.modified_tasks_ftrackids.add( + ent_info["changes"]["old"]) _ent_info["changes"].pop(ent_key, None) _ent_info["keys"].remove(ent_key) else: ent_info["changes"].pop(ent_key, None) ent_info["keys"].remove(ent_key) + if entityType.lower() != 'task': + entities_by_action["update"][ftrack_id] = _ent_info + else: + if entityType.lower() == 'task': + self.modified_tasks_ftrackids.add( + ent_info["changes"]["parent_id"]["new"]) + self.modified_tasks_ftrackids.add( + ent_info["changes"]["parent_id"]["old"] + ) - entities_by_action["update"][ftrack_id] = _ent_info - - found_actions.add(action) - entities_by_action[action][ftrack_id] = ent_info + # regular change process handles all other than Tasks + if entityType.lower() != 'task': + found_actions.add(action) + entities_by_action[action][ftrack_id] = ent_info found_actions = list(found_actions) - if not found_actions: + if not found_actions and not self.modified_tasks_ftrackids: return True # Check if auto sync was turned on/off @@ -632,26 +668,17 @@ class SyncToAvalonEvent(BaseEvent): ft_project["full_name"], debug_msg )) # Get ftrack entities - find all ftrack ids first - ftrack_ids = [] - for ftrack_id in updated: - ftrack_ids.append(ftrack_id) + ftrack_ids = set(updated.keys()) - for action, ftrack_ids in entities_by_action.items(): + for action, _ftrack_ids in entities_by_action.items(): # skip updated (already prepared) and removed (not exist in ftrack) - if action == "remove": - continue + if action not in ("remove", "update"): + ftrack_ids.union(set(_ftrack_ids)) - for ftrack_id in ftrack_ids: - if ftrack_id not in ftrack_ids: - ftrack_ids.append(ftrack_id) - - if ftrack_ids: - joined_ids = ", ".join(["\"{}\"".format(id) for id in ftrack_ids]) - ftrack_entities = self.process_session.query( - self.entities_query_by_id.format(ft_project["id"], joined_ids) - ).all() - for entity in ftrack_entities: - self.ftrack_ents_by_id[entity["id"]] = entity + # collect entity records data which might not be in event + for entity in self._get_entities_for_ftrack_ids(ft_project["id"], + ftrack_ids): + self.ftrack_ents_by_id[entity["id"]] = entity # Filter updates where name is changing for ftrack_id, ent_info in updated.items(): @@ -698,9 +725,11 @@ class SyncToAvalonEvent(BaseEvent): time_6 = time.time() # 6.) Process changes in hierarchy or hier custom attribues self.process_hier_cleanup() + time_7 = time.time() + self.process_task_updates() if self.updates: self.update_entities() - time_7 = time.time() + time_8 = time.time() time_removed = time_2 - time_1 time_renamed = time_3 - time_2 @@ -708,11 +737,15 @@ class SyncToAvalonEvent(BaseEvent): time_moved = time_5 - time_4 time_updated = time_6 - time_5 time_cleanup = time_7 - time_6 - time_total = time_7 - time_1 - self.log.debug("Process time: {} <{}, {}, {}, {}, {}, {}>".format( - time_total, time_removed, time_renamed, time_added, time_moved, - time_updated, time_cleanup - )) + time_task_updates = time_8 - time_7 + time_total = time_8 - time_1 + self.log.debug( + "Process time: {:.2f} <{:.2f}, {:.2f}, {:.2f}, ".format( + time_total, time_removed, time_renamed, time_added) + + "{:.2f}, {:.2f}, {:.2f}, {:.2f}>".format( + time_moved, time_updated, time_cleanup, time_task_updates + ) + ) except Exception: msg = "An error has happened during synchronization" @@ -724,6 +757,9 @@ class SyncToAvalonEvent(BaseEvent): return True def process_removed(self): + """ + Handles removed entities (not removed tasks - handle separately). + """ if not self.ftrack_removed: return ent_infos = self.ftrack_removed @@ -734,31 +770,12 @@ class SyncToAvalonEvent(BaseEvent): recreate_ents = [] removed_names = [] for ftrack_id, removed in ent_infos.items(): - entity_type = removed["entity_type"] - parent_id = removed["parentId"] - removed_name = removed["changes"]["name"]["old"] if entity_type == "Task": - avalon_ent = self.avalon_ents_by_ftrack_id.get(parent_id) - if not avalon_ent: - self.log.debug(( - "Parent entity of task was not found in avalon <{}>" - ).format(self.get_ent_path(parent_id))) - continue - - mongo_id = avalon_ent["_id"] - if mongo_id not in self.task_changes_by_avalon_id: - self.task_changes_by_avalon_id[mongo_id] = ( - avalon_ent["data"]["tasks"] - ) - - if removed_name in self.task_changes_by_avalon_id[mongo_id].\ - keys(): - self.task_changes_by_avalon_id[mongo_id].pop( - removed_name - ) - continue + entity_type = removed["entity_type"] + removed_name = removed["changes"]["name"]["old"] + avalon_ent = self.avalon_ents_by_ftrack_id.get(ftrack_id) if not avalon_ent: continue @@ -1084,7 +1101,8 @@ class SyncToAvalonEvent(BaseEvent): if child.entity_type.lower() != "task": continue self.log.debug("child:: {}".format(child)) - task_type = self._get_task_type(child['entityId']) + task_type = self._get_task_type(self.cur_project["id"], + child['entityId']) tasks[child["name"]] = {"type": task_type} # Visual Parent @@ -1280,21 +1298,14 @@ class SyncToAvalonEvent(BaseEvent): "Processing renamed entities: {}".format(str(ent_infos)) ) - renamed_tasks = {} - not_found = {} changeable_queue = queue.Queue() for ftrack_id, ent_info in ent_infos.items(): entity_type = ent_info["entity_type"] + if entity_type == "Task": + continue + new_name = ent_info["changes"]["name"]["new"] old_name = ent_info["changes"]["name"]["old"] - if entity_type == "Task": - parent_id = ent_info["parentId"] - renamed_tasks[parent_id] = { - "new": new_name, - "old": old_name, - "ent_info": ent_info - } - continue ent_path = self.get_ent_path(ftrack_id) avalon_ent = self.avalon_ents_by_ftrack_id.get(ftrack_id) @@ -1413,64 +1424,6 @@ class SyncToAvalonEvent(BaseEvent): if old_names: self.check_names_synchronizable(old_names) - for parent_id, task_change in renamed_tasks.items(): - avalon_ent = self.avalon_ents_by_ftrack_id.get(parent_id) - ent_info = task_change["ent_info"] - if not avalon_ent: - not_found[ent_info["entityId"]] = ent_info - continue - - new_name = task_change["new"] - old_name = task_change["old"] - passed_regex = avalon_sync.check_regex( - new_name, "task", schema_patterns=self.regex_schemas - ) - if not passed_regex: - ftrack_id = ent_info["enityId"] - self.regex_failed.append(ftrack_id) - continue - - mongo_id = avalon_ent["_id"] - if mongo_id not in self.task_changes_by_avalon_id: - self.task_changes_by_avalon_id[mongo_id] = ( - avalon_ent["data"]["tasks"] - ) - - if old_name in self.task_changes_by_avalon_id[mongo_id].keys(): - self.task_changes_by_avalon_id[mongo_id].pop(old_name) - else: - parent_ftrack_ent = self.ftrack_ents_by_id.get(parent_id) - if not parent_ftrack_ent: - parent_ftrack_ent = self.process_session.query( - self.entities_query_by_id.format( - self.cur_project["id"], parent_id - ) - ).first() - - if parent_ftrack_ent: - self.ftrack_ents_by_id[parent_id] = parent_ftrack_ent - child_names = [] - for child in parent_ftrack_ent["children"]: - if child.entity_type.lower() != "task": - continue - child_names.append(child["name"]) - - tasks = copy.deepcopy( - self.task_changes_by_avalon_id[mongo_id].keys() - ) - - for task_name in tasks: - if task_name not in child_names: - self.task_changes_by_avalon_id[mongo_id].pop( - task_name - ) - - task_type = self._get_task_type(ent_info['entityId']) - if new_name not in self.task_changes_by_avalon_id[mongo_id].keys(): - self.task_changes_by_avalon_id[mongo_id][new_name] = { - "type": task_type - } - # not_found are not processed since all not found are # not found because they are not synchronizable @@ -1488,7 +1441,6 @@ class SyncToAvalonEvent(BaseEvent): # Skip if already exit in avalon db or tasks entities # - happen when was created by any sync event/action pop_out_ents = [] - new_tasks_by_parent = collections.defaultdict(list) for ftrack_id, ent_info in ent_infos.items(): if self.avalon_ents_by_ftrack_id.get(ftrack_id): pop_out_ents.append(ftrack_id) @@ -1501,9 +1453,6 @@ class SyncToAvalonEvent(BaseEvent): entity_type = ent_info["entity_type"] if entity_type == "Task": - parent_id = ent_info["parentId"] - new_tasks_by_parent[parent_id].append(ent_info) - pop_out_ents.append(ftrack_id) continue name = ( @@ -1680,86 +1629,11 @@ class SyncToAvalonEvent(BaseEvent): self.create_entity_in_avalon(entity, parent_avalon) - for parent_id, ent_infos in new_tasks_by_parent.items(): - avalon_ent = self.avalon_ents_by_ftrack_id.get(parent_id) - if not avalon_ent: - # TODO logging - self.log.debug(( - "Skipping synchronization of task" - " because parent was not found in Avalon DB <{}>" - ).format(self.get_ent_path(parent_id))) - continue - - mongo_id = avalon_ent["_id"] - if mongo_id not in self.task_changes_by_avalon_id: - self.task_changes_by_avalon_id[mongo_id] = ( - avalon_ent["data"]["tasks"] - ) - - for ent_info in ent_infos: - new_name = ent_info["changes"]["name"]["new"] - passed_regex = avalon_sync.check_regex( - new_name, "task", schema_patterns=self.regex_schemas - ) - if not passed_regex: - self.regex_failed.append(ent_info["entityId"]) - continue - - task_type = self._get_task_type(ent_info['entityId']) - if new_name not in \ - self.task_changes_by_avalon_id[mongo_id].keys(): - self.task_changes_by_avalon_id[mongo_id][new_name] = { - "type": task_type - } - - def _mongo_id_configuration( - self, - ent_info, - cust_attrs, - hier_attrs, - temp_dict - ): - # Use hierarchical mongo id attribute if possible. - if "_hierarchical" not in temp_dict: - hier_mongo_id_configuration_id = None - for attr in hier_attrs: - if attr["key"] == CUST_ATTR_ID_KEY: - hier_mongo_id_configuration_id = attr["id"] - break - temp_dict["_hierarchical"] = hier_mongo_id_configuration_id - - hier_mongo_id_configuration_id = temp_dict.get("_hierarchical") - if hier_mongo_id_configuration_id is not None: - return hier_mongo_id_configuration_id - - # Legacy part for cases that MongoID attribute is per entity type. - entity_type = ent_info["entity_type"] - mongo_id_configuration_id = temp_dict.get(entity_type) - if mongo_id_configuration_id is not None: - return mongo_id_configuration_id - - for attr in cust_attrs: - key = attr["key"] - if key != CUST_ATTR_ID_KEY: - continue - - if attr["entity_type"] != ent_info["entityType"]: - continue - - if ( - ent_info["entityType"] == "task" and - attr["object_type_id"] != ent_info["objectTypeId"] - ): - continue - - mongo_id_configuration_id = attr["id"] - break - - temp_dict[entity_type] = mongo_id_configuration_id - - return mongo_id_configuration_id - def process_moved(self): + """ + Handles moved entities to different place in hiearchy. + (Not tasks - handled separately.) + """ if not self.ftrack_moved: return @@ -1893,7 +1767,9 @@ class SyncToAvalonEvent(BaseEvent): ) def process_updated(self): - # Only custom attributes changes should get here + """ + Only custom attributes changes should get here + """ if not self.ftrack_updated: return @@ -1991,8 +1867,7 @@ class SyncToAvalonEvent(BaseEvent): if ( not self.moved_in_avalon and not self.renamed_in_avalon and - not self.hier_cust_attrs_changes and - not self.task_changes_by_avalon_id + not self.hier_cust_attrs_changes ): return @@ -2021,14 +1896,6 @@ class SyncToAvalonEvent(BaseEvent): if not all_keys and key not in hier_cust_attrs_keys: hier_cust_attrs_keys.append(key) - # Tasks preparation **** - for mongo_id, tasks in self.task_changes_by_avalon_id.items(): - avalon_ent = self.avalon_ents_by_id[mongo_id] - if "data" not in self.updates[mongo_id]: - self.updates[mongo_id]["data"] = {} - - self.updates[mongo_id]["data"]["tasks"] = tasks - # Parents preparation *** mongo_to_ftrack_parents = {} missing_ftrack_ents = {} @@ -2310,7 +2177,69 @@ class SyncToAvalonEvent(BaseEvent): self.update_entities() + def process_task_updates(self): + """ + Pull task information for selected ftrack ids to replace stored + existing in Avalon. + Solves problem of changing type (even Status in the future) of + task without storing ftrack id for task in the DB. (Which doesn't + bring much advantage currently and it could be troublesome for + all hosts or plugins (for example Nuke) to collect and store. + Returns: + None + """ + self.log.debug( + "Processing task changes for parents: {}".format( + self.modified_tasks_ftrackids + ) + ) + if not self.modified_tasks_ftrackids: + return + entities = self._get_entities_for_ftrack_ids( + self.cur_project["id"], + self.modified_tasks_ftrackids) + + ftrack_mongo_mapping_found = {} + not_found_ids = [] + tasks_per_ftrack_id = {} + + # prepare all tasks per parentId, eg. Avalon asset record + for entity in entities: + ftrack_id = entity["parent_id"] + if ftrack_id not in tasks_per_ftrack_id: + tasks_per_ftrack_id[ftrack_id] = {} + + passed_regex = avalon_sync.check_regex( + entity["name"], "task", + schema_patterns=self.regex_schemas + ) + if not passed_regex: + entity_id = entity["id"] + self.regex_failed.append(entity_id) + continue + + task = {"type": entity["type"]["name"]} + tasks_per_ftrack_id[ftrack_id][entity["name"]] = task + + # find avalon entity by parentId + # should be there as create was run first + for ftrack_id in tasks_per_ftrack_id.keys(): + avalon_entity = self.avalon_ents_by_ftrack_id.get(ftrack_id) + if not avalon_entity: + not_found_ids.append(ftrack_id) + continue + ftrack_mongo_mapping_found[ftrack_id] = avalon_entity["_id"] + + self._update_avalon_tasks(ftrack_mongo_mapping_found, + tasks_per_ftrack_id) + def update_entities(self): + """ + Update Avalon entities by mongo bulk changes. + Expects self.updates which are transfered to $set part of update + command. + Resets self.updates afterwards. + """ mongo_changes_bulk = [] for mongo_id, changes in self.updates.items(): filter = {"_id": mongo_id} @@ -2500,10 +2429,82 @@ class SyncToAvalonEvent(BaseEvent): ) return True - def _get_task_type(self, entityId): + def _update_avalon_tasks(self, ftrack_mongo_mapping_found, + tasks_per_ftrack_id): """ - Returns task type ('Props', 'Art') from Task 'entityId' + Prepare new "tasks" content for existing records in Avalon. Args: + ftrack_mongo_mapping_found (dictionary): ftrack parentId to + Avalon _id mapping + tasks_per_ftrack_id (dictionary): task dictionaries per ftrack + parentId + + Returns: + None + """ + mongo_changes_bulk = [] + for ftrack_id, mongo_id in ftrack_mongo_mapping_found.items(): + filter = {"_id": mongo_id} + change_data = {"$set": {}} + change_data["$set"]["data.tasks"] = tasks_per_ftrack_id[ftrack_id] + mongo_changes_bulk.append(UpdateOne(filter, change_data)) + if not mongo_changes_bulk: + return + + self.dbcon.bulk_write(mongo_changes_bulk) + + def _mongo_id_configuration( + self, + ent_info, + cust_attrs, + hier_attrs, + temp_dict + ): + # Use hierarchical mongo id attribute if possible. + if "_hierarchical" not in temp_dict: + hier_mongo_id_configuration_id = None + for attr in hier_attrs: + if attr["key"] == CUST_ATTR_ID_KEY: + hier_mongo_id_configuration_id = attr["id"] + break + temp_dict["_hierarchical"] = hier_mongo_id_configuration_id + + hier_mongo_id_configuration_id = temp_dict.get("_hierarchical") + if hier_mongo_id_configuration_id is not None: + return hier_mongo_id_configuration_id + + # Legacy part for cases that MongoID attribute is per entity type. + entity_type = ent_info["entity_type"] + mongo_id_configuration_id = temp_dict.get(entity_type) + if mongo_id_configuration_id is not None: + return mongo_id_configuration_id + + for attr in cust_attrs: + key = attr["key"] + if key != CUST_ATTR_ID_KEY: + continue + + if attr["entity_type"] != ent_info["entityType"]: + continue + + if ( + ent_info["entityType"] == "task" and + attr["object_type_id"] != ent_info["objectTypeId"] + ): + continue + + mongo_id_configuration_id = attr["id"] + break + + temp_dict[entity_type] = mongo_id_configuration_id + + return mongo_id_configuration_id + + def _get_task_type(self, project_id, entityId): + """ + Returns task type ('Props', 'Art') from Task 'entityId'. + Args: + project_id (string): entityId (string): entityId of Task Returns: @@ -2512,13 +2513,37 @@ class SyncToAvalonEvent(BaseEvent): task_type = None entity = self.process_session.query( self.entities_query_by_id.format( - self.cur_project["id"], entityId + project_id, entityId ) ).first() if entity: task_type = entity["type"]["name"] return task_type + def _get_entities_for_ftrack_ids(self, ft_project_id, ftrack_ids): + """ + Query Ftrack API and return all entities for particular + 'ft_project' and their parent_id in 'ftrack_ids'. + It is much faster to run this once for multiple ids than run it + for each separately. + Used mainly for collecting task information + Args: + ft_project_id (string): + ftrack_ids (list): of strings + + Returns: + (list) of Ftrack entities + """ + ftrack_entities = [] + if ftrack_ids: + joined_ids = ", ".join(["\"{}\"".format(id) for id in ftrack_ids]) + ftrack_entities = self.process_session.query( + self.entities_query_by_parent_id.format(ft_project_id, + joined_ids) + ).all() + + return ftrack_entities + def register(session, plugins_presets): '''Register plugin. Called when used as an plugin.''' From b1af13250859360d0f36258371914d0df15d8c58 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Oct 2020 11:07:17 +0200 Subject: [PATCH 034/131] fix(nks): order of plugin, fix(nuke): typo in namespace --- pype/hosts/nuke/lib.py | 6 +++--- .../plugins/nukestudio/publish/collect_hierarchy_context.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pype/hosts/nuke/lib.py b/pype/hosts/nuke/lib.py index 8c0e37b15d..7c5a48c100 100644 --- a/pype/hosts/nuke/lib.py +++ b/pype/hosts/nuke/lib.py @@ -1065,7 +1065,7 @@ class BuildWorkfile(WorkfileSettings): Building first version of workfile. Settings are taken from presets and db. It will add all subsets - in last version for defined representaions + in last version for defined representations Arguments: variable (type): description @@ -1233,7 +1233,7 @@ class BuildWorkfile(WorkfileSettings): log.info("Building Loader to: `{}`".format(name)) version = subset["version"] log.info("Version to: `{}`".format(version["name"])) - representations = subset["representaions"] + representations = subset["representations"] for repr in representations: rn = self.read_loader(repr) rn["xpos"].setValue(self.xpos) @@ -1247,7 +1247,7 @@ class BuildWorkfile(WorkfileSettings): if len(lut_subset) > 0: lsub = lut_subset[0] - fxn = self.effect_loader(lsub["representaions"][-1]) + fxn = self.effect_loader(lsub["representations"][-1]) fxn_ypos = fxn["ypos"].value() fxn["ypos"].setValue(fxn_ypos - 100) nodes_backdrop.append(fxn) diff --git a/pype/plugins/nukestudio/publish/collect_hierarchy_context.py b/pype/plugins/nukestudio/publish/collect_hierarchy_context.py index a41e987bdb..4fc02a4b3d 100644 --- a/pype/plugins/nukestudio/publish/collect_hierarchy_context.py +++ b/pype/plugins/nukestudio/publish/collect_hierarchy_context.py @@ -13,7 +13,7 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin): """ label = "Collect Hierarchy Clip" - order = pyblish.api.CollectorOrder + 0.101 + order = pyblish.api.CollectorOrder + 0.102 families = ["clip"] def convert_to_entity(self, key, value): From c2fcfd0aaf92fc9090694dd031d2edf241761448 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Oct 2020 12:52:55 +0200 Subject: [PATCH 035/131] fix(nks): preview to review tag on representation order of hierarchy context was wrong --- pype/plugins/nukestudio/publish/collect_hierarchy_context.py | 2 +- pype/plugins/nukestudio/publish/collect_plates.py | 2 +- pype/plugins/nukestudio/publish/collect_reviews.py | 2 +- pype/plugins/nukestudio/publish/extract_review_cutup_video.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pype/plugins/nukestudio/publish/collect_hierarchy_context.py b/pype/plugins/nukestudio/publish/collect_hierarchy_context.py index 4fc02a4b3d..fddda72635 100644 --- a/pype/plugins/nukestudio/publish/collect_hierarchy_context.py +++ b/pype/plugins/nukestudio/publish/collect_hierarchy_context.py @@ -217,7 +217,7 @@ class CollectHierarchyContext(pyblish.api.ContextPlugin): ''' label = "Collect Hierarchy Context" - order = pyblish.api.CollectorOrder + 0.102 + order = pyblish.api.CollectorOrder + 0.103 def update_dict(self, ex_dict, new_dict): for key in ex_dict: diff --git a/pype/plugins/nukestudio/publish/collect_plates.py b/pype/plugins/nukestudio/publish/collect_plates.py index 770cef7e3f..44109716bb 100644 --- a/pype/plugins/nukestudio/publish/collect_plates.py +++ b/pype/plugins/nukestudio/publish/collect_plates.py @@ -183,7 +183,7 @@ class CollectPlatesData(api.InstancePlugin): "frameEnd": instance.data["sourceOut"] - instance.data["sourceIn"] + 1, 'step': 1, 'fps': instance.context.data["fps"], - 'tags': ["preview"], + 'tags': ["review"], 'name': "preview", 'ext': "mov", } diff --git a/pype/plugins/nukestudio/publish/collect_reviews.py b/pype/plugins/nukestudio/publish/collect_reviews.py index aa8c60767c..8fdcd666bc 100644 --- a/pype/plugins/nukestudio/publish/collect_reviews.py +++ b/pype/plugins/nukestudio/publish/collect_reviews.py @@ -99,7 +99,7 @@ class CollectReviews(api.InstancePlugin): "step": 1, "fps": rev_inst.data.get("fps"), "name": "preview", - "tags": ["preview"], + "tags": ["review"], "ext": ext } diff --git a/pype/plugins/nukestudio/publish/extract_review_cutup_video.py b/pype/plugins/nukestudio/publish/extract_review_cutup_video.py index d1ce3675b1..4776a282be 100644 --- a/pype/plugins/nukestudio/publish/extract_review_cutup_video.py +++ b/pype/plugins/nukestudio/publish/extract_review_cutup_video.py @@ -227,7 +227,7 @@ class ExtractReviewCutUpVideo(pype.api.Extractor): "step": 1, "fps": fps, "name": "cut_up_preview", - "tags": ["review", "delete"] + self.tags_addition, + "tags": ["review"] + self.tags_addition, "ext": ext, "anatomy_template": "publish" } From faa1d8028a5cfbfb73a2178bb7f22e2ab2bb6245 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:32:06 +0200 Subject: [PATCH 036/131] filter tasks by entity_type and do task filtration at one place --- .../ftrack/events/event_sync_to_avalon.py | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index f3ef38b868..d021938606 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -555,8 +555,16 @@ class SyncToAvalonEvent(BaseEvent): ftrack_id = ftrack_id[0] # task modified, collect parent id of task, handle separately - if entityType.lower() == 'task': - self.modified_tasks_ftrackids.add(ent_info["parentId"]) + if entity_type.lower() == "task": + changes = ent_info.get("changes") or {} + if action == "move": + parent_changes = changes["parent_id"] + self.modified_tasks_ftrackids.add(parent_changes["new"]) + self.modified_tasks_ftrackids.add(parent_changes["old"]) + + elif "typeid" in changes or "name" in changes: + self.modified_tasks_ftrackids.add(ent_info["parentId"]) + continue if action == "move": ent_keys = ent_info["keys"] @@ -565,31 +573,15 @@ class SyncToAvalonEvent(BaseEvent): _ent_info = ent_info.copy() for ent_key in ent_keys: if ent_key == "parent_id": - # task parents modified, collect both - if entityType.lower() == 'task': - self.modified_tasks_ftrackids.add( - ent_info["changes"]["new"]) - self.modified_tasks_ftrackids.add( - ent_info["changes"]["old"]) _ent_info["changes"].pop(ent_key, None) _ent_info["keys"].remove(ent_key) else: ent_info["changes"].pop(ent_key, None) ent_info["keys"].remove(ent_key) - if entityType.lower() != 'task': - entities_by_action["update"][ftrack_id] = _ent_info - else: - if entityType.lower() == 'task': - self.modified_tasks_ftrackids.add( - ent_info["changes"]["parent_id"]["new"]) - self.modified_tasks_ftrackids.add( - ent_info["changes"]["parent_id"]["old"] - ) - + entities_by_action["update"][ftrack_id] = _ent_info # regular change process handles all other than Tasks - if entityType.lower() != 'task': - found_actions.add(action) - entities_by_action[action][ftrack_id] = ent_info + found_actions.add(action) + entities_by_action[action][ftrack_id] = ent_info found_actions = list(found_actions) if not found_actions and not self.modified_tasks_ftrackids: From 04c6fd33ebeb80c68a27c9ead8c5d58f7ca26a51 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:32:27 +0200 Subject: [PATCH 037/131] fixed ftrack ids unin --- pype/modules/ftrack/events/event_sync_to_avalon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index d021938606..f7dc34054e 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -665,7 +665,7 @@ class SyncToAvalonEvent(BaseEvent): for action, _ftrack_ids in entities_by_action.items(): # skip updated (already prepared) and removed (not exist in ftrack) if action not in ("remove", "update"): - ftrack_ids.union(set(_ftrack_ids)) + ftrack_ids |= set(_ftrack_ids) # collect entity records data which might not be in event for entity in self._get_entities_for_ftrack_ids(ft_project["id"], From 3d0ab220c5c1fbc0eb740bf28e31e162de814daf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:32:56 +0200 Subject: [PATCH 038/131] do not use `_get_entities_for_ftrack_ids` for query as it find entities by parent id --- pype/modules/ftrack/events/event_sync_to_avalon.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index f7dc34054e..3dc0d92db2 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -668,9 +668,13 @@ class SyncToAvalonEvent(BaseEvent): ftrack_ids |= set(_ftrack_ids) # collect entity records data which might not be in event - for entity in self._get_entities_for_ftrack_ids(ft_project["id"], - ftrack_ids): - self.ftrack_ents_by_id[entity["id"]] = entity + if ftrack_ids: + joined_ids = ", ".join(["\"{}\"".format(id) for id in ftrack_ids]) + ftrack_entities = self.process_session.query( + self.entities_query_by_id.format(ft_project["id"], joined_ids) + ).all() + for entity in ftrack_entities: + self.ftrack_ents_by_id[entity["id"]] = entity # Filter updates where name is changing for ftrack_id, ent_info in updated.items(): From 06cf676bf8373855567f9920272d1253e1514a76 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:33:19 +0200 Subject: [PATCH 039/131] check if there are unprocessed task changes before skipping whole process --- pype/modules/ftrack/events/event_sync_to_avalon.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index 3dc0d92db2..dd7ed1386f 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -623,9 +623,10 @@ class SyncToAvalonEvent(BaseEvent): # skip most of events where nothing has changed for avalon if ( - len(found_actions) == 1 and - found_actions[0] == "update" and - not updated + len(found_actions) == 1 + and found_actions[0] == "update" + and not updated + and not self.modified_tasks_ftrackids ): return True From a434041a27de61bbb0e2672d47f7c5958b4efc9f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:33:36 +0200 Subject: [PATCH 040/131] fixed undefined variable in remove --- pype/modules/ftrack/events/event_sync_to_avalon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index dd7ed1386f..f2a94da56c 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -767,10 +767,10 @@ class SyncToAvalonEvent(BaseEvent): recreate_ents = [] removed_names = [] for ftrack_id, removed in ent_infos.items(): - if entity_type == "Task": + entity_type = removed["entity_type"] + if entity_type.lower() == "task": continue - entity_type = removed["entity_type"] removed_name = removed["changes"]["name"]["old"] avalon_ent = self.avalon_ents_by_ftrack_id.get(ftrack_id) From 09e29c3d29787d44a47d0f5e2bd6b10be84bf26f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:34:16 +0200 Subject: [PATCH 041/131] do not process tasks on entity creation but just add it's id to `modified_tasks_ftrackids` so it's processed afterwards --- pype/modules/ftrack/events/event_sync_to_avalon.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index f2a94da56c..9df3c2838f 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -1092,15 +1092,8 @@ class SyncToAvalonEvent(BaseEvent): ) ) - # Tasks - tasks = {} - for child in ftrack_ent["children"]: - if child.entity_type.lower() != "task": - continue - self.log.debug("child:: {}".format(child)) - task_type = self._get_task_type(self.cur_project["id"], - child['entityId']) - tasks[child["name"]] = {"type": task_type} + # Add entity to modified so tasks are added at the end + self.modified_tasks_ftrackids.add(ftrack_ent["id"]) # Visual Parent vis_par = None @@ -1120,7 +1113,7 @@ class SyncToAvalonEvent(BaseEvent): "entityType": ftrack_ent.entity_type, "parents": parents, "hierarchy": hierarchy, - "tasks": tasks, + "tasks": {}, "visualParent": vis_par } } From be4a25dc60177eb552a443e05f70c7ae5b52ecec Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:34:41 +0200 Subject: [PATCH 042/131] renamed `entities_query_by_parent_id` to `task_entities_query_by_parent_id` and modified query --- pype/modules/ftrack/events/event_sync_to_avalon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index 9df3c2838f..4bfb648196 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -42,8 +42,8 @@ class SyncToAvalonEvent(BaseEvent): ) # useful for getting all tasks for asset - entities_query_by_parent_id = ( - "select id, name, parent_id, link, custom_attributes from TypedContext" + task_entities_query_by_parent_id = ( + "select id, name, parent_id, type_id from Task" " where project_id is \"{}\" and parent_id in ({})" ) entities_name_query_by_name = ( From bf04ce5fd05d80614e35d916bb10f8ddff960b3f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:35:32 +0200 Subject: [PATCH 043/131] added query for task types to not query them one by one --- pype/modules/ftrack/events/event_sync_to_avalon.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index 4bfb648196..93aeb3e20a 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -46,6 +46,9 @@ class SyncToAvalonEvent(BaseEvent): "select id, name, parent_id, type_id from Task" " where project_id is \"{}\" and parent_id in ({})" ) + task_types_query = ( + "select id, name from Type" + ) entities_name_query_by_name = ( "select id, name from TypedContext" " where project_id is \"{}\" and name in ({})" @@ -2192,6 +2195,12 @@ class SyncToAvalonEvent(BaseEvent): ftrack_mongo_mapping_found = {} not_found_ids = [] tasks_per_ftrack_id = {} + # Query all task types at once + task_types = self.process_session.query(self.task_types_query).all() + task_types_by_id = { + task_type["id"]: task_type + for task_type in task_types + } # prepare all tasks per parentId, eg. Avalon asset record for entity in entities: From e2262ce929b049febed5ff67e3c0cdc1952b0382 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:36:04 +0200 Subject: [PATCH 044/131] make sure all entities with changed tasks are processed even if they dont have any task left --- pype/modules/ftrack/events/event_sync_to_avalon.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index 93aeb3e20a..5395fe756c 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -2194,7 +2194,12 @@ class SyncToAvalonEvent(BaseEvent): ftrack_mongo_mapping_found = {} not_found_ids = [] - tasks_per_ftrack_id = {} + # Make sure all parents have updated tasks, as they may not have any + tasks_per_ftrack_id = { + ftrack_id: {} + for ftrack_id in self.modified_tasks_ftrackids + } + # Query all task types at once task_types = self.process_session.query(self.task_types_query).all() task_types_by_id = { From 84b1ee867a0ca8f859b63d043325678b3fade3f2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:36:23 +0200 Subject: [PATCH 045/131] used task query for getting task entities --- pype/modules/ftrack/events/event_sync_to_avalon.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index 5395fe756c..a77f2c88fa 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -2188,9 +2188,16 @@ class SyncToAvalonEvent(BaseEvent): ) if not self.modified_tasks_ftrackids: return - entities = self._get_entities_for_ftrack_ids( - self.cur_project["id"], - self.modified_tasks_ftrackids) + + joined_ids = ", ".join([ + "\"{}\"".format(ftrack_id) + for ftrack_id in self.modified_tasks_ftrackids + ]) + task_entities = self.process_session.query( + self.task_entities_query_by_parent_id.format( + self.cur_project["id"], joined_ids + ) + ).all() ftrack_mongo_mapping_found = {} not_found_ids = [] From 5a21369cc1de9ecfb68e70e8f93076d719d2bf4a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:36:40 +0200 Subject: [PATCH 046/131] modified for loop in task update to use new changes --- .../ftrack/events/event_sync_to_avalon.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index a77f2c88fa..6baf8e2cd8 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -2215,22 +2215,23 @@ class SyncToAvalonEvent(BaseEvent): } # prepare all tasks per parentId, eg. Avalon asset record - for entity in entities: - ftrack_id = entity["parent_id"] + for task_entity in task_entities: + task_type = task_types_by_id[task_entity["type_id"]] + ftrack_id = task_entity["parent_id"] if ftrack_id not in tasks_per_ftrack_id: tasks_per_ftrack_id[ftrack_id] = {} passed_regex = avalon_sync.check_regex( - entity["name"], "task", - schema_patterns=self.regex_schemas - ) + task_entity["name"], "task", + schema_patterns=self.regex_schemas + ) if not passed_regex: - entity_id = entity["id"] - self.regex_failed.append(entity_id) + self.regex_failed.append(task_entity["id"]) continue - task = {"type": entity["type"]["name"]} - tasks_per_ftrack_id[ftrack_id][entity["name"]] = task + tasks_per_ftrack_id[ftrack_id][task_entity["name"]] = { + "type": task_type["name"] + } # find avalon entity by parentId # should be there as create was run first From 7f4d33ee573a1df48576651037bb3119c75ab8cf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:36:57 +0200 Subject: [PATCH 047/131] removed unused methods --- .../ftrack/events/event_sync_to_avalon.py | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index 6baf8e2cd8..0c76cb452e 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -2512,50 +2512,6 @@ class SyncToAvalonEvent(BaseEvent): return mongo_id_configuration_id - def _get_task_type(self, project_id, entityId): - """ - Returns task type ('Props', 'Art') from Task 'entityId'. - Args: - project_id (string): - entityId (string): entityId of Task - - Returns: - (string) - None if Task not found - """ - task_type = None - entity = self.process_session.query( - self.entities_query_by_id.format( - project_id, entityId - ) - ).first() - if entity: - task_type = entity["type"]["name"] - return task_type - - def _get_entities_for_ftrack_ids(self, ft_project_id, ftrack_ids): - """ - Query Ftrack API and return all entities for particular - 'ft_project' and their parent_id in 'ftrack_ids'. - It is much faster to run this once for multiple ids than run it - for each separately. - Used mainly for collecting task information - Args: - ft_project_id (string): - ftrack_ids (list): of strings - - Returns: - (list) of Ftrack entities - """ - ftrack_entities = [] - if ftrack_ids: - joined_ids = ", ".join(["\"{}\"".format(id) for id in ftrack_ids]) - ftrack_entities = self.process_session.query( - self.entities_query_by_parent_id.format(ft_project_id, - joined_ids) - ).all() - - return ftrack_entities - def register(session, plugins_presets): '''Register plugin. Called when used as an plugin.''' From 9225c3553de6c673cf2a5351fd4b9feaccd91a64 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Oct 2020 13:37:05 +0200 Subject: [PATCH 048/131] formatting changes --- .../ftrack/events/event_sync_to_avalon.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index 0c76cb452e..251d87331e 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -739,13 +739,13 @@ class SyncToAvalonEvent(BaseEvent): time_cleanup = time_7 - time_6 time_task_updates = time_8 - time_7 time_total = time_8 - time_1 - self.log.debug( - "Process time: {:.2f} <{:.2f}, {:.2f}, {:.2f}, ".format( - time_total, time_removed, time_renamed, time_added) + - "{:.2f}, {:.2f}, {:.2f}, {:.2f}>".format( - time_moved, time_updated, time_cleanup, time_task_updates - ) - ) + self.log.debug(( + "Process time: {:.2f} <{:.2f}, {:.2f}, {:.2f}, " + "{:.2f}, {:.2f}, {:.2f}, {:.2f}>" + ).format( + time_total, time_removed, time_renamed, time_added, + time_moved, time_updated, time_cleanup, time_task_updates + )) except Exception: msg = "An error has happened during synchronization" @@ -2242,8 +2242,10 @@ class SyncToAvalonEvent(BaseEvent): continue ftrack_mongo_mapping_found[ftrack_id] = avalon_entity["_id"] - self._update_avalon_tasks(ftrack_mongo_mapping_found, - tasks_per_ftrack_id) + self._update_avalon_tasks( + ftrack_mongo_mapping_found, + tasks_per_ftrack_id + ) def update_entities(self): """ @@ -2441,8 +2443,9 @@ class SyncToAvalonEvent(BaseEvent): ) return True - def _update_avalon_tasks(self, ftrack_mongo_mapping_found, - tasks_per_ftrack_id): + def _update_avalon_tasks( + self, ftrack_mongo_mapping_found, tasks_per_ftrack_id + ): """ Prepare new "tasks" content for existing records in Avalon. Args: @@ -2460,10 +2463,9 @@ class SyncToAvalonEvent(BaseEvent): change_data = {"$set": {}} change_data["$set"]["data.tasks"] = tasks_per_ftrack_id[ftrack_id] mongo_changes_bulk.append(UpdateOne(filter, change_data)) - if not mongo_changes_bulk: - return - self.dbcon.bulk_write(mongo_changes_bulk) + if mongo_changes_bulk: + self.dbcon.bulk_write(mongo_changes_bulk) def _mongo_id_configuration( self, From cdec4d9281593cd2e66b0f6144b349c1d8012dc0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Oct 2020 15:00:55 +0200 Subject: [PATCH 049/131] fps(nks): fps collecting into clip instances --- pype/plugins/nukestudio/publish/collect_clips.py | 6 ++++-- pype/plugins/nukestudio/publish/collect_framerate.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pype/plugins/nukestudio/publish/collect_clips.py b/pype/plugins/nukestudio/publish/collect_clips.py index d39e25bfc6..40b197897c 100644 --- a/pype/plugins/nukestudio/publish/collect_clips.py +++ b/pype/plugins/nukestudio/publish/collect_clips.py @@ -1,9 +1,9 @@ import os - from pyblish import api import hiero import nuke + class CollectClips(api.ContextPlugin): """Collect all Track items selection.""" @@ -144,7 +144,9 @@ class CollectClips(api.ContextPlugin): "family": "clip", "families": [], "handleStart": projectdata.get("handleStart", 0), - "handleEnd": projectdata.get("handleEnd", 0)}) + "handleEnd": projectdata.get("handleEnd", 0), + "fps": context.data["fps"] + }) instance = context.create_instance(**data) diff --git a/pype/plugins/nukestudio/publish/collect_framerate.py b/pype/plugins/nukestudio/publish/collect_framerate.py index 694052f802..cb3c9f215f 100644 --- a/pype/plugins/nukestudio/publish/collect_framerate.py +++ b/pype/plugins/nukestudio/publish/collect_framerate.py @@ -4,13 +4,14 @@ from pyblish import api class CollectFramerate(api.ContextPlugin): """Collect framerate from selected sequence.""" - order = api.CollectorOrder + 0.01 + order = api.CollectorOrder + 0.001 label = "Collect Framerate" hosts = ["nukestudio"] def process(self, context): sequence = context.data["activeSequence"] context.data["fps"] = self.get_rate(sequence) + self.log.info("Framerate is collected: {}".format(context.data["fps"])) def get_rate(self, sequence): num, den = sequence.framerate().toRational() From 04b35dbea592bdbfad0984ce8248180c983acec8 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 8 Oct 2020 14:04:43 +0100 Subject: [PATCH 050/131] Add source for review instances. --- pype/plugins/ftrack/publish/integrate_ftrack_instances.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py index 2646dc90cc..549dc22d79 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py @@ -154,6 +154,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Create copy with ftrack.unmanaged location if thumb or prev if comp.get('thumbnail') or comp.get('preview') \ or ("preview" in comp.get('tags', [])) \ + or ("review" in comp.get('tags', [])) \ or ("thumbnail" in comp.get('tags', [])): unmanaged_loc = self.get_ftrack_location( 'ftrack.unmanaged', ft_session From cf0d2f2adc23ec8476617ecd3b0b7f8b98ac900f Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 8 Oct 2020 15:54:00 +0200 Subject: [PATCH 051/131] bump version --- pype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/version.py b/pype/version.py index 521faacafe..896b89678f 100644 --- a/pype/version.py +++ b/pype/version.py @@ -1 +1 @@ -__version__ = "2.12.3" +__version__ = "2.12.4" From 3fc15cca6d5cd76e96c9e32a045afd199cdcf8c9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Oct 2020 15:59:26 +0200 Subject: [PATCH 052/131] fix(hiero): fps order of clip instance data collecting --- pype/plugins/hiero/publish/collect_clips.py | 5 +++-- pype/plugins/hiero/publish/collect_framerate.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pype/plugins/hiero/publish/collect_clips.py b/pype/plugins/hiero/publish/collect_clips.py index e11ad93883..2c7ea3ec60 100644 --- a/pype/plugins/hiero/publish/collect_clips.py +++ b/pype/plugins/hiero/publish/collect_clips.py @@ -144,8 +144,9 @@ class CollectClips(api.ContextPlugin): "family": "clip", "families": [], "handleStart": projectdata.get("handleStart", 0), - "handleEnd": projectdata.get("handleEnd", 0)}) - + "handleEnd": projectdata.get("handleEnd", 0), + "fps": context.data["fps"] + }) instance = context.create_instance(**data) self.log.info("Created instance: {}".format(instance)) diff --git a/pype/plugins/hiero/publish/collect_framerate.py b/pype/plugins/hiero/publish/collect_framerate.py index 6d2d2eef2b..e11433adb1 100644 --- a/pype/plugins/hiero/publish/collect_framerate.py +++ b/pype/plugins/hiero/publish/collect_framerate.py @@ -4,13 +4,14 @@ from pyblish import api class CollectFramerate(api.ContextPlugin): """Collect framerate from selected sequence.""" - order = api.CollectorOrder + 0.01 + order = api.CollectorOrder + 0.001 label = "Collect Framerate" hosts = ["hiero"] def process(self, context): sequence = context.data["activeSequence"] context.data["fps"] = self.get_rate(sequence) + self.log.info("Framerate is collected: {}".format(context.data["fps"])) def get_rate(self, sequence): num, den = sequence.framerate().toRational() From 1b82342906969714cb6de4e2f1ac41e8950e00a1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Oct 2020 19:40:14 +0200 Subject: [PATCH 053/131] feat(nuke): validate legacy writes --- .../nuke/publish/collect_legacy_write.py | 31 ---- .../nuke/publish/validate_write_legacy.py | 154 ++++++++++-------- 2 files changed, 88 insertions(+), 97 deletions(-) delete mode 100644 pype/plugins/nuke/publish/collect_legacy_write.py diff --git a/pype/plugins/nuke/publish/collect_legacy_write.py b/pype/plugins/nuke/publish/collect_legacy_write.py deleted file mode 100644 index cfb0798434..0000000000 --- a/pype/plugins/nuke/publish/collect_legacy_write.py +++ /dev/null @@ -1,31 +0,0 @@ -import pyblish.api - - -class CollectWriteLegacy(pyblish.api.InstancePlugin): - """Collect legacy write nodes.""" - - order = pyblish.api.CollectorOrder + 0.0101 - label = "Collect Write node Legacy" - hosts = ["nuke", "nukeassist"] - - def process(self, instance): - self.log.info(instance[:]) - node = instance[0] - - if node.Class() not in ["Group", "Write"]: - return - - family_knobs = ["ak:family", "avalon:family"] - test = [k for k in node.knobs().keys() if k in family_knobs] - self.log.info(test) - - if len(test) == 1: - if "render" in node[test[0]].value(): - self.log.info("render") - return - - if "render" in node.knobs(): - instance.data.update( - {"family": "write.legacy", - "families": []} - ) diff --git a/pype/plugins/nuke/publish/validate_write_legacy.py b/pype/plugins/nuke/publish/validate_write_legacy.py index 58beb56eba..72e5265d3f 100644 --- a/pype/plugins/nuke/publish/validate_write_legacy.py +++ b/pype/plugins/nuke/publish/validate_write_legacy.py @@ -6,81 +6,103 @@ import nuke from avalon import api import re import pyblish.api +import pype.api from avalon.nuke import get_avalon_knob_data -class RepairWriteLegacyAction(pyblish.api.Action): - - label = "Repair" - icon = "wrench" - on = "failed" - - def process(self, context, plugin): - - # Get the errored instances - failed = [] - for result in context.data["results"]: - if (result["error"] is not None and result["instance"] is not None - and result["instance"] not in failed): - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - - for instance in instances: - if "Write" in instance[0].Class(): - data = toml.loads(instance[0]["avalon"].value()) - else: - data = get_avalon_knob_data(instance[0]) - - self.log.info(data) - - data["xpos"] = instance[0].xpos() - data["ypos"] = instance[0].ypos() - data["input"] = instance[0].input(0) - data["publish"] = instance[0]["publish"].value() - data["render"] = instance[0]["render"].value() - data["render_farm"] = instance[0]["render_farm"].value() - data["review"] = instance[0]["review"].value() - - # nuke.delete(instance[0]) - - task = os.environ["AVALON_TASK"] - sanitized_task = re.sub('[^0-9a-zA-Z]+', '', task) - subset_name = "render{}Main".format( - sanitized_task.capitalize()) - - Create_name = "CreateWriteRender" - - creator_plugin = None - for Creator in api.discover(api.Creator): - if Creator.__name__ != Create_name: - continue - - creator_plugin = Creator - - # return api.create() - creator_plugin(data["subset"], data["asset"]).process() - - node = nuke.toNode(data["subset"]) - node.setXYpos(data["xpos"], data["ypos"]) - node.setInput(0, data["input"]) - node["publish"].setValue(data["publish"]) - node["render"].setValue(data["render"]) - node["render_farm"].setValue(data["render_farm"]) - node["review"].setValue(data["review"]) - class ValidateWriteLegacy(pyblish.api.InstancePlugin): """Validate legacy write nodes.""" order = pyblish.api.ValidatorOrder optional = True - families = ["write.legacy"] - label = "Write Legacy" + families = ["write"] + label = "Validate Write Legacy" hosts = ["nuke"] - actions = [RepairWriteLegacyAction] + actions = [pype.api.RepairAction] def process(self, instance): - + node = instance[0] msg = "Clean up legacy write node \"{}\"".format(instance) - assert False, msg + + if node.Class() not in ["Group", "Write"]: + return + + # test avalon knobs + family_knobs = ["ak:family", "avalon:family"] + family_test = [k for k in node.knobs().keys() if k in family_knobs] + self.log.debug("_ family_test: {}".format(family_test)) + + # test if render in family test knob + # and only one item should be available + if len(family_test) == 1: + assert "render" in node[family_test[0]].value(), msg + else: + assert False, msg + + # test if `file` knob in node, this way old + # non-group-node write could be detected + if "file" in node.knobs(): + assert False, msg + + # check if write node is having old render targeting + if "render_farm" in node.knobs(): + assert False, msg + + @classmethod + def repair(cls, instance): + node = instance[0] + + if "Write" in node.Class(): + data = toml.loads(node["avalon"].value()) + else: + data = get_avalon_knob_data(node) + + # collect reusable data + data["XYpos"] = (node.xpos(), node.ypos()) + data["input"] = node.input(0) + data["publish"] = node["publish"].value() + data["render"] = node["render"].value() + data["render_farm"] = node["render_farm"].value() + data["review"] = node["review"].value() + data["use_limit"] = node["use_limit"].value() + data["first"] = node["first"].value() + data["last"] = node["last"].value() + + family = data["family"] + cls.log.debug("_ orig node family: {}".format(family)) + + # define what family of write node should be recreated + if family == "render": + Create_name = "CreateWriteRender" + elif family == "prerender": + Create_name = "CreateWritePrerender" + + # get appropriate plugin class + creator_plugin = None + for Creator in api.discover(api.Creator): + if Creator.__name__ != Create_name: + continue + + creator_plugin = Creator + + # delete the legaci write node + # nuke.delete(node) + + # create write node with creator + new_node_name = data["subset"] + "_1" + creator_plugin(new_node_name, data["asset"]).process() + + node = nuke.toNode(new_node_name) + node.setXYpos(*data["XYpos"]) + node.setInput(0, data["input"]) + node["publish"].setValue(data["publish"]) + node["review"].setValue(data["review"]) + node["use_limit"].setValue(data["use_limit"]) + node["first"].setValue(data["first"]) + node["last"].setValue(data["last"]) + + # recreate render targets + if data["render"]: + node["render"].setValue("Local") + if data["render_farm"]: + node["render"].setValue("On farm") From a78cb55ae6f2ea1a8e71e7080db1f4a5dc3b727b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Oct 2020 19:44:58 +0200 Subject: [PATCH 054/131] hound(nuke) --- pype/plugins/nuke/publish/validate_write_legacy.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/pype/plugins/nuke/publish/validate_write_legacy.py b/pype/plugins/nuke/publish/validate_write_legacy.py index 72e5265d3f..d7739b448b 100644 --- a/pype/plugins/nuke/publish/validate_write_legacy.py +++ b/pype/plugins/nuke/publish/validate_write_legacy.py @@ -34,19 +34,15 @@ class ValidateWriteLegacy(pyblish.api.InstancePlugin): # test if render in family test knob # and only one item should be available - if len(family_test) == 1: - assert "render" in node[family_test[0]].value(), msg - else: - assert False, msg + assert len(family_test) != 1, msg + assert "render" in node[family_test[0]].value(), msg # test if `file` knob in node, this way old # non-group-node write could be detected - if "file" in node.knobs(): - assert False, msg + assert "file" in node.knobs(), msg # check if write node is having old render targeting - if "render_farm" in node.knobs(): - assert False, msg + assert "render_farm" in node.knobs(), msg @classmethod def repair(cls, instance): From 1f0fbdd5d6a10c11e6ff5fdf92a447073edbd60a Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 9 Oct 2020 09:41:28 +0100 Subject: [PATCH 055/131] Add mp4 support for RV action. --- pype/modules/ftrack/actions/action_rv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/modules/ftrack/actions/action_rv.py b/pype/modules/ftrack/actions/action_rv.py index 528eeeee07..f9aeb87f71 100644 --- a/pype/modules/ftrack/actions/action_rv.py +++ b/pype/modules/ftrack/actions/action_rv.py @@ -46,7 +46,7 @@ class RVAction(BaseAction): return self.allowed_types = self.config_data.get( - 'file_ext', ["img", "mov", "exr"] + 'file_ext', ["img", "mov", "exr", "mp4"] ) def discover(self, session, entities, event): From aa35168771a6823a98d174600c424a9cc2b15f52 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 9 Oct 2020 11:31:45 +0200 Subject: [PATCH 056/131] make sure we replace original node --- pype/plugins/nuke/publish/validate_write_legacy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pype/plugins/nuke/publish/validate_write_legacy.py b/pype/plugins/nuke/publish/validate_write_legacy.py index d7739b448b..46f55e896d 100644 --- a/pype/plugins/nuke/publish/validate_write_legacy.py +++ b/pype/plugins/nuke/publish/validate_write_legacy.py @@ -82,10 +82,10 @@ class ValidateWriteLegacy(pyblish.api.InstancePlugin): creator_plugin = Creator # delete the legaci write node - # nuke.delete(node) + nuke.delete(node) # create write node with creator - new_node_name = data["subset"] + "_1" + new_node_name = data["subset"] creator_plugin(new_node_name, data["asset"]).process() node = nuke.toNode(new_node_name) From 61451d3e6b03fc5b8c3a26cd1c79e71945407bd2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Oct 2020 15:46:34 +0200 Subject: [PATCH 057/131] feat(tvpaint): basic integration --- pype/hooks/tvpaint/prelaunch.py | 110 +++++++++++++++++++++++++++ pype/hosts/tvpaint/__init__.py | 1 + pype/hosts/tvpaint/template.tvpp | Bin 0 -> 62370 bytes pype/resources/app_icons/tvpaint.png | Bin 0 -> 11859 bytes 4 files changed, 111 insertions(+) create mode 100644 pype/hooks/tvpaint/prelaunch.py create mode 100644 pype/hosts/tvpaint/__init__.py create mode 100644 pype/hosts/tvpaint/template.tvpp create mode 100644 pype/resources/app_icons/tvpaint.png diff --git a/pype/hooks/tvpaint/prelaunch.py b/pype/hooks/tvpaint/prelaunch.py new file mode 100644 index 0000000000..b8233e9c93 --- /dev/null +++ b/pype/hooks/tvpaint/prelaunch.py @@ -0,0 +1,110 @@ +import os +import shutil +from pype.lib import PypeHook +from pype.api import ( + Anatomy, + Logger +) +import getpass +import avalon.api + + +class TvpaintPrelaunchHook(PypeHook): + """ + Workfile preparation hook + """ + workfile_ext = "tvpp" + + def __init__(self, logger=None): + if not logger: + self.log = Logger().get_logger(self.__class__.__name__) + else: + self.log = logger + + self.signature = "( {} )".format(self.__class__.__name__) + + def execute(self, *args, env: dict = None) -> bool: + if not env: + env = os.environ + + # get context variables + project_name = env["AVALON_PROJECT"] + asset_name = env["AVALON_ASSET"] + task_name = env["AVALON_TASK"] + workdir = env["AVALON_WORKDIR"] + + # get workfile path + workfile_path = self.get_anatomy_filled( + workdir, project_name, asset_name, task_name) + + # create workdir if doesn't exist + os.makedirs(workdir, exist_ok=True) + self.log.info(f"Work dir is: `{workdir}`") + + # get last version of workfile + workfile_last = env.get("AVALON_LAST_WORKFILE") + self.log.debug(f"_ workfile_last: `{workfile_last}`") + + if workfile_last: + workfile = workfile_last + workfile_path = os.path.join(workdir, workfile) + + # copy workfile from template if doesnt exist any on path + if not os.path.isfile(workfile_path): + # try to get path from environment or use default + # from `pype.celation` dir + template_path = env.get("TVPAINT_TEMPLATE") or os.path.join( + env.get("PYPE_MODULE_ROOT"), + "pype/hosts/tvpaint/template.tvpp" + ) + self.log.info( + f"Creating workfile from template: `{template_path}`") + shutil.copy2( + os.path.normpath(template_path), + os.path.normpath(workfile_path) + ) + + self.log.info(f"Workfile to open: `{workfile_path}`") + + # adding compulsory environment var for openting file + env["PYPE_TVPAINT_PROJECT_FILE"] = workfile_path + + return True + + def get_anatomy_filled(self, workdir, project_name, asset_name, task_name): + host_name = "tvpaint" + dbcon = avalon.api.AvalonMongoDB() + dbcon.install() + dbcon.Session["AVALON_PROJECT"] = project_name + project_document = dbcon.find_one({"type": "project"}) + asset_document = dbcon.find_one({ + "type": "asset", + "name": asset_name + }) + dbcon.uninstall() + + asset_doc_parents = asset_document["data"].get("parents") + hierarchy = "/".join(asset_doc_parents) + + data = { + "project": { + "name": project_document["name"], + "code": project_document["data"].get("code") + }, + "task": task_name, + "asset": asset_name, + "app": host_name, + "hierarchy": hierarchy + } + anatomy = Anatomy(project_name) + extensions = avalon.api.HOST_WORKFILE_EXTENSIONS[host_name] + file_template = anatomy.templates["work"]["file"] + data.update({ + "version": 1, + "user": os.environ.get("PYPE_USERNAME") or getpass.getuser(), + "ext": extensions[0] + }) + + return avalon.api.last_workfile( + workdir, file_template, data, extensions, True + ) diff --git a/pype/hosts/tvpaint/__init__.py b/pype/hosts/tvpaint/__init__.py new file mode 100644 index 0000000000..8c93d93738 --- /dev/null +++ b/pype/hosts/tvpaint/__init__.py @@ -0,0 +1 @@ +kwargs = None diff --git a/pype/hosts/tvpaint/template.tvpp b/pype/hosts/tvpaint/template.tvpp new file mode 100644 index 0000000000000000000000000000000000000000..4bf05d35955c9e7c074a5af0f182d9f1b78bea8a GIT binary patch literal 62370 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 UA)46aA1n^MmsOFekunYWADD4edH?_b literal 0 HcmV?d00001 From e0cf53a6b254631f7de086bfffe3a11dff6adc1d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Oct 2020 16:51:03 +0200 Subject: [PATCH 058/131] feat(tvpaint): updating icon to the nicest ever --- pype/resources/app_icons/tvpaint.png | Bin 11859 -> 133822 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/pype/resources/app_icons/tvpaint.png b/pype/resources/app_icons/tvpaint.png index 8b33f9665b5e39115d546a636f70ec8bd9f8ee2c..adad6b2015851b0328f8f69cb1e57198d7210152 100644 GIT binary patch literal 133822 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%0A)RCGmJa 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
n1-$ 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 Lh`_o-iMjs)3HG^8 literal 11859 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 UA)46aA1n^MmsOFekunYWADD4edH?_b From b1501fbad5519d4cf6a9925de1519aaa750f0af0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Oct 2020 16:51:37 +0200 Subject: [PATCH 059/131] feat(tvpaint): adding custom templates from project configs --- pype/hooks/tvpaint/prelaunch.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/pype/hooks/tvpaint/prelaunch.py b/pype/hooks/tvpaint/prelaunch.py index b8233e9c93..f3cc575721 100644 --- a/pype/hooks/tvpaint/prelaunch.py +++ b/pype/hooks/tvpaint/prelaunch.py @@ -13,7 +13,7 @@ class TvpaintPrelaunchHook(PypeHook): """ Workfile preparation hook """ - workfile_ext = "tvpp" + host_name = "tvpaint" def __init__(self, logger=None): if not logger: @@ -32,6 +32,7 @@ class TvpaintPrelaunchHook(PypeHook): asset_name = env["AVALON_ASSET"] task_name = env["AVALON_TASK"] workdir = env["AVALON_WORKDIR"] + extension = avalon.api.HOST_WORKFILE_EXTENSIONS[self.host_name][0] # get workfile path workfile_path = self.get_anatomy_filled( @@ -52,13 +53,29 @@ class TvpaintPrelaunchHook(PypeHook): # copy workfile from template if doesnt exist any on path if not os.path.isfile(workfile_path): # try to get path from environment or use default - # from `pype.celation` dir + # from `pype.hosts.tvpaint` dir template_path = env.get("TVPAINT_TEMPLATE") or os.path.join( env.get("PYPE_MODULE_ROOT"), "pype/hosts/tvpaint/template.tvpp" ) + + # try to get template from project config folder + proj_config_path = os.path.join( + env["PYPE_PROJECT_CONFIGS"], project_name) + if os.path.exists(proj_config_path): + self.log.info( + f"extension: `{extension}`") + template_file = next(( + f for f in os.listdir(proj_config_path) + if extension in os.path.splitext(f)[1] + )) + if template_file: + template_path = os.path.join( + proj_config_path, template_file) self.log.info( f"Creating workfile from template: `{template_path}`") + + # copy template to new destinantion shutil.copy2( os.path.normpath(template_path), os.path.normpath(workfile_path) @@ -72,7 +89,6 @@ class TvpaintPrelaunchHook(PypeHook): return True def get_anatomy_filled(self, workdir, project_name, asset_name, task_name): - host_name = "tvpaint" dbcon = avalon.api.AvalonMongoDB() dbcon.install() dbcon.Session["AVALON_PROJECT"] = project_name @@ -93,11 +109,11 @@ class TvpaintPrelaunchHook(PypeHook): }, "task": task_name, "asset": asset_name, - "app": host_name, + "app": self.host_name, "hierarchy": hierarchy } anatomy = Anatomy(project_name) - extensions = avalon.api.HOST_WORKFILE_EXTENSIONS[host_name] + extensions = avalon.api.HOST_WORKFILE_EXTENSIONS[self.host_name] file_template = anatomy.templates["work"]["file"] data.update({ "version": 1, From 10b04b8089f2a45752895ab9df05c22fc4e80ee0 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Mon, 12 Oct 2020 11:49:57 +0200 Subject: [PATCH 060/131] change list comprehension to for loop with if --- pype/hooks/tvpaint/prelaunch.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pype/hooks/tvpaint/prelaunch.py b/pype/hooks/tvpaint/prelaunch.py index f3cc575721..d616949254 100644 --- a/pype/hooks/tvpaint/prelaunch.py +++ b/pype/hooks/tvpaint/prelaunch.py @@ -63,12 +63,12 @@ class TvpaintPrelaunchHook(PypeHook): proj_config_path = os.path.join( env["PYPE_PROJECT_CONFIGS"], project_name) if os.path.exists(proj_config_path): - self.log.info( - f"extension: `{extension}`") - template_file = next(( - f for f in os.listdir(proj_config_path) - if extension in os.path.splitext(f)[1] - )) + + template_file = None + for f in os.listdir(proj_config_path): + if extension in os.path.splitext(f): + template_file = f + if template_file: template_path = os.path.join( proj_config_path, template_file) From fdc42decf50b6b5331d73c480491b395ef47d817 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Tue, 13 Oct 2020 10:04:50 +0200 Subject: [PATCH 061/131] update ssh --- .circleci/config.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a5452f329f..4d8bc75ecd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,6 +6,9 @@ jobs: - image: circleci/node:10.16 steps: + - add_ssh_keys: + fingerprints: + - "06:24:54:b5:92:03:40:04:fc:87:82:c0:71:99:c4:6c" - checkout - run: name: Deploying to GitHub Pages @@ -13,7 +16,7 @@ jobs: git config --global user.email "mkolar@users.noreply.github.com" git config --global user.name "Website Deployment Script" echo "machine github.com login mkolar password $GITHUB_TOKEN" > ~/.netrc - cd website && yarn install && GIT_USER=mkolar yarn run publish-gh-pages + cd website && yarn install && GIT_USER=mkolar yarn run deploy workflows: build_and_deploy: From f24f9983058d036bf2b30601c53d8a99d3b33fdc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 13 Oct 2020 19:10:11 +0200 Subject: [PATCH 062/131] fix(hiero): review from imagesequence --- pype/plugins/hiero/publish/collect_reviews.py | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/pype/plugins/hiero/publish/collect_reviews.py b/pype/plugins/hiero/publish/collect_reviews.py index a444d57d6b..1d961943c5 100644 --- a/pype/plugins/hiero/publish/collect_reviews.py +++ b/pype/plugins/hiero/publish/collect_reviews.py @@ -1,5 +1,7 @@ from pyblish import api import os +import re +import clique class CollectReviews(api.InstancePlugin): @@ -19,6 +21,8 @@ class CollectReviews(api.InstancePlugin): families = ["plate"] def process(self, instance): + is_sequence = instance.data["isSequence"] + # Exclude non-tagged instances. tagged = False for tag in instance.data["tags"]: @@ -83,7 +87,29 @@ class CollectReviews(api.InstancePlugin): file_path = rev_inst.data.get("sourcePath") file_dir = os.path.dirname(file_path) file = os.path.basename(file_path) - ext = os.path.splitext(file)[-1][1:] + ext = os.path.splitext(file)[-1] + + # detect if sequence + if not is_sequence: + # is video file + files = file + else: + files = list() + source_first = instance.data["sourceFirst"] + self.log.debug("_ file: {}".format(file)) + spliter, padding = self.detect_sequence(file) + self.log.debug("_ spliter, padding: {}, {}".format( + spliter, padding)) + base_name = file.split(spliter)[0] + collection = clique.Collection(base_name, ext, padding, set(range( + int(source_first + rev_inst.data.get("sourceInH")), + int(source_first + rev_inst.data.get("sourceOutH") + 1)))) + self.log.debug("_ collection: {}".format(collection)) + real_files = os.listdir(file_dir) + for item in collection: + if item not in real_files: + continue + files.append(item) # change label instance.data["label"] = "{0} - {1} - ({2})".format( @@ -94,7 +120,7 @@ class CollectReviews(api.InstancePlugin): # adding representation for review mov representation = { - "files": file, + "files": files, "stagingDir": file_dir, "frameStart": rev_inst.data.get("sourceIn"), "frameEnd": rev_inst.data.get("sourceOut"), @@ -102,15 +128,15 @@ class CollectReviews(api.InstancePlugin): "frameEndFtrack": rev_inst.data.get("sourceOutH"), "step": 1, "fps": rev_inst.data.get("fps"), - "name": "preview", - "tags": ["preview", "ftrackreview"], - "ext": ext + "name": "review", + "tags": ["review", "ftrackreview"], + "ext": ext[1:] } media_duration = instance.data.get("mediaDuration") clip_duration_h = instance.data.get("clipDurationH") - if media_duration > clip_duration_h: + if media_duration > clip_duration_h and not is_sequence: self.log.debug("Media duration higher: {}".format( (media_duration - clip_duration_h))) representation.update({ @@ -118,7 +144,7 @@ class CollectReviews(api.InstancePlugin): "frameEnd": instance.data.get("sourceOutH"), "tags": ["_cut-bigger", "delete"] }) - elif media_duration < clip_duration_h: + elif media_duration < clip_duration_h and not is_sequence: self.log.debug("Media duration higher: {}".format( (media_duration - clip_duration_h))) representation.update({ @@ -205,3 +231,25 @@ class CollectReviews(api.InstancePlugin): instance.data["versionData"] = version_data instance.data["source"] = instance.data["sourcePath"] + + def detect_sequence(self, file): + """ Get identificating pater for image sequence + + Can find file.0001.ext, file.%02d.ext, file.####.ext + + Return: + string: any matching sequence patern + int: padding of sequnce numbering + """ + foundall = re.findall(r"(#+)|(%\d+d)|[^a-zA-Z](\d+)\.\w+$", file) + if foundall: + found = sorted(list(set(foundall[0])))[-1] + + if "%" in found: + padding = int(re.findall(r"\d+", found)[-1]) + else: + padding = len(found) + + return found, padding + else: + return None From a19814de0b2d8a08be2e293afd7e15437adca74c Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Tue, 13 Oct 2020 23:21:13 +0200 Subject: [PATCH 063/131] Update config.yml --- .circleci/config.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4d8bc75ecd..09d5679e01 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,9 +6,6 @@ jobs: - image: circleci/node:10.16 steps: - - add_ssh_keys: - fingerprints: - - "06:24:54:b5:92:03:40:04:fc:87:82:c0:71:99:c4:6c" - checkout - run: name: Deploying to GitHub Pages @@ -24,4 +21,4 @@ workflows: - deploy-website: filters: branches: - only: feature/move_documentation + only: From 77ad4dce80a0abcaa2243130304d712796c83d56 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Tue, 13 Oct 2020 23:22:17 +0200 Subject: [PATCH 064/131] Update config.yml --- .circleci/config.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 09d5679e01..f821749b08 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,7 +18,3 @@ jobs: workflows: build_and_deploy: jobs: - - deploy-website: - filters: - branches: - only: From ffa3d34f7a5c31e9fdf4ae268224dae3e6ea206c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 13 Oct 2020 23:24:09 +0200 Subject: [PATCH 065/131] moved "-shortest" argument to better spot --- pype/plugins/global/publish/extract_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index f4a39a7c31..dafd2e3855 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -341,8 +341,6 @@ class ExtractReview(pyblish.api.InstancePlugin): duration_sec = float(output_frames_len / temp_data["fps"]) ffmpeg_output_args.append("-t {:0.2f}".format(duration_sec)) - # Use shortest input - ffmpeg_output_args.append("-shortest") # Add video/image input path ffmpeg_input_args.append( @@ -354,6 +352,8 @@ class ExtractReview(pyblish.api.InstancePlugin): ffmpeg_input_args.append( "-start_number {}".format(temp_data["output_frame_start"]) ) + # Use shortest input + ffmpeg_output_args.append("-shortest") # Add audio arguments if there are any. Skipped when output are images. if not temp_data["output_ext_is_image"]: From b964218fc2ee17027d7d84176a066273e5ee9d9e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 13 Oct 2020 23:24:37 +0200 Subject: [PATCH 066/131] safer handle values getting --- pype/plugins/global/publish/extract_review.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index dafd2e3855..21680177c3 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -238,15 +238,16 @@ class ExtractReview(pyblish.api.InstancePlugin): """ frame_start = instance.data["frameStart"] - handle_start = instance.data.get( - "handleStart", - instance.context.data["handleStart"] - ) frame_end = instance.data["frameEnd"] - handle_end = instance.data.get( - "handleEnd", - instance.context.data["handleEnd"] - ) + + # Try to get handles from instance + handle_start = instance.data.get("handleStart") + handle_end = instance.data.get("handleEnd") + # If even one of handle values is not set on instance use + # handles from context + if handle_start is None or handle_end is None: + handle_start = instance.context.data["handleStart"] + handle_end = instance.context.data["handleEnd"] frame_start_handle = frame_start - handle_start frame_end_handle = frame_end + handle_end From 3af13c031e7db2d87576eec3742adeda425c6f9b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 13 Oct 2020 23:24:49 +0200 Subject: [PATCH 067/131] store if handles are even set --- pype/plugins/global/publish/extract_review.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index 21680177c3..4295842f48 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -261,6 +261,8 @@ class ExtractReview(pyblish.api.InstancePlugin): output_frame_start = frame_start_handle output_frame_end = frame_end_handle + handles_are_set = handle_start > 0 or handle_end > 0 + return { "fps": float(instance.data["fps"]), "frame_start": frame_start, @@ -276,7 +278,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "resolution_height": instance.data.get("resolutionHeight"), "origin_repre": repre, "input_is_sequence": self.input_is_sequence(repre), - "without_handles": without_handles + "without_handles": without_handles, + "handles_are_set": handles_are_set } def _ffmpeg_arguments(self, output_def, instance, new_repre, temp_data): From c44d0ab339074e61f5b6da48fa0cb8f39bbcda4d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 13 Oct 2020 23:26:07 +0200 Subject: [PATCH 068/131] setting offset and duration by seconds is based only on handles --- pype/plugins/global/publish/extract_review.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index 4295842f48..c76d205284 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -338,10 +338,16 @@ class ExtractReview(pyblish.api.InstancePlugin): "-framerate {}".format(temp_data["fps"]) ) - elif temp_data["without_handles"]: - start_sec = float(temp_data["handle_start"]) / temp_data["fps"] - ffmpeg_input_args.append("-ss {:0.2f}".format(start_sec)) + # Change output's duration and start point if should not contain + # handles + if temp_data["without_handles"] and temp_data["handles_are_set"]: + # Set start time without handles + # - check if handle_start is bigger than 0 to avoid zero division + if temp_data["handle_start"] > 0: + start_sec = float(temp_data["handle_start"]) / temp_data["fps"] + ffmpeg_input_args.append("-ss {:0.2f}".format(start_sec)) + # Set output duration inn seconds duration_sec = float(output_frames_len / temp_data["fps"]) ffmpeg_output_args.append("-t {:0.2f}".format(duration_sec)) From c826aaa0a64d1a33dc1f9136fb307513365d8455 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 13 Oct 2020 23:26:26 +0200 Subject: [PATCH 069/131] add video length if input or output is sequence --- pype/plugins/global/publish/extract_review.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index c76d205284..68cbe431ff 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -351,6 +351,9 @@ class ExtractReview(pyblish.api.InstancePlugin): duration_sec = float(output_frames_len / temp_data["fps"]) ffmpeg_output_args.append("-t {:0.2f}".format(duration_sec)) + # Set frame range of output when input or output is sequence + elif temp_data["input_is_sequence"] or temp_data["output_is_sequence"]: + ffmpeg_output_args.append("-frames:v {}".format(output_frames_len)) # Add video/image input path ffmpeg_input_args.append( From 58648a87cd0b8dd6efbf5e80fa2123ecaf1a3ad0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 13 Oct 2020 23:26:42 +0200 Subject: [PATCH 070/131] moved setting output start frame to better spot --- pype/plugins/global/publish/extract_review.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index 68cbe431ff..b88bb82c42 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -338,6 +338,13 @@ class ExtractReview(pyblish.api.InstancePlugin): "-framerate {}".format(temp_data["fps"]) ) + if temp_data["output_is_sequence"]: + # Set start frame of output sequence (just frame in filename) + # - this is definition of an output + ffmpeg_output_args.append( + "-start_number {}".format(temp_data["output_frame_start"]) + ) + # Change output's duration and start point if should not contain # handles if temp_data["without_handles"] and temp_data["handles_are_set"]: @@ -360,11 +367,6 @@ class ExtractReview(pyblish.api.InstancePlugin): "-i \"{}\"".format(temp_data["full_input_path"]) ) - if temp_data["output_is_sequence"]: - # Set start frame - ffmpeg_input_args.append( - "-start_number {}".format(temp_data["output_frame_start"]) - ) # Use shortest input ffmpeg_output_args.append("-shortest") From aeeaba9bc8ab71a4db79959e71e55b4139cbe621 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 13 Oct 2020 23:26:48 +0200 Subject: [PATCH 071/131] added comment --- pype/plugins/global/publish/extract_review.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index b88bb82c42..4c31ddf0f9 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -322,7 +322,8 @@ class ExtractReview(pyblish.api.InstancePlugin): ) if temp_data["input_is_sequence"]: - # Set start frame + # Set start frame of input sequence (just frame in filename) + # - definition of input filepath ffmpeg_input_args.append( "-start_number {}".format(temp_data["output_frame_start"]) ) From b1687b86ed440cea599a53dfa90c6fe907c625bd Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 14 Oct 2020 13:53:24 +0200 Subject: [PATCH 072/131] disable auto ensure_scene_settings --- pype/hosts/harmony/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pype/hosts/harmony/__init__.py b/pype/hosts/harmony/__init__.py index fbf5ca6f12..92434abc77 100644 --- a/pype/hosts/harmony/__init__.py +++ b/pype/hosts/harmony/__init__.py @@ -155,8 +155,11 @@ def check_inventory(): def application_launch(): - ensure_scene_settings() - check_inventory() + # FIXME: This is breaking server <-> client communication. + # It is now moved so it it manually called. + # ensure_scene_settings() + # check_inventory() + pass def export_template(backdrops, nodes, filepath): From 1774c76d62d4013d6ef13a07396dbfd09a88c25f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 14 Oct 2020 15:02:24 +0200 Subject: [PATCH 073/131] disable application launch logic --- pype/hosts/harmony/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pype/hosts/harmony/__init__.py b/pype/hosts/harmony/__init__.py index fbf5ca6f12..910b7ab6ab 100644 --- a/pype/hosts/harmony/__init__.py +++ b/pype/hosts/harmony/__init__.py @@ -155,8 +155,10 @@ def check_inventory(): def application_launch(): - ensure_scene_settings() - check_inventory() + # FIXME: manually invoked because of server <-> client problems. + # ensure_scene_settings() + # check_inventory() + pass def export_template(backdrops, nodes, filepath): From 49aa669467bfccbd7d8fcb4258a7fd8f1fdb26b7 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 14 Oct 2020 16:21:51 +0200 Subject: [PATCH 074/131] use existing path for thumbnails if it's not published --- pype/plugins/ftrack/publish/integrate_ftrack_instances.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py index 549dc22d79..f1f49bc922 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py @@ -1,5 +1,6 @@ import pyblish.api import json +import os class IntegrateFtrackInstance(pyblish.api.InstancePlugin): @@ -68,6 +69,10 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): "name": "thumbnail" # Default component name is "main". } comp['thumbnail'] = True + if not comp.get("published_path"): + comp['published_path'] = os.path.join(comp['stagingDir'], + comp["files"]) + elif comp.get('ftrackreview') or ("ftrackreview" in comp.get('tags', [])): ''' Ftrack bug requirement: From 67c03d4aa05012fa03cae3e8e317aa8d16c25b8f Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 14 Oct 2020 16:22:20 +0200 Subject: [PATCH 075/131] add option to disable render cleanup --- pype/plugins/global/publish/cleanup.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pype/plugins/global/publish/cleanup.py b/pype/plugins/global/publish/cleanup.py index 264a04b8bd..5fded85ccb 100644 --- a/pype/plugins/global/publish/cleanup.py +++ b/pype/plugins/global/publish/cleanup.py @@ -21,6 +21,7 @@ class CleanUp(pyblish.api.InstancePlugin): # Presets paterns = None # list of regex paterns + remove_temp_renders = True def process(self, instance): """Plugin entry point.""" @@ -36,8 +37,9 @@ class CleanUp(pyblish.api.InstancePlugin): ) ) - self.log.info("Cleaning renders new...") - self.clean_renders(instance) + if self.remove_temp_renders: + self.log.info("Cleaning renders new...") + self.clean_renders(instance) if [ef for ef in self.exclude_families if instance.data["family"] in ef]: @@ -85,7 +87,11 @@ class CleanUp(pyblish.api.InstancePlugin): if os.path.normpath(src) != os.path.normpath(dest): if instance_family == 'render' or 'render' in current_families: self.log.info("Removing src: `{}`...".format(src)) - os.remove(src) + try: + os.remove(src) + except PermissionError: + self.log.warning("Insufficient permission to delete {}".format(src)) + continue # add dir for cleanup dirnames.append(os.path.dirname(src)) From afd1fbc40be2aaf1da31d8750334930183aee70d Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 14 Oct 2020 16:23:01 +0200 Subject: [PATCH 076/131] review and burnin don't delete representations, that they don't process --- pype/plugins/global/publish/extract_burnin.py | 4 +--- pype/plugins/global/publish/extract_review.py | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pype/plugins/global/publish/extract_burnin.py b/pype/plugins/global/publish/extract_burnin.py index 353f2f27f0..de82722515 100644 --- a/pype/plugins/global/publish/extract_burnin.py +++ b/pype/plugins/global/publish/extract_burnin.py @@ -74,12 +74,10 @@ class ExtractBurnin(pype.api.Extractor): # Remove any representations tagged for deletion. # QUESTION Is possible to have representation with "delete" tag? for repre in tuple(instance.data["representations"]): - if "delete" in repre.get("tags", []): + if all(x in repre.get("tags", []) for x in ['delete', 'burnin']): self.log.debug("Removing representation: {}".format(repre)) instance.data["representations"].remove(repre) - self.log.debug(instance.data["representations"]) - def use_legacy_code(self, instance): presets = instance.context.data.get("presets") if presets is None and self.profiles is None: diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index f4a39a7c31..c17793e682 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -51,6 +51,7 @@ class ExtractReview(pyblish.api.InstancePlugin): to_height = 1080 def process(self, instance): + self.log.debug(instance.data["representations"]) # Skip review when requested. if not instance.data.get("review", True): return @@ -77,7 +78,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # Make sure cleanup happens and pop representations with "delete" tag. for repre in tuple(instance.data["representations"]): tags = repre.get("tags") or [] - if "delete" in tags: + if "delete" in tags and "thumbnail" not in tags: instance.data["representations"].remove(repre) def main_process(self, instance): From 38c37d9cf573cf81d25108d2cc5914aebbd48019 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 14 Oct 2020 16:23:43 +0200 Subject: [PATCH 077/131] add option to rename representations with regex and filename --- .../publish/collect_representation_names.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 pype/plugins/standalonepublisher/publish/collect_representation_names.py diff --git a/pype/plugins/standalonepublisher/publish/collect_representation_names.py b/pype/plugins/standalonepublisher/publish/collect_representation_names.py new file mode 100644 index 0000000000..7c8fb3fe3d --- /dev/null +++ b/pype/plugins/standalonepublisher/publish/collect_representation_names.py @@ -0,0 +1,40 @@ +""" +Requires: + Nothing + +Provides: + Instance +""" + +import pyblish.api +from pprint import pformat +import re +import os + +class CollecRepresentationNames(pyblish.api.InstancePlugin): + """ + Sets the representation names for given families based on RegEx filter + """ + + label = "Collect Representaion Names" + order = pyblish.api.CollectorOrder + families = [] + hosts = ["standalonepublisher"] + name_filter = "" + + def process(self, instance): + self.log.debug(f"instance.data: {pformat(instance.data['representations'])}") + for repre in instance.data['representations']: + self.log.debug(repre['files']) + if isinstance(repre['files'], list): + shortened_name = os.path.splitext(repre['files'][0])[0] + new_repre_name = re.search(self.name_filter, shortened_name) + else: + new_repre_name = re.search(self.name_filter, repre['files']) + + + self.log.debug(new_repre_name.group()) + repre['name'] = new_repre_name.group() + repre['outputName'] = new_repre_name.group() + + self.log.debug(f"instance.data: {pformat(instance.data['representations'])}") From 680d4fab345453a69470f20413c3ad8b0e728cdc Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 14 Oct 2020 16:24:32 +0200 Subject: [PATCH 078/131] delete temp thumbnail file --- .../standalonepublisher/publish/extract_thumbnail.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pype/plugins/standalonepublisher/publish/extract_thumbnail.py b/pype/plugins/standalonepublisher/publish/extract_thumbnail.py index 5882775083..fca4039d0e 100644 --- a/pype/plugins/standalonepublisher/publish/extract_thumbnail.py +++ b/pype/plugins/standalonepublisher/publish/extract_thumbnail.py @@ -112,12 +112,11 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): 'ext': 'jpg', 'files': filename, "stagingDir": staging_dir, - "thumbnail": True, - "tags": [] + "tags": ["thumbnail"], } # # add Delete tag when temp file was rendered - # if not is_jpeg: - # representation["tags"].append("delete") + if not is_jpeg: + representation["tags"].append("delete") instance.data["representations"].append(representation) From f99bccd6215cd269e5e76d0a38d63ac778056afc Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 14 Oct 2020 17:17:14 +0200 Subject: [PATCH 079/131] bump version --- pype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/version.py b/pype/version.py index 896b89678f..336282f43c 100644 --- a/pype/version.py +++ b/pype/version.py @@ -1 +1 @@ -__version__ = "2.12.4" +__version__ = "2.12.5" From 17dab30fb34bf67f4022dca11d303ea047f797f2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 15 Oct 2020 09:38:58 +0200 Subject: [PATCH 080/131] feat(hiero): renaming plugin to be used by image sequence --- ...{extract_review_cutup_video.py => extract_review_cutup.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename pype/plugins/hiero/publish/{extract_review_cutup_video.py => extract_review_cutup.py} (99%) diff --git a/pype/plugins/hiero/publish/extract_review_cutup_video.py b/pype/plugins/hiero/publish/extract_review_cutup.py similarity index 99% rename from pype/plugins/hiero/publish/extract_review_cutup_video.py rename to pype/plugins/hiero/publish/extract_review_cutup.py index 868d450fd6..1890f13738 100644 --- a/pype/plugins/hiero/publish/extract_review_cutup_video.py +++ b/pype/plugins/hiero/publish/extract_review_cutup.py @@ -3,12 +3,12 @@ from pyblish import api import pype -class ExtractReviewCutUpVideo(pype.api.Extractor): +class ExtractReviewCutUp(pype.api.Extractor): """Cut up clips from long video file""" order = api.ExtractorOrder # order = api.CollectorOrder + 0.1023 - label = "Extract Review CutUp Video" + label = "Extract Review CutUp" hosts = ["hiero"] families = ["review"] From 9c35bb30fac5cdc85d74e4504502a173332ad41e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 15 Oct 2020 09:53:58 +0200 Subject: [PATCH 081/131] feat(hiero): rename collect reviews to review --- .../hiero/publish/{collect_reviews.py => collect_review.py} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename pype/plugins/hiero/publish/{collect_reviews.py => collect_review.py} (98%) diff --git a/pype/plugins/hiero/publish/collect_reviews.py b/pype/plugins/hiero/publish/collect_review.py similarity index 98% rename from pype/plugins/hiero/publish/collect_reviews.py rename to pype/plugins/hiero/publish/collect_review.py index 1d961943c5..e6715d42b0 100644 --- a/pype/plugins/hiero/publish/collect_reviews.py +++ b/pype/plugins/hiero/publish/collect_review.py @@ -4,7 +4,7 @@ import re import clique -class CollectReviews(api.InstancePlugin): +class CollectReview(api.InstancePlugin): """Collect review from tags. Tag is expected to have metadata: @@ -136,7 +136,7 @@ class CollectReviews(api.InstancePlugin): media_duration = instance.data.get("mediaDuration") clip_duration_h = instance.data.get("clipDurationH") - if media_duration > clip_duration_h and not is_sequence: + if media_duration > clip_duration_h: self.log.debug("Media duration higher: {}".format( (media_duration - clip_duration_h))) representation.update({ @@ -144,7 +144,7 @@ class CollectReviews(api.InstancePlugin): "frameEnd": instance.data.get("sourceOutH"), "tags": ["_cut-bigger", "delete"] }) - elif media_duration < clip_duration_h and not is_sequence: + elif media_duration < clip_duration_h: self.log.debug("Media duration higher: {}".format( (media_duration - clip_duration_h))) representation.update({ From 1aeaa4bba175e0354371cdc8275bb2d4afd0a1b6 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 15 Oct 2020 09:55:46 +0100 Subject: [PATCH 082/131] Missing audio on farm submission. --- pype/plugins/global/publish/submit_publish_job.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pype/plugins/global/publish/submit_publish_job.py b/pype/plugins/global/publish/submit_publish_job.py index fd109cf881..30f64f7ab9 100644 --- a/pype/plugins/global/publish/submit_publish_job.py +++ b/pype/plugins/global/publish/submit_publish_job.py @@ -732,7 +732,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "resolutionHeight": data.get("resolutionHeight", 1080), "multipartExr": data.get("multipartExr", False), "jobBatchName": data.get("jobBatchName", ""), - "review": data.get("review", True) + "review": data.get("review", True), + "audio": data.get("audio", []) } if "prerender" in instance.data["families"]: From dba1f34bd773c74d8565de26bb1c3b363c2dcdff Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 15 Oct 2020 12:38:48 +0200 Subject: [PATCH 083/131] feat(hiero): review imagesequence with cuts --- pype/plugins/hiero/publish/collect_clips.py | 4 +- pype/plugins/hiero/publish/collect_review.py | 32 +- .../hiero/publish/extract_review_cutup.py | 349 +++++++++++------- 3 files changed, 235 insertions(+), 150 deletions(-) diff --git a/pype/plugins/hiero/publish/collect_clips.py b/pype/plugins/hiero/publish/collect_clips.py index 2c7ea3ec60..724e4730ed 100644 --- a/pype/plugins/hiero/publish/collect_clips.py +++ b/pype/plugins/hiero/publish/collect_clips.py @@ -143,8 +143,8 @@ class CollectClips(api.ContextPlugin): "asset": asset, "family": "clip", "families": [], - "handleStart": projectdata.get("handleStart", 0), - "handleEnd": projectdata.get("handleEnd", 0), + "handleStart": int(projectdata.get("handleStart", 0)), + "handleEnd": int(projectdata.get("handleEnd", 0)), "fps": context.data["fps"] }) instance = context.create_instance(**data) diff --git a/pype/plugins/hiero/publish/collect_review.py b/pype/plugins/hiero/publish/collect_review.py index e6715d42b0..157b8d88f8 100644 --- a/pype/plugins/hiero/publish/collect_review.py +++ b/pype/plugins/hiero/publish/collect_review.py @@ -16,7 +16,7 @@ class CollectReview(api.InstancePlugin): # Run just before CollectSubsets order = api.CollectorOrder + 0.1022 - label = "Collect Reviews" + label = "Collect Review" hosts = ["hiero"] families = ["plate"] @@ -158,11 +158,17 @@ class CollectReview(api.InstancePlugin): self.log.debug("Added representation: {}".format(representation)) def create_thumbnail(self, instance): + is_sequence = instance.data["isSequence"] item = instance.data["item"] source_path = instance.data["sourcePath"] source_file = os.path.basename(source_path) - head, ext = os.path.splitext(source_file) + spliter, padding = self.detect_sequence(source_file) + + if spliter: + head, ext = source_file.split(spliter) + else: + head, ext = os.path.splitext(source_file) # staging dir creation staging_dir = os.path.dirname( @@ -170,30 +176,28 @@ class CollectReview(api.InstancePlugin): media_duration = instance.data.get("mediaDuration") clip_duration_h = instance.data.get("clipDurationH") + self.log.debug("__ media_duration: {}".format(media_duration)) + self.log.debug("__ clip_duration_h: {}".format(clip_duration_h)) - if media_duration > clip_duration_h: - thumb_frame = instance.data["clipInH"] + ( - (instance.data["clipOutH"] - instance.data["clipInH"]) / 2) - elif media_duration <= clip_duration_h: - thumb_frame = instance.data["sourceIn"] + ( - (instance.data["sourceOut"] - instance.data["sourceIn"]) / 2) - thumb_file = "{}_{}{}".format(head, thumb_frame, ".png") + thumb_frame = int(instance.data["sourceIn"] + ( + (instance.data["sourceOut"] - instance.data["sourceIn"]) / 2)) + + thumb_file = "{}thumbnail{}{}".format(head, thumb_frame, ".png") thumb_path = os.path.join(staging_dir, thumb_file) self.log.debug("__ thumb_path: {}".format(thumb_path)) self.log.debug("__ thumb_frame: {}".format(thumb_frame)) + self.log.debug( + "__ sourceIn: `{}`".format(instance.data["sourceIn"])) + thumbnail = item.thumbnail(thumb_frame).save( thumb_path, format='png' ) - - self.log.debug( - "__ sourceIn: `{}`".format(instance.data["sourceIn"])) self.log.debug( "__ thumbnail: `{}`, frame: `{}`".format(thumbnail, thumb_frame)) self.log.debug("__ thumbnail: {}".format(thumbnail)) - thumb_representation = { 'files': thumb_file, 'stagingDir': staging_dir, @@ -252,4 +256,4 @@ class CollectReview(api.InstancePlugin): return found, padding else: - return None + return None, None diff --git a/pype/plugins/hiero/publish/extract_review_cutup.py b/pype/plugins/hiero/publish/extract_review_cutup.py index 1890f13738..57ec6c1107 100644 --- a/pype/plugins/hiero/publish/extract_review_cutup.py +++ b/pype/plugins/hiero/publish/extract_review_cutup.py @@ -1,6 +1,11 @@ import os +import sys +import six +import errno from pyblish import api import pype +import clique +from avalon.vendor import filelink class ExtractReviewCutUp(pype.api.Extractor): @@ -22,6 +27,9 @@ class ExtractReviewCutUp(pype.api.Extractor): # get representation and loop them representations = inst_data["representations"] + # check if sequence + is_sequence = inst_data["isSequence"] + # get resolution default resolution_width = inst_data["resolutionWidth"] resolution_height = inst_data["resolutionHeight"] @@ -51,174 +59,224 @@ class ExtractReviewCutUp(pype.api.Extractor): self.log.debug("__ repre: {}".format(repre)) - file = repre.get("files") + files = repre.get("files") staging_dir = repre.get("stagingDir") - frame_start = repre.get("frameStart") - frame_end = repre.get("frameEnd") fps = repre.get("fps") ext = repre.get("ext") - new_file_name = "{}_{}".format(asset, file) - - full_input_path = os.path.join( - staging_dir, file) - + # make paths full_output_dir = os.path.join( staging_dir, "cuts") - os.path.isdir(full_output_dir) or os.makedirs(full_output_dir) + if is_sequence: + new_files = list() - full_output_path = os.path.join( - full_output_dir, new_file_name) + # frame range delivery included handles + frame_start = ( + inst_data["frameStart"] - inst_data["handleStart"]) + frame_end = ( + inst_data["frameEnd"] + inst_data["handleEnd"]) + self.log.debug("_ frame_start: {}".format(frame_start)) + self.log.debug("_ frame_end: {}".format(frame_end)) - self.log.debug("__ full_input_path: {}".format(full_input_path)) - self.log.debug("__ full_output_path: {}".format(full_output_path)) + # make collection from input files list + collections, remainder = clique.assemble(files) + collection = collections.pop() + self.log.debug("_ collection: {}".format(collection)) - # check if audio stream is in input video file - ffprob_cmd = ( - "{ffprobe_path} -i \"{full_input_path}\" -show_streams " - "-select_streams a -loglevel error" - ).format(**locals()) - self.log.debug("ffprob_cmd: {}".format(ffprob_cmd)) - audio_check_output = pype.api.subprocess(ffprob_cmd) - self.log.debug("audio_check_output: {}".format(audio_check_output)) + # name components + head = collection.format("{head}") + padding = collection.format("{padding}") + tail = collection.format("{tail}") + self.log.debug("_ head: {}".format(head)) + self.log.debug("_ padding: {}".format(padding)) + self.log.debug("_ tail: {}".format(tail)) - # translate frame to sec - start_sec = float(frame_start) / fps - duration_sec = float(frame_end - frame_start + 1) / fps + # make destination file with instance data + # frame start and end range + index = 0 + for image in collection: + dst_file_num = frame_start + index + dst_file_name = head + str(padding % dst_file_num) + tail + src = os.path.join(staging_dir, image) + dst = os.path.join(full_output_dir, dst_file_name) + self.log.info("Creating temp hardlinks: {}".format(dst)) + self.hardlink_file(src, dst) + new_files.append(dst_file_name) + index += 1 - empty_add = None + self.log.debug("_ new_files: {}".format(new_files)) - # check if not missing frames at start - if (start_sec < 0) or (media_duration < frame_end): - # for later swithing off `-c:v copy` output arg - empty_add = True + else: + # ffmpeg when single file + new_files = "{}_{}".format(asset, files) - # init empty variables - video_empty_start = video_layer_start = "" - audio_empty_start = audio_layer_start = "" - video_empty_end = video_layer_end = "" - audio_empty_end = audio_layer_end = "" - audio_input = audio_output = "" - v_inp_idx = 0 - concat_n = 1 + # frame range + frame_start = repre.get("frameStart") + frame_end = repre.get("frameEnd") - # try to get video native resolution data - try: - resolution_output = pype.api.subprocess(( - "{ffprobe_path} -i \"{full_input_path}\" -v error " - "-select_streams v:0 -show_entries " - "stream=width,height -of csv=s=x:p=0" + full_input_path = os.path.join( + staging_dir, files) + + os.path.isdir(full_output_dir) or os.makedirs(full_output_dir) + + full_output_path = os.path.join( + full_output_dir, new_files) + + self.log.debug( + "__ full_input_path: {}".format(full_input_path)) + self.log.debug( + "__ full_output_path: {}".format(full_output_path)) + + # check if audio stream is in input video file + ffprob_cmd = ( + "{ffprobe_path} -i \"{full_input_path}\" -show_streams " + "-select_streams a -loglevel error" + ).format(**locals()) + self.log.debug("ffprob_cmd: {}".format(ffprob_cmd)) + audio_check_output = pype.api.subprocess(ffprob_cmd) + self.log.debug( + "audio_check_output: {}".format(audio_check_output)) + + # translate frame to sec + start_sec = float(frame_start) / fps + duration_sec = float(frame_end - frame_start + 1) / fps + + empty_add = None + + # check if not missing frames at start + if (start_sec < 0) or (media_duration < frame_end): + # for later swithing off `-c:v copy` output arg + empty_add = True + + # init empty variables + video_empty_start = video_layer_start = "" + audio_empty_start = audio_layer_start = "" + video_empty_end = video_layer_end = "" + audio_empty_end = audio_layer_end = "" + audio_input = audio_output = "" + v_inp_idx = 0 + concat_n = 1 + + # try to get video native resolution data + try: + resolution_output = pype.api.subprocess(( + "{ffprobe_path} -i \"{full_input_path}\" -v error " + "-select_streams v:0 -show_entries " + "stream=width,height -of csv=s=x:p=0" + ).format(**locals())) + + x, y = resolution_output.split("x") + resolution_width = int(x) + resolution_height = int(y) + except Exception as _ex: + self.log.warning( + "Video native resolution is untracable: {}".format( + _ex)) + + if audio_check_output: + # adding input for empty audio + input_args.append("-f lavfi -i anullsrc") + + # define audio empty concat variables + audio_input = "[1:a]" + audio_output = ":a=1" + v_inp_idx = 1 + + # adding input for video black frame + input_args.append(( + "-f lavfi -i \"color=c=black:" + "s={resolution_width}x{resolution_height}:r={fps}\"" ).format(**locals())) - x, y = resolution_output.split("x") - resolution_width = int(x) - resolution_height = int(y) - except Exception as E: - self.log.warning( - "Video native resolution is untracable: {}".format(E)) + if (start_sec < 0): + # recalculate input video timing + empty_start_dur = abs(start_sec) + start_sec = 0 + duration_sec = float(frame_end - ( + frame_start + (empty_start_dur * fps)) + 1) / fps - if audio_check_output: - # adding input for empty audio - input_args.append("-f lavfi -i anullsrc") - - # define audio empty concat variables - audio_input = "[1:a]" - audio_output = ":a=1" - v_inp_idx = 1 - - # adding input for video black frame - input_args.append(( - "-f lavfi -i \"color=c=black:" - "s={resolution_width}x{resolution_height}:r={fps}\"" - ).format(**locals())) - - if (start_sec < 0): - # recalculate input video timing - empty_start_dur = abs(start_sec) - start_sec = 0 - duration_sec = float(frame_end - ( - frame_start + (empty_start_dur * fps)) + 1) / fps - - # define starting empty video concat variables - video_empty_start = ( - "[{v_inp_idx}]trim=duration={empty_start_dur}[gv0];" - ).format(**locals()) - video_layer_start = "[gv0]" - - if audio_check_output: - # define starting empty audio concat variables - audio_empty_start = ( - "[0]atrim=duration={empty_start_dur}[ga0];" + # define starting empty video concat variables + video_empty_start = ( + "[{v_inp_idx}]trim=duration={empty_start_dur}[gv0];" # noqa ).format(**locals()) - audio_layer_start = "[ga0]" + video_layer_start = "[gv0]" - # alter concat number of clips - concat_n += 1 + if audio_check_output: + # define starting empty audio concat variables + audio_empty_start = ( + "[0]atrim=duration={empty_start_dur}[ga0];" + ).format(**locals()) + audio_layer_start = "[ga0]" - # check if not missing frames at the end - if (media_duration < frame_end): - # recalculate timing - empty_end_dur = float(frame_end - media_duration + 1) / fps - duration_sec = float(media_duration - frame_start) / fps + # alter concat number of clips + concat_n += 1 - # define ending empty video concat variables - video_empty_end = ( - "[{v_inp_idx}]trim=duration={empty_end_dur}[gv1];" - ).format(**locals()) - video_layer_end = "[gv1]" + # check if not missing frames at the end + if (media_duration < frame_end): + # recalculate timing + empty_end_dur = float( + frame_end - media_duration + 1) / fps + duration_sec = float( + media_duration - frame_start) / fps - if audio_check_output: - # define ending empty audio concat variables - audio_empty_end = ( - "[0]atrim=duration={empty_end_dur}[ga1];" + # define ending empty video concat variables + video_empty_end = ( + "[{v_inp_idx}]trim=duration={empty_end_dur}[gv1];" ).format(**locals()) - audio_layer_end = "[ga0]" + video_layer_end = "[gv1]" - # alter concat number of clips - concat_n += 1 + if audio_check_output: + # define ending empty audio concat variables + audio_empty_end = ( + "[0]atrim=duration={empty_end_dur}[ga1];" + ).format(**locals()) + audio_layer_end = "[ga0]" - # concatting black frame togather - output_args.append(( - "-filter_complex \"" - "{audio_empty_start}" - "{video_empty_start}" - "{audio_empty_end}" - "{video_empty_end}" - "{video_layer_start}{audio_layer_start}[1:v]{audio_input}" - "{video_layer_end}{audio_layer_end}" - "concat=n={concat_n}:v=1{audio_output}\"" - ).format(**locals())) + # alter concat number of clips + concat_n += 1 - # append ffmpeg input video clip - input_args.append("-ss {:0.2f}".format(start_sec)) - input_args.append("-t {:0.2f}".format(duration_sec)) - input_args.append("-i \"{}\"".format(full_input_path)) + # concatting black frame togather + output_args.append(( + "-filter_complex \"" + "{audio_empty_start}" + "{video_empty_start}" + "{audio_empty_end}" + "{video_empty_end}" + "{video_layer_start}{audio_layer_start}[1:v]{audio_input}" # noqa + "{video_layer_end}{audio_layer_end}" + "concat=n={concat_n}:v=1{audio_output}\"" + ).format(**locals())) - # add copy audio video codec if only shortening clip - if ("_cut-bigger" in tags) and (not empty_add): - output_args.append("-c:v copy") + # append ffmpeg input video clip + input_args.append("-ss {:0.2f}".format(start_sec)) + input_args.append("-t {:0.2f}".format(duration_sec)) + input_args.append("-i \"{}\"".format(full_input_path)) - # make sure it is having no frame to frame comprassion - output_args.append("-intra") + # add copy audio video codec if only shortening clip + if ("_cut-bigger" in tags) and (not empty_add): + output_args.append("-c:v copy") - # output filename - output_args.append("-y \"{}\"".format(full_output_path)) + # make sure it is having no frame to frame comprassion + output_args.append("-intra") - mov_args = [ - ffmpeg_path, - " ".join(input_args), - " ".join(output_args) - ] - subprcs_cmd = " ".join(mov_args) + # output filename + output_args.append("-y \"{}\"".format(full_output_path)) - # run subprocess - self.log.debug("Executing: {}".format(subprcs_cmd)) - output = pype.api.subprocess(subprcs_cmd) - self.log.debug("Output: {}".format(output)) + mov_args = [ + ffmpeg_path, + " ".join(input_args), + " ".join(output_args) + ] + subprcs_cmd = " ".join(mov_args) + + # run subprocess + self.log.debug("Executing: {}".format(subprcs_cmd)) + output = pype.api.subprocess(subprcs_cmd) + self.log.debug("Output: {}".format(output)) repre_new = { - "files": new_file_name, + "files": new_files, "stagingDir": full_output_dir, "frameStart": frame_start, "frameEnd": frame_end, @@ -242,3 +300,26 @@ class ExtractReviewCutUp(pype.api.Extractor): self.log.debug( "Representations: {}".format(representations_new)) instance.data["representations"] = representations_new + + def hardlink_file(self, src, dst): + dirname = os.path.dirname(dst) + + # make sure the destination folder exist + try: + os.makedirs(dirname) + except OSError as e: + if e.errno == errno.EEXIST: + pass + else: + self.log.critical("An unexpected error occurred.") + six.reraise(*sys.exc_info()) + + # create hardlined file + try: + filelink.create(src, dst, filelink.HARDLINK) + except OSError as e: + if e.errno == errno.EEXIST: + pass + else: + self.log.critical("An unexpected error occurred.") + six.reraise(*sys.exc_info()) From 7d78f971925c8d11debbb13725f789efa9f0f8c8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 15 Oct 2020 13:04:06 +0200 Subject: [PATCH 084/131] fix(hiero): fixing regex expression --- pype/plugins/hiero/publish/collect_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/plugins/hiero/publish/collect_review.py b/pype/plugins/hiero/publish/collect_review.py index 157b8d88f8..781afd782a 100644 --- a/pype/plugins/hiero/publish/collect_review.py +++ b/pype/plugins/hiero/publish/collect_review.py @@ -245,7 +245,7 @@ class CollectReview(api.InstancePlugin): string: any matching sequence patern int: padding of sequnce numbering """ - foundall = re.findall(r"(#+)|(%\d+d)|[^a-zA-Z](\d+)\.\w+$", file) + foundall = re.findall(r"(#+)|(%\d+d)|(?<=[^a-zA-Z0-9])(\d+)(?=\.\w+$)", file) if foundall: found = sorted(list(set(foundall[0])))[-1] From 863cea4aa72ed30b2d50efeadfd0fb81686b2738 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 15 Oct 2020 13:07:02 +0200 Subject: [PATCH 085/131] hound(hiero) --- pype/plugins/hiero/publish/collect_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pype/plugins/hiero/publish/collect_review.py b/pype/plugins/hiero/publish/collect_review.py index 781afd782a..f1767b2a68 100644 --- a/pype/plugins/hiero/publish/collect_review.py +++ b/pype/plugins/hiero/publish/collect_review.py @@ -158,7 +158,6 @@ class CollectReview(api.InstancePlugin): self.log.debug("Added representation: {}".format(representation)) def create_thumbnail(self, instance): - is_sequence = instance.data["isSequence"] item = instance.data["item"] source_path = instance.data["sourcePath"] @@ -245,7 +244,8 @@ class CollectReview(api.InstancePlugin): string: any matching sequence patern int: padding of sequnce numbering """ - foundall = re.findall(r"(#+)|(%\d+d)|(?<=[^a-zA-Z0-9])(\d+)(?=\.\w+$)", file) + foundall = re.findall( + r"(#+)|(%\d+d)|(?<=[^a-zA-Z0-9])(\d+)(?=\.\w+$)", file) if foundall: found = sorted(list(set(foundall[0])))[-1] From 866d18e472c4412fd14a8d04fb7d83de08e8539a Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 15 Oct 2020 14:11:44 +0200 Subject: [PATCH 086/131] remove useless logs --- .../publish/collect_representation_names.py | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/pype/plugins/standalonepublisher/publish/collect_representation_names.py b/pype/plugins/standalonepublisher/publish/collect_representation_names.py index 7c8fb3fe3d..c9063c22ed 100644 --- a/pype/plugins/standalonepublisher/publish/collect_representation_names.py +++ b/pype/plugins/standalonepublisher/publish/collect_representation_names.py @@ -1,17 +1,9 @@ -""" -Requires: - Nothing - -Provides: - Instance -""" - -import pyblish.api -from pprint import pformat import re import os +import pyblish.api -class CollecRepresentationNames(pyblish.api.InstancePlugin): + +class CollectRepresentationNames(pyblish.api.InstancePlugin): """ Sets the representation names for given families based on RegEx filter """ @@ -23,18 +15,17 @@ class CollecRepresentationNames(pyblish.api.InstancePlugin): name_filter = "" def process(self, instance): - self.log.debug(f"instance.data: {pformat(instance.data['representations'])}") for repre in instance.data['representations']: - self.log.debug(repre['files']) + new_repre_name = None if isinstance(repre['files'], list): shortened_name = os.path.splitext(repre['files'][0])[0] - new_repre_name = re.search(self.name_filter, shortened_name) + new_repre_name = re.search(self.name_filter, + shortened_name).group() else: - new_repre_name = re.search(self.name_filter, repre['files']) + new_repre_name = re.search(self.name_filter, + repre['files']).group() + if new_repre_name: + repre['name'] = new_repre_name - self.log.debug(new_repre_name.group()) - repre['name'] = new_repre_name.group() - repre['outputName'] = new_repre_name.group() - - self.log.debug(f"instance.data: {pformat(instance.data['representations'])}") + repre['outputName'] = repre['name'] From 5015c90bda93b3c181c4a4e63bb9ee01b55becaf Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 15 Oct 2020 14:12:12 +0200 Subject: [PATCH 087/131] more robust files check --- .../ftrack/publish/integrate_ftrack_instances.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py index f1f49bc922..d6bd7f8524 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py @@ -69,9 +69,15 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): "name": "thumbnail" # Default component name is "main". } comp['thumbnail'] = True - if not comp.get("published_path"): - comp['published_path'] = os.path.join(comp['stagingDir'], - comp["files"]) + comp_files = comp["files"] + if isinstance(comp_files, (tuple, list, set)): + filename = comp_files[0] + else: + filename = comp_files + + comp['published_path'] = os.path.join( + comp['stagingDir'], filename +) elif comp.get('ftrackreview') or ("ftrackreview" in comp.get('tags', [])): ''' From 50dc4ec5f53c3ddd7187a1d22d44da760e42ad4f Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 15 Oct 2020 14:13:43 +0200 Subject: [PATCH 088/131] syntax fix --- pype/plugins/ftrack/publish/integrate_ftrack_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py index d6bd7f8524..93a07a9fae 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py @@ -77,7 +77,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): comp['published_path'] = os.path.join( comp['stagingDir'], filename -) + ) elif comp.get('ftrackreview') or ("ftrackreview" in comp.get('tags', [])): ''' From 2d841631006ddc229483fab4502444452f969785 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 15:54:39 +0200 Subject: [PATCH 089/131] change log level of ffmpeg processing --- pype/scripts/otio_burnin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/scripts/otio_burnin.py b/pype/scripts/otio_burnin.py index 6607726c73..123e7cfd1e 100644 --- a/pype/scripts/otio_burnin.py +++ b/pype/scripts/otio_burnin.py @@ -15,7 +15,7 @@ ffprobe_path = pype.lib.get_ffmpeg_tool_path("ffprobe") FFMPEG = ( - '{} -loglevel panic -i "%(input)s" %(filters)s %(args)s%(output)s' + '{} -i "%(input)s" %(filters)s %(args)s%(output)s' ).format(ffmpeg_path) FFPROBE = ( From 6470155aa29836cbaa37fc7fc3baebfd72cd0e5f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 15:54:52 +0200 Subject: [PATCH 090/131] formatting changes --- pype/scripts/otio_burnin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pype/scripts/otio_burnin.py b/pype/scripts/otio_burnin.py index 123e7cfd1e..2ff5f737c9 100644 --- a/pype/scripts/otio_burnin.py +++ b/pype/scripts/otio_burnin.py @@ -308,8 +308,11 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): % (output, command)) if is_sequence: output = output % kwargs.get("duration") + if not os.path.exists(output): - raise RuntimeError("Failed to generate this fucking file '%s'" % output) + raise RuntimeError( + "Failed to generate this f*cking file '%s'" % output + ) def example(input_path, output_path): From 06905c880b3927611b29a3c0bcfb0e87b7fe879b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 15:55:05 +0200 Subject: [PATCH 091/131] print begin and end of burnin script --- pype/scripts/otio_burnin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pype/scripts/otio_burnin.py b/pype/scripts/otio_burnin.py index 2ff5f737c9..040ce5295c 100644 --- a/pype/scripts/otio_burnin.py +++ b/pype/scripts/otio_burnin.py @@ -545,6 +545,7 @@ def burnins_from_data( if __name__ == "__main__": + print("* Burnin script started") in_data = json.loads(sys.argv[-1]) burnins_from_data( in_data["input"], @@ -554,3 +555,4 @@ if __name__ == "__main__": options=in_data.get("options"), burnin_values=in_data.get("values") ) + print("* Burnin script has finished") From 3f3dd290831e830778febf40999d5fe09d293f94 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 15:57:33 +0200 Subject: [PATCH 092/131] do not use pype Logger in burnin script --- pype/scripts/otio_burnin.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pype/scripts/otio_burnin.py b/pype/scripts/otio_burnin.py index 040ce5295c..704a08ccbe 100644 --- a/pype/scripts/otio_burnin.py +++ b/pype/scripts/otio_burnin.py @@ -4,11 +4,9 @@ import re import subprocess import json import opentimelineio_contrib.adapters.ffmpeg_burnins as ffmpeg_burnins -from pype.api import Logger, config +from pype.api import config import pype.lib -log = Logger().get_logger("BurninWrapper", "burninwrap") - ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg") ffprobe_path = pype.lib.get_ffmpeg_tool_path("ffprobe") @@ -54,7 +52,7 @@ def _streams(source): def get_fps(str_value): if str_value == "0/0": - log.warning("Source has \"r_frame_rate\" value set to \"0/0\".") + print("WARNING: Source has \"r_frame_rate\" value set to \"0/0\".") return "Unknown" items = str_value.split("/") @@ -299,10 +297,10 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): args=args, overwrite=overwrite ) - log.info("Launching command: {}".format(command)) + print("Launching command: {}".format(command)) proc = subprocess.Popen(command, shell=True) - log.info(proc.communicate()[0]) + print(proc.communicate()[0]) if proc.returncode != 0: raise RuntimeError("Failed to render '%s': %s'" % (output, command)) From d37f4a041f58957b728df17c51d1933a73defbf9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 15:59:10 +0200 Subject: [PATCH 093/131] formatting changes --- pype/scripts/otio_burnin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pype/scripts/otio_burnin.py b/pype/scripts/otio_burnin.py index 704a08ccbe..5ff8c5a766 100644 --- a/pype/scripts/otio_burnin.py +++ b/pype/scripts/otio_burnin.py @@ -302,8 +302,9 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): proc = subprocess.Popen(command, shell=True) print(proc.communicate()[0]) if proc.returncode != 0: - raise RuntimeError("Failed to render '%s': %s'" - % (output, command)) + raise RuntimeError( + "Failed to render '{}': {}'".format(output, command) + ) if is_sequence: output = output % kwargs.get("duration") From 0709da0fd63b815957be178f988a230e61c08f23 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 16:01:16 +0200 Subject: [PATCH 094/131] make sure ffmpeg output is printed out --- pype/scripts/otio_burnin.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/pype/scripts/otio_burnin.py b/pype/scripts/otio_burnin.py index 5ff8c5a766..99611b172c 100644 --- a/pype/scripts/otio_burnin.py +++ b/pype/scripts/otio_burnin.py @@ -299,8 +299,21 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): ) print("Launching command: {}".format(command)) - proc = subprocess.Popen(command, shell=True) - print(proc.communicate()[0]) + proc = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True + ) + + _stdout, _stderr = proc.communicate() + if _stdout: + print(_stdout.decode("utf-8")) + + # This will probably never happen as ffmpeg use stdout + if _stderr: + print(_stderr.decode("utf-8")) + if proc.returncode != 0: raise RuntimeError( "Failed to render '{}': {}'".format(output, command) From ffa16729dfef1b294f293f9e860bf34c08a56cd6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 16:02:37 +0200 Subject: [PATCH 095/131] formatting in _subprocess --- pype/lib.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index 6fa204b379..4f96b14c8a 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -84,11 +84,11 @@ def get_ffmpeg_tool_path(tool="ffmpeg"): def _subprocess(*args, **kwargs): """Convenience method for getting output errors for subprocess.""" - # make sure environment contains only strings - if not kwargs.get("env"): - filtered_env = {k: str(v) for k, v in os.environ.items()} - else: - filtered_env = {k: str(v) for k, v in kwargs.get("env").items()} + # Get environents from kwarg or use current process environments if were + # not passed. + env = kwargs.get("env") or os.envion + # Make sure environment contains only strings + filtered_env = {k: str(v) for k, v in env.items()} # set overrides kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE) From 63b7a2a24458edca21b69b2adbc6d63164809dc1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 16:03:20 +0200 Subject: [PATCH 096/131] allow to pass logger to _subprocess --- pype/lib.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index 4f96b14c8a..8a12e33e63 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -81,8 +81,7 @@ def get_ffmpeg_tool_path(tool="ffmpeg"): # Special naming case for subprocess since its a built-in method. -def _subprocess(*args, **kwargs): - """Convenience method for getting output errors for subprocess.""" +def _subprocess(*args, logger=None, **kwargs): # Get environents from kwarg or use current process environments if were # not passed. @@ -90,6 +89,10 @@ def _subprocess(*args, **kwargs): # Make sure environment contains only strings filtered_env = {k: str(v) for k, v in env.items()} + # Use lib's logger if was not passed with kwargs. + if not logger: + logger = log + # set overrides kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE) kwargs['stderr'] = kwargs.get('stderr', subprocess.STDOUT) From dca6d0db27a5b1e5d202d4a49db760e6b01d333e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 16:03:51 +0200 Subject: [PATCH 097/131] stderr use PIPE instead of STDOUT --- pype/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/lib.py b/pype/lib.py index 8a12e33e63..0f19a68ae6 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -95,7 +95,7 @@ def _subprocess(*args, logger=None, **kwargs): # set overrides kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE) - kwargs['stderr'] = kwargs.get('stderr', subprocess.STDOUT) + kwargs['stderr'] = kwargs.get('stderr', subprocess.PIPE) kwargs['stdin'] = kwargs.get('stdin', subprocess.PIPE) kwargs['env'] = filtered_env From 891829a4abd145bc5b0a5df36546b7458f083c8c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 16:05:15 +0200 Subject: [PATCH 098/131] stdout and stderr are logged at once to logger --- pype/lib.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index 0f19a68ae6..978386af3a 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -101,19 +101,14 @@ def _subprocess(*args, logger=None, **kwargs): proc = subprocess.Popen(*args, **kwargs) - output, error = proc.communicate() + _stdout, _stderr = proc.communicate() + if _stdout: + _stdout = _stdout.decode("utf-8") + logger.debug(_stdout) - if output: - output = output.decode("utf-8") - output += "\n" - for line in output.strip().split("\n"): - log.info(line) - - if error: - error = error.decode("utf-8") - error += "\n" - for line in error.strip().split("\n"): - log.error(line) + if _stderr: + _stderr = _stderr.decode("utf-8") + logger.warning(_stderr) if proc.returncode != 0: raise ValueError( From f5707a48e4b11fcedf505bc119959cfcdf66c9e4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 16:06:00 +0200 Subject: [PATCH 099/131] _subprocess is returning full output with stderr and stdout --- pype/lib.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pype/lib.py b/pype/lib.py index 978386af3a..5fb9091cc1 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -101,20 +101,26 @@ def _subprocess(*args, logger=None, **kwargs): proc = subprocess.Popen(*args, **kwargs) + full_output = "" _stdout, _stderr = proc.communicate() if _stdout: _stdout = _stdout.decode("utf-8") + full_output += _stdout logger.debug(_stdout) if _stderr: _stderr = _stderr.decode("utf-8") + # Add additional line break if output already containt stdout + if full_output: + full_output += "\n" + full_output += _stderr logger.warning(_stderr) if proc.returncode != 0: raise ValueError( "\"{}\" was not successful:\nOutput: {}\nError: {}".format( args, output, error)) - return output + return full_output def get_hierarchy(asset_name=None): From ae905c0e5768bb040f80d9afe4f1a6224fa2b641 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 16:06:21 +0200 Subject: [PATCH 100/131] raise Runtime error with stdout ann stderr --- pype/lib.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index 5fb9091cc1..9b21878701 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -117,9 +117,15 @@ def _subprocess(*args, logger=None, **kwargs): logger.warning(_stderr) if proc.returncode != 0: - raise ValueError( - "\"{}\" was not successful:\nOutput: {}\nError: {}".format( - args, output, error)) + exc_msg = "Executing arguments was not successful: \"{}\"".format(args) + if _stdout: + exc_msg += "\n\nOutput:\n{}".format(_stdout) + + if _stderr: + exc_msg += "Error:\n{}".format(_stderr) + + raise RuntimeError(exc_msg) + return full_output From cb2ca581e1a5fe55e342d42b6c3da5792c01ab26 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 16:06:28 +0200 Subject: [PATCH 101/131] added docstring --- pype/lib.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pype/lib.py b/pype/lib.py index 9b21878701..ff9571de77 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -82,6 +82,23 @@ def get_ffmpeg_tool_path(tool="ffmpeg"): # Special naming case for subprocess since its a built-in method. def _subprocess(*args, logger=None, **kwargs): + """Convenience method for getting output errors for subprocess. + + Entered arguments and keyword arguments are passed to subprocess Popen. + + Args: + logger (logging.Logger): Logger object if want to use different than + lib's logger. + *args: Variable length arument list passed to Popen. + **kwargs : Arbitary keyword arguments passed to Popen. + + Returns: + str: Full output of subprocess concatenated stdout and stderr. + + Raises: + RuntimeError: Exception is raised if process finished with nonzero + return code. + """ # Get environents from kwarg or use current process environments if were # not passed. From 4113c072812a5cc8b8eaf8b436820d3620020cad Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 16:07:12 +0200 Subject: [PATCH 102/131] extract review and extract burnin are passing logger to _subprocess --- pype/plugins/global/publish/extract_burnin.py | 3 +-- pype/plugins/global/publish/extract_review.py | 6 ++++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pype/plugins/global/publish/extract_burnin.py b/pype/plugins/global/publish/extract_burnin.py index 353f2f27f0..6a164fd1f0 100644 --- a/pype/plugins/global/publish/extract_burnin.py +++ b/pype/plugins/global/publish/extract_burnin.py @@ -229,8 +229,7 @@ class ExtractBurnin(pype.api.Extractor): self.log.debug("Executing: {}".format(args)) # Run burnin script - output = pype.api.subprocess(args, shell=True) - self.log.debug("Output: {}".format(output)) + pype.api.subprocess(args, shell=True, logger=self.log) for filepath in temp_data["full_input_paths"]: filepath = filepath.replace("\\", "/") diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index f4a39a7c31..0a3077ca4f 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -180,8 +180,10 @@ class ExtractReview(pyblish.api.InstancePlugin): # run subprocess self.log.debug("Executing: {}".format(subprcs_cmd)) - output = pype.api.subprocess(subprcs_cmd, shell=True) - self.log.debug("Output: {}".format(output)) + + pype.api.subprocess( + subprcs_cmd, shell=True, logger=self.log + ) output_name = output_def["filename_suffix"] if temp_data["without_handles"]: From 6e0a2542074bca51456f4d50d748281f401b0761 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:13:24 +0200 Subject: [PATCH 103/131] gave ability to enter different mongo db object than avalon.io --- pype/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/lib.py b/pype/lib.py index 6fa204b379..6349ef4cd8 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1408,7 +1408,7 @@ def source_hash(filepath, *args): return "|".join([file_name, time, size] + list(args)).replace(".", ",") -def get_latest_version(asset_name, subset_name): +def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): """Retrieve latest version from `asset_name`, and `subset_name`. Args: From 2852ad1d8e9e90e7bbe2d4522afccc0ab30dda3b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:14:47 +0200 Subject: [PATCH 104/131] if dbcon keyword argument was not passed than use avalon.io --- pype/lib.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index 6349ef4cd8..153cde806d 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1415,15 +1415,10 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): asset_name (str): Name of asset. subset_name (str): Name of subset. """ - # Get asset - asset_name = io.find_one( - {"type": "asset", "name": asset_name}, projection={"name": True} - ) - subset = io.find_one( - {"type": "subset", "name": subset_name, "parent": asset_name["_id"]}, - projection={"_id": True, "name": True}, - ) + if not dbcon: + log.debug("Using `avalon.io` for query.") + dbcon = io # Check if subsets actually exists. assert subset, "No subsets found." From b8c4dbed56221f88fc349663c5937b4c34aa3025 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:17:05 +0200 Subject: [PATCH 105/131] do different stuff if project name is specified --- pype/lib.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pype/lib.py b/pype/lib.py index 153cde806d..41ee730e2a 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1422,6 +1422,14 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): # Check if subsets actually exists. assert subset, "No subsets found." + if project_name and project_name != dbcon.Session.get("AVALON_PROJECT"): + # `avalon.io` has only `_database` attribute + # but `AvalonMongoDB` has `database` + database = getattr(dbcon, "database", dbcon._database) + collection = database[project_name] + else: + project_name = dbcon.Session.get("AVALON_PROJECT") + collection = dbcon # Get version version_projection = { From d937fde322facb27edb1e2ae90ce099ee43f78ec Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:17:31 +0200 Subject: [PATCH 106/131] do not raise exceptions --- pype/lib.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index 41ee730e2a..8f5c4527a0 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1420,8 +1420,6 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): log.debug("Using `avalon.io` for query.") dbcon = io - # Check if subsets actually exists. - assert subset, "No subsets found." if project_name and project_name != dbcon.Session.get("AVALON_PROJECT"): # `avalon.io` has only `_database` attribute # but `AvalonMongoDB` has `database` @@ -1431,21 +1429,9 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): project_name = dbcon.Session.get("AVALON_PROJECT") collection = dbcon - # Get version - version_projection = { - "name": True, - "parent": True, - } - - version = io.find_one( - {"type": "version", "parent": subset["_id"]}, - projection=version_projection, - sort=[("name", -1)], ) - assert version, "No version found, this is a bug" - return version class ApplicationLaunchFailed(Exception): From 730be772a41538afce4f8e1a567ccb1c79efb5f2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:18:17 +0200 Subject: [PATCH 107/131] implemented finding of latest version --- pype/lib.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pype/lib.py b/pype/lib.py index 8f5c4527a0..527db4a38a 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1429,9 +1429,28 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): project_name = dbcon.Session.get("AVALON_PROJECT") collection = dbcon + # Query asset document id by asset name + asset_doc = collection.find_one( + {"type": "asset", "name": asset_name}, + {"_id": True} ) + if not asset_doc: + return None + subset_doc = collection.find_one( + {"type": "subset", "name": subset_name, "parent": asset_doc["_id"]}, + {"_id": True} + ) + if not subset_doc: + return None + version_doc = collection.find_one( + {"type": "version", "parent": subset_doc["_id"]}, + sort=[("name", -1)], + ) + if not version_doc: + return None + return version_doc class ApplicationLaunchFailed(Exception): From 120865eb7fe917adb1041505c0ce75390d84927d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:18:30 +0200 Subject: [PATCH 108/131] added few loggings --- pype/lib.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pype/lib.py b/pype/lib.py index 527db4a38a..260281f6bd 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1429,12 +1429,20 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): project_name = dbcon.Session.get("AVALON_PROJECT") collection = dbcon + log.debug(( + "Getting latest version for Project: \"{}\" Asset: \"{}\"" + " and Subset: \"{}\"" + ).format(project_name, asset_name, subset_name)) + # Query asset document id by asset name asset_doc = collection.find_one( {"type": "asset", "name": asset_name}, {"_id": True} ) if not asset_doc: + log.info( + "Asset \"{}\" was not found in Database.".format(asset_name) + ) return None subset_doc = collection.find_one( @@ -1442,6 +1450,9 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): {"_id": True} ) if not subset_doc: + log.info( + "Subset \"{}\" was not found in Database.".format(subset_name) + ) return None version_doc = collection.find_one( @@ -1449,6 +1460,9 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): sort=[("name", -1)], ) if not version_doc: + log.info( + "Subset \"{}\" does not have any version yet.".format(subset_name) + ) return None return version_doc From 27dfb159436ed32d9cb0e210c335433b6788c80c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:18:37 +0200 Subject: [PATCH 109/131] filled docstring --- pype/lib.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pype/lib.py b/pype/lib.py index 260281f6bd..02ea46c0ff 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1414,6 +1414,13 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): Args: asset_name (str): Name of asset. subset_name (str): Name of subset. + dbcon (avalon.mongodb.AvalonMongoDB, optional): Avalon Mongo connection + with Session. + project_name (str, optional): Find latest version in specific project. + + Returns: + None: If asset, subset or version were not found. + dict: Last version document for entered . """ if not dbcon: From 96048ce6d7c4474df8704c016746c640b08ef24f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:18:59 +0200 Subject: [PATCH 110/131] changed way how get_latest_version is used in nuke's collect_review --- pype/plugins/nuke/publish/collect_review.py | 25 ++++++++++++--------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/pype/plugins/nuke/publish/collect_review.py b/pype/plugins/nuke/publish/collect_review.py index e7e8da19a1..3b3786ed09 100644 --- a/pype/plugins/nuke/publish/collect_review.py +++ b/pype/plugins/nuke/publish/collect_review.py @@ -1,3 +1,4 @@ +import os import pyblish.api import pype.api from avalon import io, api @@ -26,20 +27,24 @@ class CollectReview(pyblish.api.InstancePlugin): if not node["review"].value(): return - # Add audio to instance if it exists. - try: - version = pype.api.get_latest_version( - instance.context.data["assetEntity"]["name"], "audioMain" - ) - representation = io.find_one( - {"type": "representation", "parent": version["_id"]} + # * Add audio to instance if exists. + # Find latest versions document + version_doc = pype.api.get_latest_version( + instance.context.data["assetEntity"]["name"], "audioMain" + ) + repre_doc = None + if version_doc: + # Try to find it's representation (Expected there is only one) + repre_doc = io.find_one( + {"type": "representation", "parent": version_doc["_id"]} ) + + # Add audio to instance if representation was found + if repre_doc: instance.data["audio"] = [{ "offset": 0, - "filename": api.get_representation_path(representation) + "filename": api.get_representation_path(repre_doc) }] - except AssertionError: - pass instance.data["families"].append("review") instance.data['families'].append('ftrack') From 4813365aeda92c15b89ead3c5ae578006104e26d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:22:08 +0200 Subject: [PATCH 111/131] added note to doctring --- pype/lib.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pype/lib.py b/pype/lib.py index 02ea46c0ff..c4321e0605 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1411,6 +1411,10 @@ def source_hash(filepath, *args): def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): """Retrieve latest version from `asset_name`, and `subset_name`. + Do not use if you want to query more than 5 latest versions as this method + query 3 times to mongo for each call. For those cases is better to use + more efficient way, e.g. with help of aggregations. + Args: asset_name (str): Name of asset. subset_name (str): Name of subset. From c63df5277efb97f715796858d69956442704568a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:25:13 +0200 Subject: [PATCH 112/131] make sure `avalon.io` is installed when using --- pype/lib.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pype/lib.py b/pype/lib.py index c4321e0605..7370f10756 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1430,6 +1430,8 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): if not dbcon: log.debug("Using `avalon.io` for query.") dbcon = io + # Make sure is installed + io.install() if project_name and project_name != dbcon.Session.get("AVALON_PROJECT"): # `avalon.io` has only `_database` attribute From a80f1618e63dc25e3dcb7c66f5e56eceb57bede8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 15 Oct 2020 18:34:44 +0200 Subject: [PATCH 113/131] removed unused import --- pype/plugins/nuke/publish/collect_review.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pype/plugins/nuke/publish/collect_review.py b/pype/plugins/nuke/publish/collect_review.py index 3b3786ed09..42aa910917 100644 --- a/pype/plugins/nuke/publish/collect_review.py +++ b/pype/plugins/nuke/publish/collect_review.py @@ -1,4 +1,3 @@ -import os import pyblish.api import pype.api from avalon import io, api From bdc6a0ca26a833e1365cefcf0f7cb5979e5c6bc4 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 16 Oct 2020 10:25:46 +0200 Subject: [PATCH 114/131] don't crash if we only have single frame --- pype/plugins/nuke/load/load_sequence.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pype/plugins/nuke/load/load_sequence.py b/pype/plugins/nuke/load/load_sequence.py index c5ce288540..44b9cb4a34 100644 --- a/pype/plugins/nuke/load/load_sequence.py +++ b/pype/plugins/nuke/load/load_sequence.py @@ -119,13 +119,14 @@ class LoadSequence(api.Loader): repr_cont = context["representation"]["context"] if "#" not in file: frame = repr_cont.get("frame") - padding = len(frame) - file = file.replace(frame, "#" * padding) + if frame: + padding = len(frame) + file = file.replace(frame, "#" * padding) read_name = "Read_{0}_{1}_{2}".format( repr_cont["asset"], repr_cont["subset"], - repr_cont["representation"]) + context["representation"]["name"]) # Create the Loader with the filename path set with viewer_update_and_undo_stop(): @@ -249,8 +250,9 @@ class LoadSequence(api.Loader): if "#" not in file: frame = repr_cont.get("frame") - padding = len(frame) - file = file.replace(frame, "#" * padding) + if frame: + padding = len(frame) + file = file.replace(frame, "#" * padding) # Get start frame from version data version = io.find_one({ From 9880488255e134cacf208b0f20bf41f06bb3c364 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 16 Oct 2020 10:49:42 +0200 Subject: [PATCH 115/131] fixed typo --- pype/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/lib.py b/pype/lib.py index ff9571de77..f364945a86 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -102,7 +102,7 @@ def _subprocess(*args, logger=None, **kwargs): # Get environents from kwarg or use current process environments if were # not passed. - env = kwargs.get("env") or os.envion + env = kwargs.get("env") or os.environ # Make sure environment contains only strings filtered_env = {k: str(v) for k, v in env.items()} From 4b742faddd3ec29dc9da11cedb3c9c835b41e5d6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 16 Oct 2020 11:07:33 +0200 Subject: [PATCH 116/131] logger keyword argument is skipped in function definition but is popped from kwargs --- pype/lib.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index f364945a86..f85d689ef9 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -81,7 +81,7 @@ def get_ffmpeg_tool_path(tool="ffmpeg"): # Special naming case for subprocess since its a built-in method. -def _subprocess(*args, logger=None, **kwargs): +def _subprocess(*args, **kwargs): """Convenience method for getting output errors for subprocess. Entered arguments and keyword arguments are passed to subprocess Popen. @@ -107,8 +107,7 @@ def _subprocess(*args, logger=None, **kwargs): filtered_env = {k: str(v) for k, v in env.items()} # Use lib's logger if was not passed with kwargs. - if not logger: - logger = log + logger = kwargs.pop("logger", log) # set overrides kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE) From 771ba3e966b59a560d962f4e2a6ab40661eab564 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 16 Oct 2020 11:07:41 +0200 Subject: [PATCH 117/131] modified docstring --- pype/lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index f85d689ef9..a53aae2086 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -87,10 +87,10 @@ def _subprocess(*args, **kwargs): Entered arguments and keyword arguments are passed to subprocess Popen. Args: - logger (logging.Logger): Logger object if want to use different than - lib's logger. *args: Variable length arument list passed to Popen. - **kwargs : Arbitary keyword arguments passed to Popen. + **kwargs : Arbitary keyword arguments passed to Popen. Is possible to + pass `logging.Logger` object under "logger" if want to use + different than lib's logger. Returns: str: Full output of subprocess concatenated stdout and stderr. From 54983b04e372ae6a2ec81006f711fc6acff14bd0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 16 Oct 2020 11:23:59 +0200 Subject: [PATCH 118/131] add transparency to filter buttons spacer --- pype/tools/pyblish_pype/widgets.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pype/tools/pyblish_pype/widgets.py b/pype/tools/pyblish_pype/widgets.py index 4da759899e..dc4919c13f 100644 --- a/pype/tools/pyblish_pype/widgets.py +++ b/pype/tools/pyblish_pype/widgets.py @@ -543,7 +543,9 @@ class TerminalFilterWidget(QtWidgets.QWidget): layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) # Add spacers - layout.addWidget(QtWidgets.QWidget(), 1) + spacer = QtWidgets.QWidget() + spacer.setAttribute(QtCore.Qt.WA_TranslucentBackground) + layout.addWidget(spacer, 1) for btn in filter_buttons: layout.addWidget(btn) From 3ccd3181cde6b77eb4c1cd019adf7738abdf319b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 16 Oct 2020 15:19:59 +0200 Subject: [PATCH 119/131] #636 - Deadline Output Folder Pre-calculate final publish folder to add it to right mouse click option for easier checking in Deadline Monitor --- .../global/publish/submit_publish_job.py | 125 +++++++++++------- 1 file changed, 77 insertions(+), 48 deletions(-) diff --git a/pype/plugins/global/publish/submit_publish_job.py b/pype/plugins/global/publish/submit_publish_job.py index 30f64f7ab9..dcc87188f3 100644 --- a/pype/plugins/global/publish/submit_publish_job.py +++ b/pype/plugins/global/publish/submit_publish_job.py @@ -4,7 +4,8 @@ import os import json import re -from copy import copy +from copy import copy, deepcopy +import pype.api from avalon import api, io from avalon.vendor import requests, clique @@ -42,40 +43,6 @@ def _get_script(path): return str(path) -def get_latest_version(asset_name, subset_name, family): - """Retrieve latest files concerning extendFrame feature.""" - # Get asset - asset_name = io.find_one( - {"type": "asset", "name": asset_name}, projection={"name": True} - ) - - subset = io.find_one( - {"type": "subset", "name": subset_name, "parent": asset_name["_id"]}, - projection={"_id": True, "name": True}, - ) - - # Check if subsets actually exists (pre-run check) - assert subset, "No subsets found, please publish with `extendFrames` off" - - # Get version - version_projection = { - "name": True, - "data.startFrame": True, - "data.endFrame": True, - "parent": True, - } - - version = io.find_one( - {"type": "version", "parent": subset["_id"], "data.families": family}, - projection=version_projection, - sort=[("name", -1)], - ) - - assert version, "No version found, this is a bug" - - return version - - def get_resources(version, extension=None): """Get the files from the specific version.""" query = {"type": "representation", "parent": version["_id"]} @@ -250,7 +217,19 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): subset = data["subset"] job_name = "Publish - {subset}".format(subset=subset) - output_dir = instance.data["outputDir"] + # instance.data.get("subset") != instances[0]["subset"] + # 'Main' vs 'renderMain' + override_version = None + instance_version = instance.data.get("version") # take this if exists + if instance_version != 1: + override_version = instance_version + output_dir = self._get_publish_folder(instance.context.data['anatomy'], + deepcopy( + instance.data["anatomyData"]), + instance.data.get("asset"), + instances[0]["subset"], + 'render', + override_version) # Generate the payload for Deadline submission payload = { @@ -322,7 +301,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): payload["JobInfo"].pop("SecondaryPool", None) self.log.info("Submitting Deadline job ...") - # self.log.info(json.dumps(payload, indent=4, sort_keys=True)) url = "{}/api/jobs".format(self.DEADLINE_REST_URL) response = requests.post(url, json=payload, timeout=10) @@ -349,9 +327,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # get latest version of subset # this will stop if subset wasn't published yet - version = get_latest_version( - instance.data.get("asset"), - instance.data.get("subset"), "render") + version = pype.api.get_latest_version(instance.data.get("asset"), + instance.data.get("subset")) # get its files based on extension subset_resources = get_resources(version, representation.get("ext")) r_col, _ = clique.assemble(subset_resources) @@ -732,8 +709,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "resolutionHeight": data.get("resolutionHeight", 1080), "multipartExr": data.get("multipartExr", False), "jobBatchName": data.get("jobBatchName", ""), - "review": data.get("review", True), - "audio": data.get("audio", []) + "review": data.get("review", True) } if "prerender" in instance.data["families"]: @@ -742,7 +718,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "families": []}) # skip locking version if we are creating v01 - instance_version = instance.data.get("version") + instance_version = instance.data.get("version") # take this if exists if instance_version != 1: instance_skeleton_data["version"] = instance_version @@ -998,11 +974,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): prev_start = None prev_end = None - version = get_latest_version( - asset_name=asset, - subset_name=subset, - family='render' - ) + version = pype.api.get_latest_version(asset_name=asset, + subset_name=subset + ) # Set prev start / end frames for comparison if not prev_start and not prev_end: @@ -1018,3 +992,58 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): ) return updated_start, updated_end + + def _get_publish_folder(self, anatomy, template_data, + asset, subset, + family='render', version=None): + """ + Extracted logic to pre-calculate real publish folder, which is + calculated in IntegrateNew inside of Deadline process. + This should match logic in: + 'collect_anatomy_instance_data' - to + get correct anatomy, family, version for subset and + 'collect_resources_path' + get publish_path + + Args: + anatomy (pypeapp.lib.anatomy.Anatomy): + template_data (dict): pre-calculated collected data for process + asset (string): asset name + subset (string): subset name (actually group name of subset) + family (string): for current deadline process it's always 'render' + TODO - for generic use family needs to be dynamically + calculated like IntegrateNew does + version (int): override version from instance if exists + + Returns: + (string): publish folder where rendered and published files will + be stored + based on 'publish' template + """ + if not version: + version = pype.api.get_latest_version(asset, subset) + if version: + version = int(version["name"]) + 1 + + template_data["subset"] = subset + template_data["family"] = "render" + template_data["version"] = version + + anatomy_filled = anatomy.format(template_data) + + if "folder" in anatomy.templates["publish"]: + publish_folder = anatomy_filled["publish"]["folder"] + else: + # solve deprecated situation when `folder` key is not underneath + # `publish` anatomy + project_name = api.Session["AVALON_PROJECT"] + self.log.warning(( + "Deprecation warning: Anatomy does not have set `folder`" + " key underneath `publish` (in global of for project `{}`)." + ).format(project_name)) + + file_path = anatomy_filled["publish"]["path"] + # Directory + publish_folder = os.path.dirname(file_path) + + return publish_folder From 8c7e871e8ad497617d2c74f95f3e8c584b997aae Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 16 Oct 2020 15:46:53 +0200 Subject: [PATCH 120/131] Create weekly-digest.yml --- .github/weekly-digest.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/weekly-digest.yml diff --git a/.github/weekly-digest.yml b/.github/weekly-digest.yml new file mode 100644 index 0000000000..fe502fbc98 --- /dev/null +++ b/.github/weekly-digest.yml @@ -0,0 +1,7 @@ +# Configuration for weekly-digest - https://github.com/apps/weekly-digest +publishDay: sun +canPublishIssues: true +canPublishPullRequests: true +canPublishContributors: true +canPublishStargazers: true +canPublishCommits: true From 1ba4bc5a3b3c795e0f7a3a4f1c08c0b9b194f4e9 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Sat, 17 Oct 2020 00:22:51 +0200 Subject: [PATCH 121/131] bump version --- pype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/version.py b/pype/version.py index 336282f43c..930e2cd686 100644 --- a/pype/version.py +++ b/pype/version.py @@ -1 +1 @@ -__version__ = "2.12.5" +__version__ = "2.13.0" From 987a06dddab88f1ec713a37d08556faee9b30f31 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Sun, 18 Oct 2020 23:07:25 +0200 Subject: [PATCH 122/131] update changelog --- CHANGELOG.md | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba86b85eec..5b39d09327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,155 @@ # Changelog -## [2.12.0](https://github.com/pypeclub/pype/tree/2.12.0) (2020-09-09) +## [2.13.0](https://github.com/pypeclub/pype/tree/2.13.0) (2020-10-16) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.12.5...2.13.0) + +**Enhancements:** + +- Deadline Output Folder [\#636](https://github.com/pypeclub/pype/issues/636) +- Nuke Camera Loader [\#565](https://github.com/pypeclub/pype/issues/565) +- Deadline publish job shows publishing output folder [\#649](https://github.com/pypeclub/pype/pull/649) +- Get latest version in lib [\#642](https://github.com/pypeclub/pype/pull/642) +- Improved publishing of multiple representation from SP [\#638](https://github.com/pypeclub/pype/pull/638) +- Launch TvPaint shot work file from within Ftrack [\#631](https://github.com/pypeclub/pype/pull/631) +- Add mp4 support for RV action. [\#628](https://github.com/pypeclub/pype/pull/628) +- Maya: allow renders to have version synced with workfile [\#618](https://github.com/pypeclub/pype/pull/618) +- Renaming nukestudio host folder to hiero [\#617](https://github.com/pypeclub/pype/pull/617) +- Harmony: More efficient publishing [\#615](https://github.com/pypeclub/pype/pull/615) +- Ftrack server action improvement [\#608](https://github.com/pypeclub/pype/pull/608) +- Deadline user defaults to pype username if present [\#607](https://github.com/pypeclub/pype/pull/607) +- Standalone publisher now has icon [\#606](https://github.com/pypeclub/pype/pull/606) +- Nuke render write targeting knob improvement [\#603](https://github.com/pypeclub/pype/pull/603) +- Animated pyblish gui [\#602](https://github.com/pypeclub/pype/pull/602) +- Maya: Deadline - make use of asset dependencies optional [\#591](https://github.com/pypeclub/pype/pull/591) +- Nuke: Publishing, loading and updating alembic cameras [\#575](https://github.com/pypeclub/pype/pull/575) +- Maya: add look assigner to pype menu even if scriptsmenu is not available [\#573](https://github.com/pypeclub/pype/pull/573) +- Store task types in the database [\#572](https://github.com/pypeclub/pype/pull/572) +- Maya: Tiled EXRs to scanline EXRs render option [\#512](https://github.com/pypeclub/pype/pull/512) +- Fusion basic integration [\#452](https://github.com/pypeclub/pype/pull/452) + +**Fixed bugs:** + +- Burnin script did not propagate ffmpeg output [\#640](https://github.com/pypeclub/pype/issues/640) +- Pyblish-pype spacer in terminal wasn't transparent [\#646](https://github.com/pypeclub/pype/pull/646) +- Lib subprocess without logger [\#645](https://github.com/pypeclub/pype/pull/645) +- Nuke: prevent crash if we only have single frame in sequence [\#644](https://github.com/pypeclub/pype/pull/644) +- Burnin script logs better output [\#641](https://github.com/pypeclub/pype/pull/641) +- Missing audio on farm submission. [\#639](https://github.com/pypeclub/pype/pull/639) +- review from imagesequence error [\#633](https://github.com/pypeclub/pype/pull/633) +- Hiero: wrong order of fps clip instance data collecting [\#627](https://github.com/pypeclub/pype/pull/627) +- Add source for review instances. [\#625](https://github.com/pypeclub/pype/pull/625) +- Task processing in event sync [\#623](https://github.com/pypeclub/pype/pull/623) +- sync to avalon doesn t remove renamed task [\#619](https://github.com/pypeclub/pype/pull/619) +- Intent publish setting wasn't working with default value [\#562](https://github.com/pypeclub/pype/pull/562) +- Maya: Updating a look where the shader name changed, leaves the geo without a shader [\#514](https://github.com/pypeclub/pype/pull/514) + +**Merged pull requests:** + +- Avalon module without Qt [\#581](https://github.com/pypeclub/pype/pull/581) +- Ftrack module without Qt [\#577](https://github.com/pypeclub/pype/pull/577) + +## [2.12.5](https://github.com/pypeclub/pype/tree/2.12.5) (2020-10-14) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.12.4...2.12.5) + +**Enhancements:** + +- Launch TvPaint shot work file from within Ftrack [\#629](https://github.com/pypeclub/pype/issues/629) + +**Merged pull requests:** + +- Harmony: Disable application launch logic [\#637](https://github.com/pypeclub/pype/pull/637) + +## [2.12.4](https://github.com/pypeclub/pype/tree/2.12.4) (2020-10-08) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.12.3...2.12.4) + +**Enhancements:** + +- convert nukestudio to hiero host [\#616](https://github.com/pypeclub/pype/issues/616) +- Fusion basic integration [\#451](https://github.com/pypeclub/pype/issues/451) + +**Fixed bugs:** + +- Sync to avalon doesn't remove renamed task [\#605](https://github.com/pypeclub/pype/issues/605) +- NukeStudio: FPS collecting into clip instances [\#624](https://github.com/pypeclub/pype/pull/624) + +**Merged pull requests:** + +- NukeStudio: small fixes [\#622](https://github.com/pypeclub/pype/pull/622) +- NukeStudio: broken order of plugins [\#620](https://github.com/pypeclub/pype/pull/620) + +## [2.12.3](https://github.com/pypeclub/pype/tree/2.12.3) (2020-10-06) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.12.2...2.12.3) + +**Enhancements:** + +- Nuke Publish Camera [\#567](https://github.com/pypeclub/pype/issues/567) +- Harmony: open xstage file no matter of its name [\#526](https://github.com/pypeclub/pype/issues/526) +- Stop integration of unwanted data [\#387](https://github.com/pypeclub/pype/issues/387) +- Move avalon-launcher functionality to pype [\#229](https://github.com/pypeclub/pype/issues/229) +- avalon workfiles api [\#214](https://github.com/pypeclub/pype/issues/214) +- Store task types [\#180](https://github.com/pypeclub/pype/issues/180) +- Avalon Mongo Connection split [\#136](https://github.com/pypeclub/pype/issues/136) +- nk camera workflow [\#71](https://github.com/pypeclub/pype/issues/71) +- Hiero integration added [\#590](https://github.com/pypeclub/pype/pull/590) +- Anatomy instance data collection is substantially faster for many instances [\#560](https://github.com/pypeclub/pype/pull/560) + +**Fixed bugs:** + +- test issue [\#596](https://github.com/pypeclub/pype/issues/596) +- Harmony: empty scene contamination [\#583](https://github.com/pypeclub/pype/issues/583) +- Edit publishing in SP doesn't respect shot selection for publishing [\#542](https://github.com/pypeclub/pype/issues/542) +- Pathlib breaks compatibility with python2 hosts [\#281](https://github.com/pypeclub/pype/issues/281) +- Updating a look where the shader name changed leaves the geo without a shader [\#237](https://github.com/pypeclub/pype/issues/237) +- Better error handling [\#84](https://github.com/pypeclub/pype/issues/84) +- Harmony: function signature [\#609](https://github.com/pypeclub/pype/pull/609) +- Nuke: gizmo publishing error [\#594](https://github.com/pypeclub/pype/pull/594) +- Harmony: fix clashing namespace of called js functions [\#584](https://github.com/pypeclub/pype/pull/584) +- Maya: fix maya scene type preset exception [\#569](https://github.com/pypeclub/pype/pull/569) + +**Closed issues:** + +- Nuke Gizmo publishing [\#597](https://github.com/pypeclub/pype/issues/597) +- nuke gizmo publishing error [\#592](https://github.com/pypeclub/pype/issues/592) +- Publish EDL [\#579](https://github.com/pypeclub/pype/issues/579) +- Publish render from SP [\#576](https://github.com/pypeclub/pype/issues/576) +- rename ftrack custom attribute group to `pype` [\#184](https://github.com/pypeclub/pype/issues/184) + +**Merged pull requests:** + +- Audio file existence check [\#614](https://github.com/pypeclub/pype/pull/614) +- NKS small fixes [\#587](https://github.com/pypeclub/pype/pull/587) +- Standalone publisher editorial plugins interfering [\#580](https://github.com/pypeclub/pype/pull/580) + +## [2.12.2](https://github.com/pypeclub/pype/tree/2.12.2) (2020-09-25) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.12.1...2.12.2) + +**Enhancements:** + +- pype config GUI [\#241](https://github.com/pypeclub/pype/issues/241) + +**Fixed bugs:** + +- Harmony: Saving heavy scenes will crash [\#507](https://github.com/pypeclub/pype/issues/507) +- Extract review a representation name with `\*\_burnin` [\#388](https://github.com/pypeclub/pype/issues/388) +- Hierarchy data was not considering active isntances [\#551](https://github.com/pypeclub/pype/pull/551) + +## [2.12.1](https://github.com/pypeclub/pype/tree/2.12.1) (2020-09-15) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.12.0...2.12.1) + +**Fixed bugs:** + +- Pype: changelog.md is outdated [\#503](https://github.com/pypeclub/pype/issues/503) +- dependency security alert ! [\#484](https://github.com/pypeclub/pype/issues/484) +- Maya: RenderSetup is missing update [\#106](https://github.com/pypeclub/pype/issues/106) +- \ extract effects creates new instance [\#78](https://github.com/pypeclub/pype/issues/78) + +## [2.12.0](https://github.com/pypeclub/pype/tree/2.12.0) (2020-09-10) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.8...2.12.0) @@ -16,9 +165,7 @@ - Properly containerize image plane loads. [\#434](https://github.com/pypeclub/pype/pull/434) - Option to keep the review files. [\#426](https://github.com/pypeclub/pype/pull/426) - Isolate view on instance members. [\#425](https://github.com/pypeclub/pype/pull/425) -- ftrack group is bcw compatible [\#418](https://github.com/pypeclub/pype/pull/418) - Maya: Publishing of tile renderings on Deadline [\#398](https://github.com/pypeclub/pype/pull/398) -- Feature/little bit better logging gui [\#383](https://github.com/pypeclub/pype/pull/383) **Fixed bugs:** @@ -123,6 +270,14 @@ [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.0...2.11.1) +**Enhancements:** + +- Feature/little bit better logging gui [\#383](https://github.com/pypeclub/pype/pull/383) + +**Fixed bugs:** + +- celaction deadline rendering [\#378](https://github.com/pypeclub/pype/pull/378) + **Merged pull requests:** - Celaction: metadata json folder fixes on path [\#393](https://github.com/pypeclub/pype/pull/393) From b489016fd0c9886363216266e92045b2eafc3299 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Mon, 19 Oct 2020 13:27:48 +0200 Subject: [PATCH 123/131] Small fixes in docstrings --- pype/settings/lib.py | 42 +++++++++++++++--------------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/pype/settings/lib.py b/pype/settings/lib.py index c2c4d3a363..a61238f973 100644 --- a/pype/settings/lib.py +++ b/pype/settings/lib.py @@ -23,9 +23,6 @@ SYSTEM_SETTINGS_PATH = os.path.join( # File where studio's environment overrides are stored ENVIRONMENTS_KEY = "environments" -ENVIRONMENTS_PATH = os.path.join( - STUDIO_OVERRIDES_PATH, ENVIRONMENTS_KEY + ".json" -) # File where studio's default project overrides are stored PROJECT_SETTINGS_KEY = "project_settings" @@ -198,13 +195,6 @@ def studio_system_settings(): return {} -def studio_environments(): - """Environment values from defaults.""" - if os.path.exists(ENVIRONMENTS_PATH): - return load_json_file(ENVIRONMENTS_PATH) - return {} - - def studio_project_settings(): """Studio overrides of default project settings.""" if os.path.exists(PROJECT_SETTINGS_PATH): @@ -289,13 +279,7 @@ def save_project_settings(project_name, overrides): def save_project_anatomy(project_name, anatomy_data): - """Save studio overrides of project anatomy. - - Do not use to store whole project anatomy data with defaults but only it's - overrides with metadata defining how overrides should be applied in load - function. For loading should be used functions `studio_project_anatomy` - for global project settings and `project_anatomy_overrides` for - project specific settings. + """Save studio overrides of project anatomy data. Args: project_name(str, null): Project name for which overrides are @@ -350,8 +334,9 @@ def project_anatomy_overrides(project_name): return load_json_file(path_to_json) -def merge_overrides(global_dict, override_dict): - """Merge override data to source data by metadata stored in.""" +def merge_overrides(source_dict, override_dict): + """Merge data from override_dict to source_dict.""" + if M_OVERRIDEN_KEY in override_dict: overriden_keys = set(override_dict.pop(M_OVERRIDEN_KEY)) else: @@ -359,17 +344,17 @@ def merge_overrides(global_dict, override_dict): for key, value in override_dict.items(): if value == M_POP_KEY: - global_dict.pop(key) + source_dict.pop(key) - elif (key in overriden_keys or key not in global_dict): - global_dict[key] = value + elif (key in overriden_keys or key not in source_dict): + source_dict[key] = value - elif isinstance(value, dict) and isinstance(global_dict[key], dict): - global_dict[key] = merge_overrides(global_dict[key], value) + elif isinstance(value, dict) and isinstance(source_dict[key], dict): + source_dict[key] = merge_overrides(source_dict[key], value) else: - global_dict[key] = value - return global_dict + source_dict[key] = value + return source_dict def apply_overrides(source_data, override_data): @@ -399,7 +384,10 @@ def project_settings(project_name): def environments(): - """Environments from defaults and extracted from system settings. + """Calculated environment based on defaults and system settings. + + Any default environment also found in the system settings will be fully + overriden by the one from the system settings. Returns: dict: Output should be ready for `acre` module. From b5ba616ce2d7df15163074e116bd68cdaea28813 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Oct 2020 00:46:08 +0200 Subject: [PATCH 124/131] Ftrack app action expect avalon mongo object on initialization --- pype/modules/ftrack/lib/ftrack_app_handler.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pype/modules/ftrack/lib/ftrack_app_handler.py b/pype/modules/ftrack/lib/ftrack_app_handler.py index 23776aced7..004207a3a5 100644 --- a/pype/modules/ftrack/lib/ftrack_app_handler.py +++ b/pype/modules/ftrack/lib/ftrack_app_handler.py @@ -20,7 +20,7 @@ class AppAction(BaseAction): preactions = ["start.timer"] def __init__( - self, session, label, name, executable, variant=None, + self, session, dbcon, label, name, executable, variant=None, icon=None, description=None, preactions=[], plugins_presets={} ): self.label = label @@ -31,6 +31,8 @@ class AppAction(BaseAction): self.description = description self.preactions.extend(preactions) + self.dbcon = dbcon + super().__init__(session, plugins_presets) if label is None: raise ValueError("Action missing label.") @@ -89,8 +91,10 @@ class AppAction(BaseAction): if avalon_project_apps is None: if avalon_project_doc is None: ft_project = self.get_project_from_entity(entity) - database = pypelib.get_avalon_database() project_name = ft_project["full_name"] + + self.dbcon.install() + database = self.dbcon.database avalon_project_doc = database[project_name].find_one({ "type": "project" }) or False From 866756eb42dbcf891b3af07c730dc7bef07bf0dd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Oct 2020 00:47:02 +0200 Subject: [PATCH 125/131] ftrack application actions loader pass avalon mongo connection on initialization to Ftrack action --- pype/modules/ftrack/actions/action_application_loader.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pype/modules/ftrack/actions/action_application_loader.py b/pype/modules/ftrack/actions/action_application_loader.py index ecc5a4fad3..ea3ec0dad7 100644 --- a/pype/modules/ftrack/actions/action_application_loader.py +++ b/pype/modules/ftrack/actions/action_application_loader.py @@ -2,13 +2,13 @@ import os import toml import time from pype.modules.ftrack.lib import AppAction -from avalon import lib +from avalon import lib, api from pype.api import Logger, config log = Logger().get_logger(__name__) -def registerApp(app, session, plugins_presets): +def register_app(app, dbcon, session, plugins_presets): name = app['name'] variant = "" try: @@ -39,7 +39,7 @@ def registerApp(app, session, plugins_presets): # register action AppAction( - session, label, name, executable, variant, + session, dbcon, label, name, executable, variant, icon, description, preactions, plugins_presets ).register() @@ -85,11 +85,12 @@ def register(session, plugins_presets={}): ) ) + dbcon = api.AvalonMongoDB() apps = sorted(apps, key=lambda app: app["name"]) app_counter = 0 for app in apps: try: - registerApp(app, session, plugins_presets) + register_app(app, dbcon, session, plugins_presets) if app_counter % 5 == 0: time.sleep(0.1) app_counter += 1 From 2ac0357e2cb14aa7ad9f086eab41b70421604719 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Oct 2020 00:47:35 +0200 Subject: [PATCH 126/131] launch application from launcher does not use avalon.io but creates temporary mongo connection object --- pype/lib.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index afcfa98307..effd311137 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1511,12 +1511,18 @@ class ApplicationLaunchFailed(Exception): def launch_application(project_name, asset_name, task_name, app_name): - database = get_avalon_database() - project_document = database[project_name].find_one({"type": "project"}) - asset_document = database[project_name].find_one({ + # Prepare mongo connection for query of project and asset documents. + dbcon = avalon.api.AvalonMongoDB() + dbcon.install() + dbcon.Session["AVALON_PROJECT"] = project_name + + project_document = dbcon.find_one({"type": "project"}) + asset_document = dbcon.find_one({ "type": "asset", "name": asset_name }) + # Uninstall Mongo connection as is not needed anymore. + dbcon.uninstall() asset_doc_parents = asset_document["data"].get("parents") hierarchy = "/".join(asset_doc_parents) From e8e70ec4903ee13dfca419d2fbbda050cb2f9d55 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Oct 2020 00:47:53 +0200 Subject: [PATCH 127/131] removed functions using avalon.io from pype.lib --- pype/lib.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index effd311137..5ed640562c 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -538,19 +538,6 @@ def get_last_version_from_path(path_dir, filter): return None -def get_avalon_database(): - if io._database is None: - set_io_database() - return io._database - - -def set_io_database(): - required_keys = ["AVALON_PROJECT", "AVALON_ASSET", "AVALON_SILO"] - for key in required_keys: - os.environ[key] = os.environ.get(key, "") - io.install() - - def filter_pyblish_plugins(plugins): """ This servers as plugin filter / modifier for pyblish. It will load plugin From e557e4cfc9d1081c880d435b944a4fb15484d50f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 20 Oct 2020 18:54:26 +0200 Subject: [PATCH 128/131] #654 - Fix Layer name is not propagating Fixed saving namespace into Headline Fixed switch asset and update version Added delete layer by id --- .../stubs/photoshop_server_stub.py | 102 +++++++++++------- pype/plugins/photoshop/load/load_image.py | 54 +++++++++- 2 files changed, 114 insertions(+), 42 deletions(-) diff --git a/pype/modules/websocket_server/stubs/photoshop_server_stub.py b/pype/modules/websocket_server/stubs/photoshop_server_stub.py index da69127799..04fb7eff0f 100644 --- a/pype/modules/websocket_server/stubs/photoshop_server_stub.py +++ b/pype/modules/websocket_server/stubs/photoshop_server_stub.py @@ -22,8 +22,9 @@ class PhotoshopServerStub(): def open(self, path): """ Open file located at 'path' (local). - :param path: file path locally - :return: None + Args: + path(string): file path locally + Returns: None """ self.websocketserver.call(self.client.call ('Photoshop.open', path=path) @@ -32,9 +33,10 @@ class PhotoshopServerStub(): def read(self, layer, layers_meta=None): """ Parses layer metadata from Headline field of active document - :param layer: Layer("id": XXX, "name":'YYY') - :param data: json representation for single layer - :param all_layers: - for performance, could be + Args: + layer (namedtuple): Layer("id": XXX, "name":'YYY') + data(string): json representation for single layer + all_layers (list of namedtuples): for performance, could be injected for usage in loop, if not, single call will be triggered - :param layers_meta: json representation from Headline + layers_meta(string): json representation from Headline (for performance - provide only if imprint is in loop - value should be same) - :return: None + 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)]: - layers_meta[str(layer.id)].update(data) + if data: + layers_meta[str(layer.id)].update(data) + else: + layers_meta.pop(str(layer.id)) else: layers_meta[str(layer.id)] = data @@ -83,7 +89,7 @@ class PhotoshopServerStub(): """ Returns JSON document with all(?) layers in active document. - :return: + Returns: Format of tuple: { 'id':'123', 'name': 'My Layer 1', 'type': 'GUIDE'|'FG'|'BG'|'OBJ' @@ -97,8 +103,9 @@ class PhotoshopServerStub(): def get_layers_in_layers(self, layers): """ Return all layers that belong to layers (might be groups). - :param layers: - :return: + Args: + layers : + Returns: """ all_layers = self.get_layers() ret = [] @@ -116,7 +123,7 @@ class PhotoshopServerStub(): def create_group(self, name): """ Create new group (eg. LayerSet) - :return: + Returns: """ ret = self.websocketserver.call(self.client.call ('Photoshop.create_group', @@ -128,7 +135,7 @@ class PhotoshopServerStub(): def group_selected_layers(self, name): """ Group selected layers into new LayerSet (eg. group) - :return: + Returns: """ res = self.websocketserver.call(self.client.call ('Photoshop.group_selected_layers', @@ -139,7 +146,7 @@ class PhotoshopServerStub(): def get_selected_layers(self): """ Get a list of actually selected layers - :return: + Returns: """ res = self.websocketserver.call(self.client.call ('Photoshop.get_selected_layers')) @@ -147,9 +154,10 @@ class PhotoshopServerStub(): def select_layers(self, layers): """ - Selecte specified layers in Photoshop - :param layers: - :return: None + Selects specified layers in Photoshop by its ids + Args: + layers: + Returns: None """ layer_ids = [layer.id for layer in layers] @@ -161,7 +169,7 @@ class PhotoshopServerStub(): def get_active_document_full_name(self): """ Returns full name with path of active document via ws call - :return: full path with name + Returns(string): full path with name """ res = self.websocketserver.call( self.client.call('Photoshop.get_active_document_full_name')) @@ -171,7 +179,7 @@ class PhotoshopServerStub(): def get_active_document_name(self): """ Returns just a name of active document via ws call - :return: file name + Returns(string): file name """ res = self.websocketserver.call(self.client.call ('Photoshop.get_active_document_name')) @@ -181,7 +189,7 @@ class PhotoshopServerStub(): def is_saved(self): """ Returns true if no changes in active document - :return: + Returns: """ return self.websocketserver.call(self.client.call ('Photoshop.is_saved')) @@ -189,7 +197,7 @@ class PhotoshopServerStub(): def save(self): """ Saves active document - :return: None + Returns: None """ self.websocketserver.call(self.client.call ('Photoshop.save')) @@ -197,10 +205,11 @@ class PhotoshopServerStub(): def saveAs(self, image_path, ext, as_copy): """ Saves active document to psd (copy) or png or jpg - :param image_path: full local path - :param ext: - :param as_copy: - :return: None + Args: + image_path(string): full local path + ext: + as_copy: + Returns: None """ self.websocketserver.call(self.client.call ('Photoshop.saveAs', @@ -211,9 +220,10 @@ class PhotoshopServerStub(): def set_visible(self, layer_id, visibility): """ Set layer with 'layer_id' to 'visibility' - :param layer_id: - :param visibility: - :return: None + Args: + layer_id: + visibility: + Returns: None """ self.websocketserver.call(self.client.call ('Photoshop.set_visible', @@ -224,7 +234,7 @@ class PhotoshopServerStub(): """ Reads layers metadata from Headline from active document in PS. (Headline accessible by File > File Info) - :return: - json documents + Returns(string): - json documents """ layers_data = {} res = self.websocketserver.call(self.client.call('Photoshop.read')) @@ -234,22 +244,26 @@ class PhotoshopServerStub(): pass return layers_data - def import_smart_object(self, path): + def import_smart_object(self, path, layer_name): """ 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 """ res = self.websocketserver.call(self.client.call ('Photoshop.import_smart_object', - path=path)) + path=path, name=layer_name)) return self._to_records(res).pop() - def replace_smart_object(self, layer, path): + 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 (namedTuple): Layer("id":XX, "name":"YY"..). @@ -257,8 +271,18 @@ class PhotoshopServerStub(): """ self.websocketserver.call(self.client.call ('Photoshop.replace_smart_object', - layer=layer, - path=path)) + layer_id=layer.id, + path=path, name=layer_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 close(self): self.client.close() @@ -267,8 +291,8 @@ class PhotoshopServerStub(): """ Converts string json representation into list of named tuples for dot notation access to work. - :return: - :param res: - json representation + Returns: + res(string): - json representation """ try: layers_data = json.loads(res) diff --git a/pype/plugins/photoshop/load/load_image.py b/pype/plugins/photoshop/load/load_image.py index 75c02bb327..301e60fbb1 100644 --- a/pype/plugins/photoshop/load/load_image.py +++ b/pype/plugins/photoshop/load/load_image.py @@ -1,4 +1,6 @@ from avalon import api, photoshop +import os +import re stub = photoshop.stub() @@ -13,10 +15,13 @@ class ImageLoader(api.Loader): representations = ["*"] def load(self, context, name=None, namespace=None, data=None): + layer_name = self._get_unique_layer_name(context["asset"]["name"], + name) with photoshop.maintained_selection(): - layer = stub.import_smart_object(self.fname) + layer = stub.import_smart_object(self.fname, layer_name) self[:] = [layer] + namespace = namespace or layer_name return photoshop.containerise( name, @@ -27,11 +32,25 @@ class ImageLoader(api.Loader): ) def update(self, container, representation): + """ Switch asset or change version """ layer = container.pop("layer") + context = representation.get("context", {}) + + namespace_from_container = re.sub(r'_\d{3}$', '', + container["namespace"]) + layer_name = "{}_{}".format(context["asset"], context["subset"]) + # switching assets + if namespace_from_container != layer_name: + layer_name = self._get_unique_layer_name(context["asset"], + context["subset"]) + else: # switching version - keep same name + layer_name = container["namespace"] + + path = api.get_representation_path(representation) with photoshop.maintained_selection(): stub.replace_smart_object( - layer, api.get_representation_path(representation) + layer, path, layer_name ) stub.imprint( @@ -39,7 +58,36 @@ class ImageLoader(api.Loader): ) def remove(self, container): - container["layer"].Delete() + """ + Removes element from scene: deletes layer + removes from Headline + Args: + container (dict): container to be removed - used to get layer_id + """ + layer = container.pop("layer") + stub.imprint(layer, {}) + stub.delete_layer(layer.id) def switch(self, container, representation): self.update(container, representation) + + def _get_unique_layer_name(self, asset_name, subset_name): + """ + Gets all layer names and if 'name' is present in them, increases + suffix by 1 (eg. creates unique layer name - for Loader) + Args: + name (string): in format asset_subset + + Returns: + (string): name_00X (without version) + """ + name = "{}_{}".format(asset_name, subset_name) + names = {} + for layer in stub.get_layers(): + layer_name = re.sub(r'_\d{3}$', '', layer.name) + if layer_name in names.keys(): + names[layer_name] = names[layer_name] + 1 + else: + names[layer_name] = 1 + occurrences = names.get(name, 0) + + return "{}_{:0>3d}".format(name, occurrences + 1) From abc70685fa05bacf6c51ed5593041ecfa40f2616 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 22 Oct 2020 14:54:16 +0200 Subject: [PATCH 129/131] fix(hiero): cutup source audio video clip differently then nonaudio one frame difference issue --- pype/plugins/hiero/publish/extract_review_cutup.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pype/plugins/hiero/publish/extract_review_cutup.py b/pype/plugins/hiero/publish/extract_review_cutup.py index 57ec6c1107..88b19b7ec8 100644 --- a/pype/plugins/hiero/publish/extract_review_cutup.py +++ b/pype/plugins/hiero/publish/extract_review_cutup.py @@ -133,14 +133,24 @@ class ExtractReviewCutUp(pype.api.Extractor): "{ffprobe_path} -i \"{full_input_path}\" -show_streams " "-select_streams a -loglevel error" ).format(**locals()) + self.log.debug("ffprob_cmd: {}".format(ffprob_cmd)) audio_check_output = pype.api.subprocess(ffprob_cmd) self.log.debug( "audio_check_output: {}".format(audio_check_output)) + # Fix one frame difference + """ TODO: this is just work-around for issue: + https://github.com/pypeclub/pype/issues/659 + """ + frame_duration_extend = 1 + if audio_check_output: + frame_duration_extend = 0 + # translate frame to sec start_sec = float(frame_start) / fps - duration_sec = float(frame_end - frame_start + 1) / fps + duration_sec = float( + (frame_end - frame_start) + frame_duration_extend) / fps empty_add = None From 13c14b8a86bbfd0b98fedf1dfa943aaa65358aa2 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 23 Oct 2020 12:52:38 +0200 Subject: [PATCH 130/131] cherry picked #654 - Fix Layer name is not propagating --- .../stubs/photoshop_server_stub.py | 102 +++++++++++------- pype/plugins/photoshop/load/load_image.py | 54 +++++++++- 2 files changed, 114 insertions(+), 42 deletions(-) diff --git a/pype/modules/websocket_server/stubs/photoshop_server_stub.py b/pype/modules/websocket_server/stubs/photoshop_server_stub.py index da69127799..04fb7eff0f 100644 --- a/pype/modules/websocket_server/stubs/photoshop_server_stub.py +++ b/pype/modules/websocket_server/stubs/photoshop_server_stub.py @@ -22,8 +22,9 @@ class PhotoshopServerStub(): def open(self, path): """ Open file located at 'path' (local). - :param path: file path locally - :return: None + Args: + path(string): file path locally + Returns: None """ self.websocketserver.call(self.client.call ('Photoshop.open', path=path) @@ -32,9 +33,10 @@ class PhotoshopServerStub(): def read(self, layer, layers_meta=None): """ Parses layer metadata from Headline field of active document - :param layer: Layer("id": XXX, "name":'YYY') - :param data: json representation for single layer - :param all_layers: - for performance, could be + Args: + layer (namedtuple): Layer("id": XXX, "name":'YYY') + data(string): json representation for single layer + all_layers (list of namedtuples): for performance, could be injected for usage in loop, if not, single call will be triggered - :param layers_meta: json representation from Headline + layers_meta(string): json representation from Headline (for performance - provide only if imprint is in loop - value should be same) - :return: None + 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)]: - layers_meta[str(layer.id)].update(data) + if data: + layers_meta[str(layer.id)].update(data) + else: + layers_meta.pop(str(layer.id)) else: layers_meta[str(layer.id)] = data @@ -83,7 +89,7 @@ class PhotoshopServerStub(): """ Returns JSON document with all(?) layers in active document. - :return: + Returns: Format of tuple: { 'id':'123', 'name': 'My Layer 1', 'type': 'GUIDE'|'FG'|'BG'|'OBJ' @@ -97,8 +103,9 @@ class PhotoshopServerStub(): def get_layers_in_layers(self, layers): """ Return all layers that belong to layers (might be groups). - :param layers: - :return: + Args: + layers : + Returns: """ all_layers = self.get_layers() ret = [] @@ -116,7 +123,7 @@ class PhotoshopServerStub(): def create_group(self, name): """ Create new group (eg. LayerSet) - :return: + Returns: """ ret = self.websocketserver.call(self.client.call ('Photoshop.create_group', @@ -128,7 +135,7 @@ class PhotoshopServerStub(): def group_selected_layers(self, name): """ Group selected layers into new LayerSet (eg. group) - :return: + Returns: """ res = self.websocketserver.call(self.client.call ('Photoshop.group_selected_layers', @@ -139,7 +146,7 @@ class PhotoshopServerStub(): def get_selected_layers(self): """ Get a list of actually selected layers - :return: + Returns: """ res = self.websocketserver.call(self.client.call ('Photoshop.get_selected_layers')) @@ -147,9 +154,10 @@ class PhotoshopServerStub(): def select_layers(self, layers): """ - Selecte specified layers in Photoshop - :param layers: - :return: None + Selects specified layers in Photoshop by its ids + Args: + layers: + Returns: None """ layer_ids = [layer.id for layer in layers] @@ -161,7 +169,7 @@ class PhotoshopServerStub(): def get_active_document_full_name(self): """ Returns full name with path of active document via ws call - :return: full path with name + Returns(string): full path with name """ res = self.websocketserver.call( self.client.call('Photoshop.get_active_document_full_name')) @@ -171,7 +179,7 @@ class PhotoshopServerStub(): def get_active_document_name(self): """ Returns just a name of active document via ws call - :return: file name + Returns(string): file name """ res = self.websocketserver.call(self.client.call ('Photoshop.get_active_document_name')) @@ -181,7 +189,7 @@ class PhotoshopServerStub(): def is_saved(self): """ Returns true if no changes in active document - :return: + Returns: """ return self.websocketserver.call(self.client.call ('Photoshop.is_saved')) @@ -189,7 +197,7 @@ class PhotoshopServerStub(): def save(self): """ Saves active document - :return: None + Returns: None """ self.websocketserver.call(self.client.call ('Photoshop.save')) @@ -197,10 +205,11 @@ class PhotoshopServerStub(): def saveAs(self, image_path, ext, as_copy): """ Saves active document to psd (copy) or png or jpg - :param image_path: full local path - :param ext: - :param as_copy: - :return: None + Args: + image_path(string): full local path + ext: + as_copy: + Returns: None """ self.websocketserver.call(self.client.call ('Photoshop.saveAs', @@ -211,9 +220,10 @@ class PhotoshopServerStub(): def set_visible(self, layer_id, visibility): """ Set layer with 'layer_id' to 'visibility' - :param layer_id: - :param visibility: - :return: None + Args: + layer_id: + visibility: + Returns: None """ self.websocketserver.call(self.client.call ('Photoshop.set_visible', @@ -224,7 +234,7 @@ class PhotoshopServerStub(): """ Reads layers metadata from Headline from active document in PS. (Headline accessible by File > File Info) - :return: - json documents + Returns(string): - json documents """ layers_data = {} res = self.websocketserver.call(self.client.call('Photoshop.read')) @@ -234,22 +244,26 @@ class PhotoshopServerStub(): pass return layers_data - def import_smart_object(self, path): + def import_smart_object(self, path, layer_name): """ 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 """ res = self.websocketserver.call(self.client.call ('Photoshop.import_smart_object', - path=path)) + path=path, name=layer_name)) return self._to_records(res).pop() - def replace_smart_object(self, layer, path): + 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 (namedTuple): Layer("id":XX, "name":"YY"..). @@ -257,8 +271,18 @@ class PhotoshopServerStub(): """ self.websocketserver.call(self.client.call ('Photoshop.replace_smart_object', - layer=layer, - path=path)) + layer_id=layer.id, + path=path, name=layer_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 close(self): self.client.close() @@ -267,8 +291,8 @@ class PhotoshopServerStub(): """ Converts string json representation into list of named tuples for dot notation access to work. - :return: - :param res: - json representation + Returns: + res(string): - json representation """ try: layers_data = json.loads(res) diff --git a/pype/plugins/photoshop/load/load_image.py b/pype/plugins/photoshop/load/load_image.py index 75c02bb327..301e60fbb1 100644 --- a/pype/plugins/photoshop/load/load_image.py +++ b/pype/plugins/photoshop/load/load_image.py @@ -1,4 +1,6 @@ from avalon import api, photoshop +import os +import re stub = photoshop.stub() @@ -13,10 +15,13 @@ class ImageLoader(api.Loader): representations = ["*"] def load(self, context, name=None, namespace=None, data=None): + layer_name = self._get_unique_layer_name(context["asset"]["name"], + name) with photoshop.maintained_selection(): - layer = stub.import_smart_object(self.fname) + layer = stub.import_smart_object(self.fname, layer_name) self[:] = [layer] + namespace = namespace or layer_name return photoshop.containerise( name, @@ -27,11 +32,25 @@ class ImageLoader(api.Loader): ) def update(self, container, representation): + """ Switch asset or change version """ layer = container.pop("layer") + context = representation.get("context", {}) + + namespace_from_container = re.sub(r'_\d{3}$', '', + container["namespace"]) + layer_name = "{}_{}".format(context["asset"], context["subset"]) + # switching assets + if namespace_from_container != layer_name: + layer_name = self._get_unique_layer_name(context["asset"], + context["subset"]) + else: # switching version - keep same name + layer_name = container["namespace"] + + path = api.get_representation_path(representation) with photoshop.maintained_selection(): stub.replace_smart_object( - layer, api.get_representation_path(representation) + layer, path, layer_name ) stub.imprint( @@ -39,7 +58,36 @@ class ImageLoader(api.Loader): ) def remove(self, container): - container["layer"].Delete() + """ + Removes element from scene: deletes layer + removes from Headline + Args: + container (dict): container to be removed - used to get layer_id + """ + layer = container.pop("layer") + stub.imprint(layer, {}) + stub.delete_layer(layer.id) def switch(self, container, representation): self.update(container, representation) + + def _get_unique_layer_name(self, asset_name, subset_name): + """ + Gets all layer names and if 'name' is present in them, increases + suffix by 1 (eg. creates unique layer name - for Loader) + Args: + name (string): in format asset_subset + + Returns: + (string): name_00X (without version) + """ + name = "{}_{}".format(asset_name, subset_name) + names = {} + for layer in stub.get_layers(): + layer_name = re.sub(r'_\d{3}$', '', layer.name) + if layer_name in names.keys(): + names[layer_name] = names[layer_name] + 1 + else: + names[layer_name] = 1 + occurrences = names.get(name, 0) + + return "{}_{:0>3d}".format(name, occurrences + 1) From 0e813126796e232ee79386e8a197f899833f1b7b Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 23 Oct 2020 13:11:51 +0200 Subject: [PATCH 131/131] update changelog --- CHANGELOG.md | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b39d09327..9349589f8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # Changelog -## [2.13.0](https://github.com/pypeclub/pype/tree/2.13.0) (2020-10-16) +## [2.13.1](https://github.com/pypeclub/pype/tree/2.13.1) (2020-10-23) + +[Full Changelog](https://github.com/pypeclub/pype/compare/2.13.0...2.13.1) + +**Enhancements:** + +- move maya look assigner to pype menu [\#292](https://github.com/pypeclub/pype/issues/292) + +**Fixed bugs:** + +- Layer name is not propagating to metadata in Photoshop [\#654](https://github.com/pypeclub/pype/issues/654) +- Loader in Photoshop fails with "can't set attribute" [\#650](https://github.com/pypeclub/pype/issues/650) +- Hiero: Review video file adding one frame to the end [\#659](https://github.com/pypeclub/pype/issues/659) + +## [2.13.0](https://github.com/pypeclub/pype/tree/2.13.0) (2020-10-18) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.5...2.13.0) @@ -46,6 +60,7 @@ **Merged pull requests:** +- Audio file existence check [\#614](https://github.com/pypeclub/pype/pull/614) - Avalon module without Qt [\#581](https://github.com/pypeclub/pype/pull/581) - Ftrack module without Qt [\#577](https://github.com/pypeclub/pype/pull/577) @@ -120,7 +135,6 @@ **Merged pull requests:** -- Audio file existence check [\#614](https://github.com/pypeclub/pype/pull/614) - NKS small fixes [\#587](https://github.com/pypeclub/pype/pull/587) - Standalone publisher editorial plugins interfering [\#580](https://github.com/pypeclub/pype/pull/580) @@ -166,10 +180,12 @@ - Option to keep the review files. [\#426](https://github.com/pypeclub/pype/pull/426) - Isolate view on instance members. [\#425](https://github.com/pypeclub/pype/pull/425) - Maya: Publishing of tile renderings on Deadline [\#398](https://github.com/pypeclub/pype/pull/398) +- Feature/little bit better logging gui [\#383](https://github.com/pypeclub/pype/pull/383) **Fixed bugs:** - Maya: Fix tile order for Draft Tile Assembler [\#511](https://github.com/pypeclub/pype/pull/511) +- NukeStudio: Fix comment tag collection and integration. [\#508](https://github.com/pypeclub/pype/pull/508) - Remove extra dash [\#501](https://github.com/pypeclub/pype/pull/501) - Fix: strip dot from repre names in single frame renders [\#498](https://github.com/pypeclub/pype/pull/498) - Better handling of destination during integrating [\#485](https://github.com/pypeclub/pype/pull/485) @@ -270,14 +286,6 @@ [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.0...2.11.1) -**Enhancements:** - -- Feature/little bit better logging gui [\#383](https://github.com/pypeclub/pype/pull/383) - -**Fixed bugs:** - -- celaction deadline rendering [\#378](https://github.com/pypeclub/pype/pull/378) - **Merged pull requests:** - Celaction: metadata json folder fixes on path [\#393](https://github.com/pypeclub/pype/pull/393)