diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index 17ea957066..94152b5706 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -12,7 +12,10 @@ from .pipeline import ( remove_instance, install, uninstall, - containerise + containerise, + get_context_data, + update_context_data, + get_context_title ) from .plugin import ( PhotoshopLoader, @@ -43,6 +46,9 @@ __all__ = [ "install", "uninstall", "containerise", + "get_context_data", + "update_context_data", + "get_context_title", # Plugin "PhotoshopLoader", diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 1f069c2636..6ac5be1104 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -8,6 +8,8 @@ from avalon import io from openpype.api import Logger from openpype.lib import register_event_callback from openpype.pipeline import ( + BaseCreator, + LegacyCreator, register_loader_plugin_path, register_creator_plugin_path, deregister_loader_plugin_path, @@ -149,13 +151,9 @@ def list_instances(): instances = [] layers_meta = stub.get_layers_metadata() if layers_meta: - for key, instance in layers_meta.items(): - schema = instance.get("schema") - if schema and "container" in schema: - continue - - instance['uuid'] = key - instances.append(instance) + for instance in layers_meta: + if instance.get("id") == "pyblish.avalon.instance": + instances.append(instance) return instances @@ -176,11 +174,18 @@ def remove_instance(instance): if not stub: return - stub.remove_instance(instance.get("uuid")) - layer = stub.get_layer(instance.get("uuid")) - if layer: - stub.rename_layer(instance.get("uuid"), - layer.name.replace(stub.PUBLISH_ICON, '')) + inst_id = instance.get("instance_id") or instance.get("uuid") # legacy + if not inst_id: + log.warning("No instance identifier for {}".format(instance)) + return + + stub.remove_instance(inst_id) + + if instance.get("members"): + item = stub.get_layer(instance["members"][0]) + if item: + stub.rename_layer(item.id, + item.name.replace(stub.PUBLISH_ICON, '')) def _get_stub(): @@ -232,6 +237,34 @@ def containerise( "members": [str(layer.id)] } stub = lib.stub() - stub.imprint(layer, data) + stub.imprint(layer.id, data) return layer + + +def get_context_data(): + """Get stored values for context (validation enable/disable etc)""" + meta = _get_stub().get_layers_metadata() + for item in meta: + if item.get("id") == "publish_context": + item.pop("id") + return item + + return {} + + +def update_context_data(data, changes): + """Store value needed for context""" + item = data + item["id"] = "publish_context" + _get_stub().imprint(item["id"], item) + + +def get_context_title(): + """Returns title for Creator window""" + import avalon.api + + project_name = avalon.api.Session["AVALON_PROJECT"] + asset_name = avalon.api.Session["AVALON_ASSET"] + task_name = avalon.api.Session["AVALON_TASK"] + return "{}/{}/{}".format(project_name, asset_name, task_name) diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index 64d89f5420..fa076ecc7e 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -27,6 +27,7 @@ class PSItem(object): members = attr.ib(factory=list) long_name = attr.ib(default=None) color_code = attr.ib(default=None) # color code of layer + instance_id = attr.ib(default=None) class PhotoshopServerStub: @@ -76,13 +77,31 @@ class PhotoshopServerStub: layer: (PSItem) layers_meta: full list from Headline (for performance in loops) Returns: + (dict) of layer metadata stored in PS file + + Example: + { + 'id': 'pyblish.avalon.container', + 'loader': 'ImageLoader', + 'members': ['64'], + 'name': 'imageMainMiddle', + 'namespace': 'Hero_imageMainMiddle_001', + 'representation': '6203dc91e80934d9f6ee7d96', + 'schema': 'openpype:container-2.0' + } """ if layers_meta is None: layers_meta = self.get_layers_metadata() - return layers_meta.get(str(layer.id)) + for layer_meta in layers_meta: + layer_id = layer_meta.get("uuid") # legacy + if layer_meta.get("members"): + layer_id = layer_meta["members"][0] + if str(layer.id) == str(layer_id): + return layer_meta + print("Unable to find layer metadata for {}".format(layer.id)) - def imprint(self, layer, data, all_layers=None, layers_meta=None): + def imprint(self, item_id, data, all_layers=None, items_meta=None): """Save layer metadata to Headline field of active document Stores metadata in format: @@ -108,28 +127,37 @@ class PhotoshopServerStub: }] - for loaded instances Args: - layer (PSItem): + item_id (str): data(string): json representation for single layer all_layers (list of PSItem): for performance, could be injected for usage in loop, if not, single call will be triggered - layers_meta(string): json representation from Headline + items_meta(string): json representation from Headline (for performance - provide only if imprint is in loop - value should be same) Returns: None """ - if not layers_meta: - layers_meta = self.get_layers_metadata() + if not items_meta: + items_meta = self.get_layers_metadata() # json.dumps writes integer values in a dictionary to string, so # anticipating it here. - if str(layer.id) in layers_meta and layers_meta[str(layer.id)]: - if data: - layers_meta[str(layer.id)].update(data) + item_id = str(item_id) + is_new = True + result_meta = [] + for item_meta in items_meta: + if ((item_meta.get('members') and + item_id == str(item_meta.get('members')[0])) or + item_meta.get("instance_id") == item_id): + is_new = False + if data: + item_meta.update(data) + result_meta.append(item_meta) else: - layers_meta.pop(str(layer.id)) - else: - layers_meta[str(layer.id)] = data + result_meta.append(item_meta) + + if is_new: + result_meta.append(data) # Ensure only valid ids are stored. if not all_layers: @@ -137,12 +165,14 @@ class PhotoshopServerStub: layer_ids = [layer.id for layer in all_layers] cleaned_data = [] - for layer_id in layers_meta: - if int(layer_id) in layer_ids: - cleaned_data.append(layers_meta[layer_id]) + for item in result_meta: + if item.get("members"): + if int(item["members"][0]) not in layer_ids: + continue + + cleaned_data.append(item) payload = json.dumps(cleaned_data, indent=4) - self.websocketserver.call( self.client.call('Photoshop.imprint', payload=payload) ) @@ -370,38 +400,27 @@ class PhotoshopServerStub: (Headline accessible by File > File Info) Returns: - (string): - json documents + (list) example: {"8":{"active":true,"subset":"imageBG", "family":"image","id":"pyblish.avalon.instance", "asset":"Town"}} 8 is layer(group) id - used for deletion, update etc. """ - layers_data = {} res = self.websocketserver.call(self.client.call('Photoshop.read')) + layers_data = [] try: - layers_data = json.loads(res) + if res: + layers_data = json.loads(res) except json.decoder.JSONDecodeError: - pass + raise ValueError("{} cannot be parsed, recreate meta".format(res)) # format of metadata changed from {} to [] because of standardization # keep current implementation logic as its working - if not isinstance(layers_data, dict): - temp_layers_meta = {} - for layer_meta in layers_data: - layer_id = layer_meta.get("uuid") - if not layer_id: - layer_id = layer_meta.get("members")[0] - - temp_layers_meta[layer_id] = layer_meta - layers_data = temp_layers_meta - else: - # legacy version of metadata + if isinstance(layers_data, dict): for layer_id, layer_meta in layers_data.items(): if layer_meta.get("schema") != "openpype:container-2.0": - layer_meta["uuid"] = str(layer_id) - else: layer_meta["members"] = [str(layer_id)] - + layers_data = list(layers_data.values()) return layers_data def import_smart_object(self, path, layer_name, as_reference=False): @@ -472,11 +491,12 @@ class PhotoshopServerStub: ) def remove_instance(self, instance_id): - cleaned_data = {} + cleaned_data = [] - for key, instance in self.get_layers_metadata().items(): - if key != instance_id: - cleaned_data[key] = instance + for item in self.get_layers_metadata(): + inst_id = item.get("instance_id") or item.get("uuid") + if inst_id != instance_id: + cleaned_data.append(item) payload = json.dumps(cleaned_data, indent=4) @@ -528,6 +548,7 @@ class PhotoshopServerStub: d.get('type'), d.get('members'), d.get('long_name'), - d.get("color_code") + d.get("color_code"), + d.get("instance_id") )) return ret diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 5078cbb587..c2fe8b6c78 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -1,99 +1,145 @@ -from Qt import QtWidgets -from openpype.pipeline import LegacyCreator -from openpype.hosts.photoshop import api as photoshop +from avalon import api as avalon_api +from openpype.hosts.photoshop import api +from openpype.lib import BoolDef +from openpype.pipeline import ( + Creator, + CreatedInstance +) -class CreateImage(LegacyCreator): - """Image folder for publish.""" - - name = "imageDefault" +class ImageCreator(Creator): + """Creates image instance for publishing.""" + identifier = "image" label = "Image" family = "image" - defaults = ["Main"] + description = "Image creator" - def process(self): - groups = [] - layers = [] - create_group = False + def collect_instances(self): + for instance_data in api.list_instances(): + # legacy instances have family=='image' + creator_id = (instance_data.get("creator_identifier") or + instance_data.get("family")) - stub = photoshop.stub() - if (self.options or {}).get("useSelection"): - multiple_instances = False - selection = stub.get_selected_layers() - self.log.info("selection {}".format(selection)) - if len(selection) > 1: - # Ask user whether to create one image or image per selected - # item. - msg_box = QtWidgets.QMessageBox() - msg_box.setIcon(QtWidgets.QMessageBox.Warning) - msg_box.setText( - "Multiple layers selected." - "\nDo you want to make one image per layer?" + if creator_id == self.identifier: + instance_data = self._handle_legacy(instance_data) + layer = api.stub().get_layer(instance_data["members"][0]) + instance_data["layer"] = layer + instance = CreatedInstance.from_existing( + instance_data, self ) - msg_box.setStandardButtons( - QtWidgets.QMessageBox.Yes | - QtWidgets.QMessageBox.No | - QtWidgets.QMessageBox.Cancel - ) - ret = msg_box.exec_() - if ret == QtWidgets.QMessageBox.Yes: - multiple_instances = True - elif ret == QtWidgets.QMessageBox.Cancel: - return + self._add_instance_to_context(instance) - if multiple_instances: - for item in selection: - if item.group: - groups.append(item) - else: - layers.append(item) + def create(self, subset_name_from_ui, data, pre_create_data): + groups_to_create = [] + top_layers_to_wrap = [] + create_empty_group = False + + stub = api.stub() # only after PS is up + top_level_selected_items = stub.get_selected_layers() + if pre_create_data.get("use_selection"): + only_single_item_selected = len(top_level_selected_items) == 1 + for selected_item in top_level_selected_items: + if ( + only_single_item_selected or + pre_create_data.get("create_multiple")): + if selected_item.group: + groups_to_create.append(selected_item) + else: + top_layers_to_wrap.append(selected_item) else: - group = stub.group_selected_layers(self.name) - groups.append(group) + group = stub.group_selected_layers(subset_name_from_ui) + groups_to_create.append(group) - elif len(selection) == 1: - # One selected item. Use group if its a LayerSet (group), else - # create a new group. - if selection[0].group: - groups.append(selection[0]) - else: - layers.append(selection[0]) - elif len(selection) == 0: - # No selection creates an empty group. - create_group = True - else: - group = stub.create_group(self.name) - groups.append(group) + if not groups_to_create and not top_layers_to_wrap: + group = stub.create_group(subset_name_from_ui) + groups_to_create.append(group) - if create_group: - group = stub.create_group(self.name) - groups.append(group) - - for layer in layers: + # wrap each top level layer into separate new group + for layer in top_layers_to_wrap: stub.select_layers([layer]) group = stub.group_selected_layers(layer.name) - groups.append(group) + groups_to_create.append(group) - creator_subset_name = self.data["subset"] - for group in groups: - long_names = [] - group.name = group.name.replace(stub.PUBLISH_ICON, ''). \ - replace(stub.LOADED_ICON, '') + creating_multiple_groups = len(groups_to_create) > 1 + for group in groups_to_create: + subset_name = subset_name_from_ui # reset to name from creator UI + layer_names_in_hierarchy = [] + created_group_name = self._clean_highlights(stub, group.name) - subset_name = creator_subset_name - if len(groups) > 1: + if creating_multiple_groups: + # concatenate with layer name to differentiate subsets subset_name += group.name.title().replace(" ", "") if group.long_name: for directory in group.long_name[::-1]: - name = directory.replace(stub.PUBLISH_ICON, '').\ - replace(stub.LOADED_ICON, '') - long_names.append(name) + name = self._clean_highlights(stub, directory) + layer_names_in_hierarchy.append(name) - self.data.update({"subset": subset_name}) - self.data.update({"uuid": str(group.id)}) - self.data.update({"long_name": "_".join(long_names)}) - stub.imprint(group, self.data) + data.update({"subset": subset_name}) + data.update({"members": [str(group.id)]}) + data.update({"long_name": "_".join(layer_names_in_hierarchy)}) + + new_instance = CreatedInstance(self.family, subset_name, data, + self) + + stub.imprint(new_instance.get("instance_id"), + new_instance.data_to_store()) + self._add_instance_to_context(new_instance) # reusing existing group, need to rename afterwards - if not create_group: - stub.rename_layer(group.id, stub.PUBLISH_ICON + group.name) + if not create_empty_group: + stub.rename_layer(group.id, + stub.PUBLISH_ICON + created_group_name) + + def update_instances(self, update_list): + self.log.debug("update_list:: {}".format(update_list)) + for created_inst, _changes in update_list: + if created_inst.get("layer"): + # not storing PSItem layer to metadata + created_inst.pop("layer") + api.stub().imprint(created_inst.get("instance_id"), + created_inst.data_to_store()) + + def remove_instances(self, instances): + for instance in instances: + api.remove_instance(instance) + self._remove_instance_from_context(instance) + + def get_default_variants(self): + return [ + "Main" + ] + + def get_pre_create_attr_defs(self): + output = [ + BoolDef("use_selection", default=True, + label="Create only for selected"), + BoolDef("create_multiple", + default=True, + label="Create separate instance for each selected") + ] + return output + + def get_detail_description(self): + return """Creator for Image instances""" + + def _handle_legacy(self, instance_data): + """Converts old instances to new format.""" + if not instance_data.get("members"): + instance_data["members"] = [instance_data.get("uuid")] + + if instance_data.get("uuid"): + # uuid not needed, replaced with unique instance_id + api.stub().remove_instance(instance_data.get("uuid")) + instance_data.pop("uuid") + + if not instance_data.get("task"): + instance_data["task"] = avalon_api.Session.get("AVALON_TASK") + + if not instance_data.get("variant"): + instance_data["variant"] = '' + + return instance_data + + def _clean_highlights(self, stub, item): + return item.replace(stub.PUBLISH_ICON, '').replace(stub.LOADED_ICON, + '') diff --git a/openpype/hosts/photoshop/plugins/create/create_legacy_image.py b/openpype/hosts/photoshop/plugins/create/create_legacy_image.py new file mode 100644 index 0000000000..9736471a26 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/create/create_legacy_image.py @@ -0,0 +1,100 @@ +from Qt import QtWidgets +from openpype.pipeline import create +from openpype.hosts.photoshop import api as photoshop + + +class CreateImage(create.LegacyCreator): + """Image folder for publish.""" + + name = "imageDefault" + label = "Image" + family = "image" + defaults = ["Main"] + + def process(self): + groups = [] + layers = [] + create_group = False + + stub = photoshop.stub() + if (self.options or {}).get("useSelection"): + multiple_instances = False + selection = stub.get_selected_layers() + self.log.info("selection {}".format(selection)) + if len(selection) > 1: + # Ask user whether to create one image or image per selected + # item. + msg_box = QtWidgets.QMessageBox() + msg_box.setIcon(QtWidgets.QMessageBox.Warning) + msg_box.setText( + "Multiple layers selected." + "\nDo you want to make one image per layer?" + ) + msg_box.setStandardButtons( + QtWidgets.QMessageBox.Yes | + QtWidgets.QMessageBox.No | + QtWidgets.QMessageBox.Cancel + ) + ret = msg_box.exec_() + if ret == QtWidgets.QMessageBox.Yes: + multiple_instances = True + elif ret == QtWidgets.QMessageBox.Cancel: + return + + if multiple_instances: + for item in selection: + if item.group: + groups.append(item) + else: + layers.append(item) + else: + group = stub.group_selected_layers(self.name) + groups.append(group) + + elif len(selection) == 1: + # One selected item. Use group if its a LayerSet (group), else + # create a new group. + if selection[0].group: + groups.append(selection[0]) + else: + layers.append(selection[0]) + elif len(selection) == 0: + # No selection creates an empty group. + create_group = True + else: + group = stub.create_group(self.name) + groups.append(group) + + if create_group: + group = stub.create_group(self.name) + groups.append(group) + + for layer in layers: + stub.select_layers([layer]) + group = stub.group_selected_layers(layer.name) + groups.append(group) + + creator_subset_name = self.data["subset"] + for group in groups: + long_names = [] + group.name = group.name.replace(stub.PUBLISH_ICON, ''). \ + replace(stub.LOADED_ICON, '') + + subset_name = creator_subset_name + if len(groups) > 1: + subset_name += group.name.title().replace(" ", "") + + if group.long_name: + for directory in group.long_name[::-1]: + name = directory.replace(stub.PUBLISH_ICON, '').\ + replace(stub.LOADED_ICON, '') + long_names.append(name) + + self.data.update({"subset": subset_name}) + self.data.update({"uuid": str(group.id)}) + self.data.update({"members": [str(group.id)]}) + self.data.update({"long_name": "_".join(long_names)}) + stub.imprint(group, self.data) + # reusing existing group, need to rename afterwards + if not create_group: + stub.rename_layer(group.id, stub.PUBLISH_ICON + group.name) diff --git a/openpype/hosts/photoshop/plugins/create/workfile_creator.py b/openpype/hosts/photoshop/plugins/create/workfile_creator.py new file mode 100644 index 0000000000..d66a05cad7 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/create/workfile_creator.py @@ -0,0 +1,73 @@ +from avalon import io + +import openpype.hosts.photoshop.api as api +from openpype.pipeline import ( + AutoCreator, + CreatedInstance +) + + +class PSWorkfileCreator(AutoCreator): + identifier = "workfile" + family = "workfile" + + def get_instance_attr_defs(self): + return [] + + def collect_instances(self): + for instance_data in api.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + subset_name = instance_data["subset"] + instance = CreatedInstance( + self.family, subset_name, instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + # nothing to change on workfiles + pass + + def create(self, options=None): + existing_instance = None + for instance in self.create_context.instances: + if instance.family == self.family: + existing_instance = instance + break + + variant = '' + project_name = io.Session["AVALON_PROJECT"] + asset_name = io.Session["AVALON_ASSET"] + task_name = io.Session["AVALON_TASK"] + host_name = io.Session["AVALON_APP"] + if existing_instance is None: + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": variant + } + data.update(self.get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name + )) + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + self._add_instance_to_context(new_instance) + api.stub().imprint(new_instance.get("instance_id"), + new_instance.data_to_store()) + + elif ( + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 0a9421b8f2..91a9787781 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -61,7 +61,7 @@ class ImageLoader(photoshop.PhotoshopLoader): ) stub.imprint( - layer, {"representation": str(representation["_id"])} + layer.id, {"representation": str(representation["_id"])} ) def remove(self, container): @@ -73,7 +73,7 @@ class ImageLoader(photoshop.PhotoshopLoader): stub = self.get_stub() layer = container.pop("layer") - stub.imprint(layer, {}) + stub.imprint(layer.id, {}) stub.delete_layer(layer.id) def switch(self, container, representation): diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index f5f0545d39..1f32a5d23c 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -61,7 +61,7 @@ class ReferenceLoader(photoshop.PhotoshopLoader): ) stub.imprint( - layer, {"representation": str(representation["_id"])} + layer.id, {"representation": str(representation["_id"])} ) def remove(self, container): @@ -72,7 +72,7 @@ class ReferenceLoader(photoshop.PhotoshopLoader): """ stub = self.get_stub() layer = container.pop("layer") - stub.imprint(layer, {}) + stub.imprint(layer.id, {}) stub.delete_layer(layer.id) def switch(self, container, representation): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index 9f95441e6f..6032a238ee 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -1,3 +1,4 @@ +import pprint from avalon import api import pyblish.api @@ -9,8 +10,8 @@ from openpype.lib import prepare_template_data class CollectInstances(pyblish.api.ContextPlugin): """Gather instances by LayerSet and file metadata - This collector takes into account assets that are associated with - an LayerSet and marked with a unique identifier; + Collects publishable instances from file metadata or enhance + already collected by creator (family == "image"). If no image instances are explicitly created, it looks if there is value in `flatten_subset_template` (configurable in Settings), in that case it @@ -20,7 +21,7 @@ class CollectInstances(pyblish.api.ContextPlugin): id (str): "pyblish.avalon.instance" """ - label = "Instances" + label = "Collect Instances" order = pyblish.api.CollectorOrder hosts = ["photoshop"] families_mapping = { @@ -30,42 +31,53 @@ class CollectInstances(pyblish.api.ContextPlugin): flatten_subset_template = "" def process(self, context): + instance_by_layer_id = {} + for instance in context: + if ( + instance.data["family"] == "image" and + instance.data.get("members")): + layer_id = str(instance.data["members"][0]) + instance_by_layer_id[layer_id] = instance + stub = photoshop.stub() - layers = stub.get_layers() + layer_items = stub.get_layers() layers_meta = stub.get_layers_metadata() instance_names = [] + all_layer_ids = [] - for layer in layers: - all_layer_ids.append(layer.id) - layer_data = stub.read(layer, layers_meta) + for layer_item in layer_items: + layer_meta_data = stub.read(layer_item, layers_meta) + all_layer_ids.append(layer_item.id) # Skip layers without metadata. - if layer_data is None: + if layer_meta_data is None: continue # Skip containers. - if "container" in layer_data["id"]: + if "container" in layer_meta_data["id"]: continue - # child_layers = [*layer.Layers] - # self.log.debug("child_layers {}".format(child_layers)) - # if not child_layers: - # self.log.info("%s skipped, it was empty." % layer.Name) - # continue + # active might not be in legacy meta + if not layer_meta_data.get("active", True): + continue - instance = context.create_instance(layer_data["subset"]) - instance.data["layer"] = layer - instance.data.update(layer_data) + instance = instance_by_layer_id.get(str(layer_item.id)) + if instance is None: + instance = context.create_instance(layer_meta_data["subset"]) + + instance.data["layer"] = layer_item + instance.data.update(layer_meta_data) instance.data["families"] = self.families_mapping[ - layer_data["family"] + layer_meta_data["family"] ] - instance.data["publish"] = layer.visible - instance_names.append(layer_data["subset"]) + instance.data["publish"] = layer_item.visible + instance_names.append(layer_meta_data["subset"]) # Produce diagnostic message for any graphical # user interface interested in visualising it. self.log.info("Found: \"%s\" " % instance.data["name"]) - self.log.info("instance: {} ".format(instance.data)) + self.log.info("instance: {} ".format( + pprint.pformat(instance.data, indent=4))) if len(instance_names) != len(set(instance_names)): self.log.warning("Duplicate instances found. " + diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 89432553c5..2ea5503f3f 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -1,3 +1,11 @@ +""" +Requires: + None + +Provides: + instance -> family ("review") +""" + import os import pyblish.api @@ -6,11 +14,16 @@ from openpype.lib import get_subset_name_with_asset_doc class CollectReview(pyblish.api.ContextPlugin): - """Gather the active document as review instance.""" + """Gather the active document as review instance. + Triggers once even if no 'image' is published as by defaults it creates + flatten image from a workfile. + """ + + label = "Collect Review" label = "Review" - order = pyblish.api.CollectorOrder + 0.1 hosts = ["photoshop"] + order = pyblish.api.CollectorOrder + 0.1 def process(self, context): family = "review" @@ -23,16 +36,13 @@ class CollectReview(pyblish.api.ContextPlugin): host_name=context.data["hostName"] ) - file_path = context.data["currentFile"] - base_name = os.path.basename(file_path) - instance = context.create_instance(subset) instance.data.update({ "subset": subset, - "label": base_name, - "name": base_name, + "label": subset, + "name": subset, "family": family, - "families": ["ftrack"], + "families": [], "representations": [], "asset": os.environ["AVALON_ASSET"] }) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index 0dbe2c6609..e4f0a07b34 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -12,6 +12,13 @@ class CollectWorkfile(pyblish.api.ContextPlugin): hosts = ["photoshop"] def process(self, context): + existing_instance = None + for instance in context: + if instance.data["family"] == "workfile": + self.log.debug("Workfile instance found, won't create new") + existing_instance = instance + break + family = "workfile" subset = get_subset_name_with_asset_doc( family, @@ -27,16 +34,19 @@ class CollectWorkfile(pyblish.api.ContextPlugin): base_name = os.path.basename(file_path) # Create instance - instance = context.create_instance(subset) - instance.data.update({ - "subset": subset, - "label": base_name, - "name": base_name, - "family": family, - "families": [], - "representations": [], - "asset": os.environ["AVALON_ASSET"] - }) + if existing_instance is None: + instance = context.create_instance(subset) + instance.data.update({ + "subset": subset, + "label": base_name, + "name": base_name, + "family": family, + "families": [], + "representations": [], + "asset": os.environ["AVALON_ASSET"] + }) + else: + instance = existing_instance # creating representation _, ext = os.path.splitext(file_path) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index b07d0740c1..a133e33409 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -16,7 +16,6 @@ class ExtractImage(openpype.api.Extractor): formats = ["png", "jpg"] def process(self, instance): - staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) diff --git a/openpype/hosts/photoshop/plugins/publish/help/validate_naming.xml b/openpype/hosts/photoshop/plugins/publish/help/validate_naming.xml new file mode 100644 index 0000000000..5a1e266748 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/help/validate_naming.xml @@ -0,0 +1,21 @@ + + + +Subset name + +## Invalid subset or layer name + +Subset or layer name cannot contain specific characters (spaces etc) which could cause issue when subset name is used in a published file name. + {msg} + +### How to repair? + +You can fix this with "repair" button on the right. + + +### __Detailed Info__ (optional) + +Not all characters are available in a file names on all OS. Wrong characters could be configured in Settings. + + + \ No newline at end of file diff --git a/openpype/hosts/photoshop/plugins/publish/help/validate_unique_subsets.xml b/openpype/hosts/photoshop/plugins/publish/help/validate_unique_subsets.xml new file mode 100644 index 0000000000..4b47973193 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/help/validate_unique_subsets.xml @@ -0,0 +1,14 @@ + + + +Subset not unique + +## Non unique subset name found + + Non unique subset names: '{non_unique}' +### How to repair? + +Remove offending instance, rename it to have unique name. Maybe layer name wasn't used for multiple instances? + + + \ No newline at end of file diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py index 583e9c7a4e..bcae24108c 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py @@ -2,6 +2,7 @@ import re import pyblish.api import openpype.api +from openpype.pipeline import PublishXmlValidationError from openpype.hosts.photoshop import api as photoshop @@ -22,33 +23,34 @@ class ValidateNamingRepair(pyblish.api.Action): failed.append(result["instance"]) invalid_chars, replace_char = plugin.get_replace_chars() - self.log.info("{} --- {}".format(invalid_chars, replace_char)) + self.log.debug("{} --- {}".format(invalid_chars, replace_char)) # Apply pyblish.logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(failed, plugin) stub = photoshop.stub() for instance in instances: - self.log.info("validate_naming instance {}".format(instance)) - layer_item = instance.data["layer"] - metadata = stub.read(layer_item) - self.log.info("metadata instance {}".format(metadata)) - layer_name = None - if metadata.get("uuid"): - layer_data = stub.get_layer(metadata["uuid"]) - self.log.info("layer_data {}".format(layer_data)) - if layer_data: - layer_name = re.sub(invalid_chars, - replace_char, - layer_data.name) + self.log.debug("validate_naming instance {}".format(instance)) + current_layer_state = stub.get_layer(instance.data["layer"].id) + self.log.debug("current_layer{}".format(current_layer_state)) - stub.rename_layer(instance.data["uuid"], layer_name) + layer_meta = stub.read(current_layer_state) + instance_id = (layer_meta.get("instance_id") or + layer_meta.get("uuid")) + if not instance_id: + self.log.warning("Unable to repair, cannot find layer") + continue + + layer_name = re.sub(invalid_chars, + replace_char, + current_layer_state.name) + + stub.rename_layer(current_layer_state.id, layer_name) subset_name = re.sub(invalid_chars, replace_char, instance.data["subset"]) - layer_item.name = layer_name or subset_name - metadata["subset"] = subset_name - stub.imprint(layer_item, metadata) + layer_meta["subset"] = subset_name + stub.imprint(instance_id, layer_meta) return True @@ -73,11 +75,18 @@ class ValidateNaming(pyblish.api.InstancePlugin): help_msg = ' Use Repair action (A) in Pyblish to fix it.' msg = "Name \"{}\" is not allowed.{}".format(instance.data["name"], help_msg) - assert not re.search(self.invalid_chars, instance.data["name"]), msg + + formatting_data = {"msg": msg} + if re.search(self.invalid_chars, instance.data["name"]): + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) msg = "Subset \"{}\" is not allowed.{}".format(instance.data["subset"], help_msg) - assert not re.search(self.invalid_chars, instance.data["subset"]), msg + formatting_data = {"msg": msg} + if re.search(self.invalid_chars, instance.data["subset"]): + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) @classmethod def get_replace_chars(cls): diff --git a/openpype/hosts/photoshop/plugins/publish/validate_unique_subsets.py b/openpype/hosts/photoshop/plugins/publish/validate_unique_subsets.py index 40abfb1bbd..01f2323157 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_unique_subsets.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_unique_subsets.py @@ -1,6 +1,7 @@ import collections import pyblish.api import openpype.api +from openpype.pipeline import PublishXmlValidationError class ValidateSubsetUniqueness(pyblish.api.ContextPlugin): @@ -27,4 +28,10 @@ class ValidateSubsetUniqueness(pyblish.api.ContextPlugin): if count > 1] msg = ("Instance subset names {} are not unique. ".format(non_unique) + "Remove duplicates via SubsetManager.") - assert not non_unique, msg + formatting_data = { + "non_unique": ",".join(non_unique) + } + + if non_unique: + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index 36bccd427e..cbe19da064 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -89,7 +89,9 @@ class BaseCreator: @property def log(self): if self._log is None: - self._log = logging.getLogger(self.__class__.__name__) + from openpype.api import Logger + + self._log = Logger.get_logger(self.__class__.__name__) return self._log def _add_instance_to_context(self, instance): diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index deade08c0b..a846a596c2 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -349,6 +349,18 @@ "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] + }, + { + "hosts": [ + "photoshop" + ], + "families": [ + "review" + ], + "task_types": [], + "tasks": [], + "add_ftrack_family": true, + "advanced_filtering": [] } ] }, diff --git a/website/docs/artist_hosts_photoshop.md b/website/docs/artist_hosts_photoshop.md index a140170c49..36670054ee 100644 --- a/website/docs/artist_hosts_photoshop.md +++ b/website/docs/artist_hosts_photoshop.md @@ -111,3 +111,67 @@ You can switch to a previous version of the image or update to the latest. ![Loader](assets/photoshop_manage_switch.gif) ![Loader](assets/photoshop_manage_update.gif) + + +### New Publisher + +All previous screenshot came from regular [pyblish](https://pyblish.com/) process, there is also a different UI available. This process extends existing implementation and adds new functionalities. + +To test this in Photoshop, the artist needs first to enable experimental `New publisher` in Settings. (Tray > Settings > Experimental tools) +![Settings](assets/experimental_tools_settings.png) + +New dialog opens after clicking on `Experimental tools` button in Openpype extension menu. +![Menu](assets/experimental_tools_menu.png) + +After you click on this button, this dialog will show up. + +![Menu](assets/artist_photoshop_new_publisher_workfile.png) + +You can see the first instance, called `workfileYourTaskName`. (Name depends on studio naming convention for Photoshop's workfiles.). This instance is so called "automatic", +it was created without instigation by the artist. You shouldn't delete this instance as it might hold necessary values for future publishing, but you can choose to skip it +from publishing (by toggling the pill button inside of the rectangular object denoting instance). + +New publisher allows publishing into different context, just click on a workfile instance, update `Variant`, `Asset` or `Task` in the form in the middle and don't forget to click on the 'Confirm' button. + +Similarly to the old publishing approach, you need to create instances for everything you want to publish. You will initiate by clicking on the '+' sign in the bottom left corner. + +![Instance creator](assets/artist_photoshop_new_publisher_instance.png) + +In this dialog you can select the family for the published layer or group. Currently only 'image' is implemented. + +On right hand side you can see creator attributes: +- `Create only for selected` - mimics `Use selected` option of regular publish +- `Create separate instance for each selected` - if separate instance should be created for each layer if multiple selected + +![Instance created](assets/artist_photoshop_new_publisher_instance_created.png) + +Here you can see a newly created instance of image family. (Name depends on studio naming convention for image family.) You can disable instance from publishing in the same fashion as a workfile instance. +You could also decide delete instance by selecting it and clicking on a trashcan icon (next to plus button on left button) + +Buttons on the bottom right are for: +- `Refresh publishing` - set publishing process to starting position - useful if previous publish failed, or you changed configuration of a publish +- `Stop/pause publishing` - if you would like to pause publishing process at any time +- `Validate` - if you would like to run only collecting and validating phases (nothing will be published yet) +- `Publish` - standard way how to kick off full publishing process + +In the unfortunate case of some error during publishing, you would receive this kind of error dialog. + +![Publish failed](assets/artist_photoshop_new_publisher_publish_failed.png) + +In this case there is an issue that you are publishing two or more instances with the same subset name ('imageMaing'). If the error is recoverable by the artist, you should +see helpful information in a `How to repair?` section or fix it automatically by clicking on a 'Wrench' button on the right if present. + +If you would like to ask for help admin or support, you could use any of the three buttons on bottom left: +- `Copy report` - stash full publishing log to a clipboard +- `Export and save report` - save log into a file for sending it via mail or any communication tool +- `Show details` - switches into a more detailed list of published instances and plugins. Similar to the old pyblish list. + +If you are able to fix the workfile yourself, use the first button on the right to set the UI to initial state before publish. (Click the `Publish` button to start again.) + +New publishing process should be backward compatible, eg. if you have a workfile with instances created in the previous publishing approach, they will be translated automatically and +could be used right away. + +If you would create instances in a new publisher, you cannot use them in the old approach though! + +If you would hit on unexpected behaviour with old instances, contact support first, then you could try some steps to recover your publish. Delete instances in New publisher UI, or try `Subset manager` in the extension menu. +Nuclear option is to purge workfile metadata in `File > File Info > Origin > Headline`. This is only for most determined daredevils though! diff --git a/website/docs/assets/artist_photoshop_new_publisher_instance.png b/website/docs/assets/artist_photoshop_new_publisher_instance.png new file mode 100644 index 0000000000..723a032c94 Binary files /dev/null and b/website/docs/assets/artist_photoshop_new_publisher_instance.png differ diff --git a/website/docs/assets/artist_photoshop_new_publisher_instance_created.png b/website/docs/assets/artist_photoshop_new_publisher_instance_created.png new file mode 100644 index 0000000000..0cf6d1d18c Binary files /dev/null and b/website/docs/assets/artist_photoshop_new_publisher_instance_created.png differ diff --git a/website/docs/assets/artist_photoshop_new_publisher_publish_failed.png b/website/docs/assets/artist_photoshop_new_publisher_publish_failed.png new file mode 100644 index 0000000000..e34497b77d Binary files /dev/null and b/website/docs/assets/artist_photoshop_new_publisher_publish_failed.png differ diff --git a/website/docs/assets/artist_photoshop_new_publisher_workfile.png b/website/docs/assets/artist_photoshop_new_publisher_workfile.png new file mode 100644 index 0000000000..006206519f Binary files /dev/null and b/website/docs/assets/artist_photoshop_new_publisher_workfile.png differ diff --git a/website/docs/assets/experimental_tools_menu.png b/website/docs/assets/experimental_tools_menu.png new file mode 100644 index 0000000000..79fa8d3655 Binary files /dev/null and b/website/docs/assets/experimental_tools_menu.png differ diff --git a/website/docs/assets/experimental_tools_settings.png b/website/docs/assets/experimental_tools_settings.png new file mode 100644 index 0000000000..4d514e8a8f Binary files /dev/null and b/website/docs/assets/experimental_tools_settings.png differ