diff --git a/pype/modules/websocket_server/hosts/photoshop.py b/pype/modules/websocket_server/hosts/photoshop.py new file mode 100644 index 0000000000..cdfb9413a0 --- /dev/null +++ b/pype/modules/websocket_server/hosts/photoshop.py @@ -0,0 +1,64 @@ +from pype.api import Logger +from wsrpc_aiohttp import WebSocketRoute +import functools + +import avalon.photoshop as photoshop + +log = Logger().get_logger("WebsocketServer") + + +class Photoshop(WebSocketRoute): + """ + One route, mimicking external application (like Harmony, etc). + All functions could be called from client. + 'do_notify' function calls function on the client - mimicking + notification after long running job on the server or similar + """ + instance = None + + def init(self, **kwargs): + # Python __init__ must be return "self". + # This method might return anything. + log.debug("someone called Photoshop route") + self.instance = self + return kwargs + + # server functions + async def ping(self): + log.debug("someone called Photoshop route ping") + + # This method calls function on the client side + # client functions + + async def read(self): + log.debug("photoshop.read client calls server server calls " + "Photo client") + return await self.socket.call('Photoshop.read') + + # panel routes for tools + async def creator_route(self): + self._tool_route("creator") + + async def workfiles_route(self): + self._tool_route("workfiles") + + async def loader_route(self): + self._tool_route("loader") + + async def publish_route(self): + self._tool_route("publish") + + async def sceneinventory_route(self): + self._tool_route("sceneinventory") + + async def projectmanager_route(self): + self._tool_route("projectmanager") + + def _tool_route(self, tool_name): + """The address accessed when clicking on the buttons.""" + partial_method = functools.partial(photoshop.show, tool_name) + + photoshop.execute_in_main_thread(partial_method) + + # Required return statement. + return "nothing" diff --git a/pype/modules/websocket_server/stubs/photoshop_server_stub.py b/pype/modules/websocket_server/stubs/photoshop_server_stub.py new file mode 100644 index 0000000000..da69127799 --- /dev/null +++ b/pype/modules/websocket_server/stubs/photoshop_server_stub.py @@ -0,0 +1,283 @@ +from pype.modules.websocket_server import WebSocketServer +""" + Stub handling connection from server to client. + Used anywhere solution is calling client methods. +""" +import json +from collections import namedtuple + + +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 + """ + + def __init__(self): + self.websocketserver = WebSocketServer.get_instance() + self.client = self.websocketserver.get_client() + + def open(self, path): + """ + Open file located at 'path' (local). + :param path: file path locally + :return: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.open', path=path) + ) + + def read(self, layer, layers_meta=None): + """ + Parses layer metadata from Headline field of active document + :param layer: Layer("id": XXX, "name":'YYY') + :param data: json representation for single layer + :param all_layers: - for performance, could be + injected for usage in loop, if not, single call will be + triggered + :param layers_meta: json representation from Headline + (for performance - provide only if imprint is in + loop - value should be same) + :return: 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) + else: + layers_meta[str(layer.id)] = data + + # Ensure only valid ids are stored. + if not all_layers: + all_layers = self.get_layers() + layer_ids = [layer.id for layer in all_layers] + cleaned_data = {} + + for id in layers_meta: + if int(id) in layer_ids: + cleaned_data[id] = layers_meta[id] + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call(self.client.call + ('Photoshop.imprint', payload=payload) + ) + + def get_layers(self): + """ + Returns JSON document with all(?) layers in active document. + + :return: + Format of tuple: { 'id':'123', + 'name': 'My Layer 1', + 'type': 'GUIDE'|'FG'|'BG'|'OBJ' + 'visible': 'true'|'false' + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_layers')) + + return self._to_records(res) + + def get_layers_in_layers(self, layers): + """ + Return all layers that belong to layers (might be groups). + :param layers: + :return: + """ + all_layers = self.get_layers() + ret = [] + parent_ids = set([lay.id for lay in layers]) + + for layer in all_layers: + parents = set(layer.parents) + if len(parent_ids & parents) > 0: + ret.append(layer) + if layer.id in parent_ids: + ret.append(layer) + + return ret + + def create_group(self, name): + """ + Create new group (eg. LayerSet) + :return: + """ + ret = self.websocketserver.call(self.client.call + ('Photoshop.create_group', + name=name)) + # create group on PS is asynchronous, returns only id + layer = {"id": ret, "name": name, "group": True} + return namedtuple('Layer', layer.keys())(*layer.values()) + + def group_selected_layers(self, name): + """ + Group selected layers into new LayerSet (eg. group) + :return: + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.group_selected_layers', + name=name) + ) + return self._to_records(res) + + def get_selected_layers(self): + """ + Get a list of actually selected layers + :return: + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_selected_layers')) + return self._to_records(res) + + def select_layers(self, layers): + """ + Selecte specified layers in Photoshop + :param layers: + :return: None + """ + layer_ids = [layer.id for layer in layers] + + self.websocketserver.call(self.client.call + ('Photoshop.get_layers', + layers=layer_ids) + ) + + def get_active_document_full_name(self): + """ + Returns full name with path of active document via ws call + :return: full path with name + """ + res = self.websocketserver.call( + self.client.call('Photoshop.get_active_document_full_name')) + + return res + + def get_active_document_name(self): + """ + Returns just a name of active document via ws call + :return: file name + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_active_document_name')) + + return res + + def is_saved(self): + """ + Returns true if no changes in active document + :return: + """ + return self.websocketserver.call(self.client.call + ('Photoshop.is_saved')) + + def save(self): + """ + Saves active document + :return: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.save')) + + def saveAs(self, image_path, ext, as_copy): + """ + Saves active document to psd (copy) or png or jpg + :param image_path: full local path + :param ext: + :param as_copy: + :return: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.saveAs', + image_path=image_path, + ext=ext, + as_copy=as_copy)) + + def set_visible(self, layer_id, visibility): + """ + Set layer with 'layer_id' to 'visibility' + :param layer_id: + :param visibility: + :return: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.set_visible', + layer_id=layer_id, + visibility=visibility)) + + def get_layers_metadata(self): + """ + Reads layers metadata from Headline from active document in PS. + (Headline accessible by File > File Info) + :return: - json documents + """ + layers_data = {} + res = self.websocketserver.call(self.client.call('Photoshop.read')) + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + pass + return layers_data + + def import_smart_object(self, path): + """ + Import the file at `path` as a smart object to active document. + + Args: + path (str): File path to import. + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.import_smart_object', + path=path)) + + return self._to_records(res).pop() + + def replace_smart_object(self, layer, path): + """ + Replace the smart object `layer` with file at `path` + + Args: + layer (namedTuple): Layer("id":XX, "name":"YY"..). + path (str): File to import. + """ + self.websocketserver.call(self.client.call + ('Photoshop.replace_smart_object', + layer=layer, + path=path)) + + def close(self): + self.client.close() + + def _to_records(self, res): + """ + Converts string json representation into list of named tuples for + dot notation access to work. + :return: + :param res: - json representation + """ + 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 + layers_data = [layers_data] + for d in layers_data: + ret.append(namedtuple('Layer', d.keys())(*d.values())) + return ret diff --git a/pype/modules/websocket_server/websocket_server.py b/pype/modules/websocket_server/websocket_server.py index 56e71ea895..1152c65e00 100644 --- a/pype/modules/websocket_server/websocket_server.py +++ b/pype/modules/websocket_server/websocket_server.py @@ -1,4 +1,4 @@ -from pype.api import config, Logger +from pype.api import Logger import threading from aiohttp import web @@ -9,6 +9,7 @@ import os import sys import pyclbr import importlib +import urllib log = Logger().get_logger("WebsocketServer") @@ -19,24 +20,23 @@ class WebSocketServer(): Uses class in external_app_1.py to mimic implementation for single external application. 'test_client' folder contains two test implementations of client - - WIP """ + _instance = None def __init__(self): self.qaction = None self.failed_icon = None self._is_running = False - default_port = 8099 + WebSocketServer._instance = self + self.client = None + self.handlers = {} - try: - self.presets = config.get_presets()["services"]["websocket_server"] - except Exception: - self.presets = {"default_port": default_port, "exclude_ports": []} - log.debug(( - "There are not set presets for WebsocketServer." - " Using defaults \"{}\"" - ).format(str(self.presets))) + websocket_url = os.getenv("WEBSOCKET_URL") + if websocket_url: + parsed = urllib.parse.urlparse(websocket_url) + port = parsed.port + if not port: + port = 8099 # fallback self.app = web.Application() @@ -48,7 +48,7 @@ class WebSocketServer(): directories_with_routes = ['hosts'] self.add_routes_for_directories(directories_with_routes) - self.websocket_thread = WebsocketServerThread(self, default_port) + self.websocket_thread = WebsocketServerThread(self, port) def add_routes_for_directories(self, directories_with_routes): """ Loops through selected directories to find all modules and @@ -78,6 +78,33 @@ class WebSocketServer(): WebSocketAsync.add_route(class_name, cls) sys.path.pop() + def call(self, func): + log.debug("websocket.call {}".format(func)) + future = asyncio.run_coroutine_threadsafe(func, + self.websocket_thread.loop) + result = future.result() + return result + + def get_client(self): + """ + Return first connected client to WebSocket + TODO implement selection by Route + :return: client + """ + clients = WebSocketAsync.get_clients() + client = None + if len(clients) > 0: + key = list(clients.keys())[0] + client = clients.get(key) + + return client + + @staticmethod + def get_instance(): + if WebSocketServer._instance is None: + WebSocketServer() + return WebSocketServer._instance + def tray_start(self): self.websocket_thread.start() @@ -124,6 +151,7 @@ class WebsocketServerThread(threading.Thread): self.loop = None self.runner = None self.site = None + self.tasks = [] def run(self): self.is_running = True @@ -169,6 +197,12 @@ class WebsocketServerThread(threading.Thread): periodically. """ while self.is_running: + while self.tasks: + task = self.tasks.pop(0) + log.debug("waiting for task {}".format(task)) + await task + log.debug("returned value {}".format(task.result)) + await asyncio.sleep(0.5) log.debug("Starting shutdown") diff --git a/pype/plugins/photoshop/create/create_image.py b/pype/plugins/photoshop/create/create_image.py index 5b2f9f7981..c1a7d92a2c 100644 --- a/pype/plugins/photoshop/create/create_image.py +++ b/pype/plugins/photoshop/create/create_image.py @@ -1,5 +1,6 @@ -from avalon import api, photoshop +from avalon import api from avalon.vendor import Qt +from avalon import photoshop class CreateImage(api.Creator): @@ -13,11 +14,12 @@ class CreateImage(api.Creator): groups = [] layers = [] create_group = False - group_constant = photoshop.get_com_objects().constants().psLayerSet + + stub = photoshop.stub() if (self.options or {}).get("useSelection"): multiple_instances = False - selection = photoshop.get_selected_layers() - + 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. @@ -40,19 +42,18 @@ class CreateImage(api.Creator): if multiple_instances: for item in selection: - if item.LayerType == group_constant: + if item.group: groups.append(item) else: layers.append(item) else: - group = photoshop.group_selected_layers() - group.Name = self.name + 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].LayerType == group_constant: + if selection[0].group: groups.append(selection[0]) else: layers.append(selection[0]) @@ -63,16 +64,14 @@ class CreateImage(api.Creator): create_group = True if create_group: - group = photoshop.app().ActiveDocument.LayerSets.Add() - group.Name = self.name + group = stub.create_group(self.name) groups.append(group) for layer in layers: - photoshop.select_layers([layer]) - group = photoshop.group_selected_layers() - group.Name = layer.Name + stub.select_layers([layer]) + group = stub.group_selected_layers(layer.name) groups.append(group) for group in groups: - self.data.update({"subset": "image" + group.Name}) - photoshop.imprint(group, self.data) + self.data.update({"subset": "image" + group.name}) + stub.imprint(group, self.data) diff --git a/pype/plugins/photoshop/load/load_image.py b/pype/plugins/photoshop/load/load_image.py index 18efe750d5..75c02bb327 100644 --- a/pype/plugins/photoshop/load/load_image.py +++ b/pype/plugins/photoshop/load/load_image.py @@ -1,5 +1,7 @@ from avalon import api, photoshop +stub = photoshop.stub() + class ImageLoader(api.Loader): """Load images @@ -12,7 +14,7 @@ class ImageLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): with photoshop.maintained_selection(): - layer = photoshop.import_smart_object(self.fname) + layer = stub.import_smart_object(self.fname) self[:] = [layer] @@ -28,11 +30,11 @@ class ImageLoader(api.Loader): layer = container.pop("layer") with photoshop.maintained_selection(): - photoshop.replace_smart_object( + stub.replace_smart_object( layer, api.get_representation_path(representation) ) - photoshop.imprint( + stub.imprint( layer, {"representation": str(representation["_id"])} ) diff --git a/pype/plugins/photoshop/publish/collect_current_file.py b/pype/plugins/photoshop/publish/collect_current_file.py index 4308588559..3cc3e3f636 100644 --- a/pype/plugins/photoshop/publish/collect_current_file.py +++ b/pype/plugins/photoshop/publish/collect_current_file.py @@ -1,6 +1,7 @@ import os import pyblish.api + from avalon import photoshop @@ -13,5 +14,5 @@ class CollectCurrentFile(pyblish.api.ContextPlugin): def process(self, context): context.data["currentFile"] = os.path.normpath( - photoshop.app().ActiveDocument.FullName + photoshop.stub().get_active_document_full_name() ).replace("\\", "/") diff --git a/pype/plugins/photoshop/publish/collect_instances.py b/pype/plugins/photoshop/publish/collect_instances.py index 4937f2a1e4..81d1c80bf6 100644 --- a/pype/plugins/photoshop/publish/collect_instances.py +++ b/pype/plugins/photoshop/publish/collect_instances.py @@ -1,9 +1,9 @@ import pythoncom -from avalon import photoshop - import pyblish.api +from avalon import photoshop + class CollectInstances(pyblish.api.ContextPlugin): """Gather instances by LayerSet and file metadata @@ -27,8 +27,11 @@ class CollectInstances(pyblish.api.ContextPlugin): # can be. pythoncom.CoInitialize() - for layer in photoshop.get_layers_in_document(): - layer_data = photoshop.read(layer) + stub = photoshop.stub() + layers = stub.get_layers() + layers_meta = stub.get_layers_metadata() + for layer in layers: + layer_data = stub.read(layer, layers_meta) # Skip layers without metadata. if layer_data is None: @@ -38,18 +41,19 @@ class CollectInstances(pyblish.api.ContextPlugin): if "container" in layer_data["id"]: continue - child_layers = [*layer.Layers] - if not child_layers: - self.log.info("%s skipped, it was empty." % layer.Name) - continue + # child_layers = [*layer.Layers] + # self.log.debug("child_layers {}".format(child_layers)) + # if not child_layers: + # self.log.info("%s skipped, it was empty." % layer.Name) + # continue - instance = context.create_instance(layer.Name) + instance = context.create_instance(layer.name) instance.append(layer) instance.data.update(layer_data) instance.data["families"] = self.families_mapping[ layer_data["family"] ] - instance.data["publish"] = layer.Visible + instance.data["publish"] = layer.visible # Produce diagnostic message for any graphical # user interface interested in visualising it. diff --git a/pype/plugins/photoshop/publish/extract_image.py b/pype/plugins/photoshop/publish/extract_image.py index 6dfccdc4f2..38920b5557 100644 --- a/pype/plugins/photoshop/publish/extract_image.py +++ b/pype/plugins/photoshop/publish/extract_image.py @@ -21,35 +21,37 @@ class ExtractImage(pype.api.Extractor): self.log.info("Outputting image to {}".format(staging_dir)) # Perform extraction + stub = photoshop.stub() files = {} with photoshop.maintained_selection(): self.log.info("Extracting %s" % str(list(instance))) with photoshop.maintained_visibility(): # Hide all other layers. - extract_ids = [ - x.id for x in photoshop.get_layers_in_layers([instance[0]]) - ] - for layer in photoshop.get_layers_in_document(): - if layer.id not in extract_ids: - layer.Visible = False + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers([instance[0]])]) - save_options = {} + for layer in stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + stub.set_visible(layer.id, False) + if not layer.visible and layer.id in extract_ids: + stub.set_visible(layer.id, True) + + save_options = [] if "png" in self.formats: - save_options["png"] = photoshop.com_objects.PNGSaveOptions() + save_options.append('png') if "jpg" in self.formats: - save_options["jpg"] = photoshop.com_objects.JPEGSaveOptions() + save_options.append('jpg') file_basename = os.path.splitext( - photoshop.app().ActiveDocument.Name + stub.get_active_document_name() )[0] - for extension, save_option in save_options.items(): + for extension in save_options: _filename = "{}.{}".format(file_basename, extension) files[extension] = _filename full_filename = os.path.join(staging_dir, _filename) - photoshop.app().ActiveDocument.SaveAs( - full_filename, save_option, True - ) + stub.saveAs(full_filename, extension, True) representations = [] for extension, filename in files.items(): diff --git a/pype/plugins/photoshop/publish/extract_review.py b/pype/plugins/photoshop/publish/extract_review.py index 078ee53899..6fb50bba9f 100644 --- a/pype/plugins/photoshop/publish/extract_review.py +++ b/pype/plugins/photoshop/publish/extract_review.py @@ -13,10 +13,11 @@ class ExtractReview(pype.api.Extractor): families = ["review"] def process(self, instance): - staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) + stub = photoshop.stub() + layers = [] for image_instance in instance.context: if image_instance.data["family"] != "image": @@ -25,25 +26,22 @@ class ExtractReview(pype.api.Extractor): # Perform extraction output_image = "{}.jpg".format( - os.path.splitext(photoshop.app().ActiveDocument.Name)[0] + os.path.splitext(stub.get_active_document_name())[0] ) output_image_path = os.path.join(staging_dir, output_image) with photoshop.maintained_visibility(): # Hide all other layers. - extract_ids = [ - x.id for x in photoshop.get_layers_in_layers(layers) - ] - for layer in photoshop.get_layers_in_document(): - if layer.id in extract_ids: - layer.Visible = True - else: - layer.Visible = False + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers(layers)]) + self.log.info("extract_ids {}".format(extract_ids)) + for layer in stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + stub.set_visible(layer.id, False) + if not layer.visible and layer.id in extract_ids: + stub.set_visible(layer.id, True) - photoshop.app().ActiveDocument.SaveAs( - output_image_path, - photoshop.com_objects.JPEGSaveOptions(), - True - ) + stub.saveAs(output_image_path, 'jpg', True) ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg") @@ -66,8 +64,6 @@ class ExtractReview(pype.api.Extractor): ] output = pype.lib._subprocess(args) - self.log.debug(output) - instance.data["representations"].append({ "name": "thumbnail", "ext": "jpg", @@ -75,7 +71,6 @@ class ExtractReview(pype.api.Extractor): "stagingDir": staging_dir, "tags": ["thumbnail"] }) - # Generate mov. mov_path = os.path.join(staging_dir, "review.mov") args = [ @@ -86,9 +81,7 @@ class ExtractReview(pype.api.Extractor): mov_path ] output = pype.lib._subprocess(args) - self.log.debug(output) - instance.data["representations"].append({ "name": "mov", "ext": "mov", diff --git a/pype/plugins/photoshop/publish/extract_save_scene.py b/pype/plugins/photoshop/publish/extract_save_scene.py index b3d4f0e447..63a4b7b7ea 100644 --- a/pype/plugins/photoshop/publish/extract_save_scene.py +++ b/pype/plugins/photoshop/publish/extract_save_scene.py @@ -11,4 +11,4 @@ class ExtractSaveScene(pype.api.Extractor): families = ["workfile"] def process(self, instance): - photoshop.app().ActiveDocument.Save() + photoshop.stub().save() diff --git a/pype/plugins/photoshop/publish/increment_workfile.py b/pype/plugins/photoshop/publish/increment_workfile.py index ba9ab8606a..eca2583595 100644 --- a/pype/plugins/photoshop/publish/increment_workfile.py +++ b/pype/plugins/photoshop/publish/increment_workfile.py @@ -1,6 +1,7 @@ import pyblish.api from pype.action import get_errored_plugins_from_data from pype.lib import version_up + from avalon import photoshop @@ -24,6 +25,6 @@ class IncrementWorkfile(pyblish.api.InstancePlugin): ) scene_path = version_up(instance.context.data["currentFile"]) - photoshop.app().ActiveDocument.SaveAs(scene_path) + photoshop.stub().saveAs(scene_path, 'psd', True) self.log.info("Incremented workfile to: {}".format(scene_path)) diff --git a/pype/plugins/photoshop/publish/validate_instance_asset.py b/pype/plugins/photoshop/publish/validate_instance_asset.py index ab1d02269f..f05d9601dd 100644 --- a/pype/plugins/photoshop/publish/validate_instance_asset.py +++ b/pype/plugins/photoshop/publish/validate_instance_asset.py @@ -23,11 +23,12 @@ class ValidateInstanceAssetRepair(pyblish.api.Action): # 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: - data = photoshop.read(instance[0]) + data = stub.read(instance[0]) + data["asset"] = os.environ["AVALON_ASSET"] - photoshop.imprint(instance[0], data) + stub.imprint(instance[0], data) class ValidateInstanceAsset(pyblish.api.InstancePlugin): diff --git a/pype/plugins/photoshop/publish/validate_naming.py b/pype/plugins/photoshop/publish/validate_naming.py index 51e00da352..2483adcb5e 100644 --- a/pype/plugins/photoshop/publish/validate_naming.py +++ b/pype/plugins/photoshop/publish/validate_naming.py @@ -21,13 +21,14 @@ class ValidateNamingRepair(pyblish.api.Action): # 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)) name = instance.data["name"].replace(" ", "_") instance[0].Name = name - data = photoshop.read(instance[0]) + data = stub.read(instance[0]) data["subset"] = "image" + name - photoshop.imprint(instance[0], data) + stub.imprint(instance[0], data) return True