AE - added background loader

Reworked structure of json metadata
This commit is contained in:
Petr Kalis 2020-12-02 21:10:45 +01:00
parent a47042a7ce
commit 3449779fde
4 changed files with 325 additions and 68 deletions

View file

@ -4,13 +4,27 @@ from pype.modules.websocket_server import WebSocketServer
Used anywhere solution is calling client methods.
"""
import json
from collections import namedtuple
import attr
import logging
log = logging.getLogger(__name__)
@attr.s
class AEItem(object):
"""
Object denoting Item in AE. Each item is created in AE 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
item_type = attr.ib(default=None) # item type (footage, folder, comp)
# all imported elements, single for
# regular image, array for Backgrounds
members = attr.ib(factory=list)
class AfterEffectsServerStub():
"""
Stub for calling function on client (Photoshop js) side.
@ -34,22 +48,14 @@ class AfterEffectsServerStub():
('AfterEffects.open', path=path)
)
def read(self, layer, layers_meta=None):
"""
Parses layer metadata from Label field of active document
Args:
layer: <namedTuple Layer("id":XX, "name":"YYY")
layers_meta: full list from Headline (for performance in loops)
Returns:
"""
if layers_meta is None:
layers_meta = self.get_metadata()
return layers_meta.get(str(layer.id))
def get_metadata(self):
"""
Get stored JSON with metadata from AE.Metadata.Label field
Get complete stored JSON with metadata from AE.Metadata.Label
field.
It contains containers loaded by any Loader OR instances creted
by Creator.
Returns:
(dict)
"""
@ -57,54 +63,85 @@ class AfterEffectsServerStub():
('AfterEffects.get_metadata')
)
try:
layers_data = json.loads(res)
metadata = json.loads(res)
except json.decoder.JSONDecodeError:
raise ValueError("Unparsable metadata {}".format(res))
return layers_data or {}
return metadata or {}
def imprint(self, layer, data, all_layers=None, layers_meta=None):
def read(self, item, layers_meta=None):
"""
Save layer metadata to Label field of metadata of active document
Parses item metadata from Label field of active document.
Used as filter to pick metadata for specific 'item' only.
Args:
layer (namedtuple): Layer("id": XXX, "name":'YYY')
item (AEItem): pulled info from AE
layers_meta (dict): full list from Headline
(load and inject for better performance in loops)
Returns:
(dict):
"""
if layers_meta is None:
layers_meta = self.get_metadata()
for item_meta in layers_meta:
if 'container' in item_meta.get('id') and \
str(item.id) == str(item_meta.get('members')[0]):
return item_meta
log.debug("Couldn't find layer metadata")
def imprint(self, item, data, all_items=None, items_meta=None):
"""
Save item metadata to Label field of metadata of active document
Args:
item (AEItem):
data(string): json representation for single layer
all_layers (list of namedtuples): for performance, could be
all_items (list of item): 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_metadata()
if not items_meta:
items_meta = self.get_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)
result_meta = []
# fix existing
is_new = True
for item_meta in items_meta:
if item_meta.get('members') \
and str(item.id) == str(item_meta.get('members')[0]):
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:
if not all_items:
# loaders create FootageItem now
all_layers = self.get_items(comps=True,
folders=False,
footages=True)
item_ids = [int(item.id) for item in all_layers]
cleaned_data = {}
for id in layers_meta:
if int(id) in item_ids:
cleaned_data[id] = layers_meta[id]
all_items = self.get_items(comps=True,
folders=True,
footages=True)
item_ids = [int(item.id) for item in all_items]
cleaned_data = []
for meta in result_meta:
# for creation of instance OR loaded container
if 'instance' in meta.get('id') or \
int(meta.get('members')[0]) in item_ids:
cleaned_data.append(meta)
payload = json.dumps(cleaned_data, indent=4)
self.websocketserver.call(self.client.call
('AfterEffects.imprint', payload=payload)
)
('AfterEffects.imprint', payload=payload))
def get_active_document_full_name(self):
"""
@ -130,8 +167,10 @@ class AfterEffectsServerStub():
"""
Get all items from Project panel according to arguments.
There are multiple different types:
CompItem (could have multiple layers - source for Creator)
FolderItem (collection type, currently not used
CompItem (could have multiple layers - source for Creator,
will be rendered)
FolderItem (collection type, currently used for Background
loading)
FootageItem (imported file - created by Loader)
Args:
comps (bool): return CompItems
@ -218,15 +257,15 @@ class AfterEffectsServerStub():
item_id=item.id,
item_name=item_name))
def delete_item(self, item):
""" Deletes FootageItem with new file
def delete_item(self, item_id):
""" Deletes *Item in a file
Args:
item (dict):
item_id (int):
"""
self.websocketserver.call(self.client.call
('AfterEffects.delete_item',
item_id=item.id
item_id=item_id
))
def is_saved(self):
@ -340,12 +379,95 @@ class AfterEffectsServerStub():
def close(self):
self.client.close()
def import_background(self, comp_id, comp_name, files):
"""
Imports backgrounds images to existing or new composition.
If comp_id is not provided, new composition is created, basic
values (width, heights, frameRatio) takes from first imported
image.
All images from background json are imported as a FootageItem and
separate layer is created for each of them under composition.
Order of imported 'files' is important.
Args:
comp_id (int): id of existing composition (null if new)
comp_name (str): used when new composition
files (list): list of absolute paths to import and
add as layers
Returns:
(AEItem): object with id of created folder, all imported images
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.import_background',
comp_id=comp_id,
comp_name=comp_name,
files=files))
records = self._to_records(res)
if records:
return records.pop()
log.debug("Import background failed.")
def reload_background(self, comp_id, comp_name, files):
"""
Reloads backgrounds images to existing composition.
It actually deletes complete folder with imported images and
created composition for safety.
Args:
comp_id (int): id of existing composition to be overwritten
comp_name (str): new name of composition (could be same as old
if version up only)
files (list): list of absolute paths to import and
add as layers
Returns:
(AEItem): object with id of created folder, all imported images
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.reload_background',
comp_id=comp_id,
comp_name=comp_name,
files=files))
records = self._to_records(res)
if records:
return records.pop()
log.debug("Reload of background failed.")
def add_item_as_layer(self, comp_id, item_id):
"""
Adds already imported FootageItem ('item_id') as a new
layer to composition ('comp_id').
Args:
comp_id (int): id of target composition
item_id (int): FootageItem.id
comp already found previously
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.add_item_as_layer',
comp_id=comp_id,
item_id=item_id))
records = self._to_records(res)
if records:
return records.pop()
log.debug("Adding new layer failed.")
def _to_records(self, res):
"""
Converts string json representation into list of named tuples for
Converts string json representation into list of AEItem
dot notation access to work.
Returns: <list of named tuples>
res(string): - json representation
Returns: <list of AEItem>
res(string): - json representation
"""
if not res:
return []
@ -358,9 +480,14 @@ class AfterEffectsServerStub():
return []
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 = AEItem(d.get('id'),
d.get('name'),
d.get('type'),
d.get('members'))
ret.append(item)
return ret

View file

@ -0,0 +1,96 @@
import re
from avalon import api, aftereffects
from pype.plugins.lib import get_background_layers, get_unique_layer_name
stub = aftereffects.stub()
class BackgroundLoader(api.Loader):
"""
Load images from Background family
Creates for each background separate folder with all imported images
from background json AND automatically created composition with layers,
each layer for separate image.
For each load container is created and stored in project (.aep)
metadata
"""
families = ["background"]
representations = ["json"]
def load(self, context, name=None, namespace=None, data=None):
items = stub.get_items(comps=True)
existing_items = [layer.name for layer in items]
comp_name = get_unique_layer_name(existing_items,
"{}_{}".format(context["asset"]["name"], name))
layers = get_background_layers(self.fname)
comp = stub.import_background(None, comp_name, layers)
if not comp:
self.log.warning(
"Import background failed.")
self.log.warning("Check host app for alert error.")
return
self[:] = [comp]
namespace = namespace or comp_name
return aftereffects.containerise(
name,
namespace,
comp,
context,
self.__class__.__name__
)
def update(self, container, representation):
""" Switch asset or change version """
context = representation.get("context", {})
# without iterator number (_001, 002...)
namespace_from_container = re.sub(r'_\d{3}$', '',
container["namespace"])
comp_name = "{}_{}".format(context["asset"], context["subset"])
# switching assets
if namespace_from_container != comp_name:
items = stub.get_items(comps=True)
existing_items = [layer.name for layer in items]
comp_name = get_unique_layer_name(existing_items,
"{}_{}".format(context["asset"], context["subset"]))
else: # switching version - keep same name
comp_name = container["namespace"]
path = api.get_representation_path(representation)
layers = get_background_layers(path)
comp = stub.reload_background(container["members"][1],
comp_name,
layers)
# update container
container["representation"] = str(representation["_id"])
container["name"] = context["subset"]
container["namespace"] = comp_name
container["members"] = comp.members
stub.imprint(comp, container)
def remove(self, container):
"""
Removes element from scene: deletes layer + removes from file
metadata.
Args:
container (dict): container to be removed - used to get layer_id
"""
print("!!!! container:: {}".format(container))
layer = container.pop("layer")
stub.imprint(layer, {})
stub.delete_item(layer.id)
def switch(self, container, representation):
self.update(container, representation)

View file

@ -21,9 +21,10 @@ class FileLoader(api.Loader):
representations = ["*"]
def load(self, context, name=None, namespace=None, data=None):
comp_name = lib.get_unique_layer_name(stub.get_items(comps=True),
context["asset"]["name"],
name)
layers = stub.get_items(comps=True, folders=True, footages=True)
existing_layers = [layer.name for layer in layers]
comp_name = lib.get_unique_layer_name(existing_layers,
"{}_{}".format(context["asset"]["name"], name))
import_options = {}
@ -77,9 +78,11 @@ class FileLoader(api.Loader):
layer_name = "{}_{}".format(context["asset"], context["subset"])
# switching assets
if namespace_from_container != layer_name:
layer_name = lib.get_unique_layer_name(stub.get_items(comps=True),
context["asset"],
context["subset"])
layers = stub.get_items(comps=True)
existing_layers = [layer.name for layer in layers]
layer_name = lib.get_unique_layer_name(existing_layers,
"{}_{}".format(context["asset"],
context["subset"]))
else: # switching version - keep same name
layer_name = container["namespace"]
path = api.get_representation_path(representation)

View file

@ -1,22 +1,22 @@
import re
import json
import os
def get_unique_layer_name(layers, asset_name, subset_name):
def get_unique_layer_name(layers, name):
"""
Gets all layer names and if 'name' is present in them, increases
suffix by 1 (eg. creates unique layer name - for Loader)
Args:
layers (list): of namedtuples, expects 'name' field present
asset_name (string): in format asset_subset (Hero)
subset_name (string): (LOD)
layers (list): of strings, names only
name (string): checked value
Returns:
(string): name_00X (without version)
"""
name = "{}_{}".format(asset_name, subset_name)
names = {}
for layer in layers:
layer_name = re.sub(r'_\d{3}$', '', layer.name)
layer_name = re.sub(r'_\d{3}$', '', layer)
if layer_name in names.keys():
names[layer_name] = names[layer_name] + 1
else:
@ -24,3 +24,34 @@ def get_unique_layer_name(layers, asset_name, subset_name):
occurrences = names.get(name, 0)
return "{}_{:0>3d}".format(name, occurrences + 1)
def get_background_layers(file_url):
"""
Pulls file name from background json file, enrich with folder url for
AE to be able import files.
Order is important, follows order in json.
Args:
file_url (str): abs url of background json
Returns:
(list): of abs paths to images
"""
with open(file_url) as json_file:
data = json.load(json_file)
layers = list()
bg_folder = os.path.dirname(file_url)
for child in data['children']:
if child.get("filename"):
layers.append(os.path.join(bg_folder, child.get("filename")).
replace("\\", "/"))
else:
for layer in child['children']:
if layer.get("filename"):
layers.append(os.path.join(bg_folder,
layer.get("filename")).
replace("\\", "/"))
return layers