[Automated] Merged develop into main

This commit is contained in:
pypebot 2021-10-27 05:34:53 +02:00 committed by GitHub
commit 6dfb5246b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1843 additions and 447 deletions

View file

@ -158,6 +158,25 @@ def publish(debug, paths, targets):
PypeCommands.publish(list(paths), targets)
@main.command()
@click.argument("path")
@click.option("-d", "--debug", is_flag=True, help="Print debug messages")
@click.option("-h", "--host", help="Host")
@click.option("-u", "--user", help="User email address")
@click.option("-p", "--project", help="Project")
@click.option("-t", "--targets", help="Targets", default=None,
multiple=True)
def remotepublishfromapp(debug, project, path, host, targets=None, user=None):
"""Start CLI publishing.
Publish collects json from paths provided as an argument.
More than one path is allowed.
"""
if debug:
os.environ['OPENPYPE_DEBUG'] = '3'
PypeCommands.remotepublishfromapp(project, path, host, user,
targets=targets)
@main.command()
@click.argument("path")
@click.option("-d", "--debug", is_flag=True, help="Print debug messages")

View file

@ -13,7 +13,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook):
# Should be as last hook because must change launch arguments to string
order = 1000
app_groups = ["nuke", "nukex", "hiero", "nukestudio"]
app_groups = ["nuke", "nukex", "hiero", "nukestudio", "photoshop"]
platforms = ["windows"]
def execute(self):

View file

@ -3,11 +3,12 @@
import bpy
from avalon import api
from avalon.blender import lib
import openpype.hosts.blender.api.plugin
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
class CreateCamera(openpype.hosts.blender.api.plugin.Creator):
class CreateCamera(plugin.Creator):
"""Polygonal static geometry"""
name = "cameraMain"
@ -16,17 +17,46 @@ class CreateCamera(openpype.hosts.blender.api.plugin.Creator):
icon = "video-camera"
def process(self):
""" Run the creator on Blender main thread"""
mti = ops.MainThreadItem(self._process)
ops.execute_in_main_thread(mti)
def _process(self):
# Get Instance Containter or create it if it does not exist
instances = bpy.data.collections.get(AVALON_INSTANCES)
if not instances:
instances = bpy.data.collections.new(name=AVALON_INSTANCES)
bpy.context.scene.collection.children.link(instances)
# Create instance object
asset = self.data["asset"]
subset = self.data["subset"]
name = openpype.hosts.blender.api.plugin.asset_name(asset, subset)
collection = bpy.data.collections.new(name=name)
bpy.context.scene.collection.children.link(collection)
name = plugin.asset_name(asset, subset)
camera = bpy.data.cameras.new(subset)
camera_obj = bpy.data.objects.new(subset, camera)
instances.objects.link(camera_obj)
asset_group = bpy.data.objects.new(name=name, object_data=None)
asset_group.empty_display_type = 'SINGLE_ARROW'
instances.objects.link(asset_group)
self.data['task'] = api.Session.get('AVALON_TASK')
lib.imprint(collection, self.data)
print(f"self.data: {self.data}")
lib.imprint(asset_group, self.data)
if (self.options or {}).get("useSelection"):
for obj in lib.get_selection():
collection.objects.link(obj)
bpy.context.view_layer.objects.active = asset_group
selected = lib.get_selection()
for obj in selected:
obj.select_set(True)
selected.append(asset_group)
bpy.ops.object.parent_set(keep_transform=True)
else:
plugin.deselect_all()
camera_obj.select_set(True)
asset_group.select_set(True)
bpy.context.view_layer.objects.active = asset_group
bpy.ops.object.parent_set(keep_transform=True)
return collection
return asset_group

View file

@ -1,247 +0,0 @@
"""Load a camera asset in Blender."""
import logging
from pathlib import Path
from pprint import pformat
from typing import Dict, List, Optional
from avalon import api, blender
import bpy
import openpype.hosts.blender.api.plugin
logger = logging.getLogger("openpype").getChild("blender").getChild("load_camera")
class BlendCameraLoader(openpype.hosts.blender.api.plugin.AssetLoader):
"""Load a camera from a .blend file.
Warning:
Loading the same asset more then once is not properly supported at the
moment.
"""
families = ["camera"]
representations = ["blend"]
label = "Link Camera"
icon = "code-fork"
color = "orange"
def _remove(self, objects, lib_container):
for obj in list(objects):
bpy.data.cameras.remove(obj.data)
bpy.data.collections.remove(bpy.data.collections[lib_container])
def _process(self, libpath, lib_container, container_name, actions):
relative = bpy.context.preferences.filepaths.use_relative_paths
with bpy.data.libraries.load(
libpath, link=True, relative=relative
) as (_, data_to):
data_to.collections = [lib_container]
scene = bpy.context.scene
scene.collection.children.link(bpy.data.collections[lib_container])
camera_container = scene.collection.children[lib_container].make_local()
objects_list = []
for obj in camera_container.objects:
local_obj = obj.make_local()
local_obj.data.make_local()
if not local_obj.get(blender.pipeline.AVALON_PROPERTY):
local_obj[blender.pipeline.AVALON_PROPERTY] = dict()
avalon_info = local_obj[blender.pipeline.AVALON_PROPERTY]
avalon_info.update({"container_name": container_name})
if actions[0] is not None:
if local_obj.animation_data is None:
local_obj.animation_data_create()
local_obj.animation_data.action = actions[0]
if actions[1] is not None:
if local_obj.data.animation_data is None:
local_obj.data.animation_data_create()
local_obj.data.animation_data.action = actions[1]
objects_list.append(local_obj)
camera_container.pop(blender.pipeline.AVALON_PROPERTY)
bpy.ops.object.select_all(action='DESELECT')
return objects_list
def process_asset(
self, context: dict, name: str, namespace: Optional[str] = None,
options: Optional[Dict] = None
) -> Optional[List]:
"""
Arguments:
name: Use pre-defined name
namespace: Use pre-defined namespace
context: Full parenthood of representation to load
options: Additional settings dictionary
"""
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
lib_container = openpype.hosts.blender.api.plugin.asset_name(asset, subset)
container_name = openpype.hosts.blender.api.plugin.asset_name(
asset, subset, namespace
)
container = bpy.data.collections.new(lib_container)
container.name = container_name
blender.pipeline.containerise_existing(
container,
name,
namespace,
context,
self.__class__.__name__,
)
container_metadata = container.get(
blender.pipeline.AVALON_PROPERTY)
container_metadata["libpath"] = libpath
container_metadata["lib_container"] = lib_container
objects_list = self._process(
libpath, lib_container, container_name, (None, None))
# Save the list of objects in the metadata container
container_metadata["objects"] = objects_list
nodes = list(container.objects)
nodes.append(container)
self[:] = nodes
return nodes
def update(self, container: Dict, representation: Dict):
"""Update the loaded asset.
This will remove all objects of the current collection, load the new
ones and add them to the collection.
If the objects of the collection are used in another collection they
will not be removed, only unlinked. Normally this should not be the
case though.
Warning:
No nested collections are supported at the moment!
"""
collection = bpy.data.collections.get(
container["objectName"]
)
libpath = Path(api.get_representation_path(representation))
extension = libpath.suffix.lower()
logger.info(
"Container: %s\nRepresentation: %s",
pformat(container, indent=2),
pformat(representation, indent=2),
)
assert collection, (
f"The asset is not loaded: {container['objectName']}"
)
assert not (collection.children), (
"Nested collections are not supported."
)
assert libpath, (
"No existing library file found for {container['objectName']}"
)
assert libpath.is_file(), (
f"The file doesn't exist: {libpath}"
)
assert extension in openpype.hosts.blender.api.plugin.VALID_EXTENSIONS, (
f"Unsupported file: {libpath}"
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
collection_libpath = collection_metadata["libpath"]
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
normalized_collection_libpath = (
str(Path(bpy.path.abspath(collection_libpath)).resolve())
)
normalized_libpath = (
str(Path(bpy.path.abspath(str(libpath))).resolve())
)
logger.debug(
"normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s",
normalized_collection_libpath,
normalized_libpath,
)
if normalized_collection_libpath == normalized_libpath:
logger.info("Library already loaded, not updating...")
return
camera = objects[0]
camera_action = None
camera_data_action = None
if camera.animation_data and camera.animation_data.action:
camera_action = camera.animation_data.action
if camera.data.animation_data and camera.data.animation_data.action:
camera_data_action = camera.data.animation_data.action
actions = (camera_action, camera_data_action)
self._remove(objects, lib_container)
objects_list = self._process(
str(libpath), lib_container, collection.name, actions)
# Save the list of objects in the metadata container
collection_metadata["objects"] = objects_list
collection_metadata["libpath"] = str(libpath)
collection_metadata["representation"] = str(representation["_id"])
bpy.ops.object.select_all(action='DESELECT')
def remove(self, container: Dict) -> bool:
"""Remove an existing container from a Blender scene.
Arguments:
container (openpype:container-1.0): Container to remove,
from `host.ls()`.
Returns:
bool: Whether the container was deleted.
Warning:
No nested collections are supported at the moment!
"""
collection = bpy.data.collections.get(
container["objectName"]
)
if not collection:
return False
assert not (collection.children), (
"Nested collections are not supported."
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
self._remove(objects, lib_container)
bpy.data.collections.remove(collection)
return True

View file

@ -0,0 +1,252 @@
"""Load a camera asset in Blender."""
import logging
from pathlib import Path
from pprint import pformat
from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
logger = logging.getLogger("openpype").getChild(
"blender").getChild("load_camera")
class BlendCameraLoader(plugin.AssetLoader):
"""Load a camera from a .blend file.
Warning:
Loading the same asset more then once is not properly supported at the
moment.
"""
families = ["camera"]
representations = ["blend"]
label = "Link Camera (Blend)"
icon = "code-fork"
color = "orange"
def _remove(self, asset_group):
objects = list(asset_group.children)
for obj in objects:
if obj.type == 'CAMERA':
bpy.data.cameras.remove(obj.data)
def _process(self, libpath, asset_group, group_name):
with bpy.data.libraries.load(
libpath, link=True, relative=False
) as (data_from, data_to):
data_to.objects = data_from.objects
parent = bpy.context.scene.collection
empties = [obj for obj in data_to.objects if obj.type == 'EMPTY']
container = None
for empty in empties:
if empty.get(AVALON_PROPERTY):
container = empty
break
assert container, "No asset group found"
# Children must be linked before parents,
# otherwise the hierarchy will break
objects = []
nodes = list(container.children)
for obj in nodes:
obj.parent = asset_group
for obj in nodes:
objects.append(obj)
nodes.extend(list(obj.children))
objects.reverse()
for obj in objects:
parent.objects.link(obj)
for obj in objects:
local_obj = plugin.prepare_data(obj, group_name)
if local_obj.type != 'EMPTY':
plugin.prepare_data(local_obj.data, group_name)
if not local_obj.get(AVALON_PROPERTY):
local_obj[AVALON_PROPERTY] = dict()
avalon_info = local_obj[AVALON_PROPERTY]
avalon_info.update({"container_name": group_name})
objects.reverse()
bpy.data.orphans_purge(do_local_ids=False)
plugin.deselect_all()
return objects
def process_asset(
self, context: dict, name: str, namespace: Optional[str] = None,
options: Optional[Dict] = None
) -> Optional[List]:
"""
Arguments:
name: Use pre-defined name
namespace: Use pre-defined namespace
context: Full parenthood of representation to load
options: Additional settings dictionary
"""
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
asset_name = plugin.asset_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)
if not avalon_container:
avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS)
bpy.context.scene.collection.children.link(avalon_container)
asset_group = bpy.data.objects.new(group_name, object_data=None)
asset_group.empty_display_type = 'SINGLE_ARROW'
avalon_container.objects.link(asset_group)
objects = self._process(libpath, asset_group, group_name)
bpy.context.scene.collection.objects.link(asset_group)
asset_group[AVALON_PROPERTY] = {
"schema": "openpype:container-2.0",
"id": AVALON_CONTAINER_ID,
"name": name,
"namespace": namespace or '',
"loader": str(self.__class__.__name__),
"representation": str(context["representation"]["_id"]),
"libpath": libpath,
"asset_name": asset_name,
"parent": str(context["representation"]["parent"]),
"family": context["representation"]["context"]["family"],
"objectName": group_name
}
self[:] = objects
return objects
def exec_update(self, container: Dict, representation: Dict):
"""Update the loaded asset.
This will remove all children of the asset group, load the new ones
and add them as children of the group.
"""
object_name = container["objectName"]
asset_group = bpy.data.objects.get(object_name)
libpath = Path(api.get_representation_path(representation))
extension = libpath.suffix.lower()
self.log.info(
"Container: %s\nRepresentation: %s",
pformat(container, indent=2),
pformat(representation, indent=2),
)
assert asset_group, (
f"The asset is not loaded: {container['objectName']}"
)
assert libpath, (
"No existing library file found for {container['objectName']}"
)
assert libpath.is_file(), (
f"The file doesn't exist: {libpath}"
)
assert extension in plugin.VALID_EXTENSIONS, (
f"Unsupported file: {libpath}"
)
metadata = asset_group.get(AVALON_PROPERTY)
group_libpath = metadata["libpath"]
normalized_group_libpath = (
str(Path(bpy.path.abspath(group_libpath)).resolve())
)
normalized_libpath = (
str(Path(bpy.path.abspath(str(libpath))).resolve())
)
self.log.debug(
"normalized_group_libpath:\n %s\nnormalized_libpath:\n %s",
normalized_group_libpath,
normalized_libpath,
)
if normalized_group_libpath == normalized_libpath:
self.log.info("Library already loaded, not updating...")
return
# Check how many assets use the same library
count = 0
for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects:
if obj.get(AVALON_PROPERTY).get('libpath') == group_libpath:
count += 1
mat = asset_group.matrix_basis.copy()
self._remove(asset_group)
# If it is the last object to use that library, remove it
if count == 1:
library = bpy.data.libraries.get(bpy.path.basename(group_libpath))
if library:
bpy.data.libraries.remove(library)
self._process(str(libpath), asset_group, object_name)
asset_group.matrix_basis = mat
metadata["libpath"] = str(libpath)
metadata["representation"] = str(representation["_id"])
metadata["parent"] = str(representation["parent"])
def exec_remove(self, container: Dict) -> bool:
"""Remove an existing container from a Blender scene.
Arguments:
container (openpype:container-1.0): Container to remove,
from `host.ls()`.
Returns:
bool: Whether the container was deleted.
"""
object_name = container["objectName"]
asset_group = bpy.data.objects.get(object_name)
libpath = asset_group.get(AVALON_PROPERTY).get('libpath')
# Check how many assets use the same library
count = 0
for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects:
if obj.get(AVALON_PROPERTY).get('libpath') == libpath:
count += 1
if not asset_group:
return False
self._remove(asset_group)
bpy.data.objects.remove(asset_group)
# If it is the last object to use that library, remove it
if count == 1:
library = bpy.data.libraries.get(bpy.path.basename(libpath))
bpy.data.libraries.remove(library)
return True

View file

@ -0,0 +1,218 @@
"""Load an asset in Blender from an Alembic file."""
from pathlib import Path
from pprint import pformat
from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender import lib
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
class FbxCameraLoader(plugin.AssetLoader):
"""Load a camera from FBX.
Stores the imported asset in an empty named after the asset.
"""
families = ["camera"]
representations = ["fbx"]
label = "Load Camera (FBX)"
icon = "code-fork"
color = "orange"
def _remove(self, asset_group):
objects = list(asset_group.children)
for obj in objects:
if obj.type == 'CAMERA':
bpy.data.cameras.remove(obj.data)
elif obj.type == 'EMPTY':
objects.extend(obj.children)
bpy.data.objects.remove(obj)
def _process(self, libpath, asset_group, group_name):
plugin.deselect_all()
collection = bpy.context.view_layer.active_layer_collection.collection
bpy.ops.import_scene.fbx(filepath=libpath)
parent = bpy.context.scene.collection
objects = lib.get_selection()
for obj in objects:
obj.parent = asset_group
for obj in objects:
parent.objects.link(obj)
collection.objects.unlink(obj)
for obj in objects:
name = obj.name
obj.name = f"{group_name}:{name}"
if obj.type != 'EMPTY':
name_data = obj.data.name
obj.data.name = f"{group_name}:{name_data}"
if not obj.get(AVALON_PROPERTY):
obj[AVALON_PROPERTY] = dict()
avalon_info = obj[AVALON_PROPERTY]
avalon_info.update({"container_name": group_name})
plugin.deselect_all()
return objects
def process_asset(
self, context: dict, name: str, namespace: Optional[str] = None,
options: Optional[Dict] = None
) -> Optional[List]:
"""
Arguments:
name: Use pre-defined name
namespace: Use pre-defined namespace
context: Full parenthood of representation to load
options: Additional settings dictionary
"""
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
asset_name = plugin.asset_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)
if not avalon_container:
avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS)
bpy.context.scene.collection.children.link(avalon_container)
asset_group = bpy.data.objects.new(group_name, object_data=None)
avalon_container.objects.link(asset_group)
objects = self._process(libpath, asset_group, group_name)
objects = []
nodes = list(asset_group.children)
for obj in nodes:
objects.append(obj)
nodes.extend(list(obj.children))
bpy.context.scene.collection.objects.link(asset_group)
asset_group[AVALON_PROPERTY] = {
"schema": "openpype:container-2.0",
"id": AVALON_CONTAINER_ID,
"name": name,
"namespace": namespace or '',
"loader": str(self.__class__.__name__),
"representation": str(context["representation"]["_id"]),
"libpath": libpath,
"asset_name": asset_name,
"parent": str(context["representation"]["parent"]),
"family": context["representation"]["context"]["family"],
"objectName": group_name
}
self[:] = objects
return objects
def exec_update(self, container: Dict, representation: Dict):
"""Update the loaded asset.
This will remove all objects of the current collection, load the new
ones and add them to the collection.
If the objects of the collection are used in another collection they
will not be removed, only unlinked. Normally this should not be the
case though.
Warning:
No nested collections are supported at the moment!
"""
object_name = container["objectName"]
asset_group = bpy.data.objects.get(object_name)
libpath = Path(api.get_representation_path(representation))
extension = libpath.suffix.lower()
self.log.info(
"Container: %s\nRepresentation: %s",
pformat(container, indent=2),
pformat(representation, indent=2),
)
assert asset_group, (
f"The asset is not loaded: {container['objectName']}"
)
assert libpath, (
"No existing library file found for {container['objectName']}"
)
assert libpath.is_file(), (
f"The file doesn't exist: {libpath}"
)
assert extension in plugin.VALID_EXTENSIONS, (
f"Unsupported file: {libpath}"
)
metadata = asset_group.get(AVALON_PROPERTY)
group_libpath = metadata["libpath"]
normalized_group_libpath = (
str(Path(bpy.path.abspath(group_libpath)).resolve())
)
normalized_libpath = (
str(Path(bpy.path.abspath(str(libpath))).resolve())
)
self.log.debug(
"normalized_group_libpath:\n %s\nnormalized_libpath:\n %s",
normalized_group_libpath,
normalized_libpath,
)
if normalized_group_libpath == normalized_libpath:
self.log.info("Library already loaded, not updating...")
return
mat = asset_group.matrix_basis.copy()
self._remove(asset_group)
self._process(str(libpath), asset_group, object_name)
asset_group.matrix_basis = mat
metadata["libpath"] = str(libpath)
metadata["representation"] = str(representation["_id"])
def exec_remove(self, container: Dict) -> bool:
"""Remove an existing container from a Blender scene.
Arguments:
container (openpype:container-1.0): Container to remove,
from `host.ls()`.
Returns:
bool: Whether the container was deleted.
Warning:
No nested collections are supported at the moment!
"""
object_name = container["objectName"]
asset_group = bpy.data.objects.get(object_name)
if not asset_group:
return False
self._remove(asset_group)
bpy.data.objects.remove(asset_group)
return True

View file

@ -12,6 +12,7 @@ from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype import lib
from openpype.hosts.blender.api import plugin
@ -103,6 +104,21 @@ class JsonLayoutLoader(plugin.AssetLoader):
options=options
)
# Create the camera asset and the camera instance
creator_plugin = lib.get_creator_by_name("CreateCamera")
if not creator_plugin:
raise ValueError("Creator plugin \"CreateCamera\" was "
"not found.")
api.create(
creator_plugin,
name="camera",
# name=f"{unique_number}_{subset}_animation",
asset=asset,
options={"useSelection": False}
# data={"dependencies": str(context["representation"]["_id"])}
)
def process_asset(self,
context: dict,
name: str,

View file

@ -0,0 +1,73 @@
import os
from openpype import api
from openpype.hosts.blender.api import plugin
import bpy
class ExtractCamera(api.Extractor):
"""Extract as the camera as FBX."""
label = "Extract Camera"
hosts = ["blender"]
families = ["camera"]
optional = True
def process(self, instance):
# Define extract output file path
stagingdir = self.staging_dir(instance)
filename = f"{instance.name}.fbx"
filepath = os.path.join(stagingdir, filename)
# Perform extraction
self.log.info("Performing extraction..")
plugin.deselect_all()
selected = []
camera = None
for obj in instance:
if obj.type == "CAMERA":
obj.select_set(True)
selected.append(obj)
camera = obj
break
assert camera, "No camera found"
context = plugin.create_blender_context(
active=camera, selected=selected)
scale_length = bpy.context.scene.unit_settings.scale_length
bpy.context.scene.unit_settings.scale_length = 0.01
# We export the fbx
bpy.ops.export_scene.fbx(
context,
filepath=filepath,
use_active_collection=False,
use_selection=True,
object_types={'CAMERA'},
bake_anim_simplify_factor=0.0
)
bpy.context.scene.unit_settings.scale_length = scale_length
plugin.deselect_all()
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'fbx',
'ext': 'fbx',
'files': filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '%s' to: %s",
instance.name, representation)

View file

@ -0,0 +1,48 @@
from typing import List
import mathutils
import pyblish.api
import openpype.hosts.blender.api.action
class ValidateCameraZeroKeyframe(pyblish.api.InstancePlugin):
"""Camera must have a keyframe at frame 0.
Unreal shifts the first keyframe to frame 0. Forcing the camera to have
a keyframe at frame 0 will ensure that the animation will be the same
in Unreal and Blender.
"""
order = openpype.api.ValidateContentsOrder
hosts = ["blender"]
families = ["camera"]
category = "geometry"
version = (0, 1, 0)
label = "Zero Keyframe"
actions = [openpype.hosts.blender.api.action.SelectInvalidAction]
_identity = mathutils.Matrix()
@classmethod
def get_invalid(cls, instance) -> List:
invalid = []
for obj in [obj for obj in instance]:
if obj.type == "CAMERA":
if obj.animation_data and obj.animation_data.action:
action = obj.animation_data.action
frames_set = set()
for fcu in action.fcurves:
for kp in fcu.keyframe_points:
frames_set.add(kp.co[0])
frames = list(frames_set)
frames.sort()
if frames[0] != 0.0:
invalid.append(obj)
return invalid
def process(self, instance):
invalid = self.get_invalid(instance)
if invalid:
raise RuntimeError(
f"Object found in instance is not in Object Mode: {invalid}")

View file

@ -437,7 +437,8 @@ def empty_sets(sets, force=False):
cmds.connectAttr(src, dest)
# Restore original members
for origin_set, members in original.iteritems():
_iteritems = getattr(original, "iteritems", original.items)
for origin_set, members in _iteritems():
cmds.sets(members, forceElement=origin_set)
@ -581,7 +582,7 @@ def get_shader_assignments_from_shapes(shapes, components=True):
# Build a mapping from parent to shapes to include in lookup.
transforms = {shape.rsplit("|", 1)[0]: shape for shape in shapes}
lookup = set(shapes + transforms.keys())
lookup = set(shapes) | set(transforms.keys())
component_assignments = defaultdict(list)
for shading_group in assignments.keys():
@ -669,7 +670,8 @@ def displaySmoothness(nodes,
yield
finally:
# Revert state
for node, state in originals.iteritems():
_iteritems = getattr(originals, "iteritems", originals.items)
for node, state in _iteritems():
if state:
cmds.displaySmoothness(node, **state)
@ -712,7 +714,8 @@ def no_display_layers(nodes):
yield
finally:
# Restore original members
for layer, members in original.iteritems():
_iteritems = getattr(original, "iteritems", original.items)
for layer, members in _iteritems():
cmds.editDisplayLayerMembers(layer, members, noRecurse=True)

View file

@ -5,6 +5,7 @@ import os
import contextlib
import copy
import six
from maya import cmds
from avalon import api, io
@ -69,7 +70,8 @@ def unlocked(nodes):
yield
finally:
# Reapply original states
for uuid, state in states.iteritems():
_iteritems = getattr(states, "iteritems", states.items)
for uuid, state in _iteritems():
nodes_from_id = cmds.ls(uuid, long=True)
if nodes_from_id:
node = nodes_from_id[0]
@ -94,7 +96,7 @@ def load_package(filepath, name, namespace=None):
# Define a unique namespace for the package
namespace = os.path.basename(filepath).split(".")[0]
unique_namespace(namespace)
assert isinstance(namespace, basestring)
assert isinstance(namespace, six.string_types)
# Load the setdress package data
with open(filepath, "r") as fp:

View file

@ -183,7 +183,8 @@ class ExtractFBX(openpype.api.Extractor):
# Apply the FBX overrides through MEL since the commands
# only work correctly in MEL according to online
# available discussions on the topic
for option, value in options.iteritems():
_iteritems = getattr(options, "iteritems", options.items)
for option, value in _iteritems():
key = option[0].upper() + option[1:] # uppercase first letter
# Boolean must be passed as lower-case strings

View file

@ -383,7 +383,7 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin):
"attributes": {
"environmental_variables": {
"value": ", ".join("{!s}={!r}".format(k, v)
for (k, v) in env.iteritems()),
for (k, v) in env.items()),
"state": True,
"subst": False

View file

@ -2,6 +2,8 @@ import pyblish.api
import openpype.api
import string
import six
# Allow only characters, numbers and underscore
allowed = set(string.ascii_lowercase +
string.ascii_uppercase +
@ -29,7 +31,7 @@ class ValidateSubsetName(pyblish.api.InstancePlugin):
raise RuntimeError("Instance is missing subset "
"name: {0}".format(subset))
if not isinstance(subset, basestring):
if not isinstance(subset, six.string_types):
raise TypeError("Instance subset name must be string, "
"got: {0} ({1})".format(subset, type(subset)))

View file

@ -52,7 +52,8 @@ class ValidateNodeIdsUnique(pyblish.api.InstancePlugin):
# Take only the ids with more than one member
invalid = list()
for _ids, members in ids.iteritems():
_iteritems = getattr(ids, "iteritems", ids.items)
for _ids, members in _iteritems():
if len(members) > 1:
cls.log.error("ID found on multiple nodes: '%s'" % members)
invalid.extend(members)

View file

@ -32,7 +32,10 @@ class ValidateNodeNoGhosting(pyblish.api.InstancePlugin):
nodes = cmds.ls(instance, long=True, type=['transform', 'shape'])
invalid = []
for node in nodes:
for attr, required_value in cls._attributes.iteritems():
_iteritems = getattr(
cls._attributes, "iteritems", cls._attributes.items
)
for attr, required_value in _iteritems():
if cmds.attributeQuery(attr, node=node, exists=True):
value = cmds.getAttr('{0}.{1}'.format(node, attr))

View file

@ -33,7 +33,8 @@ class ValidateShapeRenderStats(pyblish.api.Validator):
shapes = cmds.ls(instance, long=True, type='surfaceShape')
invalid = []
for shape in shapes:
for attr, default_value in cls.defaults.iteritems():
_iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items)
for attr, default_value in _iteritems():
if cmds.attributeQuery(attr, node=shape, exists=True):
value = cmds.getAttr('{}.{}'.format(shape, attr))
if value != default_value:
@ -52,7 +53,8 @@ class ValidateShapeRenderStats(pyblish.api.Validator):
@classmethod
def repair(cls, instance):
for shape in cls.get_invalid(instance):
for attr, default_value in cls.defaults.iteritems():
_iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items)
for attr, default_value in _iteritems():
if cmds.attributeQuery(attr, node=shape, exists=True):
plug = '{0}.{1}'.format(shape, attr)

View file

@ -6,7 +6,6 @@ from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name
stub = photoshop.stub()
class ImageLoader(api.Loader):
"""Load images
@ -21,7 +20,7 @@ class ImageLoader(api.Loader):
context["asset"]["name"],
name)
with photoshop.maintained_selection():
layer = stub.import_smart_object(self.fname, layer_name)
layer = self.import_layer(self.fname, layer_name)
self[:] = [layer]
namespace = namespace or layer_name
@ -45,8 +44,9 @@ class ImageLoader(api.Loader):
layer_name = "{}_{}".format(context["asset"], context["subset"])
# switching assets
if namespace_from_container != layer_name:
layer_name = self._get_unique_layer_name(context["asset"],
context["subset"])
layer_name = get_unique_layer_name(stub.get_layers(),
context["asset"],
context["subset"])
else: # switching version - keep same name
layer_name = container["namespace"]
@ -72,3 +72,6 @@ class ImageLoader(api.Loader):
def switch(self, container, representation):
self.update(container, representation)
def import_layer(self, file_name, layer_name):
return stub.import_smart_object(file_name, layer_name)

View file

@ -0,0 +1,82 @@
import re
from avalon import api, photoshop
from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name
stub = photoshop.stub()
class ReferenceLoader(api.Loader):
"""Load reference images
Stores the imported asset in a container named after the asset.
Inheriting from 'load_image' didn't work because of
"Cannot write to closing transport", possible refactor.
"""
families = ["image", "render"]
representations = ["*"]
def load(self, context, name=None, namespace=None, data=None):
layer_name = get_unique_layer_name(stub.get_layers(),
context["asset"]["name"],
name)
with photoshop.maintained_selection():
layer = self.import_layer(self.fname, layer_name)
self[:] = [layer]
namespace = namespace or layer_name
return photoshop.containerise(
name,
namespace,
layer,
context,
self.__class__.__name__
)
def update(self, container, representation):
""" Switch asset or change version """
layer = container.pop("layer")
context = representation.get("context", {})
namespace_from_container = re.sub(r'_\d{3}$', '',
container["namespace"])
layer_name = "{}_{}".format(context["asset"], context["subset"])
# switching assets
if namespace_from_container != layer_name:
layer_name = get_unique_layer_name(stub.get_layers(),
context["asset"],
context["subset"])
else: # switching version - keep same name
layer_name = container["namespace"]
path = api.get_representation_path(representation)
with photoshop.maintained_selection():
stub.replace_smart_object(
layer, path, layer_name
)
stub.imprint(
layer, {"representation": str(representation["_id"])}
)
def remove(self, container):
"""
Removes element from scene: deletes layer + removes from Headline
Args:
container (dict): container to be removed - used to get layer_id
"""
layer = container.pop("layer")
stub.imprint(layer, {})
stub.delete_layer(layer.id)
def switch(self, container, representation):
self.update(container, representation)
def import_layer(self, file_name, layer_name):
return stub.import_smart_object(file_name, layer_name,
as_reference=True)

View file

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""Close PS after publish. For Webpublishing only."""
import os
import pyblish.api
from avalon import photoshop
class ClosePS(pyblish.api.ContextPlugin):
"""Close PS after publish. For Webpublishing only.
"""
order = pyblish.api.IntegratorOrder + 14
label = "Close PS"
optional = True
active = True
hosts = ["photoshop"]
def process(self, context):
self.log.info("ClosePS")
if not os.environ.get("IS_HEADLESS"):
return
stub = photoshop.stub()
self.log.info("Shutting down PS")
stub.save()
stub.close()
self.log.info("PS closed")

View file

@ -0,0 +1,136 @@
import pyblish.api
import os
import re
from avalon import photoshop
from openpype.lib import prepare_template_data
from openpype.lib.plugin_tools import parse_json
class CollectRemoteInstances(pyblish.api.ContextPlugin):
"""Gather instances configured color code of a layer.
Used in remote publishing when artists marks publishable layers by color-
coding.
Identifier:
id (str): "pyblish.avalon.instance"
"""
order = pyblish.api.CollectorOrder + 0.100
label = "Instances"
order = pyblish.api.CollectorOrder
hosts = ["photoshop"]
# configurable by Settings
color_code_mapping = []
def process(self, context):
self.log.info("CollectRemoteInstances")
self.log.info("mapping:: {}".format(self.color_code_mapping))
if not os.environ.get("IS_HEADLESS"):
self.log.debug("Not headless publishing, skipping.")
return
# parse variant if used in webpublishing, comes from webpublisher batch
batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA")
variant = "Main"
if batch_dir and os.path.exists(batch_dir):
# TODO check if batch manifest is same as tasks manifests
task_data = parse_json(os.path.join(batch_dir,
"manifest.json"))
if not task_data:
raise ValueError(
"Cannot parse batch meta in {} folder".format(batch_dir))
variant = task_data["variant"]
stub = photoshop.stub()
layers = stub.get_layers()
instance_names = []
for layer in layers:
self.log.info("Layer:: {}".format(layer))
resolved_family, resolved_subset_template = self._resolve_mapping(
layer
)
self.log.info("resolved_family {}".format(resolved_family))
self.log.info("resolved_subset_template {}".format(
resolved_subset_template))
if not resolved_subset_template or not resolved_family:
self.log.debug("!!! Not marked, skip")
continue
if layer.parents:
self.log.debug("!!! Not a top layer, skip")
continue
instance = context.create_instance(layer.name)
instance.append(layer)
instance.data["family"] = resolved_family
instance.data["publish"] = layer.visible
instance.data["asset"] = context.data["assetEntity"]["name"]
instance.data["task"] = context.data["taskType"]
fill_pairs = {
"variant": variant,
"family": instance.data["family"],
"task": instance.data["task"],
"layer": layer.name
}
subset = resolved_subset_template.format(
**prepare_template_data(fill_pairs))
instance.data["subset"] = subset
instance_names.append(layer.name)
# 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")
def _resolve_mapping(self, layer):
"""Matches 'layer' color code and name to mapping.
If both color code AND name regex is configured, BOTH must be valid
If layer matches to multiple mappings, only first is used!
"""
family_list = []
family = None
subset_name_list = []
resolved_subset_template = None
for mapping in self.color_code_mapping:
if mapping["color_code"] and \
layer.color_code not in mapping["color_code"]:
break
if mapping["layer_name_regex"] and \
not any(re.search(pattern, layer.name)
for pattern in mapping["layer_name_regex"]):
break
family_list.append(mapping["family"])
subset_name_list.append(mapping["subset_template_name"])
if len(subset_name_list) > 1:
self.log.warning("Multiple mappings found for '{}'".
format(layer.name))
self.log.warning("Only first subset name template used!")
subset_name_list[:] = subset_name_list[0]
if len(family_list) > 1:
self.log.warning("Multiple mappings found for '{}'".
format(layer.name))
self.log.warning("Only first family used!")
family_list[:] = family_list[0]
if subset_name_list:
resolved_subset_template = subset_name_list.pop()
if family_list:
family = family_list.pop()
return family, resolved_subset_template

View file

@ -12,7 +12,7 @@ class ExtractImage(openpype.api.Extractor):
label = "Extract Image"
hosts = ["photoshop"]
families = ["image"]
families = ["image", "background"]
formats = ["png", "jpg"]
def process(self, instance):

View file

@ -253,6 +253,7 @@ def create_unreal_project(project_name: str,
"Plugins": [
{"Name": "PythonScriptPlugin", "Enabled": True},
{"Name": "EditorScriptingUtilities", "Enabled": True},
{"Name": "SequencerScripting", "Enabled": True},
{"Name": "Avalon", "Enabled": True}
]
}

View file

@ -0,0 +1,43 @@
import unreal
from unreal import EditorAssetLibrary as eal
from unreal import EditorLevelLibrary as ell
from openpype.hosts.unreal.api.plugin import Creator
from avalon.unreal import (
instantiate,
)
class CreateCamera(Creator):
"""Layout output for character rigs"""
name = "layoutMain"
label = "Camera"
family = "camera"
icon = "cubes"
root = "/Game/Avalon/Instances"
suffix = "_INS"
def __init__(self, *args, **kwargs):
super(CreateCamera, self).__init__(*args, **kwargs)
def process(self):
data = self.data
name = data["subset"]
data["level"] = ell.get_editor_world().get_path_name()
if not eal.does_directory_exist(self.root):
eal.make_directory(self.root)
factory = unreal.LevelSequenceFactoryNew()
tools = unreal.AssetToolsHelpers().get_asset_tools()
tools.create_asset(name, f"{self.root}/{name}", None, factory)
asset_name = f"{self.root}/{name}/{name}.{name}"
data["members"] = [asset_name]
instantiate(f"{self.root}", name, data, None, self.suffix)

View file

@ -0,0 +1,206 @@
import os
from avalon import api, io, pipeline
from avalon.unreal import lib
from avalon.unreal import pipeline as unreal_pipeline
import unreal
class CameraLoader(api.Loader):
"""Load Unreal StaticMesh from FBX"""
families = ["camera"]
label = "Load Camera"
representations = ["fbx"]
icon = "cube"
color = "orange"
def load(self, context, name, namespace, data):
"""
Load and containerise representation into Content Browser.
This is two step process. First, import FBX to temporary path and
then call `containerise()` on it - this moves all content to new
directory and then it will create AssetContainer there and imprint it
with metadata. This will mark this path as container.
Args:
context (dict): application context
name (str): subset name
namespace (str): in Unreal this is basically path to container.
This is not passed here, so namespace is set
by `containerise()` because only then we know
real path.
data (dict): Those would be data to be imprinted. This is not used
now, data are imprinted by `containerise()`.
Returns:
list(str): list of container content
"""
# Create directory for asset and avalon container
root = "/Game/Avalon/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
asset_name = "{}_{}".format(asset, name)
else:
asset_name = "{}".format(name)
tools = unreal.AssetToolsHelpers().get_asset_tools()
unique_number = 1
if unreal.EditorAssetLibrary.does_directory_exist(f"{root}/{asset}"):
asset_content = unreal.EditorAssetLibrary.list_assets(
f"{root}/{asset}", recursive=False, include_folder=True
)
# Get highest number to make a unique name
folders = [a for a in asset_content
if a[-1] == "/" and f"{name}_" in a]
f_numbers = []
for f in folders:
# Get number from folder name. Splits the string by "_" and
# removes the last element (which is a "/").
f_numbers.append(int(f.split("_")[-1][:-1]))
f_numbers.sort()
if not f_numbers:
unique_number = 1
else:
unique_number = f_numbers[-1] + 1
asset_dir, container_name = tools.create_unique_asset_name(
f"{root}/{asset}/{name}_{unique_number:02d}", suffix="")
container_name += suffix
unreal.EditorAssetLibrary.make_directory(asset_dir)
sequence = tools.create_asset(
asset_name=asset_name,
package_path=asset_dir,
asset_class=unreal.LevelSequence,
factory=unreal.LevelSequenceFactoryNew()
)
io_asset = io.Session["AVALON_ASSET"]
asset_doc = io.find_one({
"type": "asset",
"name": io_asset
})
data = asset_doc.get("data")
if data:
sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0))
sequence.set_playback_start(data.get("frameStart"))
sequence.set_playback_end(data.get("frameEnd"))
settings = unreal.MovieSceneUserImportFBXSettings()
settings.set_editor_property('reduce_keys', False)
unreal.SequencerTools.import_fbx(
unreal.EditorLevelLibrary.get_editor_world(),
sequence,
sequence.get_bindings(),
settings,
self.fname
)
# Create Asset Container
lib.create_avalon_container(container=container_name, path=asset_dir)
data = {
"schema": "openpype:container-2.0",
"id": pipeline.AVALON_CONTAINER_ID,
"asset": asset,
"namespace": asset_dir,
"container_name": container_name,
"asset_name": asset_name,
"loader": str(self.__class__.__name__),
"representation": context["representation"]["_id"],
"parent": context["representation"]["parent"],
"family": context["representation"]["context"]["family"]
}
unreal_pipeline.imprint(
"{}/{}".format(asset_dir, container_name), data)
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
)
for a in asset_content:
unreal.EditorAssetLibrary.save_asset(a)
return asset_content
def update(self, container, representation):
path = container["namespace"]
ar = unreal.AssetRegistryHelpers.get_asset_registry()
tools = unreal.AssetToolsHelpers().get_asset_tools()
asset_content = unreal.EditorAssetLibrary.list_assets(
path, recursive=False, include_folder=False
)
asset_name = ""
for a in asset_content:
asset = ar.get_asset_by_object_path(a)
if a.endswith("_CON"):
loaded_asset = unreal.EditorAssetLibrary.load_asset(a)
unreal.EditorAssetLibrary.set_metadata_tag(
loaded_asset, "representation", str(representation["_id"])
)
unreal.EditorAssetLibrary.set_metadata_tag(
loaded_asset, "parent", str(representation["parent"])
)
asset_name = unreal.EditorAssetLibrary.get_metadata_tag(
loaded_asset, "asset_name"
)
elif asset.asset_class == "LevelSequence":
unreal.EditorAssetLibrary.delete_asset(a)
sequence = tools.create_asset(
asset_name=asset_name,
package_path=path,
asset_class=unreal.LevelSequence,
factory=unreal.LevelSequenceFactoryNew()
)
io_asset = io.Session["AVALON_ASSET"]
asset_doc = io.find_one({
"type": "asset",
"name": io_asset
})
data = asset_doc.get("data")
if data:
sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0))
sequence.set_playback_start(data.get("frameStart"))
sequence.set_playback_end(data.get("frameEnd"))
settings = unreal.MovieSceneUserImportFBXSettings()
settings.set_editor_property('reduce_keys', False)
unreal.SequencerTools.import_fbx(
unreal.EditorLevelLibrary.get_editor_world(),
sequence,
sequence.get_bindings(),
settings,
str(representation["data"]["path"])
)
def remove(self, container):
path = container["namespace"]
parent_path = os.path.dirname(path)
unreal.EditorAssetLibrary.delete_directory(path)
asset_content = unreal.EditorAssetLibrary.list_assets(
parent_path, recursive=False, include_folder=True
)
if len(asset_content) == 0:
unreal.EditorAssetLibrary.delete_directory(parent_path)

View file

@ -0,0 +1,54 @@
import os
import unreal
from unreal import EditorAssetLibrary as eal
from unreal import EditorLevelLibrary as ell
import openpype.api
class ExtractCamera(openpype.api.Extractor):
"""Extract a camera."""
label = "Extract Camera"
hosts = ["unreal"]
families = ["camera"]
optional = True
def process(self, instance):
# Define extract output file path
stagingdir = self.staging_dir(instance)
fbx_filename = "{}.fbx".format(instance.name)
# Perform extraction
self.log.info("Performing extraction..")
# Check if the loaded level is the same of the instance
current_level = ell.get_editor_world().get_path_name()
assert current_level == instance.data.get("level"), \
"Wrong level loaded"
for member in instance[:]:
data = eal.find_asset_data(member)
if data.asset_class == "LevelSequence":
ar = unreal.AssetRegistryHelpers.get_asset_registry()
sequence = ar.get_asset_by_object_path(member).get_asset()
unreal.SequencerTools.export_fbx(
ell.get_editor_world(),
sequence,
sequence.get_bindings(),
unreal.FbxExportOption(),
os.path.join(stagingdir, fbx_filename)
)
break
if "representations" not in instance.data:
instance.data["representations"] = []
fbx_representation = {
'name': 'fbx',
'ext': 'fbx',
'files': fbx_filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(fbx_representation)

View file

@ -15,6 +15,7 @@ import tempfile
import pyblish.api
from avalon import io
from openpype.lib import prepare_template_data
from openpype.lib.plugin_tools import parse_json, get_batch_asset_task_info
class CollectPublishedFiles(pyblish.api.ContextPlugin):
@ -33,22 +34,6 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
# from Settings
task_type_to_family = {}
def _load_json(self, path):
path = path.strip('\"')
assert os.path.isfile(path), (
"Path to json file doesn't exist. \"{}\"".format(path)
)
data = None
with open(path, "r") as json_file:
try:
data = json.load(json_file)
except Exception as exc:
self.log.error(
"Error loading json: "
"{} - Exception: {}".format(path, exc)
)
return data
def _process_batch(self, dir_url):
task_subfolders = [
os.path.join(dir_url, o)
@ -56,22 +41,15 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
if os.path.isdir(os.path.join(dir_url, o))]
self.log.info("task_sub:: {}".format(task_subfolders))
for task_dir in task_subfolders:
task_data = self._load_json(os.path.join(task_dir,
"manifest.json"))
task_data = parse_json(os.path.join(task_dir,
"manifest.json"))
self.log.info("task_data:: {}".format(task_data))
ctx = task_data["context"]
task_type = "default_task_type"
task_name = None
if ctx["type"] == "task":
items = ctx["path"].split('/')
asset = items[-2]
os.environ["AVALON_TASK"] = ctx["name"]
task_name = ctx["name"]
task_type = ctx["attributes"]["type"]
else:
asset = ctx["name"]
os.environ["AVALON_TASK"] = ""
asset, task_name, task_type = get_batch_asset_task_info(ctx)
if task_name:
os.environ["AVALON_TASK"] = task_name
is_sequence = len(task_data["files"]) > 1
@ -261,7 +239,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
assert batch_dir, (
"Missing `OPENPYPE_PUBLISH_DATA`")
assert batch_dir, \
assert os.path.exists(batch_dir), \
"Folder {} doesn't exist".format(batch_dir)
project_name = os.environ.get("AVALON_PROJECT")

View file

@ -461,13 +461,8 @@ class ApplicationExecutable:
# On MacOS check if exists path to executable when ends with `.app`
# - it is common that path will lead to "/Applications/Blender" but
# real path is "/Applications/Blender.app"
if (
platform.system().lower() == "darwin"
and not os.path.exists(executable)
):
_executable = executable + ".app"
if os.path.exists(_executable):
executable = _executable
if platform.system().lower() == "darwin":
executable = self.macos_executable_prep(executable)
self.executable_path = executable
@ -477,6 +472,45 @@ class ApplicationExecutable:
def __repr__(self):
return "<{}> {}".format(self.__class__.__name__, self.executable_path)
@staticmethod
def macos_executable_prep(executable):
"""Try to find full path to executable file.
Real executable is stored in '*.app/Contents/MacOS/<executable>'.
Having path to '*.app' gives ability to read it's plist info and
use "CFBundleExecutable" key from plist to know what is "executable."
Plist is stored in '*.app/Contents/Info.plist'.
This is because some '*.app' directories don't have same permissions
as real executable.
"""
# Try to find if there is `.app` file
if not os.path.exists(executable):
_executable = executable + ".app"
if os.path.exists(_executable):
executable = _executable
# Try to find real executable if executable has `Contents` subfolder
contents_dir = os.path.join(executable, "Contents")
if os.path.exists(contents_dir):
executable_filename = None
# Load plist file and check for bundle executable
plist_filepath = os.path.join(contents_dir, "Info.plist")
if os.path.exists(plist_filepath):
import plistlib
parsed_plist = plistlib.readPlist(plist_filepath)
executable_filename = parsed_plist.get("CFBundleExecutable")
if executable_filename:
executable = os.path.join(
contents_dir, "MacOS", executable_filename
)
return executable
def as_args(self):
return [self.executable_path]

View file

@ -487,3 +487,48 @@ def should_decompress(file_url):
"compression: \"dwab\"" in output
return False
def parse_json(path):
"""Parses json file at 'path' location
Returns:
(dict) or None if unparsable
Raises:
AsssertionError if 'path' doesn't exist
"""
path = path.strip('\"')
assert os.path.isfile(path), (
"Path to json file doesn't exist. \"{}\"".format(path)
)
data = None
with open(path, "r") as json_file:
try:
data = json.load(json_file)
except Exception as exc:
log.error(
"Error loading json: "
"{} - Exception: {}".format(path, exc)
)
return data
def get_batch_asset_task_info(ctx):
"""Parses context data from webpublisher's batch metadata
Returns:
(tuple): asset, task_name (Optional), task_type
"""
task_type = "default_task_type"
task_name = None
asset = None
if ctx["type"] == "task":
items = ctx["path"].split('/')
asset = items[-2]
task_name = ctx["name"]
task_type = ctx["attributes"]["type"]
else:
asset = ctx["name"]
return asset, task_name, task_type

View file

@ -0,0 +1,110 @@
import os
from datetime import datetime
import sys
from bson.objectid import ObjectId
import pyblish.util
import pyblish.api
from openpype import uninstall
from openpype.lib.mongo import OpenPypeMongoConnection
def get_webpublish_conn():
"""Get connection to OP 'webpublishes' collection."""
mongo_client = OpenPypeMongoConnection.get_mongo_client()
database_name = os.environ["OPENPYPE_DATABASE_NAME"]
return mongo_client[database_name]["webpublishes"]
def start_webpublish_log(dbcon, batch_id, user):
"""Start new log record for 'batch_id'
Args:
dbcon (OpenPypeMongoConnection)
batch_id (str)
user (str)
Returns
(ObjectId) from DB
"""
return dbcon.insert_one({
"batch_id": batch_id,
"start_date": datetime.now(),
"user": user,
"status": "in_progress"
}).inserted_id
def publish_and_log(dbcon, _id, log, close_plugin_name=None):
"""Loops through all plugins, logs ok and fails into OP DB.
Args:
dbcon (OpenPypeMongoConnection)
_id (str)
log (OpenPypeLogger)
close_plugin_name (str): name of plugin with responsibility to
close host app
"""
# Error exit as soon as any error occurs.
error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}"
close_plugin = _get_close_plugin(close_plugin_name, log)
if isinstance(_id, str):
_id = ObjectId(_id)
log_lines = []
for result in pyblish.util.publish_iter():
for record in result["records"]:
log_lines.append("{}: {}".format(
result["plugin"].label, record.msg))
if result["error"]:
log.error(error_format.format(**result))
uninstall()
log_lines.append(error_format.format(**result))
dbcon.update_one(
{"_id": _id},
{"$set":
{
"finish_date": datetime.now(),
"status": "error",
"log": os.linesep.join(log_lines)
}}
)
if close_plugin: # close host app explicitly after error
context = pyblish.api.Context()
close_plugin().process(context)
sys.exit(1)
else:
dbcon.update_one(
{"_id": _id},
{"$set":
{
"progress": max(result["progress"], 0.95),
"log": os.linesep.join(log_lines)
}}
)
# final update
dbcon.update_one(
{"_id": _id},
{"$set":
{
"finish_date": datetime.now(),
"status": "finished_ok",
"progress": 1,
"log": os.linesep.join(log_lines)
}}
)
def _get_close_plugin(close_plugin_name, log):
if close_plugin_name:
plugins = pyblish.api.discover()
for plugin in plugins:
if plugin.__name__ == close_plugin_name:
return plugin
log.warning("Close plugin not found, app might not close.")

View file

@ -26,14 +26,21 @@ class CollectUsername(pyblish.api.ContextPlugin):
"""
order = pyblish.api.CollectorOrder - 0.488
label = "Collect ftrack username"
hosts = ["webpublisher"]
hosts = ["webpublisher", "photoshop"]
_context = None
def process(self, context):
self.log.info("CollectUsername")
# photoshop could be triggered remotely in webpublisher fashion
if os.environ["AVALON_APP"] == "photoshop":
if not os.environ.get("IS_HEADLESS"):
self.log.debug("Regular process, skipping")
return
os.environ["FTRACK_API_USER"] = os.environ["FTRACK_BOT_API_USER"]
os.environ["FTRACK_API_KEY"] = os.environ["FTRACK_BOT_API_KEY"]
self.log.info("CollectUsername")
for instance in context:
email = instance.data["user_email"]
self.log.info("email:: {}".format(email))

View file

@ -4,9 +4,16 @@ import os
import sys
import json
from datetime import datetime
import time
from openpype.lib import PypeLogger
from openpype.api import get_app_environments_for_context
from openpype.lib.plugin_tools import parse_json, get_batch_asset_task_info
from openpype.lib.remote_publish import (
get_webpublish_conn,
start_webpublish_log,
publish_and_log
)
class PypeCommands:
@ -110,10 +117,100 @@ class PypeCommands:
log.info("Publish finished.")
uninstall()
@staticmethod
def remotepublishfromapp(project, batch_dir, host, user, targets=None):
"""Opens installed variant of 'host' and run remote publish there.
Currently implemented and tested for Photoshop where customer
wants to process uploaded .psd file and publish collected layers
from there.
Requires installed host application on the machine.
Runs publish process as user would, in automatic fashion.
"""
from openpype import install, uninstall
from openpype.api import Logger
log = Logger.get_logger()
log.info("remotepublishphotoshop command")
install()
from openpype.lib import ApplicationManager
application_manager = ApplicationManager()
app_group = application_manager.app_groups.get(host)
if not app_group or not app_group.enabled:
raise ValueError("No application {} configured".format(host))
found_variant_key = None
# finds most up-to-date variant if any installed
for variant_key, variant in app_group.variants.items():
for executable in variant.executables:
if executable.exists():
found_variant_key = variant_key
if not found_variant_key:
raise ValueError("No executable for {} found".format(host))
app_name = "{}/{}".format(host, found_variant_key)
batch_data = None
if batch_dir and os.path.exists(batch_dir):
# TODO check if batch manifest is same as tasks manifests
batch_data = parse_json(os.path.join(batch_dir, "manifest.json"))
if not batch_data:
raise ValueError(
"Cannot parse batch meta in {} folder".format(batch_dir))
asset, task_name, _task_type = get_batch_asset_task_info(
batch_data["context"])
workfile_path = os.path.join(batch_dir,
batch_data["task"],
batch_data["files"][0])
print("workfile_path {}".format(workfile_path))
# must have for proper launch of app
env = get_app_environments_for_context(
project,
asset,
task_name,
app_name
)
os.environ.update(env)
_, batch_id = os.path.split(batch_dir)
dbcon = get_webpublish_conn()
# safer to start logging here, launch might be broken altogether
_id = start_webpublish_log(dbcon, batch_id, user)
os.environ["OPENPYPE_PUBLISH_DATA"] = batch_dir
os.environ["IS_HEADLESS"] = "true"
# must pass identifier to update log lines for a batch
os.environ["BATCH_LOG_ID"] = str(_id)
data = {
"last_workfile_path": workfile_path,
"start_last_workfile": True
}
launched_app = application_manager.launch(app_name, **data)
while launched_app.poll() is None:
time.sleep(0.5)
uninstall()
@staticmethod
def remotepublish(project, batch_path, host, user, targets=None):
"""Start headless publishing.
Used to publish rendered assets, workfiles etc.
Publish use json from passed paths argument.
Args:
@ -134,7 +231,6 @@ class PypeCommands:
from openpype import install, uninstall
from openpype.api import Logger
from openpype.lib import OpenPypeMongoConnection
# Register target and host
import pyblish.api
@ -166,62 +262,11 @@ class PypeCommands:
log.info("Running publish ...")
# Error exit as soon as any error occurs.
error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}"
mongo_client = OpenPypeMongoConnection.get_mongo_client()
database_name = os.environ["OPENPYPE_DATABASE_NAME"]
dbcon = mongo_client[database_name]["webpublishes"]
_, batch_id = os.path.split(batch_path)
_id = dbcon.insert_one({
"batch_id": batch_id,
"start_date": datetime.now(),
"user": user,
"status": "in_progress"
}).inserted_id
dbcon = get_webpublish_conn()
_id = start_webpublish_log(dbcon, batch_id, user)
log_lines = []
for result in pyblish.util.publish_iter():
for record in result["records"]:
log_lines.append("{}: {}".format(
result["plugin"].label, record.msg))
if result["error"]:
log.error(error_format.format(**result))
uninstall()
log_lines.append(error_format.format(**result))
dbcon.update_one(
{"_id": _id},
{"$set":
{
"finish_date": datetime.now(),
"status": "error",
"log": os.linesep.join(log_lines)
}}
)
sys.exit(1)
else:
dbcon.update_one(
{"_id": _id},
{"$set":
{
"progress": max(result["progress"], 0.95),
"log": os.linesep.join(log_lines)
}}
)
dbcon.update_one(
{"_id": _id},
{"$set":
{
"finish_date": datetime.now(),
"status": "finished_ok",
"progress": 1,
"log": os.linesep.join(log_lines)
}}
)
publish_and_log(dbcon, _id, log)
log.info("Publish finished.")
uninstall()

View file

@ -162,9 +162,7 @@
]
}
],
"customNodes": [
]
"customNodes": []
},
"regexInputs": {
"inputs": [

View file

@ -12,6 +12,16 @@
"optional": true,
"active": true
},
"CollectRemoteInstances": {
"color_code_mapping": [
{
"color_code": [],
"layer_name_regex": [],
"family": "",
"subset_template_name": ""
}
]
},
"ExtractImage": {
"formats": [
"png",

View file

@ -43,6 +43,56 @@
}
]
},
{
"type": "dict",
"collapsible": true,
"is_group": true,
"key": "CollectRemoteInstances",
"label": "Collect Instances for Webpublish",
"children": [
{
"type": "label",
"label": "Set color for publishable layers, set publishable families."
},
{
"type": "list",
"key": "color_code_mapping",
"label": "Color code mappings",
"use_label_wrap": false,
"collapsible": false,
"object_type": {
"type": "dict",
"children": [
{
"type": "list",
"key": "color_code",
"label": "Color codes for layers",
"object_type": "text"
},
{
"type": "list",
"key": "layer_name_regex",
"label": "Layer name regex",
"object_type": "text"
},
{
"type": "splitter"
},
{
"key": "family",
"label": "Resulting family",
"type": "text"
},
{
"type": "text",
"key": "subset_template_name",
"label": "Subset template name"
}
]
}
}
]
},
{
"type": "dict",
"collapsible": true,

View file

@ -164,8 +164,9 @@ class LoaderWindow(QtWidgets.QDialog):
subsets_widget.load_started.connect(self._on_load_start)
subsets_widget.load_ended.connect(self._on_load_end)
repres_widget.load_started.connect(self._on_load_start)
repres_widget.load_ended.connect(self._on_load_end)
if repres_widget:
repres_widget.load_started.connect(self._on_load_start)
repres_widget.load_ended.connect(self._on_load_end)
self._sync_server_enabled = sync_server_enabled

View file

@ -2,20 +2,27 @@ import sys
import time
import logging
from Qt import QtWidgets, QtCore
from openpype.hosts.maya.api.lib import assign_look_by_version
from avalon import style, io
from avalon.tools import lib
from avalon.vendor.Qt import QtWidgets, QtCore
from maya import cmds
# old api for MFileIO
import maya.OpenMaya
import maya.api.OpenMaya as om
from . import widgets
from . import commands
from . vray_proxies import vrayproxy_assign_look
from .widgets import (
AssetOutliner,
LookOutliner
)
from .commands import (
get_workfile,
remove_unused_looks
)
from .vray_proxies import vrayproxy_assign_look
module = sys.modules[__name__]
@ -32,7 +39,7 @@ class App(QtWidgets.QWidget):
# Store callback references
self._callbacks = []
filename = commands.get_workfile()
filename = get_workfile()
self.setObjectName("lookManager")
self.setWindowTitle("Look Manager 1.3.0 - [{}]".format(filename))
@ -57,13 +64,13 @@ class App(QtWidgets.QWidget):
"""Build the UI"""
# Assets (left)
asset_outliner = widgets.AssetOutliner()
asset_outliner = AssetOutliner()
# Looks (right)
looks_widget = QtWidgets.QWidget()
looks_layout = QtWidgets.QVBoxLayout(looks_widget)
look_outliner = widgets.LookOutliner() # Database look overview
look_outliner = LookOutliner() # Database look overview
assign_selected = QtWidgets.QCheckBox("Assign to selected only")
assign_selected.setToolTip("Whether to assign only to selected nodes "
@ -124,7 +131,7 @@ class App(QtWidgets.QWidget):
lambda: self.echo("Loaded assets.."))
self.look_outliner.menu_apply_action.connect(self.on_process_selected)
self.remove_unused.clicked.connect(commands.remove_unused_looks)
self.remove_unused.clicked.connect(remove_unused_looks)
# Maya renderlayer switch callback
callback = om.MEventMessage.addEventCallback(

View file

@ -9,7 +9,7 @@ from openpype.hosts.maya.api import lib
from avalon import io, api
import vray_proxies
from .vray_proxies import get_alembic_ids_cache
log = logging.getLogger(__name__)
@ -146,7 +146,7 @@ def create_items_from_nodes(nodes):
vray_proxy_nodes = cmds.ls(nodes, type="VRayProxy")
for vp in vray_proxy_nodes:
path = cmds.getAttr("{}.fileName".format(vp))
ids = vray_proxies.get_alembic_ids_cache(path)
ids = get_alembic_ids_cache(path)
parent_id = {}
for k, _ in ids.items():
pid = k.split(":")[0]

View file

@ -1,7 +1,8 @@
from collections import defaultdict
from avalon.tools import models
from avalon.vendor.Qt import QtCore
from Qt import QtCore
from avalon.tools import models
from avalon.vendor import qtawesome
from avalon.style import colors

View file

@ -1,4 +1,4 @@
from avalon.vendor.Qt import QtWidgets, QtCore
from Qt import QtWidgets, QtCore
DEFAULT_COLOR = "#fb9c15"

View file

@ -1,13 +1,16 @@
import logging
from collections import defaultdict
from avalon.vendor.Qt import QtWidgets, QtCore
from Qt import QtWidgets, QtCore
# TODO: expose this better in avalon core
from avalon.tools import lib
from avalon.tools.models import TreeModel
from . import models
from .models import (
AssetModel,
LookModel
)
from . import commands
from . import views
@ -30,7 +33,7 @@ class AssetOutliner(QtWidgets.QWidget):
title.setAlignment(QtCore.Qt.AlignCenter)
title.setStyleSheet("font-weight: bold; font-size: 12px")
model = models.AssetModel()
model = AssetModel()
view = views.View()
view.setModel(model)
view.customContextMenuRequested.connect(self.right_mouse_menu)
@ -201,7 +204,7 @@ class LookOutliner(QtWidgets.QWidget):
title.setStyleSheet("font-weight: bold; font-size: 12px")
title.setAlignment(QtCore.Qt.AlignCenter)
model = models.LookModel()
model = LookModel()
# Proxy for dynamic sorting
proxy = QtCore.QSortFilterProxyModel()
@ -257,5 +260,3 @@ class LookOutliner(QtWidgets.QWidget):
menu.addAction(apply_action)
menu.exec_(globalpos)

View file

@ -142,18 +142,23 @@ class ConsoleTrayApp:
self.tray_reconnect = False
ConsoleTrayApp.webserver_client.close()
def _send_text(self, new_text):
def _send_text_queue(self):
"""Sends lines and purges queue"""
lines = tuple(self.new_text)
self.new_text.clear()
if lines:
self._send_lines(lines)
def _send_lines(self, lines):
""" Send console content. """
if not ConsoleTrayApp.webserver_client:
return
if isinstance(new_text, str):
new_text = collections.deque(new_text.split("\n"))
payload = {
"host": self.host_id,
"action": host_console_listener.MsgAction.ADD,
"text": "\n".join(new_text)
"text": "\n".join(lines)
}
self._send(payload)
@ -174,14 +179,7 @@ class ConsoleTrayApp:
if self.tray_reconnect:
self._connect() # reconnect
if ConsoleTrayApp.webserver_client and self.new_text:
self._send_text(self.new_text)
self.new_text = collections.deque()
if self.new_text: # no webserver_client, text keeps stashing
start = max(len(self.new_text) - self.MAX_LINES, 0)
self.new_text = itertools.islice(self.new_text,
start, self.MAX_LINES)
self._send_text_queue()
if not self.initialized:
if self.initializing:
@ -191,7 +189,7 @@ class ConsoleTrayApp:
elif not host_connected:
text = "{} process is not alive. Exiting".format(self.host)
print(text)
self._send_text([text])
self._send_lines([text])
ConsoleTrayApp.websocket_server.stop()
sys.exit(1)
elif host_connected:
@ -205,14 +203,15 @@ class ConsoleTrayApp:
self.initializing = True
self.launch_method(*self.subprocess_args)
elif ConsoleTrayApp.process.poll() is not None:
self.exit()
elif ConsoleTrayApp.callback_queue:
elif ConsoleTrayApp.callback_queue and \
not ConsoleTrayApp.callback_queue.empty():
try:
callback = ConsoleTrayApp.callback_queue.get(block=False)
callback()
except queue.Empty:
pass
elif ConsoleTrayApp.process.poll() is not None:
self.exit()
@classmethod
def execute_in_main_thread(cls, func_to_call_from_main_thread):
@ -232,8 +231,9 @@ class ConsoleTrayApp:
self._close()
if ConsoleTrayApp.websocket_server:
ConsoleTrayApp.websocket_server.stop()
ConsoleTrayApp.process.kill()
ConsoleTrayApp.process.wait()
if ConsoleTrayApp.process:
ConsoleTrayApp.process.kill()
ConsoleTrayApp.process.wait()
if self.timer:
self.timer.stop()
QtCore.QCoreApplication.exit()

View file

@ -161,37 +161,62 @@ def capture(camera=None,
cmds.currentTime(cmds.currentTime(query=True))
padding = 10 # Extend panel to accommodate for OS window manager
with _independent_panel(width=width + padding,
height=height + padding,
off_screen=off_screen) as panel:
cmds.setFocus(panel)
with contextlib.nested(
_disabled_inview_messages(),
_maintain_camera(panel, camera),
_applied_viewport_options(viewport_options, panel),
_applied_camera_options(camera_options, panel),
_applied_display_options(display_options),
_applied_viewport2_options(viewport2_options),
_isolated_nodes(isolate, panel),
_maintained_time()):
all_playblast_kwargs = {
"compression": compression,
"format": format,
"percent": 100,
"quality": quality,
"viewer": viewer,
"startTime": start_frame,
"endTime": end_frame,
"offScreen": off_screen,
"showOrnaments": show_ornaments,
"forceOverwrite": overwrite,
"filename": filename,
"widthHeight": [width, height],
"rawFrameNumbers": raw_frame_numbers,
"framePadding": frame_padding
}
all_playblast_kwargs.update(playblast_kwargs)
output = cmds.playblast(
compression=compression,
format=format,
percent=100,
quality=quality,
viewer=viewer,
startTime=start_frame,
endTime=end_frame,
offScreen=off_screen,
showOrnaments=show_ornaments,
forceOverwrite=overwrite,
filename=filename,
widthHeight=[width, height],
rawFrameNumbers=raw_frame_numbers,
framePadding=frame_padding,
**playblast_kwargs)
if getattr(contextlib, "nested", None):
with contextlib.nested(
_disabled_inview_messages(),
_maintain_camera(panel, camera),
_applied_viewport_options(viewport_options, panel),
_applied_camera_options(camera_options, panel),
_applied_display_options(display_options),
_applied_viewport2_options(viewport2_options),
_isolated_nodes(isolate, panel),
_maintained_time()
):
output = cmds.playblast(**all_playblast_kwargs)
else:
with contextlib.ExitStack() as stack:
stack.enter_context(_disabled_inview_messages())
stack.enter_context(_maintain_camera(panel, camera))
stack.enter_context(
_applied_viewport_options(viewport_options, panel)
)
stack.enter_context(
_applied_camera_options(camera_options, panel)
)
stack.enter_context(
_applied_display_options(display_options)
)
stack.enter_context(
_applied_viewport2_options(viewport2_options)
)
stack.enter_context(_isolated_nodes(isolate, panel))
stack.enter_context(_maintained_time())
output = cmds.playblast(**all_playblast_kwargs)
return output
@ -364,7 +389,8 @@ def apply_view(panel, **options):
# Display options
display_options = options.get("display_options", {})
for key, value in display_options.iteritems():
_iteritems = getattr(display_options, "iteritems", display_options.items)
for key, value in _iteritems():
if key in _DisplayOptionsRGB:
cmds.displayRGBColor(key, *value)
else:
@ -372,16 +398,21 @@ def apply_view(panel, **options):
# Camera options
camera_options = options.get("camera_options", {})
for key, value in camera_options.iteritems():
_iteritems = getattr(camera_options, "iteritems", camera_options.items)
for key, value in _iteritems:
cmds.setAttr("{0}.{1}".format(camera, key), value)
# Viewport options
viewport_options = options.get("viewport_options", {})
for key, value in viewport_options.iteritems():
_iteritems = getattr(viewport_options, "iteritems", viewport_options.items)
for key, value in _iteritems():
cmds.modelEditor(panel, edit=True, **{key: value})
viewport2_options = options.get("viewport2_options", {})
for key, value in viewport2_options.iteritems():
_iteritems = getattr(
viewport2_options, "iteritems", viewport2_options.items
)
for key, value in _iteritems():
attr = "hardwareRenderingGlobals.{0}".format(key)
cmds.setAttr(attr, value)
@ -629,14 +660,16 @@ def _applied_camera_options(options, panel):
"for capture: %s" % opt)
options.pop(opt)
for opt, value in options.iteritems():
_iteritems = getattr(options, "iteritems", options.items)
for opt, value in _iteritems():
cmds.setAttr(camera + "." + opt, value)
try:
yield
finally:
if old_options:
for opt, value in old_options.iteritems():
_iteritems = getattr(old_options, "iteritems", old_options.items)
for opt, value in _iteritems():
cmds.setAttr(camera + "." + opt, value)
@ -722,14 +755,16 @@ def _applied_viewport2_options(options):
options.pop(opt)
# Apply settings
for opt, value in options.iteritems():
_iteritems = getattr(options, "iteritems", options.items)
for opt, value in _iteritems():
cmds.setAttr("hardwareRenderingGlobals." + opt, value)
try:
yield
finally:
# Restore previous settings
for opt, value in original.iteritems():
_iteritems = getattr(original, "iteritems", original.items)
for opt, value in _iteritems():
cmds.setAttr("hardwareRenderingGlobals." + opt, value)
@ -769,7 +804,8 @@ def _maintain_camera(panel, camera):
try:
yield
finally:
for camera, renderable in state.iteritems():
_iteritems = getattr(state, "iteritems", state.items)
for camera, renderable in _iteritems():
cmds.setAttr(camera + ".rnd", renderable)

View file

@ -0,0 +1,94 @@
import pytest
import os
import shutil
from tests.lib.testing_classes import PublishTest
class TestPublishInPhotoshop(PublishTest):
"""Basic test case for publishing in Photoshop
Uses generic TestCase to prepare fixtures for test data, testing DBs,
env vars.
Opens Maya, run publish on prepared workile.
Then checks content of DB (if subset, version, representations were
created.
Checks tmp folder if all expected files were published.
"""
PERSIST = True
TEST_FILES = [
("1Bciy2pCwMKl1UIpxuPnlX_LHMo_Xkq0K", "test_photoshop_publish.zip", "")
]
APP = "photoshop"
APP_VARIANT = "2020"
APP_NAME = "{}/{}".format(APP, APP_VARIANT)
TIMEOUT = 120 # publish timeout
@pytest.fixture(scope="module")
def last_workfile_path(self, download_test_data):
"""Get last_workfile_path from source data.
Maya expects workfile in proper folder, so copy is done first.
"""
src_path = os.path.join(download_test_data,
"input",
"workfile",
"test_project_test_asset_TestTask_v001.psd")
dest_folder = os.path.join(download_test_data,
self.PROJECT,
self.ASSET,
"work",
self.TASK)
os.makedirs(dest_folder)
dest_path = os.path.join(dest_folder,
"test_project_test_asset_TestTask_v001.psd")
shutil.copy(src_path, dest_path)
yield dest_path
@pytest.fixture(scope="module")
def startup_scripts(self, monkeypatch_session, download_test_data):
"""Points Maya to userSetup file from input data"""
os.environ["IS_HEADLESS"] = "true"
def test_db_asserts(self, dbcon, publish_finished):
"""Host and input data dependent expected results in DB."""
print("test_db_asserts")
assert 5 == dbcon.count_documents({"type": "version"}), \
"Not expected no of versions"
assert 0 == dbcon.count_documents({"type": "version",
"name": {"$ne": 1}}), \
"Only versions with 1 expected"
assert 1 == dbcon.count_documents({"type": "subset",
"name": "modelMain"}), \
"modelMain subset must be present"
assert 1 == dbcon.count_documents({"type": "subset",
"name": "workfileTest_task"}), \
"workfileTest_task subset must be present"
assert 11 == dbcon.count_documents({"type": "representation"}), \
"Not expected no of representations"
assert 2 == dbcon.count_documents({"type": "representation",
"context.subset": "modelMain",
"context.ext": "abc"}), \
"Not expected no of representations with ext 'abc'"
assert 2 == dbcon.count_documents({"type": "representation",
"context.subset": "modelMain",
"context.ext": "ma"}), \
"Not expected no of representations with ext 'abc'"
if __name__ == "__main__":
test_case = TestPublishInPhotoshop()

View file

@ -228,6 +228,7 @@ class PublishTest(ModuleUnitTest):
while launched_app.poll() is None:
time.sleep(0.5)
if time.time() - time_start > self.TIMEOUT:
launched_app.terminate()
raise ValueError("Timeout reached")
# some clean exit test possible?