"""Maya Capture Playblasting with independent viewport, camera and display options """ import re import sys import contextlib from maya import cmds from maya import mel try: from PySide2 import QtGui, QtWidgets except ImportError: from PySide import QtGui QtWidgets = QtGui version_info = (2, 3, 0) __version__ = "%s.%s.%s" % version_info __license__ = "MIT" def capture(camera=None, width=None, height=None, filename=None, start_frame=None, end_frame=None, frame=None, format='qt', compression='H.264', quality=100, off_screen=False, viewer=True, show_ornaments=True, sound=None, isolate=None, maintain_aspect_ratio=True, overwrite=False, frame_padding=4, raw_frame_numbers=False, camera_options=None, display_options=None, viewport_options=None, viewport2_options=None, complete_filename=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. 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 "H.264" quality (int, optional): The quality of the output, defaults to 100 off_screen (bool, optional): Whether or not to playblast off screen viewer (bool, optional): Display results in native player show_ornaments (bool, optional): Whether or not model view ornaments (e.g. axis icon, grid and HUD) should be displayed. sound (str, optional): Specify the sound node to be used during playblast. When None (default) no sound will be used. 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. frame_padding (bool, optional): Number of zeros used to pad file name for image sequences. 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` complete_filename (str, optional): Exact name of output file. Use this to override the output of `filename` so it excludes frame padding. 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 = round(width / ratio) if start_frame is None: start_frame = cmds.playbackOptions(minTime=True, query=True) if end_frame is None: end_frame = cmds.playbackOptions(maxTime=True, query=True) # (#74) Bugfix: `maya.cmds.playblast` will raise an error when playblasting # with `rawFrameNumbers` set to True but no explicit `frames` provided. # Since we always know what frames will be included we can provide it # explicitly if raw_frame_numbers and frame is None: frame = range(int(start_frame), int(end_frame) + 1) # 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 is not None: playblast_kwargs['frame'] = frame if sound is not None: playblast_kwargs['sound'] = sound # We need to raise an error when the user gives a custom frame range with # negative frames in combination with raw frame numbers. This will result # in a minimal integer frame number : filename.-2147483648.png for any # negative rendered frame if frame and raw_frame_numbers: check = frame if isinstance(frame, (list, tuple)) else [frame] if any(f < 0 for f in check): raise RuntimeError("Negative frames are not supported with " "raw frame numbers and explicit frame numbers") # (#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(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()): 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) 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 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 "imagePlane": True, "nurbsCurves": False, "nurbsSurfaces": False, "polymeshes": True, "subdivSurfaces": False, "planes": True, "cameras": False, "controlVertices": True, "lights": False, "grid": False, "hulls": True, "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": True, "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_panel(): """Parse the active modelPanel. Raises RuntimeError: When no active modelPanel an error is raised. Returns: str: Name of modelPanel """ panel = cmds.getPanel(withFocus=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 active model panel found") return panel def parse_active_view(): """Parse the current settings from the active view""" panel = parse_active_panel() 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 = {} # capture plugin display filters first to ensure we never override # built-in arguments if ever possible a plugin has similarly named # plugin display filters (which it shouldn't!) plugins = cmds.pluginDisplayFilter(query=True, listFilters=True) for plugin in plugins: plugin = str(plugin) # unicode->str for simplicity of the dict state = cmds.modelEditor(panel, query=True, queryPluginObjects=plugin) viewport_options[plugin] = state 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. """ time_control = mel.eval("$gPlayBackSlider = $gPlayBackSlider") 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), "show_ornaments": (True if cmds.optionVar(query="playblastShowOrnaments") else False), "quality": cmds.optionVar(query="playblastQuality"), "sound": cmds.timeControl(time_control, q=True, sound=True) or None } 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 "show_ornaments" in options: cmds.optionVar( intValue=["show_ornaments", options["show_ornaments"]]) if "quality" in options: cmds.optionVar( floatValue=["playblastQuality", options["quality"]]) @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" % 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 {})) # separate the plugin display filter options since they need to # be set differently (see #55) plugins = cmds.pluginDisplayFilter(query=True, listFilters=True) plugin_options = dict() for plugin in plugins: if plugin in options: plugin_options[plugin] = options.pop(plugin) # default options cmds.modelEditor(panel, edit=True, **options) # plugin display filter options for plugin, state in plugin_options.items(): cmds.modelEditor(panel, edit=True, pluginObjects=(plugin, state)) 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) @contextlib.contextmanager def _disabled_inview_messages(): """Disable in-view help messages during the context""" original = cmds.optionVar(q="inViewMessageEnable") cmds.optionVar(iv=("inViewMessageEnable", 0)) try: yield finally: cmds.optionVar(iv=("inViewMessageEnable", original)) 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") image = QtGui.QImage(path) clipboard = QtWidgets.QApplication.clipboard() clipboard.setImage(image, mode=QtGui.QClipboard.Clipboard) def _get_screen_size(): """Return available screen size without space occupied by taskbar""" if _in_standalone(): return [0, 0] rect = QtWidgets.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 # # -------------------------------- version = mel.eval("getApplicationVersionAsFloat") if version > 2015: 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, }) ViewportOptions.update({ "motionTrails": False })