"""Maya Capture Playblasting with independent viewport, camera and display options """ import os import re import sys import json import shutil import tempfile import threading import subprocess import contextlib from maya import cmds version_info = (2, 1, 0) __version__ = "%s.%s.%s" % version_info __license__ = "MIT" def capture(camera=None, width=None, height=None, filename=None, complete_filename=None, start_frame=None, end_frame=None, frame=None, format='qt', compression='h264', quality=100, off_screen=False, viewer=True, isolate=None, maintain_aspect_ratio=True, overwrite=False, raw_frame_numbers=False, camera_options=None, display_options=None, viewport_options=None, viewport2_options=None): """Playblast in an independent panel Arguments: camera (str, optional): Name of camera, defaults to "persp" width (int, optional): Width of output in pixels height (int, optional): Height of output in pixels filename (str, optional): Name of output file. If none is specified, no files are saved. complete_filename (str, optional): Exact name of output file. Use this to override the output of `filename` so it excludes frame padding. start_frame (float, optional): Defaults to current start frame. end_frame (float, optional): Defaults to current end frame. frame (float or tuple, optional): A single frame or list of frames. Use this to capture a single frame or an arbitrary sequence of frames. format (str, optional): Name of format, defaults to "qt". compression (str, optional): Name of compression, defaults to "h264" off_screen (bool, optional): Whether or not to playblast off screen viewer (bool, optional): Display results in native player isolate (list): List of nodes to isolate upon capturing maintain_aspect_ratio (bool, optional): Modify height in order to maintain aspect ratio. overwrite (bool, optional): Whether or not to overwrite if file already exists. If disabled and file exists and error will be raised. raw_frame_numbers (bool, optional): Whether or not to use the exact frame numbers from the scene or capture to a sequence starting at zero. Defaults to False. When set to True `viewer` can't be used and will be forced to False. camera_options (dict, optional): Supplied camera options, using `CameraOptions` display_options (dict, optional): Supplied display options, using `DisplayOptions` viewport_options (dict, optional): Supplied viewport options, using `ViewportOptions` viewport2_options (dict, optional): Supplied display options, using `Viewport2Options` Example: >>> # Launch default capture >>> capture() >>> # Launch capture with custom viewport settings >>> capture('persp', 800, 600, ... viewport_options={ ... "displayAppearance": "wireframe", ... "grid": False, ... "polymeshes": True, ... }, ... camera_options={ ... "displayResolution": True ... } ... ) """ camera = camera or "persp" # Ensure camera exists if not cmds.objExists(camera): raise RuntimeError("Camera does not exist: {0}".format(camera)) width = width or cmds.getAttr("defaultResolution.width") height = height or cmds.getAttr("defaultResolution.height") if maintain_aspect_ratio: ratio = cmds.getAttr("defaultResolution.deviceAspectRatio") height = width / ratio start_frame = start_frame or cmds.playbackOptions(minTime=True, query=True) end_frame = end_frame or cmds.playbackOptions(maxTime=True, query=True) # We need to wrap `completeFilename`, otherwise even when None is provided # it will use filename as the exact name. Only when lacking as argument # does it function correctly. playblast_kwargs = dict() if complete_filename: playblast_kwargs['completeFilename'] = complete_filename if frame: playblast_kwargs['frame'] = frame # (#21) Bugfix: `maya.cmds.playblast` suffers from undo bug where it # always sets the currentTime to frame 1. By setting currentTime before # the playblast call it'll undo correctly. cmds.currentTime(cmds.currentTime(q=1)) 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( _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( compression=compression, format=format, percent=100, quality=quality, viewer=viewer, startTime=start_frame, endTime=end_frame, offScreen=off_screen, forceOverwrite=overwrite, filename=filename, widthHeight=[width, height], rawFrameNumbers=raw_frame_numbers, **playblast_kwargs) return output def snap(*args, **kwargs): """Single frame playblast in an independent panel. The arguments of `capture` are all valid here as well, except for `start_frame` and `end_frame`. Arguments: frame (float, optional): The frame to snap. If not provided current frame is used. clipboard (bool, optional): Whether to add the output image to the global clipboard. This allows to easily paste the snapped image into another application, eg. into Photoshop. Keywords: See `capture`. """ # capture single frame frame = kwargs.pop('frame', cmds.currentTime(q=1)) kwargs['start_frame'] = frame kwargs['end_frame'] = frame kwargs['frame'] = frame if not isinstance(frame, (int, float)): raise TypeError("frame must be a single frame (integer or float). " "Use `capture()` for sequences.") # override capture defaults format = kwargs.pop('format', "image") compression = kwargs.pop('compression', "png") viewer = kwargs.pop('viewer', False) raw_frame_numbers = kwargs.pop('raw_frame_numbers', True) kwargs['compression'] = compression kwargs['format'] = format kwargs['viewer'] = viewer kwargs['raw_frame_numbers'] = raw_frame_numbers # pop snap only keyword arguments clipboard = kwargs.pop('clipboard', False) # perform capture output = capture(*args, **kwargs) def replace(m): """Substitute # with frame number""" return str(int(frame)).zfill(len(m.group())) output = re.sub("#+", replace, output) # add image to clipboard if clipboard: _image_to_clipboard(output) return output def wedge(layers, async=False, on_finished=None, silent=True, **kwargs): """Capture from a camera once per animation layer Use this to create wedges of varying settings. Arguments: layers (list): Layers, or combinations of layers, to use per capture async (bool): Whether to run asynchronously, or one at a time silent (bool): Whether or not to print output of subprocesses on_finished (callable): Callback for when multiprocess the entire operation is finished (only relevant with `multiprocess`). Outputted files are passed to callback as a list of absolute paths. """ missing = [l for l in layers if not cmds.objExists(l)] if missing: raise ValueError("These animation layers was not found: %s" % missing) # Do not show player for each finished capture # kwargs["viewer"] = False if not async: # Keep it simple output = list() with _muted_animation_layers(layers): for layer in layers: with _solo_animation_layer(layer): output.append(capture(**kwargs)) if on_finished is not None: on_finished(output) return output else: processes = list() tempdir = tempfile.mkdtemp() def __monitor(): """Threaded callback""" output = list() cmds.warning("Running post-operation..") for process in processes: fname = None # Listen for file output for line in iter(process.stdout.readline, b""): if not silent: sys.stdout.write(line) if line.startswith("out: "): print(line[5:]) # Keep an eye out for when the output is # being printed. if "__maya_capture_output" in line: fname = line.split("__maya_capture_output: ")[-1] fname = fname.strip() # Remove newline if fname is None: sys.stderr.write( "Process did not output capture output.\n") else: output.append(fname) # remove newline at end cmds.warning("Done, cleaning up temporary files..") shutil.rmtree(tempdir) # Trigger callback if on_finished is not None: cmds.warning("Running callback..") on_finished(output) # Export scene cmds.warning("Saving scene..") scene = os.path.join(tempdir, "temp.mb") cmds.file(scene, exportAll=True, type="mayaBinary") cmds.warning("Running wedges in background..") for layer in layers: script = """ import os import sys import json import logging log = logging.getLogger() log.info("out: Within subprocess..") from maya import cmds, standalone standalone.initialize() assert cmds.objExists("persp") scene = r\"{scene}\" layer = \"{layer}\" preset = json.loads('{preset}') log.info("out: JSON: %s" % json.dumps(preset, indent=4)) log.info("out: Opening %s" % scene) cmds.file(scene, open=True, force=True) import capture # One file per layer preset["filename"] = layer preset["off_screen"] = True preset["camera"] = "persp" output = capture.wedge([layer], **preset) # output = cmds.playblast() # output = capture.capture() log.info("out: Made it past capture..") log.info("__maya_capture_output: %s" % output[0]) # Safely exit without throwing an exception sys.exit() """ script = script.format( layer=layer, scene=scene, preset=json.dumps(kwargs), paths=json.dumps(sys.path) ) print("Running script: %s" % script) scriptpath = os.path.join(tempdir, layer + ".py") with open(scriptpath, "w") as f: f.write(script) # print("Running script: %s" % script) popen = subprocess.Popen("mayapy -u %s" % scriptpath, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) # Track process processes.append(popen) cmds.warning("Awaiting background processes to finish..") threading.Thread(target=__monitor).start() CameraOptions = { "displayGateMask": False, "displayResolution": False, "displayFilmGate": False, "displayFieldChart": False, "displaySafeAction": False, "displaySafeTitle": False, "displayFilmPivot": False, "displayFilmOrigin": False, "overscan": 1.0, "depthOfField": False, } DisplayOptions = { "displayGradient": True, "background": (0.631, 0.631, 0.631), "backgroundTop": (0.535, 0.617, 0.702), "backgroundBottom": (0.052, 0.052, 0.052), } # These display options require a different command to be queried and set _DisplayOptionsRGB = set(["background", "backgroundTop", "backgroundBottom"]) ViewportOptions = { # renderer "rendererName": "vp2Renderer", "fogging": False, "fogMode": "linear", "fogDensity": 1, "fogStart": 1, "fogEnd": 1, "fogColor": (0, 0, 0, 0), "shadows": False, "displayTextures": True, "displayLights": "default", "useDefaultMaterial": False, "wireframeOnShaded": False, "displayAppearance": 'smoothShaded', "selectionHiliteDisplay": False, "headsUpDisplay": True, # object display "nurbsCurves": False, "nurbsSurfaces": False, "polymeshes": True, "subdivSurfaces": False, "cameras": False, "lights": False, "grid": False, "joints": False, "ikHandles": False, "deformers": False, "dynamics": False, "fluids": False, "hairSystems": False, "follicles": False, "nCloths": False, "nParticles": False, "nRigids": False, "dynamicConstraints": False, "locators": False, "manipulators": False, "dimensions": False, "handles": False, "pivots": False, "textures": False, "strokes": False } Viewport2Options = { "consolidateWorld": True, "enableTextureMaxRes": False, "bumpBakeResolution": 64, "colorBakeResolution": 64, "floatingPointRTEnable": False, "floatingPointRTFormat": 1, "gammaCorrectionEnable": False, "gammaValue": 2.2, "lineAAEnable": False, "maxHardwareLights": 8, "motionBlurEnable": False, "motionBlurSampleCount": 8, "motionBlurShutterOpenFraction": 0.2, "motionBlurType": 0, "multiSampleCount": 8, "multiSampleEnable": False, "singleSidedLighting": False, "ssaoEnable": False, "ssaoAmount": 1.0, "ssaoFilterRadius": 16, "ssaoRadius": 16, "ssaoSamples": 16, "textureMaxResolution": 4096, "threadDGEvaluation": False, "transparencyAlgorithm": 1, "transparencyQuality": 0.33, "useMaximumHardwareLights": True, "vertexAnimationCache": 0 } def apply_view(panel, **options): """Apply options to panel""" camera = cmds.modelPanel(panel, camera=True, query=True) # Display options display_options = options.get("display_options", {}) for key, value in display_options.iteritems(): if key in _DisplayOptionsRGB: cmds.displayRGBColor(key, *value) else: cmds.displayPref(**{key: value}) # Camera options camera_options = options.get("camera_options", {}) for key, value in camera_options.iteritems(): cmds.setAttr("{0}.{1}".format(camera, key), value) # Viewport options viewport_options = options.get("viewport_options", {}) for key, value in viewport_options.iteritems(): cmds.modelEditor(panel, edit=True, **{key: value}) viewport2_options = options.get("viewport2_options", {}) for key, value in viewport2_options.iteritems(): attr = "hardwareRenderingGlobals.{0}".format(key) cmds.setAttr(attr, value) def parse_active_view(): """Parse the current settings from the active view""" panel = cmds.getPanel(wf=True) # This happens when last focus was on panel # that got deleted (e.g. `capture()` then `parse_active_view()`) if not panel or "modelPanel" not in panel: raise RuntimeError("No acive model panel found") return parse_view(panel) def parse_view(panel): """Parse the scene, panel and camera for their current settings Example: >>> parse_view("modelPanel1") Arguments: panel (str): Name of modelPanel """ camera = cmds.modelPanel(panel, query=True, camera=True) # Display options display_options = {} for key in DisplayOptions: if key in _DisplayOptionsRGB: display_options[key] = cmds.displayRGBColor(key, query=True) else: display_options[key] = cmds.displayPref(query=True, **{key: True}) # Camera options camera_options = {} for key in CameraOptions: camera_options[key] = cmds.getAttr("{0}.{1}".format(camera, key)) # Viewport options viewport_options = {} for key in ViewportOptions: viewport_options[key] = cmds.modelEditor( panel, query=True, **{key: True}) viewport2_options = {} for key in Viewport2Options.keys(): attr = "hardwareRenderingGlobals.{0}".format(key) try: viewport2_options[key] = cmds.getAttr(attr) except ValueError: continue return { "camera": camera, "display_options": display_options, "camera_options": camera_options, "viewport_options": viewport_options, "viewport2_options": viewport2_options } def parse_active_scene(): """Parse active scene for arguments for capture() *Resolution taken from render settings. """ return { "start_frame": cmds.playbackOptions(minTime=True, query=True), "end_frame": cmds.playbackOptions(maxTime=True, query=True), "width": cmds.getAttr("defaultResolution.width"), "height": cmds.getAttr("defaultResolution.height"), "compression": cmds.optionVar(query="playblastCompression"), "filename": (cmds.optionVar(query="playblastFile") if cmds.optionVar(query="playblastSaveToFile") else None), "format": cmds.optionVar(query="playblastFormat"), "off_screen": (True if cmds.optionVar(query="playblastOffscreen") else False), "quality": cmds.optionVar(query="playblastQuality") } def apply_scene(**options): """Apply options from scene Example: >>> apply_scene({"start_frame": 1009}) Arguments: options (dict): Scene options """ if "start_frame" in options: cmds.playbackOptions(minTime=options["start_frame"]) if "end_frame" in options: cmds.playbackOptions(maxTime=options["end_frame"]) if "width" in options: cmds.setAttr("defaultResolution.width", options["width"]) if "height" in options: cmds.setAttr("defaultResolution.height", options["height"]) if "compression" in options: cmds.optionVar( stringValue=["playblastCompression", options["compression"]]) if "filename" in options: cmds.optionVar( stringValue=["playblastFile", options["filename"]]) if "format" in options: cmds.optionVar( stringValue=["playblastFormat", options["format"]]) if "off_screen" in options: cmds.optionVar( intValue=["playblastFormat", options["off_screen"]]) if "quality" in options: cmds.optionVar( floatValue=["playblastQuality", options["quality"]]) @contextlib.contextmanager def _solo_animation_layer(layer): """Isolate animation layer""" if not cmds.animLayer(layer, query=True, mute=True): raise ValueError("%s must be muted" % layer) try: cmds.animLayer(layer, edit=True, mute=False) yield finally: cmds.animLayer(layer, edit=True, mute=True) @contextlib.contextmanager def _muted_animation_layers(layers): state = dict((layer, cmds.animLayer(layer, query=True, mute=True)) for layer in layers) try: for layer in layers: cmds.animLayer(layer, edit=True, mute=True) yield finally: for layer, muted in state.items(): cmds.animLayer(layer, edit=True, mute=muted) @contextlib.contextmanager def _applied_view(panel, **options): """Apply options to panel""" original = parse_view(panel) apply_view(panel, **options) try: yield finally: apply_view(panel, **original) @contextlib.contextmanager def _independent_panel(width, height, off_screen=False): """Create capture-window context without decorations Arguments: width (int): Width of panel height (int): Height of panel Example: >>> with _independent_panel(800, 600): ... cmds.capture() """ # center panel on screen screen_width, screen_height = _get_screen_size() topLeft = [int((screen_height-height)/2.0), int((screen_width-width)/2.0)] window = cmds.window(width=width, height=height, topLeftCorner=topLeft, menuBarVisible=False, titleBar=False, visible=not off_screen) cmds.paneLayout() panel = cmds.modelPanel(menuBarVisible=False, label='CapturePanel') # Hide icons under panel menus bar_layout = cmds.modelPanel(panel, q=True, barLayout=True) cmds.frameLayout(bar_layout, edit=True, collapse=True) if not off_screen: cmds.showWindow(window) # Set the modelEditor of the modelPanel as the active view so it takes # the playback focus. Does seem redundant with the `refresh` added in. editor = cmds.modelPanel(panel, query=True, modelEditor=True) cmds.modelEditor(editor, edit=True, activeView=True) # Force a draw refresh of Maya so it keeps focus on the new panel # This focus is required to force preview playback in the independent panel cmds.refresh(force=True) try: yield panel finally: # Delete the panel to fix memory leak (about 5 mb per capture) cmds.deleteUI(panel, panel=True) cmds.deleteUI(window) @contextlib.contextmanager def _applied_camera_options(options, panel): """Context manager for applying `options` to `camera`""" camera = cmds.modelPanel(panel, query=True, camera=True) options = dict(CameraOptions, **(options or {})) old_options = dict() for opt in options.copy(): try: old_options[opt] = cmds.getAttr(camera + "." + opt) except: sys.stderr.write("Could not get camera attribute " "for capture: %s\n" % opt) options.pop(opt) for opt, value in options.iteritems(): cmds.setAttr(camera + "." + opt, value) try: yield finally: if old_options: for opt, value in old_options.iteritems(): cmds.setAttr(camera + "." + opt, value) @contextlib.contextmanager def _applied_display_options(options): """Context manager for setting background color display options.""" options = dict(DisplayOptions, **(options or {})) colors = ['background', 'backgroundTop', 'backgroundBottom'] preferences = ['displayGradient'] # Store current settings original = {} for color in colors: original[color] = cmds.displayRGBColor(color, query=True) or [] for preference in preferences: original[preference] = cmds.displayPref( query=True, **{preference: True}) # Apply settings for color in colors: value = options[color] cmds.displayRGBColor(color, *value) for preference in preferences: value = options[preference] cmds.displayPref(**{preference: value}) try: yield finally: # Restore original settings for color in colors: cmds.displayRGBColor(color, *original[color]) for preference in preferences: cmds.displayPref(**{preference: original[preference]}) @contextlib.contextmanager def _applied_viewport_options(options, panel): """Context manager for applying `options` to `panel`""" options = dict(ViewportOptions, **(options or {})) cmds.modelEditor(panel, edit=True, allObjects=False, grid=False, manipulators=False) cmds.modelEditor(panel, edit=True, **options) yield @contextlib.contextmanager def _applied_viewport2_options(options): """Context manager for setting viewport 2.0 options. These options are applied by setting attributes on the "hardwareRenderingGlobals" node. """ options = dict(Viewport2Options, **(options or {})) # Store current settings original = {} for opt in options.copy(): try: original[opt] = cmds.getAttr("hardwareRenderingGlobals." + opt) except ValueError: options.pop(opt) # Apply settings for opt, value in options.iteritems(): cmds.setAttr("hardwareRenderingGlobals." + opt, value) try: yield finally: # Restore previous settings for opt, value in original.iteritems(): cmds.setAttr("hardwareRenderingGlobals." + opt, value) @contextlib.contextmanager def _isolated_nodes(nodes, panel): """Context manager for isolating `nodes` in `panel`""" if nodes is not None: cmds.isolateSelect(panel, state=True) for obj in nodes: cmds.isolateSelect(panel, addDagObject=obj) yield @contextlib.contextmanager def _maintained_time(): """Context manager for preserving (resetting) the time after the context""" current_time = cmds.currentTime(query=1) try: yield finally: cmds.currentTime(current_time) @contextlib.contextmanager def _maintain_camera(panel, camera): state = {} if not _in_standalone(): cmds.lookThru(panel, camera) else: state = dict((camera, cmds.getAttr(camera + ".rnd")) for camera in cmds.ls(type="camera")) cmds.setAttr(camera + ".rnd", True) try: yield finally: for camera, renderable in state.iteritems(): cmds.setAttr(camera + ".rnd", renderable) def _image_to_clipboard(path): """Copies the image at path to the system's global clipboard.""" if _in_standalone(): raise Exception("Cannot copy to clipboard from Maya Standalone") import PySide.QtGui image = PySide.QtGui.QImage(path) clipboard = PySide.QtGui.QApplication.clipboard() clipboard.setImage(image, mode=PySide.QtGui.QClipboard.Clipboard) def _get_screen_size(): """Return available screen size without space occupied by taskbar""" if _in_standalone(): return [0, 0] import PySide.QtGui rect = PySide.QtGui.QDesktopWidget().screenGeometry(-1) return [rect.width(), rect.height()] def _in_standalone(): return not hasattr(cmds, "about") or cmds.about(batch=True) # -------------------------------- # # Apply version specific settings # # -------------------------------- if _in_standalone(): # This setting doesn't appear to work in mayapy. # Tested in Linux Scientific 6 and Windows 8, # Nvidia Quadro and GeForce 650m Viewport2Options["floatingPointRTEnable"] = False try: version = cmds.about(version=True) if "2016" in version: Viewport2Options.update({ "hwFogAlpha": 1.0, "hwFogFalloff": 0, "hwFogDensity": 0.1, "hwFogEnable": False, "holdOutDetailMode": 1, "hwFogEnd": 100.0, "holdOutMode": True, "hwFogColorR": 0.5, "hwFogColorG": 0.5, "hwFogColorB": 0.5, "hwFogStart": 0.0, }) except: # about might not exist in mayapy # if not first initialized. pass