Implement extract animation

This commit is contained in:
Roy Nieterau 2017-06-28 12:49:15 +02:00
parent 6190980df6
commit 7bc42e9fd9
3 changed files with 251 additions and 208 deletions

View file

@ -3,6 +3,11 @@
import re
import contextlib
from collections import OrderedDict
import logging
import os
import json
log = logging.getLogger(__name__)
from maya import cmds
@ -301,4 +306,179 @@ def is_visible(node,
visibility=visibility):
return False
return True
return True
# The maya alembic export types
_alembic_options = {
"startFrame": float,
"endFrame": float,
"frameRange": str, # "start end"; overrides startFrame & endFrame
"eulerFilter": bool,
"frameRelativeSample": float,
"noNormals": bool,
"renderableOnly": bool,
"step": float,
"stripNamespaces": bool,
"uvWrite": bool,
"wholeFrameGeo": bool,
"worldSpace": bool,
"writeVisibility": bool,
"writeColorSets": bool,
"writeFaceSets": bool,
"writeCreases": bool, # Maya 2015 Ext1+
"dataFormat": str,
"root": (list, tuple),
"attr": (list, tuple),
"attrPrefix": (list, tuple),
"userAttr": (list, tuple),
"melPerFrameCallback": str,
"melPostJobCallback": str,
"pythonPerFrameCallback": str,
"pythonPostJobCallback": str,
"selection": bool
}
def extract_alembic(file,
startFrame=None,
endFrame=None,
selection= True,
uvWrite= True,
eulerFilter= True,
dataFormat="ogawa",
verbose=False,
**kwargs):
"""Extract a single Alembic Cache.
This extracts an Alembic cache using the `-selection` flag to minimize
the extracted content to solely what was Collected into the instance.
Arguments:
startFrame (float): Start frame of output. Ignored if `frameRange`
provided.
endFrame (float): End frame of output. Ignored if `frameRange`
provided.
frameRange (tuple or str): Two-tuple with start and end frame or a
string formatted as: "startFrame endFrame". This argument
overrides `startFrame` and `endFrame` arguments.
dataFormat (str): The data format to use for the cache,
defaults to "ogawa"
verbose (bool): When on, outputs frame number information to the
Script Editor or output window during extraction.
noNormals (bool): When on, normal data from the original polygon
objects is not included in the exported Alembic cache file.
renderableOnly (bool): When on, any non-renderable nodes or hierarchy,
such as hidden objects, are not included in the Alembic file.
Defaults to False.
stripNamespaces (bool): When on, any namespaces associated with the
exported objects are removed from the Alembic file. For example, an
object with the namespace taco:foo:bar appears as bar in the
Alembic file.
uvWrite (bool): When on, UV data from polygon meshes and subdivision
objects are written to the Alembic file. Only the current UV map is
included.
worldSpace (bool): When on, the top node in the node hierarchy is
stored as world space. By default, these nodes are stored as local
space. Defaults to False.
eulerFilter (bool): When on, X, Y, and Z rotation data is filtered with
an Euler filter. Euler filtering helps resolve irregularities in
rotations especially if X, Y, and Z rotations exceed 360 degrees.
Defaults to True.
"""
# Ensure alembic exporter is loaded
cmds.loadPlugin('AbcExport', quiet=True)
# Alembic Exporter requires forward slashes
file = file.replace('\\', '/')
# Pass the start and end frame on as `frameRange` so that it
# never conflicts with that argument
if "frameRange" not in kwargs:
# Fallback to maya timeline if no start or end frame provided.
if startFrame is None:
startFrame = cmds.playbackOptions(query=True,
animationStartTime=True)
if endFrame is None:
endFrame = cmds.playbackOptions(query=True,
animationEndTime=True)
# Ensure valid types are converted to frame range
assert isinstance(startFrame, _alembic_options["startFrame"])
assert isinstance(endFrame, _alembic_options["endFrame"])
kwargs["frameRange"] = "{0} {1}".format(startFrame, endFrame)
else:
# Allow conversion from tuple for `frameRange`
frame_range = kwargs["frameRange"]
if isinstance(frame_range, (list, tuple)):
assert len(frame_range) == 2
kwargs["frameRange"] = "{0} {1}".format(frame_range[0],
frame_range[1])
# Assemble options
options = {
"selection": selection,
"uvWrite": uvWrite,
"eulerFilter": eulerFilter,
"dataFormat": dataFormat
}
options.update(kwargs)
# Validate options
for key, value in options.copy().items():
# Discard unknown options
if key not in _alembic_options:
options.pop(key)
continue
# Validate value type
valid_types = _alembic_options[key]
if not isinstance(value, valid_types):
raise TypeError("Alembic option unsupported type: "
"{0} (expected {1}}".format(value, valid_types))
# Format the job string from options
job_args = list()
for key, value in options.items():
if isinstance(value, (list, tuple)):
for entry in value:
job_args.append("-{0} {1}".format(key=key, value=entry))
elif isinstance(value, bool):
job_args.append("-{0}".format(key))
else:
job_args.append("-{0} {1}".format(key, value))
job_str = " ".join(job_args)
job_str += ' -file "%s"' % file
# Ensure output directory exists
parent_dir = os.path.dirname(file)
if not os.path.exists(parent_dir):
os.makedirs(parent_dir)
if verbose:
log.debug("Preparing Alembic export with options: %s",
json.dumps(options, indent=4))
log.debug("Extracting Alembic with job arguments: %s", job_str)
# Perform extraction
cmds.AbcExport(j=job_str, verbose=verbose)
if verbose:
log.debug("Extracted Alembic to: %s", file)
return file

View file

@ -1,20 +1,9 @@
import os
import json
import contextlib
from maya import cmds
import copy
import avalon.maya
import colorbleed.api
@contextlib.contextmanager
def suspension():
try:
cmds.refresh(suspend=True)
yield
finally:
cmds.refresh(suspend=False)
from colorbleed.maya.lib import extract_alembic
class ExtractAlembic(colorbleed.api.Extractor):
@ -23,213 +12,23 @@ class ExtractAlembic(colorbleed.api.Extractor):
This extracts an Alembic cache using the `-selection` flag to minimize
the extracted content to solely what was Collected into the instance.
Arguments:
startFrame (float): Start frame of output. Ignored if `frameRange`
provided.
endFrame (float): End frame of output. Ignored if `frameRange`
provided.
frameRange (str): Frame range in the format of "startFrame endFrame".
Overrides `startFrame` and `endFrame` arguments.
dataFormat (str): The data format to use for the cache,
defaults to "ogawa"
verbose (bool): When on, outputs frame number information to the
Script Editor or output window during extraction.
noNormals (bool): When on, normal data from the original polygon
objects is not included in the exported Alembic cache file.
renderableOnly (bool): When on, any non-renderable nodes or hierarchy,
such as hidden objects, are not included in the Alembic file.
Defaults to False.
stripNamespaces (bool): When on, any namespaces associated with the
exported objects are removed from the Alembic file. For example, an
object with the namespace taco:foo:bar appears as bar in the
Alembic file.
uvWrite (bool): When on, UV data from polygon meshes and subdivision
objects are written to the Alembic file. Only the current UV map is
included.
worldSpace (bool): When on, the top node in the node hierarchy is
stored as world space. By default, these nodes are stored as local
space. Defaults to False.
eulerFilter (bool): When on, X, Y, and Z rotation data is filtered with
an Euler filter. Euler filtering helps resolve irregularities in
rotations especially if X, Y, and Z rotations exceed 360 degrees.
Defaults to True.
"""
label = "Alembic"
families = ["colorbleed.model",
"colorbleed.pointcache",
"colorbleed.animation",
"colorbleed.proxy"]
optional = True
@property
def options(self):
"""Overridable options for Alembic export
Given in the following format
- {NAME: EXPECTED TYPE}
If the overridden option's type does not match,
the option is not included and a warning is logged.
"""
return {"startFrame": float,
"endFrame": float,
"frameRange": str, # "start end"; overrides startFrame & endFrame
"eulerFilter": bool,
"frameRelativeSample": float,
"noNormals": bool,
"renderableOnly": bool,
"step": float,
"stripNamespaces": bool,
"uvWrite": bool,
"wholeFrameGeo": bool,
"worldSpace": bool,
"writeVisibility": bool,
"writeColorSets": bool,
"writeFaceSets": bool,
"writeCreases": bool, # Maya 2015 Ext1+
"dataFormat": str,
"root": (list, tuple),
"attr": (list, tuple),
"attrPrefix": (list, tuple),
"userAttr": (list, tuple),
"melPerFrameCallback": str,
"melPostJobCallback": str,
"pythonPerFrameCallback": str,
"pythonPostJobCallback": str,
"selection": bool}
@property
def default_options(self):
"""Supply default options to extraction.
This may be overridden by a subclass to provide
alternative defaults.
"""
start_frame = cmds.playbackOptions(query=True, animationStartTime=True)
end_frame = cmds.playbackOptions(query=True, animationEndTime=True)
return {"startFrame": start_frame,
"endFrame": end_frame,
"selection": True,
"uvWrite": True,
"eulerFilter": True,
"dataFormat": "ogawa" # ogawa, hdf5
}
def process(self, instance):
# Ensure alembic exporter is loaded
cmds.loadPlugin('AbcExport', quiet=True)
parent_dir = self.staging_dir(instance)
filename = "%s.abc" % instance.name
path = os.path.join(parent_dir, filename)
# Alembic Exporter requires forward slashes
path = path.replace('\\', '/')
options = copy.deepcopy(instance.data)
options = self.default_options
options["userAttr"] = ("uuid",)
options = self.parse_overrides(instance, options)
options['selection'] = True
job_str = self.parse_options(options)
job_str += ' -file "%s"' % path
self.log.info('Extracting alembic to: "%s"' % path)
verbose = instance.data('verbose', False)
if verbose:
self.log.debug('Alembic job string: "%s"'% job_str)
if not os.path.exists(parent_dir):
os.makedirs(parent_dir)
with suspension():
with avalon.maya.suspended_refresh():
with avalon.maya.maintained_selection():
self.log.debug(
"Preparing %s for export using the following options: %s\n"
"and the following string: %s"
% (list(instance),
json.dumps(options, indent=4),
job_str))
cmds.select(instance.data("setMembers"), hierarchy=True)
cmds.AbcExport(j=job_str, verbose=verbose)
def parse_overrides(self, instance, options):
"""Inspect data of instance to determine overridden options
An instance may supply any of the overridable options
as data, the option is then added to the extraction.
"""
for key in instance.data():
if key not in self.options:
continue
# Ensure the data is of correct type
value = instance.data(key)
if not isinstance(value, self.options[key]):
self.log.warning(
"Overridden attribute {key} was of "
"the wrong type: {invalid_type} "
"- should have been {valid_type}".format(
key=key,
invalid_type=type(value).__name__,
valid_type=self.options[key].__name__))
continue
options[key] = value
return options
@classmethod
def parse_options(cls, options):
"""Convert key-word arguments to job arguments string
Args:
options (dict): the options for the command
"""
# Convert `startFrame` and `endFrame` arguments
if 'startFrame' in options or 'endFrame' in options:
start_frame = options.pop('startFrame', None)
end_frame = options.pop('endFrame', None)
if 'frameRange' in options:
cls.log.debug("The `startFrame` and/or `endFrame` arguments "
"are overridden by the provided `frameRange`.")
elif start_frame is None or end_frame is None:
cls.log.warning("The `startFrame` and `endFrame` arguments "
"must be supplied together.")
else:
options['frameRange'] = "%s %s" % (start_frame, end_frame)
job_args = list()
for key, value in options.items():
if isinstance(value, (list, tuple)):
for entry in value:
job_args.append("-%s %s" % (key, entry))
elif isinstance(value, bool):
job_args.append("%s" % key)
else:
job_args.append("-%s %s" % (key, value))
job_str = " ".join(job_args)
return job_str
extract_alembic(file=path, **options)

View file

@ -0,0 +1,64 @@
import colorbleed.api
class ExtractColorbleedAnimation(colorbleed.api.Extractor):
"""Produce an alembic of just point positions and normals.
Positions and normals are preserved, but nothing more,
for plain and predictable point caches.
"""
label = "Extract Animation"
hosts = ["maya"]
families = ["colorbleed.animation"]
def process(self, instance):
import os
from maya import cmds
import avalon.maya
from colorbleed.maya.lib import extract_alembic
# Collect the out set nodes
out_sets = [node for node in instance if node.endswith("out_SET")]
if len(out_sets) != 1:
raise RuntimeError("Couldn't find exactly one out_SET: "
"{0}".format(out_sets))
out_set = out_sets[0]
nodes = cmds.sets(out_set, query=True)
# Include all descendents
nodes += cmds.listRelatives(nodes,
allDescendents=True,
fullPath=True) or []
# Collect the start and end including handles
start = instance.data["startFrame"]
end = instance.data["endFrame"]
handles = instance.data.get("handles", 0)
if handles:
start -= handles
end += handles
self.log.info("Extracting animation..")
dirname = self.staging_dir(instance)
self.log.info("nodes: %s" % str(nodes))
parent_dir = self.staging_dir(instance)
filename = "{name}.abc".format(**instance.data)
path = os.path.join(parent_dir, filename)
with avalon.maya.suspended_refresh():
with avalon.maya.maintained_selection():
cmds.select(nodes, noExpand=True)
extract_alembic(file=path, **{
"selection": True,
"frameRange": (start, end),
"writeVisibility": True,
"writeUV": True,
"step": instance.data.get("step", 1.0),
"attributePrefix": ("mb",)
})
self.log.info("Extracted {} to {}".format(instance, dirname))