diff --git a/openpype/hosts/hiero/plugins/load/load_clip.py b/openpype/hosts/hiero/plugins/load/load_clip.py index 9e12fa360e..b905dd4431 100644 --- a/openpype/hosts/hiero/plugins/load/load_clip.py +++ b/openpype/hosts/hiero/plugins/load/load_clip.py @@ -54,6 +54,10 @@ class LoadClip(phiero.SequenceLoader): object_name = self.clip_name_template.format( **context["representation"]["context"]) + # set colorspace + if colorspace: + track_item.source().setSourceMediaColourTransform(colorspace) + # add additional metadata from the version to imprint Avalon knob add_keys = [ "frameStart", "frameEnd", "source", "author", @@ -109,9 +113,14 @@ class LoadClip(phiero.SequenceLoader): colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) file = api.get_representation_path(representation).replace("\\", "/") + clip = track_item.source() # reconnect media to new path - track_item.source().reconnectMedia(file) + clip.reconnectMedia(file) + + # set colorspace + if colorspace: + clip.setSourceMediaColourTransform(colorspace) # add additional metadata from the version to imprint Avalon knob add_keys = [ @@ -160,6 +169,7 @@ class LoadClip(phiero.SequenceLoader): @classmethod def set_item_color(cls, track_item, version): + clip = track_item.source() # define version name version_name = version.get("name", None) # get all versions in list @@ -172,6 +182,6 @@ class LoadClip(phiero.SequenceLoader): # set clip colour if version_name == max_version: - track_item.source().binItem().setColor(cls.clip_color_last) + clip.binItem().setColor(cls.clip_color_last) else: - track_item.source().binItem().setColor(cls.clip_color) + clip.binItem().setColor(cls.clip_color) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 4984849aa7..9b529edf88 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -120,6 +120,13 @@ class PrecollectInstances(pyblish.api.ContextPlugin): # create instance instance = context.create_instance(**data) + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": track_item.sourceMediaColourTransform(), + } + }) + # create shot instance for shot attributes create/update self.create_shot_instance(context, **data) @@ -133,13 +140,6 @@ class PrecollectInstances(pyblish.api.ContextPlugin): # create audio subset instance self.create_audio_instance(context, **data) - # add colorspace data - instance.data.update({ - "versionData": { - "colorspace": track_item.sourceMediaColourTransform(), - } - }) - # add audioReview attribute to plate instance data # if reviewTrack is on if tag_data.get("reviewTrack") is not None: diff --git a/openpype/hosts/nuke/plugins/load/load_mov.py b/openpype/hosts/nuke/plugins/load/load_mov.py index d84c3d4c71..f7523d0a6e 100644 --- a/openpype/hosts/nuke/plugins/load/load_mov.py +++ b/openpype/hosts/nuke/plugins/load/load_mov.py @@ -259,7 +259,8 @@ class LoadMov(api.Loader): read_node["last"].setValue(last) read_node['frame_mode'].setValue("start at") - if int(self.first_frame) == int(read_node['frame'].value()): + if int(float(self.first_frame)) == int( + float(read_node['frame'].value())): # start at workfile start read_node['frame'].setValue(str(self.first_frame)) else: diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py index d70a0a75b8..596a8ccfd2 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -270,6 +270,7 @@ class CollectTextures(pyblish.api.ContextPlugin): # store origin if family == 'workfile': families = self.workfile_families + families.append("texture_batch_workfile") new_instance.data["source"] = "standalone publisher" else: diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py index af200b59e0..d592a4a059 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py @@ -8,7 +8,7 @@ class ValidateTextureBatch(pyblish.api.InstancePlugin): label = "Validate Texture Presence" hosts = ["standalonepublisher"] order = openpype.api.ValidateContentsOrder - families = ["workfile"] + families = ["texture_batch_workfile"] optional = False def process(self, instance): diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py index 92f930c3fc..f210be3631 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py @@ -8,7 +8,7 @@ class ValidateTextureBatchNaming(pyblish.api.InstancePlugin): label = "Validate Texture Batch Naming" hosts = ["standalonepublisher"] order = openpype.api.ValidateContentsOrder - families = ["workfile", "textures"] + families = ["texture_batch_workfile", "textures"] optional = False def process(self, instance): diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py index aa3aad71db..25bb5aea4a 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py @@ -11,7 +11,7 @@ class ValidateTextureBatchWorkfiles(pyblish.api.InstancePlugin): label = "Validate Texture Workfile Has Resources" hosts = ["standalonepublisher"] order = openpype.api.ValidateContentsOrder - families = ["workfile"] + families = ["texture_batch_workfile"] optional = True # from presets diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index ada194f15f..fe964d3bab 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1138,7 +1138,8 @@ def prepare_host_environments(data, implementation_envs=True): # Merge dictionaries env_values = _merge_env(tool_env, env_values) - loaded_env = _merge_env(acre.compute(env_values), data["env"]) + merged_env = _merge_env(env_values, data["env"]) + loaded_env = acre.compute(merged_env, cleanup=False) final_env = None # Add host specific environments @@ -1189,7 +1190,10 @@ def apply_project_environments_value(project_name, env, project_settings=None): env_value = project_settings["global"]["project_environments"] if env_value: - env.update(_merge_env(acre.parse(env_value), env)) + env.update(acre.compute( + _merge_env(acre.parse(env_value), env), + cleanup=False + )) return env diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index e58281644a..01f61d8bdf 100644 --- a/openpype/settings/entities/lib.py +++ b/openpype/settings/entities/lib.py @@ -3,6 +3,7 @@ import re import json import copy import inspect +import contextlib from .exceptions import ( SchemaTemplateMissingKeys, @@ -111,6 +112,10 @@ class SchemasHub: self._loaded_templates = {} self._loaded_schemas = {} + # Store validating and validated dynamic template or schemas + self._validating_dynamic = set() + self._validated_dynamic = set() + # It doesn't make sence to reload types on each reset as they can't be # changed self._load_types() @@ -126,6 +131,60 @@ class SchemasHub: def gui_types(self): return self._gui_types + def get_template_name(self, item_def, default=None): + """Get template name from passed item definition. + + Args: + item_def(dict): Definition of item with "type". + default(object): Default return value. + """ + output = default + if not item_def or not isinstance(item_def, dict): + return output + + item_type = item_def.get("type") + if item_type in ("template", "schema_template"): + output = item_def["name"] + return output + + def is_dynamic_template_validating(self, template_name): + """Is template validating using different entity. + + Returns: + bool: Is template validating. + """ + if template_name in self._validating_dynamic: + return True + return False + + def is_dynamic_template_validated(self, template_name): + """Is template already validated. + + Returns: + bool: Is template validated. + """ + + if template_name in self._validated_dynamic: + return True + return False + + @contextlib.contextmanager + def validating_dynamic(self, template_name): + """Template name is validating and validated. + + Context manager that cares about storing template name validations of + template. + + This is to avoid infinite loop of dynamic children validation. + """ + self._validating_dynamic.add(template_name) + try: + yield + self._validated_dynamic.add(template_name) + + finally: + self._validating_dynamic.remove(template_name) + def get_schema(self, schema_name): """Get schema definition data by it's name. diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py index 64bbad28a7..b07441251a 100644 --- a/openpype/settings/entities/list_entity.py +++ b/openpype/settings/entities/list_entity.py @@ -141,7 +141,20 @@ class ListEntity(EndpointEntity): item_schema = self.schema_data["object_type"] if not isinstance(item_schema, dict): item_schema = {"type": item_schema} - self.item_schema = item_schema + + obj_template_name = self.schema_hub.get_template_name(item_schema) + _item_schemas = self.schema_hub.resolve_schema_data(item_schema) + if len(_item_schemas) == 1: + self.item_schema = _item_schemas[0] + if self.item_schema != item_schema: + if "label" in self.item_schema: + self.item_schema.pop("label") + self.item_schema["use_label_wrap"] = False + else: + self.item_schema = _item_schemas + + # Store if was used template or schema + self._obj_template_name = obj_template_name if self.group_item is None: self.is_group = True @@ -150,6 +163,12 @@ class ListEntity(EndpointEntity): self.initial_value = [] def schema_validations(self): + if isinstance(self.item_schema, list): + reason = ( + "`ListWidget` has multiple items as object type." + ) + raise EntitySchemaError(self, reason) + super(ListEntity, self).schema_validations() if self.is_dynamic_item and self.use_label_wrap: @@ -167,18 +186,36 @@ class ListEntity(EndpointEntity): raise EntitySchemaError(self, reason) # Validate object type schema - child_validated = False + validate_children = True for child_entity in self.children: child_entity.schema_validations() - child_validated = True + validate_children = False break - if not child_validated: + if validate_children and self._obj_template_name: + _validated = self.schema_hub.is_dynamic_template_validated( + self._obj_template_name + ) + _validating = self.schema_hub.is_dynamic_template_validating( + self._obj_template_name + ) + validate_children = not _validated and not _validating + + if not validate_children: + return + + def _validate(): idx = 0 tmp_child = self._add_new_item(idx) tmp_child.schema_validations() self.children.pop(idx) + if self._obj_template_name: + with self.schema_hub.validating_dynamic(self._obj_template_name): + _validate() + else: + _validate() + def get_child_path(self, child_obj): result_idx = None for idx, _child_obj in enumerate(self.children): diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 399c4ac1d9..42a8973f43 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -417,6 +417,8 @@ How output of the schema could look like on save: - there are 2 possible ways how to set the type: 1.) dictionary with item modifiers (`number` input has `minimum`, `maximum` and `decimals`) in that case item type must be set as value of `"type"` (example below) 2.) item type name as string without modifiers (e.g. `text`) + 3.) enhancement of 1.) there is also support of `template` type but be carefull about endless loop of templates + - goal of using `template` is to easily change same item definitions in multiple lists 1.) with item modifiers ``` @@ -442,6 +444,65 @@ How output of the schema could look like on save: } ``` +3.) with template definition +``` +# Schema of list item where template is used +{ + "type": "list", + "key": "menu_items", + "label": "Menu Items", + "object_type": { + "type": "template", + "name": "template_object_example" + } +} + +# WARNING: +# In this example the template use itself inside which will work in `list` +# but may cause an issue in other entity types (e.g. `dict`). + +'template_object_example.json' : +[ + { + "type": "dict-conditional", + "use_label_wrap": true, + "collapsible": true, + "key": "menu_items", + "label": "Menu items", + "enum_key": "type", + "enum_label": "Type", + "enum_children": [ + { + "key": "action", + "label": "Action", + "children": [ + { + "type": "text", + "key": "key", + "label": "Key" + } + ] + }, + { + "key": "menu", + "label": "Menu", + "children": [ + { + "key": "children", + "label": "Children", + "type": "list", + "object_type": { + "type": "template", + "name": "template_object_example" + } + } + ] + } + ] + } +] +``` + ### dict-modifiable - one of dictionary inputs, this is only used as value input - items in this input can be removed and added same way as in `list` input diff --git a/openpype/settings/entities/schemas/system_schema/example_infinite_hierarchy.json b/openpype/settings/entities/schemas/system_schema/example_infinite_hierarchy.json new file mode 100644 index 0000000000..a2660e9bf2 --- /dev/null +++ b/openpype/settings/entities/schemas/system_schema/example_infinite_hierarchy.json @@ -0,0 +1,58 @@ +[ + { + "type": "dict-conditional", + "use_label_wrap": true, + "collapsible": true, + "key": "menu_items", + "label": "Menu items", + "enum_key": "type", + "enum_label": "Type", + "enum_children": [ + { + "key": "action", + "label": "Action", + "children": [ + { + "type": "text", + "key": "key", + "label": "Key" + }, + { + "type": "text", + "key": "label", + "label": "Label" + }, + { + "type": "text", + "key": "command", + "label": "Comand" + } + ] + }, + { + "key": "menu", + "label": "Menu", + "children": [ + { + "type": "text", + "key": "label", + "label": "Label" + }, + { + "key": "children", + "label": "Children", + "type": "list", + "object_type": { + "type": "template", + "name": "example_infinite_hierarchy" + } + } + ] + }, + { + "key": "separator", + "label": "Separator" + } + ] + } +] diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json index 8ec97064a1..f633d5cb1a 100644 --- a/openpype/settings/entities/schemas/system_schema/example_schema.json +++ b/openpype/settings/entities/schemas/system_schema/example_schema.json @@ -82,6 +82,17 @@ } ] }, + { + "type": "list", + "use_label_wrap": true, + "collapsible": true, + "key": "infinite_hierarchy", + "label": "Infinite list template hierarchy", + "object_type": { + "type": "template", + "name": "example_infinite_hierarchy" + } + }, { "type": "dict", "key": "schema_template_exaples", diff --git a/openpype/tools/settings/settings/list_item_widget.py b/openpype/tools/settings/settings/list_item_widget.py index 82ca541132..c9df5caf01 100644 --- a/openpype/tools/settings/settings/list_item_widget.py +++ b/openpype/tools/settings/settings/list_item_widget.py @@ -117,6 +117,9 @@ class ListItem(QtWidgets.QWidget): self.spacer_widget = spacer_widget + self._row = -1 + self._is_last = False + @property def category_widget(self): return self.entity_widget.category_widget @@ -136,28 +139,40 @@ class ListItem(QtWidgets.QWidget): def add_widget_to_layout(self, widget, label=None): self.content_layout.addWidget(widget, 1) + def set_row(self, row, is_last): + if row == self._row and is_last == self._is_last: + return + + trigger_order_changed = ( + row != self._row + or is_last != self._is_last + ) + self._row = row + self._is_last = is_last + + if trigger_order_changed: + self.order_changed() + + @property def row(self): - return self.entity_widget.input_fields.index(self) + return self._row def parent_rows_count(self): return len(self.entity_widget.input_fields) def _on_add_clicked(self): - self.entity_widget.add_new_item(row=self.row() + 1) + self.entity_widget.add_new_item(row=self.row + 1) def _on_remove_clicked(self): self.entity_widget.remove_row(self) def _on_up_clicked(self): - row = self.row() - self.entity_widget.swap_rows(row - 1, row) + self.entity_widget.swap_rows(self.row - 1, self.row) def _on_down_clicked(self): - row = self.row() - self.entity_widget.swap_rows(row, row + 1) + self.entity_widget.swap_rows(self.row, self.row + 1) def order_changed(self): - row = self.row() parent_row_count = self.parent_rows_count() if parent_row_count == 1: self.up_btn.setVisible(False) @@ -168,11 +183,11 @@ class ListItem(QtWidgets.QWidget): self.up_btn.setVisible(True) self.down_btn.setVisible(True) - if row == 0: + if self.row == 0: self.up_btn.setEnabled(False) self.down_btn.setEnabled(True) - elif row == parent_row_count - 1: + elif self.row == parent_row_count - 1: self.up_btn.setEnabled(True) self.down_btn.setEnabled(False) @@ -191,6 +206,7 @@ class ListWidget(InputWidget): def create_ui(self): self._child_style_state = "" self.input_fields = [] + self._input_fields_by_entity_id = {} main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -243,8 +259,7 @@ class ListWidget(InputWidget): self.entity_widget.add_widget_to_layout(self, entity_label) def set_entity_value(self): - for input_field in tuple(self.input_fields): - self.remove_row(input_field) + self.remove_all_rows() for entity in self.entity.children: self.add_row(entity) @@ -262,39 +277,60 @@ class ListWidget(InputWidget): def _on_entity_change(self): # TODO do less inefficient - input_field_last_idx = len(self.input_fields) - 1 - child_len = len(self.entity) + childen_order = [] + new_children = [] for idx, child_entity in enumerate(self.entity): - if idx > input_field_last_idx: - self.add_row(child_entity, idx) - input_field_last_idx += 1 + input_field = self._input_fields_by_entity_id.get(child_entity.id) + if input_field is not None: + childen_order.append(input_field) + else: + new_children.append((idx, child_entity)) + + order_changed = False + for idx, input_field in enumerate(childen_order): + current_field = self.input_fields[idx] + if current_field is input_field: continue + order_changed = True + old_idx = self.input_fields.index(input_field) + self.input_fields[old_idx], self.input_fields[idx] = ( + current_field, input_field + ) + self.content_layout.insertWidget(idx + 1, input_field) - if self.input_fields[idx].entity is child_entity: - continue + kept_len = len(childen_order) + fields_len = len(self.input_fields) + if fields_len > kept_len: + order_changed = True + for row in reversed(range(kept_len, fields_len)): + self.remove_row(row=row) - input_field_idx = None - for _input_field_idx, input_field in enumerate(self.input_fields): - if input_field.entity is child_entity: - input_field_idx = _input_field_idx - break + for idx, child_entity in new_children: + order_changed = False + self.add_row(child_entity, idx) - if input_field_idx is None: - self.add_row(child_entity, idx) - input_field_last_idx += 1 - continue + if not order_changed: + return - input_field = self.input_fields.pop(input_field_idx) - self.input_fields.insert(idx, input_field) - self.content_layout.insertWidget(idx, input_field) + self._on_order_change() - new_input_field_len = len(self.input_fields) - if child_len != new_input_field_len: - for _idx in range(child_len, new_input_field_len): - # Remove row at the same index - self.remove_row(self.input_fields[child_len]) + input_field_len = self.count() + self.empty_row.setVisible(input_field_len == 0) - self.empty_row.setVisible(self.count() == 0) + def _on_order_change(self): + last_idx = self.count() - 1 + previous_input = None + for idx, input_field in enumerate(self.input_fields): + input_field.set_row(idx, idx == last_idx) + next_input = input_field.input_field.focusProxy() + if previous_input is not None: + self.setTabOrder(previous_input, next_input) + else: + self.setTabOrder(self, next_input) + previous_input = next_input + + if previous_input is not None: + self.setTabOrder(previous_input, self) def count(self): return len(self.input_fields) @@ -307,32 +343,20 @@ class ListWidget(InputWidget): def add_new_item(self, row=None): new_entity = self.entity.add_new_item(row) - for input_field in self.input_fields: - if input_field.entity is new_entity: - input_field.input_field.setFocus(True) - break + input_field = self._input_fields_by_entity_id.get(new_entity.id) + if input_field is not None: + input_field.input_field.setFocus(True) return new_entity def add_row(self, child_entity, row=None): # Create new item item_widget = ListItem(child_entity, self) - - previous_field = None - next_field = None + self._input_fields_by_entity_id[child_entity.id] = item_widget if row is None: - if self.input_fields: - previous_field = self.input_fields[-1] self.content_layout.addWidget(item_widget) self.input_fields.append(item_widget) else: - if row > 0: - previous_field = self.input_fields[row - 1] - - max_index = self.count() - if row < max_index: - next_field = self.input_fields[row] - self.content_layout.insertWidget(row + 1, item_widget) self.input_fields.insert(row, item_widget) @@ -342,49 +366,53 @@ class ListWidget(InputWidget): # added as widget here which won't because is not in input_fields item_widget.input_field.set_entity_value() - if previous_field: - previous_field.order_changed() + self._on_order_change() - if next_field: - next_field.order_changed() - - item_widget.order_changed() - - previous_input = None - for input_field in self.input_fields: - if previous_input is not None: - self.setTabOrder( - previous_input, input_field.input_field.focusProxy() - ) - previous_input = input_field.input_field.focusProxy() + input_field_len = self.count() + self.empty_row.setVisible(input_field_len == 0) self.updateGeometry() - def remove_row(self, item_widget): - row = self.input_fields.index(item_widget) - previous_field = None - next_field = None - if row > 0: - previous_field = self.input_fields[row - 1] + def remove_all_rows(self): + self._input_fields_by_entity_id = {} + while self.input_fields: + item_widget = self.input_fields.pop(0) + self.content_layout.removeWidget(item_widget) + item_widget.setParent(None) + item_widget.deleteLater() - if row != len(self.input_fields) - 1: - next_field = self.input_fields[row + 1] + self.empty_row.setVisible(True) + + self.updateGeometry() + + def remove_row(self, item_widget=None, row=None): + if item_widget is None: + item_widget = self.input_fields[row] + elif row is None: + row = self.input_fields.index(item_widget) self.content_layout.removeWidget(item_widget) self.input_fields.pop(row) + self._input_fields_by_entity_id.pop(item_widget.entity.id) item_widget.setParent(None) item_widget.deleteLater() if item_widget.entity in self.entity: self.entity.remove(item_widget.entity) - if previous_field: - previous_field.order_changed() + rows = self.count() + any_item = rows == 0 + if any_item: + start_row = 0 + if row > 0: + start_row = row - 1 - if next_field: - next_field.order_changed() + last_row = rows - 1 + _enum = enumerate(self.input_fields[start_row:rows]) + for idx, _item_widget in _enum: + _item_widget.set_row(idx, idx == last_row) - self.empty_row.setVisible(self.count() == 0) + self.empty_row.setVisible(any_item) self.updateGeometry() diff --git a/poetry.lock b/poetry.lock index aad1898983..e011b781c9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,7 +11,7 @@ develop = false type = "git" url = "https://github.com/pypeclub/acre.git" reference = "master" -resolved_reference = "5a812c6dcfd3aada87adb49be98c548c894d6566" +resolved_reference = "55a7c331e6dc5f81639af50ca4a8cc9d73e9273d" [[package]] name = "aiohttp" diff --git a/repos/avalon-core b/repos/avalon-core index cfd4191e36..e5c8a15fde 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit cfd4191e364b47de7364096f45d9d9d9a901692a +Subproject commit e5c8a15fde77708c924eab3018bda255f17b5390 diff --git a/start.py b/start.py index 419a956835..6473a926d0 100644 --- a/start.py +++ b/start.py @@ -221,10 +221,14 @@ def set_openpype_global_environments() -> None: all_env = get_environments() general_env = all_env["global"] - env = acre.merge( + merged_env = acre.merge( acre.parse(general_env), dict(os.environ) ) + env = acre.compute( + merged_env, + cleanup=False + ) os.environ.clear() os.environ.update(env)