diff --git a/pype/hosts/photoshop/plugins/create/create_image.py b/pype/hosts/photoshop/plugins/create/create_image.py index 54b6efad29..03250acd48 100644 --- a/pype/hosts/photoshop/plugins/create/create_image.py +++ b/pype/hosts/photoshop/plugins/create/create_image.py @@ -73,5 +73,17 @@ class CreateImage(pype.api.Creator): groups.append(group) for group in groups: + long_names = [] + 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": "image" + group.name}) + self.data.update({"uuid": 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/pype/hosts/photoshop/plugins/publish/collect_instances.py b/pype/hosts/photoshop/plugins/publish/collect_instances.py index 14803cceee..5390df768b 100644 --- a/pype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/pype/hosts/photoshop/plugins/publish/collect_instances.py @@ -24,6 +24,7 @@ class CollectInstances(pyblish.api.ContextPlugin): stub = photoshop.stub() layers = stub.get_layers() layers_meta = stub.get_layers_metadata() + instance_names = [] for layer in layers: layer_data = stub.read(layer, layers_meta) @@ -41,14 +42,20 @@ class CollectInstances(pyblish.api.ContextPlugin): # self.log.info("%s skipped, it was empty." % layer.Name) # continue - instance = context.create_instance(layer.name) + instance = context.create_instance(layer_data["subset"]) instance.append(layer) instance.data.update(layer_data) instance.data["families"] = self.families_mapping[ layer_data["family"] ] instance.data["publish"] = layer.visible + instance_names.append(layer_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)) + + if len(instance_names) != len(set(instance_names)): + self.log.warning("Duplicate instances found. " + + "Remove unwanted via SubsetManager") diff --git a/pype/hosts/photoshop/plugins/publish/validate_naming.py b/pype/hosts/photoshop/plugins/publish/validate_naming.py index 2483adcb5e..48f5901233 100644 --- a/pype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/pype/hosts/photoshop/plugins/publish/validate_naming.py @@ -25,11 +25,15 @@ class ValidateNamingRepair(pyblish.api.Action): for instance in instances: self.log.info("validate_naming instance {}".format(instance)) name = instance.data["name"].replace(" ", "_") + name = name.replace(instance.data["family"], '') instance[0].Name = name data = stub.read(instance[0]) data["subset"] = "image" + name stub.imprint(instance[0], data) + name = stub.PUBLISH_ICON + name + stub.rename_layer(instance.data["uuid"], name) + return True @@ -46,8 +50,11 @@ class ValidateNaming(pyblish.api.InstancePlugin): actions = [ValidateNamingRepair] def process(self, instance): - msg = "Name \"{}\" is not allowed.".format(instance.data["name"]) + help_msg = ' Use Repair action (A) in Pyblish to fix it.' + msg = "Name \"{}\" is not allowed.{}".format(instance.data["name"], + help_msg) assert " " not in instance.data["name"], msg - msg = "Subset \"{}\" is not allowed.".format(instance.data["subset"]) + msg = "Subset \"{}\" is not allowed.{}".format(instance.data["subset"], + help_msg) assert " " not in instance.data["subset"], msg diff --git a/pype/hosts/photoshop/plugins/publish/validate_unique_subsets.py b/pype/hosts/photoshop/plugins/publish/validate_unique_subsets.py new file mode 100644 index 0000000000..5871f79668 --- /dev/null +++ b/pype/hosts/photoshop/plugins/publish/validate_unique_subsets.py @@ -0,0 +1,26 @@ +import pyblish.api +import pype.api + + +class ValidateSubsetUniqueness(pyblish.api.ContextPlugin): + """ + Validate that all subset's names are unique. + """ + + label = "Validate Subset Uniqueness" + hosts = ["photoshop"] + order = pype.api.ValidateContentsOrder + families = ["image"] + + def process(self, context): + subset_names = [] + + for instance in context: + if instance.data.get('publish'): + subset_names.append(instance.data.get('subset')) + + msg = ( + "Instance subset names are not unique. " + + "Remove duplicates via SubsetManager." + ) + assert len(subset_names) == len(set(subset_names)), msg diff --git a/pype/modules/websocket_server/hosts/photoshop.py b/pype/modules/websocket_server/hosts/photoshop.py index cdfb9413a0..b75874aa6c 100644 --- a/pype/modules/websocket_server/hosts/photoshop.py +++ b/pype/modules/websocket_server/hosts/photoshop.py @@ -54,6 +54,9 @@ class Photoshop(WebSocketRoute): async def projectmanager_route(self): self._tool_route("projectmanager") + async def subsetmanager_route(self): + self._tool_route("subsetmanager") + def _tool_route(self, tool_name): """The address accessed when clicking on the buttons.""" partial_method = functools.partial(photoshop.show, tool_name) diff --git a/pype/modules/websocket_server/stubs/photoshop_server_stub.py b/pype/modules/websocket_server/stubs/photoshop_server_stub.py index d223153797..5409120a65 100644 --- a/pype/modules/websocket_server/stubs/photoshop_server_stub.py +++ b/pype/modules/websocket_server/stubs/photoshop_server_stub.py @@ -4,16 +4,37 @@ from pype.modules.websocket_server import WebSocketServer Used anywhere solution is calling client methods. """ import json -from collections import namedtuple +import attr -class PhotoshopServerStub(): +@attr.s +class PSItem(object): + """ + Object denoting layer or group item in PS. Each item is created in + PS by any Loader, but contains same fields, which are being used + in later processing. + """ + # metadata + id = attr.ib() # id created by AE, could be used for querying + name = attr.ib() # name of item + group = attr.ib(default=None) # item type (footage, folder, comp) + parents = attr.ib(factory=list) + visible = attr.ib(default=True) + type = attr.ib(default=None) + # all imported elements, single for + members = attr.ib(factory=list) + long_name = attr.ib(default=None) + + +class PhotoshopServerStub: """ Stub for calling function on client (Photoshop js) side. Expects that client is already connected (started when avalon menu is opened). 'self.websocketserver.call' is used as async wrapper """ + PUBLISH_ICON = '\u2117 ' + LOADED_ICON = '\u25bc' def __init__(self): self.websocketserver = WebSocketServer.get_instance() @@ -34,7 +55,7 @@ class PhotoshopServerStub(): """ Parses layer metadata from Headline field of active document Args: - layer: + Returns: Format of tuple: { 'id':'123', 'name': 'My Layer 1', 'type': 'GUIDE'|'FG'|'BG'|'OBJ' @@ -100,12 +145,26 @@ class PhotoshopServerStub(): return self._to_records(res) + def get_layer(self, layer_id): + """ + Returns PSItem for specific 'layer_id' or None if not found + Args: + layer_id (string): unique layer id, stored in 'uuid' field + + Returns: + (PSItem) or None + """ + layers = self.get_layers() + for layer in layers: + if str(layer.id) == str(layer_id): + return layer + def get_layers_in_layers(self, layers): """ Return all layers that belong to layers (might be groups). Args: - layers : - Returns: + layers : + Returns: """ all_layers = self.get_layers() ret = [] @@ -123,28 +182,30 @@ class PhotoshopServerStub(): def create_group(self, name): """ Create new group (eg. LayerSet) - Returns: + Returns: """ + enhanced_name = self.PUBLISH_ICON + name ret = self.websocketserver.call(self.client.call ('Photoshop.create_group', - name=name)) + name=enhanced_name)) # create group on PS is asynchronous, returns only id - layer = {"id": ret, "name": name, "group": True} - return namedtuple('Layer', layer.keys())(*layer.values()) + return PSItem(id=ret, name=name, group=True) def group_selected_layers(self, name): """ Group selected layers into new LayerSet (eg. group) Returns: (Layer) """ + enhanced_name = self.PUBLISH_ICON + name res = self.websocketserver.call(self.client.call ('Photoshop.group_selected_layers', - name=name) + name=enhanced_name) ) res = self._to_records(res) - if res: - return res.pop() + rec = res.pop() + rec.name = rec.name.replace(self.PUBLISH_ICON, '') + return rec raise ValueError("No group record returned") def get_selected_layers(self): @@ -163,11 +224,10 @@ class PhotoshopServerStub(): layers: Returns: None """ - layer_ids = [layer.id for layer in layers] - + layers_id = [str(lay.id) for lay in layers] self.websocketserver.call(self.client.call - ('Photoshop.get_layers', - layers=layer_ids) + ('Photoshop.select_layers', + layers=json.dumps(layers_id)) ) def get_active_document_full_name(self): @@ -238,7 +298,14 @@ class PhotoshopServerStub(): """ Reads layers metadata from Headline from active document in PS. (Headline accessible by File > File Info) - Returns(string): - json documents + + Returns: + (string): - json documents + example: + {"8":{"active":true,"subset":"imageBG", + "family":"image","id":"pyblish.avalon.instance", + "asset":"Town"}} + 8 is layer(group) id - used for deletion, update etc. """ layers_data = {} res = self.websocketserver.call(self.client.call('Photoshop.read')) @@ -246,6 +313,23 @@ class PhotoshopServerStub(): layers_data = json.loads(res) except json.decoder.JSONDecodeError: pass + # format of metadata changed from {} to [] because of standardization + # keep current implementation logic as its working + if not isinstance(layers_data, dict): + temp_layers_meta = {} + for layer_meta in layers_data: + layer_id = layer_meta.get("uuid") or \ + (layer_meta.get("members")[0]) + temp_layers_meta[layer_id] = layer_meta + layers_data = temp_layers_meta + else: + # legacy version of metadata + for layer_id, layer_meta in layers_data.items(): + if layer_meta.get("schema") != "avalon-core:container-2.0": + layer_meta["uuid"] = str(layer_id) + else: + layer_meta["members"] = [str(layer_id)] + return layers_data def import_smart_object(self, path, layer_name): @@ -257,11 +341,14 @@ class PhotoshopServerStub(): layer_name (str): Unique layer name to differentiate how many times same smart object was loaded """ + enhanced_name = self.LOADED_ICON + layer_name res = self.websocketserver.call(self.client.call ('Photoshop.import_smart_object', - path=path, name=layer_name)) - - return self._to_records(res).pop() + path=path, name=enhanced_name)) + rec = self._to_records(res).pop() + if rec: + rec.name = rec.name.replace(self.LOADED_ICON, '') + return rec def replace_smart_object(self, layer, path, layer_name): """ @@ -270,13 +357,14 @@ class PhotoshopServerStub(): same smart object was loaded Args: - layer (namedTuple): Layer("id":XX, "name":"YY"..). + layer (PSItem): path (str): File to import. """ + enhanced_name = self.LOADED_ICON + layer_name self.websocketserver.call(self.client.call ('Photoshop.replace_smart_object', layer_id=layer.id, - path=path, name=layer_name)) + path=path, name=enhanced_name)) def delete_layer(self, layer_id): """ @@ -288,24 +376,62 @@ class PhotoshopServerStub(): ('Photoshop.delete_layer', layer_id=layer_id)) + def rename_layer(self, layer_id, name): + """ + Renames specific layer by it's id. + Args: + layer_id (int): id of layer to delete + name (str): new name + """ + self.websocketserver.call(self.client.call + ('Photoshop.rename_layer', + layer_id=layer_id, + name=name)) + + def remove_instance(self, instance_id): + cleaned_data = {} + + for key, instance in self.get_layers_metadata().items(): + if key != instance_id: + cleaned_data[key] = instance + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call(self.client.call + ('Photoshop.imprint', payload=payload) + ) + def close(self): self.client.close() def _to_records(self, res): """ - Converts string json representation into list of named tuples for + Converts string json representation into list of PSItem for dot notation access to work. - Returns: - res(string): - json representation + Args: + res (string): valid json + Returns: + """ try: layers_data = json.loads(res) except json.decoder.JSONDecodeError: raise ValueError("Received broken JSON {}".format(res)) ret = [] - # convert to namedtuple to use dot donation - if isinstance(layers_data, dict): # TODO refactore + + # convert to AEItem to use dot donation + if isinstance(layers_data, dict): layers_data = [layers_data] for d in layers_data: - ret.append(namedtuple('Layer', d.keys())(*d.values())) + # currently implemented and expected fields + item = PSItem(d.get('id'), + d.get('name'), + d.get('group'), + d.get('parents'), + d.get('visible'), + d.get('type'), + d.get('members'), + d.get('long_name')) + + ret.append(item) return ret