diff --git a/colorbleed/maya/lib.py b/colorbleed/maya/lib.py index 2c71c05d32..c6314049ae 100644 --- a/colorbleed/maya/lib.py +++ b/colorbleed/maya/lib.py @@ -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 \ No newline at end of file + 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 diff --git a/colorbleed/plugins/maya/publish/extract_alembic.py b/colorbleed/plugins/maya/publish/extract_alembic.py index 9236082268..2bde0bfe9a 100644 --- a/colorbleed/plugins/maya/publish/extract_alembic.py +++ b/colorbleed/plugins/maya/publish/extract_alembic.py @@ -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) diff --git a/colorbleed/plugins/maya/publish/extract_animation.py b/colorbleed/plugins/maya/publish/extract_animation.py new file mode 100644 index 0000000000..48cb4711dc --- /dev/null +++ b/colorbleed/plugins/maya/publish/extract_animation.py @@ -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))