Skip to content
This repository has been archived by the owner on Sep 20, 2024. It is now read-only.

Photoshop: mark publishable instances #1093

Merged
2 changes: 1 addition & 1 deletion pype/hooks/aftereffects/prelaunch.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def execute(self, *args, env: dict = None) -> bool:

# adding compulsory environment var for opening file
# used in .bat launcher
env["PYPE_AE_WORKFILE_PATH"] = workfile_path.replace('\\', '/')
env["PYPE_AE_WORKFILE_PATH"] = workfile_path.replace('\\', '\\\\')

return True

Expand Down
3 changes: 2 additions & 1 deletion pype/hooks/photoshop/prelaunch.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def execute(self, *args, env: dict = None) -> bool:
workfile_path = self.get_workfile_path(env, self.host_name)

# adding compulsory environment var for opening file
env["PYPE_WORKFILE_PATH"] = workfile_path.replace('\\', '/')
# windows must have \\ not /
env["PYPE_WORKFILE_PATH"] = workfile_path.replace('\\', '\\\\')

return True

Expand Down
3 changes: 3 additions & 0 deletions pype/modules/websocket_server/hosts/photoshop.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ async def sceneinventory_route(self):
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)
Expand Down
192 changes: 159 additions & 33 deletions pype/modules/websocket_server/stubs/photoshop_server_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,37 @@
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()
Expand All @@ -34,7 +55,7 @@ def read(self, layer, layers_meta=None):
"""
Parses layer metadata from Headline field of active document
Args:
layer: <namedTuple Layer("id":XX, "name":"YYY")
layer: (PSItem)
layers_meta: full list from Headline (for performance in loops)
Returns:
"""
Expand All @@ -46,10 +67,33 @@ def read(self, layer, layers_meta=None):
def imprint(self, layer, data, all_layers=None, layers_meta=None):
"""
Save layer metadata to Headline field of active document

Stores metadata in format:
[{
"active":true,
"subset":"imageBG",
"family":"image",
"id":"pyblish.avalon.instance",
"asset":"Town",
"uuid": "8"
}] - for created instances
OR
[{
"schema": "avalon-core:container-2.0",
"id": "pyblish.avalon.instance",
"name": "imageMG",
"namespace": "Jungle_imageMG_001",
"loader": "ImageLoader",
"representation": "5fbfc0ee30a946093c6ff18a",
"members": [
"40"
]
}] - for loaded instances

Args:
layer (namedtuple): Layer("id": XXX, "name":'YYY')
layer (PSItem):
data(string): json representation for single layer
all_layers (list of namedtuples): for performance, could be
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
Expand All @@ -59,6 +103,7 @@ def imprint(self, layer, data, all_layers=None, layers_meta=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)]:
Expand All @@ -73,11 +118,11 @@ def imprint(self, layer, data, all_layers=None, layers_meta=None):
if not all_layers:
all_layers = self.get_layers()
layer_ids = [layer.id for layer in all_layers]
cleaned_data = {}
cleaned_data = []

for id in layers_meta:
if int(id) in layer_ids:
cleaned_data[id] = layers_meta[id]
cleaned_data.append(layers_meta[id])

payload = json.dumps(cleaned_data, indent=4)

Expand All @@ -89,7 +134,7 @@ def get_layers(self):
"""
Returns JSON document with all(?) layers in active document.

Returns: <list of namedtuples>
Returns: <list of PSItem>
Format of tuple: { 'id':'123',
'name': 'My Layer 1',
'type': 'GUIDE'|'FG'|'BG'|'OBJ'
Expand All @@ -100,12 +145,26 @@ def get_layers(self):

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 <list of namedTuples>:
Returns: <list of namedTuples>
layers <list of PSItem>:
Returns: <list of PSItem>
"""
all_layers = self.get_layers()
ret = []
Expand All @@ -123,28 +182,30 @@ def get_layers_in_layers(self, layers):
def create_group(self, name):
"""
Create new group (eg. LayerSet)
Returns: <namedTuple Layer("id":XX, "name":"YYY")>
Returns: <PSItem>
"""
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):
Expand All @@ -163,11 +224,10 @@ def select_layers(self, layers):
layers: <list of Layer('id':XX, 'name':"YYY")>
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):
Expand Down Expand Up @@ -238,14 +298,38 @@ def get_layers_metadata(self):
"""
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'))
try:
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])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

continuation line over-indented for hanging indent

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):
Expand All @@ -257,11 +341,14 @@ def import_smart_object(self, path, layer_name):
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):
"""
Expand All @@ -270,13 +357,14 @@ def replace_smart_object(self, layer, path, layer_name):
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):
"""
Expand All @@ -288,24 +376,62 @@ def delete_layer(self, layer_id):
('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: <list of named tuples>
res(string): - json representation
Args:
res (string): valid json
Returns:
<list of PSItem>
"""
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
12 changes: 12 additions & 0 deletions pype/plugins/photoshop/create/create_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,17 @@ def process(self):
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, '')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

continuation line over-indented for hanging indent

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