Merge pull request #456 from kalisp/feature/photoshop_with_websocket

photoshop with websocket
This commit is contained in:
Milan Kolar 2020-09-17 11:56:40 +02:00 committed by GitHub
commit 00de522a08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 469 additions and 84 deletions

View file

@ -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"

View file

@ -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: <string> 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: <namedTuple Layer("id":XX, "name":"YYY")
:param layers_meta: full list from Headline (for performance in loops)
:return:
"""
if layers_meta is None:
layers_meta = self.get_layers_metadata()
return layers_meta.get(str(layer.id))
def imprint(self, layer, data, all_layers=None, layers_meta=None):
"""
Save layer metadata to Headline field of active document
:param layer: <namedTuple> Layer("id": XXX, "name":'YYY')
:param data: <string> json representation for single layer
:param all_layers: <list of namedTuples> - for performance, could be
injected for usage in loop, if not, single call will be
triggered
:param layers_meta: <string> 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: <list of namedtuples>
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: <list of namedTuples>
:return: <list of namedTuples>
"""
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: <namedTuple Layer("id":XX, "name":"YYY")>
"""
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: <json representation of Layer>
"""
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: <list of Layer('id':XX, 'name':"YYY")>
"""
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: <list of Layer('id':XX, 'name':"YYY")>
: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: <string> 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: <string> 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: <boolean>
"""
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: <string> full local path
:param ext: <string psd|jpg|png>
:param as_copy: <boolean>
: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: <int>
:param visibility: <true - set visible, false - hide>
: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: <string> - 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: <list of named tuples>
:param res: <string> - 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

View file

@ -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: <WebSocketAsync> 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")

View file

@ -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)

View file

@ -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"])}
)

View file

@ -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("\\", "/")

View file

@ -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.

View file

@ -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():

View file

@ -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",

View file

@ -11,4 +11,4 @@ class ExtractSaveScene(pype.api.Extractor):
families = ["workfile"]
def process(self, instance):
photoshop.app().ActiveDocument.Save()
photoshop.stub().save()

View file

@ -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))

View file

@ -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):

View file

@ -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