From f7cd93d723d3ba76c7460bf3f69656169059a710 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 29 Nov 2018 23:32:35 +0100 Subject: [PATCH] add maya-capture and maya-capture-gui to vendor --- pype/vendor/capture.py | 833 +++++++++++++++++++++++++ pype/vendor/capture_gui/__init__.py | 29 + pype/vendor/capture_gui/accordion.py | 624 ++++++++++++++++++ pype/vendor/capture_gui/app.py | 711 +++++++++++++++++++++ pype/vendor/capture_gui/colorpicker.py | 55 ++ 5 files changed, 2252 insertions(+) create mode 100644 pype/vendor/capture.py create mode 100644 pype/vendor/capture_gui/__init__.py create mode 100644 pype/vendor/capture_gui/accordion.py create mode 100644 pype/vendor/capture_gui/app.py create mode 100644 pype/vendor/capture_gui/colorpicker.py diff --git a/pype/vendor/capture.py b/pype/vendor/capture.py new file mode 100644 index 0000000000..83816ec92a --- /dev/null +++ b/pype/vendor/capture.py @@ -0,0 +1,833 @@ +"""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 + }) diff --git a/pype/vendor/capture_gui/__init__.py b/pype/vendor/capture_gui/__init__.py new file mode 100644 index 0000000000..6c6a813636 --- /dev/null +++ b/pype/vendor/capture_gui/__init__.py @@ -0,0 +1,29 @@ +from .vendor.Qt import QtWidgets +from . import app +from . import lib + + +def main(show=True): + """Convenience method to run the Application inside Maya. + + Args: + show (bool): Whether to directly show the instantiated application. + Defaults to True. Set this to False if you want to manage the + application (like callbacks) prior to showing the interface. + + Returns: + capture_gui.app.App: The pyblish gui application instance. + + """ + # get main maya window to parent widget to + parent = lib.get_maya_main_window() + instance = parent.findChild(QtWidgets.QWidget, app.App.object_name) + if instance: + instance.close() + + # launch app + window = app.App(title="Capture GUI", parent=parent) + if show: + window.show() + + return window diff --git a/pype/vendor/capture_gui/accordion.py b/pype/vendor/capture_gui/accordion.py new file mode 100644 index 0000000000..f721837c57 --- /dev/null +++ b/pype/vendor/capture_gui/accordion.py @@ -0,0 +1,624 @@ +from .vendor.Qt import QtCore, QtWidgets, QtGui + + +class AccordionItem(QtWidgets.QGroupBox): + trigger = QtCore.Signal(bool) + + def __init__(self, accordion, title, widget): + QtWidgets.QGroupBox.__init__(self, parent=accordion) + + # create the layout + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(6, 12, 6, 6) + layout.setSpacing(0) + layout.addWidget(widget) + + self._accordianWidget = accordion + self._rolloutStyle = 2 + self._dragDropMode = 0 + + self.setAcceptDrops(True) + self.setLayout(layout) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.showMenu) + + # create custom properties + self._widget = widget + self._collapsed = False + self._collapsible = True + self._clicked = False + self._customData = {} + + # set common properties + self.setTitle(title) + + def accordionWidget(self): + """ + \remarks grabs the parent item for the accordian widget + \return + """ + return self._accordianWidget + + def customData(self, key, default=None): + """ + \remarks return a custom pointer to information stored with this item + \param key + \param default default value to return if the key was not found + \return data + """ + return self._customData.get(str(key), default) + + def dragEnterEvent(self, event): + if not self._dragDropMode: + return + + source = event.source() + if source != self and source.parent() == self.parent() and isinstance( + source, AccordionItem): + event.acceptProposedAction() + + def dragDropRect(self): + return QtCore.QRect(25, 7, 10, 6) + + def dragDropMode(self): + return self._dragDropMode + + def dragMoveEvent(self, event): + if not self._dragDropMode: + return + + source = event.source() + if source != self and source.parent() == self.parent() and isinstance( + source, AccordionItem): + event.acceptProposedAction() + + def dropEvent(self, event): + widget = event.source() + layout = self.parent().layout() + layout.insertWidget(layout.indexOf(self), widget) + self._accordianWidget.emitItemsReordered() + + def expandCollapseRect(self): + return QtCore.QRect(0, 0, self.width(), 20) + + def enterEvent(self, event): + self.accordionWidget().leaveEvent(event) + event.accept() + + def leaveEvent(self, event): + self.accordionWidget().enterEvent(event) + event.accept() + + def mouseReleaseEvent(self, event): + if self._clicked and self.expandCollapseRect().contains(event.pos()): + self.toggleCollapsed() + event.accept() + else: + event.ignore() + + self._clicked = False + + def mouseMoveEvent(self, event): + event.ignore() + + def mousePressEvent(self, event): + # handle an internal move + + # start a drag event + if event.button() == QtCore.Qt.LeftButton and self.dragDropRect().contains( + event.pos()): + # create the pixmap + pixmap = QtGui.QPixmap.grabWidget(self, self.rect()) + + # create the mimedata + mimeData = QtCore.QMimeData() + mimeData.setText('ItemTitle::%s' % (self.title())) + + # create the drag + drag = QtGui.QDrag(self) + drag.setMimeData(mimeData) + drag.setPixmap(pixmap) + drag.setHotSpot(event.pos()) + + if not drag.exec_(): + self._accordianWidget.emitItemDragFailed(self) + + event.accept() + + # determine if the expand/collapse should occur + elif event.button() == QtCore.Qt.LeftButton and self.expandCollapseRect().contains( + event.pos()): + self._clicked = True + event.accept() + + else: + event.ignore() + + def isCollapsed(self): + return self._collapsed + + def isCollapsible(self): + return self._collapsible + + def __drawTriangle(self, painter, x, y): + + brush = QtGui.QBrush(QtGui.QColor(255, 255, 255, 160), + QtCore.Qt.SolidPattern) + if not self.isCollapsed(): + tl, tr, tp = QtCore.QPoint(x + 9, y + 8), QtCore.QPoint(x + 19, + y + 8), QtCore.QPoint( + x + 14, y + 13.0) + points = [tl, tr, tp] + triangle = QtGui.QPolygon(points) + else: + tl, tr, tp = QtCore.QPoint(x + 11, y + 6), QtCore.QPoint(x + 16, + y + 11), QtCore.QPoint( + x + 11, y + 16.0) + points = [tl, tr, tp] + triangle = QtGui.QPolygon(points) + + currentBrush = painter.brush() + painter.setBrush(brush) + painter.drawPolygon(triangle) + painter.setBrush(currentBrush) + + def paintEvent(self, event): + painter = QtGui.QPainter() + painter.begin(self) + painter.setRenderHint(painter.Antialiasing) + font = painter.font() + font.setBold(True) + painter.setFont(font) + + x = self.rect().x() + y = self.rect().y() + w = self.rect().width() - 1 + h = self.rect().height() - 1 + r = 8 + + # draw a rounded style + if self._rolloutStyle == 2: + # draw the text + painter.drawText(x + 33, y + 3, w, 16, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop, + self.title()) + + # draw the triangle + self.__drawTriangle(painter, x, y) + + # draw the borders + pen = QtGui.QPen(self.palette().color(QtGui.QPalette.Light)) + pen.setWidthF(0.6) + painter.setPen(pen) + + painter.drawRoundedRect(x + 1, y + 1, w - 1, h - 1, r, r) + + pen.setColor(self.palette().color(QtGui.QPalette.Shadow)) + painter.setPen(pen) + + painter.drawRoundedRect(x, y, w - 1, h - 1, r, r) + + # draw a square style + if self._rolloutStyle == 3: + # draw the text + painter.drawText(x + 33, y + 3, w, 16, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop, + self.title()) + + self.__drawTriangle(painter, x, y) + + # draw the borders + pen = QtGui.QPen(self.palette().color(QtGui.QPalette.Light)) + pen.setWidthF(0.6) + painter.setPen(pen) + + painter.drawRect(x + 1, y + 1, w - 1, h - 1) + + pen.setColor(self.palette().color(QtGui.QPalette.Shadow)) + painter.setPen(pen) + + painter.drawRect(x, y, w - 1, h - 1) + + # draw a Maya style + if self._rolloutStyle == 4: + # draw the text + painter.drawText(x + 33, y + 3, w, 16, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop, + self.title()) + + painter.setRenderHint(QtGui.QPainter.Antialiasing, False) + + self.__drawTriangle(painter, x, y) + + # draw the borders - top + headerHeight = 20 + + headerRect = QtCore.QRect(x + 1, y + 1, w - 1, headerHeight) + headerRectShadow = QtCore.QRect(x - 1, y - 1, w + 1, + headerHeight + 2) + + # Highlight + pen = QtGui.QPen(self.palette().color(QtGui.QPalette.Light)) + pen.setWidthF(0.4) + painter.setPen(pen) + + painter.drawRect(headerRect) + painter.fillRect(headerRect, QtGui.QColor(255, 255, 255, 18)) + + # Shadow + pen.setColor(self.palette().color(QtGui.QPalette.Dark)) + painter.setPen(pen) + painter.drawRect(headerRectShadow) + + if not self.isCollapsed(): + # draw the lover border + pen = QtGui.QPen(self.palette().color(QtGui.QPalette.Dark)) + pen.setWidthF(0.8) + painter.setPen(pen) + + offSet = headerHeight + 3 + bodyRect = QtCore.QRect(x, y + offSet, w, h - offSet) + bodyRectShadow = QtCore.QRect(x + 1, y + offSet, w + 1, + h - offSet + 1) + painter.drawRect(bodyRect) + + pen.setColor(self.palette().color(QtGui.QPalette.Light)) + pen.setWidthF(0.4) + painter.setPen(pen) + + painter.drawRect(bodyRectShadow) + + # draw a boxed style + elif self._rolloutStyle == 1: + if self.isCollapsed(): + arect = QtCore.QRect(x + 1, y + 9, w - 1, 4) + brect = QtCore.QRect(x, y + 8, w - 1, 4) + text = '+' + else: + arect = QtCore.QRect(x + 1, y + 9, w - 1, h - 9) + brect = QtCore.QRect(x, y + 8, w - 1, h - 9) + text = '-' + + # draw the borders + pen = QtGui.QPen(self.palette().color(QtGui.QPalette.Light)) + pen.setWidthF(0.6) + painter.setPen(pen) + + painter.drawRect(arect) + + pen.setColor(self.palette().color(QtGui.QPalette.Shadow)) + painter.setPen(pen) + + painter.drawRect(brect) + + painter.setRenderHint(painter.Antialiasing, False) + painter.setBrush( + self.palette().color(QtGui.QPalette.Window).darker(120)) + painter.drawRect(x + 10, y + 1, w - 20, 16) + painter.drawText(x + 16, y + 1, + w - 32, 16, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + text) + painter.drawText(x + 10, y + 1, + w - 20, 16, + QtCore.Qt.AlignCenter, + self.title()) + + if self.dragDropMode(): + rect = self.dragDropRect() + + # draw the lines + l = rect.left() + r = rect.right() + cy = rect.center().y() + + for y in (cy - 3, cy, cy + 3): + painter.drawLine(l, y, r, y) + + painter.end() + + def setCollapsed(self, state=True): + if self.isCollapsible(): + accord = self.accordionWidget() + accord.setUpdatesEnabled(False) + + self._collapsed = state + + if state: + self.setMinimumHeight(22) + self.setMaximumHeight(22) + self.widget().setVisible(False) + else: + self.setMinimumHeight(0) + self.setMaximumHeight(1000000) + self.widget().setVisible(True) + + self._accordianWidget.emitItemCollapsed(self) + accord.setUpdatesEnabled(True) + + def setCollapsible(self, state=True): + self._collapsible = state + + def setCustomData(self, key, value): + """ + \remarks set a custom pointer to information stored on this item + \param key + \param value + """ + self._customData[str(key)] = value + + def setDragDropMode(self, mode): + self._dragDropMode = mode + + def setRolloutStyle(self, style): + self._rolloutStyle = style + + def showMenu(self): + if QtCore.QRect(0, 0, self.width(), 20).contains( + self.mapFromGlobal(QtGui.QCursor.pos())): + self._accordianWidget.emitItemMenuRequested(self) + + def rolloutStyle(self): + return self._rolloutStyle + + def toggleCollapsed(self): + # enable signaling here + collapse_state = not self.isCollapsed() + self.setCollapsed(collapse_state) + return collapse_state + + def widget(self): + return self._widget + + +class AccordionWidget(QtWidgets.QScrollArea): + """Accordion style widget. + + A collapsible accordion widget like Maya's attribute editor. + + This is a modified version bsed on Blur's Accordion Widget to + include a Maya style. + + """ + itemCollapsed = QtCore.Signal(AccordionItem) + itemMenuRequested = QtCore.Signal(AccordionItem) + itemDragFailed = QtCore.Signal(AccordionItem) + itemsReordered = QtCore.Signal() + + Boxed = 1 + Rounded = 2 + Square = 3 + Maya = 4 + + NoDragDrop = 0 + InternalMove = 1 + + def __init__(self, parent): + + QtWidgets.QScrollArea.__init__(self, parent) + + self.setFrameShape(QtWidgets.QScrollArea.NoFrame) + self.setAutoFillBackground(False) + self.setWidgetResizable(True) + self.setMouseTracking(True) + self.verticalScrollBar().setMaximumWidth(10) + + widget = QtWidgets.QWidget(self) + + # define custom properties + self._rolloutStyle = AccordionWidget.Rounded + self._dragDropMode = AccordionWidget.NoDragDrop + self._scrolling = False + self._scrollInitY = 0 + self._scrollInitVal = 0 + self._itemClass = AccordionItem + + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(2, 2, 2, 6) + layout.setSpacing(2) + layout.addStretch(1) + + widget.setLayout(layout) + + self.setWidget(widget) + + def setSpacing(self, spaceInt): + self.widget().layout().setSpacing(spaceInt) + + def addItem(self, title, widget, collapsed=False): + self.setUpdatesEnabled(False) + item = self._itemClass(self, title, widget) + item.setRolloutStyle(self.rolloutStyle()) + item.setDragDropMode(self.dragDropMode()) + layout = self.widget().layout() + layout.insertWidget(layout.count() - 1, item) + layout.setStretchFactor(item, 0) + + if collapsed: + item.setCollapsed(collapsed) + + self.setUpdatesEnabled(True) + + return item + + def clear(self): + self.setUpdatesEnabled(False) + layout = self.widget().layout() + while layout.count() > 1: + item = layout.itemAt(0) + + # remove the item from the layout + w = item.widget() + layout.removeItem(item) + + # close the widget and delete it + w.close() + w.deleteLater() + + self.setUpdatesEnabled(True) + + def eventFilter(self, object, event): + if event.type() == QtCore.QEvent.MouseButtonPress: + self.mousePressEvent(event) + return True + + elif event.type() == QtCore.QEvent.MouseMove: + self.mouseMoveEvent(event) + return True + + elif event.type() == QtCore.QEvent.MouseButtonRelease: + self.mouseReleaseEvent(event) + return True + + return False + + def canScroll(self): + return self.verticalScrollBar().maximum() > 0 + + def count(self): + return self.widget().layout().count() - 1 + + def dragDropMode(self): + return self._dragDropMode + + def indexOf(self, widget): + """ + \remarks Searches for widget(not including child layouts). + Returns the index of widget, or -1 if widget is not found + \return + """ + layout = self.widget().layout() + for index in range(layout.count()): + if layout.itemAt(index).widget().widget() == widget: + return index + return -1 + + def isBoxedMode(self): + return self._rolloutStyle == AccordionWidget.Maya + + def itemClass(self): + return self._itemClass + + def itemAt(self, index): + layout = self.widget().layout() + if 0 <= index and index < layout.count() - 1: + return layout.itemAt(index).widget() + return None + + def emitItemCollapsed(self, item): + if not self.signalsBlocked(): + self.itemCollapsed.emit(item) + + def emitItemDragFailed(self, item): + if not self.signalsBlocked(): + self.itemDragFailed.emit(item) + + def emitItemMenuRequested(self, item): + if not self.signalsBlocked(): + self.itemMenuRequested.emit(item) + + def emitItemsReordered(self): + if not self.signalsBlocked(): + self.itemsReordered.emit() + + def enterEvent(self, event): + if self.canScroll(): + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.OpenHandCursor) + + def leaveEvent(self, event): + if self.canScroll(): + QtWidgets.QApplication.restoreOverrideCursor() + + def mouseMoveEvent(self, event): + if self._scrolling: + sbar = self.verticalScrollBar() + smax = sbar.maximum() + + # calculate the distance moved for the moust point + dy = event.globalY() - self._scrollInitY + + # calculate the percentage that is of the scroll bar + dval = smax * (dy / float(sbar.height())) + + # calculate the new value + sbar.setValue(self._scrollInitVal - dval) + + event.accept() + + def mousePressEvent(self, event): + # handle a scroll event + if event.button() == QtCore.Qt.LeftButton and self.canScroll(): + self._scrolling = True + self._scrollInitY = event.globalY() + self._scrollInitVal = self.verticalScrollBar().value() + + QtWidgets.QApplication.setOverrideCursor( + QtCore.Qt.ClosedHandCursor) + + event.accept() + + def mouseReleaseEvent(self, event): + if self._scrolling: + QtWidgets.QApplication.restoreOverrideCursor() + + self._scrolling = False + self._scrollInitY = 0 + self._scrollInitVal = 0 + event.accept() + + def moveItemDown(self, index): + layout = self.widget().layout() + if (layout.count() - 1) > (index + 1): + widget = layout.takeAt(index).widget() + layout.insertWidget(index + 1, widget) + + def moveItemUp(self, index): + if index > 0: + layout = self.widget().layout() + widget = layout.takeAt(index).widget() + layout.insertWidget(index - 1, widget) + + def setBoxedMode(self, state): + if state: + self._rolloutStyle = AccordionWidget.Boxed + else: + self._rolloutStyle = AccordionWidget.Rounded + + def setDragDropMode(self, dragDropMode): + self._dragDropMode = dragDropMode + + for item in self.findChildren(AccordionItem): + item.setDragDropMode(self._dragDropMode) + + def setItemClass(self, itemClass): + self._itemClass = itemClass + + def setRolloutStyle(self, rolloutStyle): + self._rolloutStyle = rolloutStyle + + for item in self.findChildren(AccordionItem): + item.setRolloutStyle(self._rolloutStyle) + + def rolloutStyle(self): + return self._rolloutStyle + + def takeAt(self, index): + self.setUpdatesEnabled(False) + layout = self.widget().layout() + widget = None + if 0 <= index and index < layout.count() - 1: + item = layout.itemAt(index) + widget = item.widget() + + layout.removeItem(item) + widget.close() + self.setUpdatesEnabled(True) + return widget + + def widgetAt(self, index): + item = self.itemAt(index) + if item: + return item.widget() + return None + + pyBoxedMode = QtCore.Property('bool', isBoxedMode, setBoxedMode) diff --git a/pype/vendor/capture_gui/app.py b/pype/vendor/capture_gui/app.py new file mode 100644 index 0000000000..1860b084ba --- /dev/null +++ b/pype/vendor/capture_gui/app.py @@ -0,0 +1,711 @@ +import json +import logging +import os +import tempfile + +import capture +import maya.cmds as cmds + +from .vendor.Qt import QtCore, QtWidgets, QtGui +from . import lib +from . import plugin +from . import presets +from . import version +from . import tokens +from .accordion import AccordionWidget + +log = logging.getLogger("Capture Gui") + + +class ClickLabel(QtWidgets.QLabel): + """A QLabel that emits a clicked signal when clicked upon.""" + clicked = QtCore.Signal() + + def mouseReleaseEvent(self, event): + self.clicked.emit() + return super(ClickLabel, self).mouseReleaseEvent(event) + + +class PreviewWidget(QtWidgets.QWidget): + """The playblast image preview widget. + + Upon refresh it will retrieve the options through the function set as + `options_getter` and make a call to `capture.capture()` for a single + frame (playblasted) snapshot. The result is displayed as image. + """ + + preview_width = 320 + preview_height = 180 + + def __init__(self, options_getter, validator, parent=None): + QtWidgets.QWidget.__init__(self, parent=parent) + + # Add attributes + self.options_getter = options_getter + self.validator = validator + self.preview = ClickLabel() + self.preview.setFixedWidth(self.preview_width) + self.preview.setFixedHeight(self.preview_height) + + tip = "Click to force a refresh" + self.preview.setToolTip(tip) + self.preview.setStatusTip(tip) + + # region Build + self.layout = QtWidgets.QVBoxLayout() + self.layout.setAlignment(QtCore.Qt.AlignHCenter) + self.layout.setContentsMargins(0, 0, 0, 0) + + self.setLayout(self.layout) + self.layout.addWidget(self.preview) + # endregion Build + + # Connect widgets to functions + self.preview.clicked.connect(self.refresh) + + def refresh(self): + """Refresh the playblast preview""" + + frame = cmds.currentTime(query=True) + + # When playblasting outside of an undo queue it seems that undoing + # actually triggers a reset to frame 0. As such we sneak in the current + # time into the undo queue to enforce correct undoing. + cmds.currentTime(frame, update=True) + + # check if plugin outputs are correct + valid = self.validator() + if not valid: + return + + with lib.no_undo(): + options = self.options_getter() + tempdir = tempfile.mkdtemp() + + # override settings that are constants for the preview + options = options.copy() + options['filename'] = None + options['complete_filename'] = os.path.join(tempdir, "temp.jpg") + options['width'] = self.preview_width + options['height'] = self.preview_height + options['viewer'] = False + options['frame'] = frame + options['off_screen'] = True + options['format'] = "image" + options['compression'] = "jpg" + options['sound'] = None + + fname = capture.capture(**options) + if not fname: + log.warning("Preview failed") + return + + image = QtGui.QPixmap(fname) + self.preview.setPixmap(image) + os.remove(fname) + + def showEvent(self, event): + """Initialize when shown""" + self.refresh() + event.accept() + + +class PresetWidget(QtWidgets.QWidget): + """Preset Widget + + Allows the user to set preferences and create presets to load before + capturing. + + """ + + preset_loaded = QtCore.Signal(dict) + config_opened = QtCore.Signal() + + id = "Presets" + label = "Presets" + + def __init__(self, inputs_getter, parent=None): + QtWidgets.QWidget.__init__(self, parent=parent) + + self.inputs_getter = inputs_getter + + layout = QtWidgets.QHBoxLayout(self) + layout.setAlignment(QtCore.Qt.AlignCenter) + layout.setContentsMargins(0, 0, 0, 0) + + presets = QtWidgets.QComboBox() + presets.setFixedWidth(220) + presets.addItem("*") + + # Icons + icon_path = os.path.join(os.path.dirname(__file__), "resources") + save_icon = os.path.join(icon_path, "save.png") + load_icon = os.path.join(icon_path, "import.png") + config_icon = os.path.join(icon_path, "config.png") + + # Create buttons + save = QtWidgets.QPushButton() + save.setIcon(QtGui.QIcon(save_icon)) + save.setFixedWidth(30) + save.setToolTip("Save Preset") + save.setStatusTip("Save Preset") + + load = QtWidgets.QPushButton() + load.setIcon(QtGui.QIcon(load_icon)) + load.setFixedWidth(30) + load.setToolTip("Load Preset") + load.setStatusTip("Load Preset") + + config = QtWidgets.QPushButton() + config.setIcon(QtGui.QIcon(config_icon)) + config.setFixedWidth(30) + config.setToolTip("Preset configuration") + config.setStatusTip("Preset configuration") + + layout.addWidget(presets) + layout.addWidget(save) + layout.addWidget(load) + layout.addWidget(config) + + # Make available for all methods + self.presets = presets + self.config = config + self.load = load + self.save = save + + # Signals + self.save.clicked.connect(self.on_save_preset) + self.load.clicked.connect(self.import_preset) + self.config.clicked.connect(self.config_opened) + self.presets.currentIndexChanged.connect(self.load_active_preset) + + self._process_presets() + + def _process_presets(self): + """Adds all preset files from preset paths to the Preset widget. + + Returns: + None + + """ + for presetfile in presets.discover(): + self.add_preset(presetfile) + + def import_preset(self): + """Load preset files to override output values""" + + path = self._default_browse_path() + filters = "Text file (*.json)" + dialog = QtWidgets.QFileDialog + filename, _ = dialog.getOpenFileName(self, "Open preference file", + path, filters) + if not filename: + return + + # create new entry in combobox + self.add_preset(filename) + + # read file + return self.load_active_preset() + + def load_active_preset(self): + """Load the active preset. + + Returns: + dict: The preset inputs. + + """ + current_index = self.presets.currentIndex() + filename = self.presets.itemData(current_index) + if not filename: + return {} + + preset = lib.load_json(filename) + + # Emit preset load signal + log.debug("Emitting preset_loaded: {0}".format(filename)) + self.preset_loaded.emit(preset) + + # Ensure we preserve the index after loading the changes + # for all the plugin widgets + self.presets.blockSignals(True) + self.presets.setCurrentIndex(current_index) + self.presets.blockSignals(False) + + return preset + + def add_preset(self, filename): + """Add the filename to the preset list. + + This also sets the index to the filename. + + Returns: + None + + """ + + filename = os.path.normpath(filename) + if not os.path.exists(filename): + log.warning("Preset file does not exist: {0}".format(filename)) + return + + label = os.path.splitext(os.path.basename(filename))[0] + item_count = self.presets.count() + + paths = [self.presets.itemData(i) for i in range(item_count)] + if filename in paths: + log.info("Preset is already in the " + "presets list: {0}".format(filename)) + item_index = paths.index(filename) + else: + self.presets.addItem(label, userData=filename) + item_index = item_count + + self.presets.blockSignals(True) + self.presets.setCurrentIndex(item_index) + self.presets.blockSignals(False) + + return item_index + + def _default_browse_path(self): + """Return the current browse path for save/load preset. + + If a preset is currently loaded it will use that specific path + otherwise it will go to the last registered preset path. + + Returns: + str: Path to use as default browse location. + + """ + + current_index = self.presets.currentIndex() + path = self.presets.itemData(current_index) + + if not path: + # Fallback to last registered preset path + paths = presets.preset_paths() + if paths: + path = paths[-1] + + return path + + def save_preset(self, inputs): + """Save inputs to a file""" + + path = self._default_browse_path() + filters = "Text file (*.json)" + filename, _ = QtWidgets.QFileDialog.getSaveFileName(self, + "Save preferences", + path, + filters) + if not filename: + return + + with open(filename, "w") as f: + json.dump(inputs, f, sort_keys=True, + indent=4, separators=(',', ': ')) + + self.add_preset(filename) + + return filename + + def get_presets(self): + """Return all currently listed presets""" + configurations = [self.presets.itemText(i) for + i in range(self.presets.count())] + + return configurations + + def on_save_preset(self): + """Save the inputs of all the plugins in a preset.""" + + inputs = self.inputs_getter(as_preset=True) + self.save_preset(inputs) + + def apply_inputs(self, settings): + + path = settings.get("selected", None) + index = self.presets.findData(path) + if index == -1: + # If the last loaded preset still exists but wasn't on the + # "discovered preset paths" then add it. + if os.path.exists(path): + log.info("Adding previously selected preset explicitly: %s", + path) + self.add_preset(path) + return + else: + log.warning("Previously selected preset is not available: %s", + path) + index = 0 + + self.presets.setCurrentIndex(index) + + def get_inputs(self, as_preset=False): + + if as_preset: + # Don't save the current preset into the preset because + # that would just be recursive and make no sense + return {} + else: + current_index = self.presets.currentIndex() + selected = self.presets.itemData(current_index) + return {"selected": selected} + + +class App(QtWidgets.QWidget): + """The main application in which the widgets are placed""" + + # Signals + options_changed = QtCore.Signal(dict) + playblast_start = QtCore.Signal(dict) + playblast_finished = QtCore.Signal(dict) + viewer_start = QtCore.Signal(dict) + + # Attributes + object_name = "CaptureGUI" + application_sections = ["config", "app"] + + def __init__(self, title, parent=None): + QtWidgets.QWidget.__init__(self, parent=parent) + + # Settings + # Remove pointer for memory when closed + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.settingfile = self._ensure_config_exist() + self.plugins = {"app": list(), + "config": list()} + + self._config_dialog = None + self._build_configuration_dialog() + + # region Set Attributes + title_version = "{} v{}".format(title, version.version) + self.setObjectName(self.object_name) + self.setWindowTitle(title_version) + self.setMinimumWidth(380) + + # Set dialog window flags so the widget can be correctly parented + # to Maya main window + self.setWindowFlags(self.windowFlags() | QtCore.Qt.Dialog) + self.setProperty("saveWindowPref", True) + # endregion Set Attributes + + self.layout = QtWidgets.QVBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.layout) + + # Add accordion widget (Maya attribute editor style) + self.widgetlibrary = AccordionWidget(self) + self.widgetlibrary.setRolloutStyle(AccordionWidget.Maya) + + # Add separate widgets + self.widgetlibrary.addItem("Preview", + PreviewWidget(self.get_outputs, + self.validate, + parent=self), + collapsed=True) + + self.presetwidget = PresetWidget(inputs_getter=self.get_inputs, + parent=self) + self.widgetlibrary.addItem("Presets", self.presetwidget) + + # add plug-in widgets + for widget in plugin.discover(): + self.add_plugin(widget) + + self.layout.addWidget(self.widgetlibrary) + + # add standard buttons + self.apply_button = QtWidgets.QPushButton("Capture") + self.layout.addWidget(self.apply_button) + + # default actions + self.apply_button.clicked.connect(self.apply) + + # signals and slots + self.presetwidget.config_opened.connect(self.show_config) + self.presetwidget.preset_loaded.connect(self.apply_inputs) + + self.apply_inputs(self._read_widget_configuration()) + + def apply(self): + """Run capture action with current settings""" + + valid = self.validate() + if not valid: + return + + options = self.get_outputs() + filename = options.get("filename", None) + + self.playblast_start.emit(options) + + # The filename can be `None` when the + # playblast will *not* be saved. + if filename is not None: + # Format the tokens in the filename + filename = tokens.format_tokens(filename, options) + + # expand environment variables + filename = os.path.expandvars(filename) + + # Make relative paths absolute to the "images" file rule by default + if not os.path.isabs(filename): + root = lib.get_project_rule("images") + filename = os.path.join(root, filename) + + # normalize (to remove double slashes and alike) + filename = os.path.normpath(filename) + + options["filename"] = filename + + # Perform capture and store returned filename with extension + options["filename"] = lib.capture_scene(options) + + self.playblast_finished.emit(options) + filename = options["filename"] # get filename after callbacks + + # Show viewer + viewer = options.get("viewer", False) + if viewer: + if filename and os.path.exists(filename): + self.viewer_start.emit(options) + lib.open_file(filename) + else: + raise RuntimeError("Can't open playblast because file " + "doesn't exist: {0}".format(filename)) + + return filename + + def apply_inputs(self, inputs): + """Apply all the settings of the widgets. + + Arguments: + inputs (dict): input values per plug-in widget + + Returns: + None + + """ + if not inputs: + return + + widgets = self._get_plugin_widgets() + widgets.append(self.presetwidget) + for widget in widgets: + widget_inputs = inputs.get(widget.id, None) + if not widget_inputs: + continue + widget.apply_inputs(widget_inputs) + + def show_config(self): + """Show the advanced configuration""" + # calculate center of main widget + geometry = self.geometry() + self._config_dialog.move(QtCore.QPoint(geometry.x()+30, + geometry.y())) + self._config_dialog.show() + + def add_plugin(self, plugin): + """Add an options widget plug-in to the UI""" + + if plugin.section not in self.application_sections: + log.warning("{}'s section is invalid: " + "{}".format(plugin.label, plugin.section)) + return + + widget = plugin(parent=self) + widget.initialize() + widget.options_changed.connect(self.on_widget_settings_changed) + self.playblast_finished.connect(widget.on_playblast_finished) + + # Add to plug-ins in its section + self.plugins[widget.section].append(widget) + + # Implement additional settings depending on section + if widget.section == "app": + if not widget.hidden: + item = self.widgetlibrary.addItem(widget.label, widget) + # connect label change behaviour + widget.label_changed.connect(item.setTitle) + + # Add the plugin in a QGroupBox to the configuration dialog + if widget.section == "config": + layout = self._config_dialog.layout() + # create group box + group_widget = QtWidgets.QGroupBox(widget.label) + group_layout = QtWidgets.QVBoxLayout(group_widget) + group_layout.addWidget(widget) + + layout.addWidget(group_widget) + + def validate(self): + """Validate whether the outputs of the widgets are good. + + Returns: + bool: Whether it's valid to capture the current settings. + + """ + + errors = list() + for widget in self._get_plugin_widgets(): + widget_errors = widget.validate() + if widget_errors: + errors.extend(widget_errors) + + if errors: + message_title = "%s Validation Error(s)" % len(errors) + message = "\n".join(errors) + QtWidgets.QMessageBox.critical(self, + message_title, + message, + QtWidgets.QMessageBox.Ok) + return False + + return True + + def get_outputs(self): + """Return settings for a capture as currently set in the Application. + + Returns: + dict: Current output settings + + """ + + # Get settings from widgets + outputs = dict() + for widget in self._get_plugin_widgets(): + widget_outputs = widget.get_outputs() + if not widget_outputs: + continue + + for key, value in widget_outputs.items(): + + # We merge dictionaries by updating them so we have + # the "mixed" values of both settings + if isinstance(value, dict) and key in outputs: + outputs[key].update(value) + else: + outputs[key] = value + + return outputs + + def get_inputs(self, as_preset=False): + """Return the inputs per plug-in widgets by `plugin.id`. + + Returns: + dict: The inputs per widget + + """ + + inputs = dict() + # Here we collect all the widgets from which we want to store the + # current inputs. This will be restored in the next session + # The preset widget is added to make sure the user starts with the + # previously selected preset configuration + config_widgets = self._get_plugin_widgets() + config_widgets.append(self.presetwidget) + for widget in config_widgets: + widget_inputs = widget.get_inputs(as_preset=as_preset) + if not isinstance(widget_inputs, dict): + log.debug("Widget inputs are not a dictionary " + "'{}': {}".format(widget.id, widget_inputs)) + return + + if not widget_inputs: + continue + + inputs[widget.id] = widget_inputs + + return inputs + + def on_widget_settings_changed(self): + """Set current preset to '*' on settings change""" + + self.options_changed.emit(self.get_outputs) + self.presetwidget.presets.setCurrentIndex(0) + + def _build_configuration_dialog(self): + """Build a configuration to store configuration widgets in""" + + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle("Capture - Preset Configuration") + QtWidgets.QVBoxLayout(dialog) + + self._config_dialog = dialog + + def _ensure_config_exist(self): + """Create the configuration file if it does not exist yet. + + Returns: + unicode: filepath of the configuration file + + """ + + userdir = os.path.expanduser("~") + capturegui_dir = os.path.join(userdir, "CaptureGUI") + capturegui_inputs = os.path.join(capturegui_dir, "capturegui.json") + if not os.path.exists(capturegui_dir): + os.makedirs(capturegui_dir) + + if not os.path.isfile(capturegui_inputs): + config = open(capturegui_inputs, "w") + config.close() + + return capturegui_inputs + + def _store_widget_configuration(self): + """Store all used widget settings in the local json file""" + + inputs = self.get_inputs(as_preset=False) + path = self.settingfile + + with open(path, "w") as f: + log.debug("Writing JSON file: {0}".format(path)) + json.dump(inputs, f, sort_keys=True, + indent=4, separators=(',', ': ')) + + def _read_widget_configuration(self): + """Read the stored widget inputs""" + + inputs = {} + path = self.settingfile + + if not os.path.isfile(path) or os.stat(path).st_size == 0: + return inputs + + with open(path, "r") as f: + log.debug("Reading JSON file: {0}".format(path)) + try: + inputs = json.load(f) + except ValueError as error: + log.error(str(error)) + + return inputs + + def _get_plugin_widgets(self): + """List all plug-in widgets. + + Returns: + list: The plug-in widgets in *all* sections + + """ + + widgets = list() + for section in self.plugins.values(): + widgets.extend(section) + + return widgets + + # override close event to ensure the input are stored + + def closeEvent(self, event): + """Store current configuration upon closing the application.""" + + self._store_widget_configuration() + for section_widgets in self.plugins.values(): + for widget in section_widgets: + widget.uninitialize() + + event.accept() diff --git a/pype/vendor/capture_gui/colorpicker.py b/pype/vendor/capture_gui/colorpicker.py new file mode 100644 index 0000000000..aa00a7386d --- /dev/null +++ b/pype/vendor/capture_gui/colorpicker.py @@ -0,0 +1,55 @@ +from capture_gui.vendor.Qt import QtCore, QtWidgets, QtGui + + +class ColorPicker(QtWidgets.QPushButton): + """Custom color pick button to store and retrieve color values""" + + valueChanged = QtCore.Signal() + + def __init__(self): + QtWidgets.QPushButton.__init__(self) + + self.clicked.connect(self.show_color_dialog) + self._color = None + + self.color = [1, 1, 1] + + # region properties + @property + def color(self): + return self._color + + @color.setter + def color(self, values): + """Set the color value and update the stylesheet + + Arguments: + values (list): the color values; red, green, blue + + Returns: + None + + """ + self._color = values + self.valueChanged.emit() + + values = [int(x*255) for x in values] + self.setStyleSheet("background: rgb({},{},{})".format(*values)) + + # endregion properties + + def show_color_dialog(self): + """Display a color picker to change color. + + When a color has been chosen this updates the color of the button + and its current value + + :return: the red, green and blue values + :rtype: list + """ + current = QtGui.QColor() + current.setRgbF(*self._color) + colors = QtWidgets.QColorDialog.getColor(current) + if not colors: + return + self.color = [colors.redF(), colors.greenF(), colors.blueF()]