"""Maya Capture.
A modified version of https://github.com/abstractfactory/maya-capture by Marcus Ottosson.
"""
import contextlib
import re
import sys
from contextlib import ExitStack, contextmanager
try:
import maya.cmds as cmds
import maya.mel as mel
except ImportError:
raise ImportError('Could not find the Maya modules.')
from PySide2 import QtGui, QtWidgets
[docs]@contextmanager
def nested(*contexts):
"""
Reimplementation of nested in python 3.
"""
with ExitStack() as stack:
for ctx in contexts:
stack.enter_context(ctx)
yield contexts
[docs]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.
Args:
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 to playblast off-screen
viewer (bool, optional): Display results in native player
show_ornaments (bool, optional): Whether 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 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 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(f'Camera does not exist: {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 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
[docs]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, e.g. 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 = {'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
}
[docs]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.items():
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.items():
cmds.setAttr(f'{camera}.{key}', value)
# Viewport options
viewport_options = options.get('viewport_options', {})
for key, value in viewport_options.items():
cmds.modelEditor(panel, edit=True, **{key: value})
viewport2_options = options.get('viewport2_options', {})
for key, value in viewport2_options.items():
attr = f'hardwareRenderingGlobals.{key}'
cmds.setAttr(attr, value)
[docs]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
[docs]def parse_active_view():
"""Parse the current settings from the active view"""
panel = parse_active_panel()
return parse_view(panel)
[docs]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(f'{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 = f'hardwareRenderingGlobals.{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
}
[docs]def parse_active_scene():
"""Parse active scene for arguments for capture()
Resolution taken from render common.
"""
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
}
[docs]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)
def get_width_height(bound, width, height):
aspect = float(max((width, height))) / float(min((width, height)))
is_horizontal = width > height
if is_horizontal:
_width = bound
_height = bound / aspect
else:
_width = bound / aspect
_height = bound
return int(_width), int(_height)
@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()
"""
_width, _height = get_width_height(800, width, height)
window = cmds.window(
width=_width,
height=_height,
# topLeftCorner=topLeft,
menuBarVisible=False,
titleBar=False,
visible=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(
f'Could not get camera attribute for capture: {opt}'
)
options.pop(opt)
for opt, value in options.items():
cmds.setAttr(camera + '.' + opt, value)
try:
yield
finally:
if old_options:
for opt, value in old_options.items():
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.items():
cmds.setAttr('hardwareRenderingGlobals.' + opt, value)
try:
yield
finally:
# Restore previous settings
for opt, value in original.items():
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.items():
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)
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
}
)