diff --git a/README.md b/README.md index e6cd62b..d016c9d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ capture_gui.main() ### Advanced +#### Callbacks Register a pre-view callback to allow a custom conversion or overlays on the resulting footage in your pipeline (e.g. through FFMPEG) @@ -71,6 +72,22 @@ app.viewer_start.connect(callback, QtCore.Qt.DirectConnection) app.show() ``` +#### Register preset paths + +Register a preset path that will be used by the capture gui to load default presets from. + +```python +import capture_gui.presets +import capture_gui + +path = "path/to/directory" +capture_gui.presets.register_path(path) + +# After registering capture gui will automatically load +# the presets found in all registered preset paths +capture_gui.main() +``` + ### Known issues diff --git a/RELEASES.md b/RELEASES.md index 37ec873..5b2edc4 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,5 +1,24 @@ # Maya Capture GUI RELEASES +## 03 / 05 / 2017 - v1.3.0 +- Changed mode name in Time Plugin, old presets incompatible with current version +- Removed unused keyword argument + +## 03 / 05 / 2017 - v1.2.0 +- Extended README with example of adding presets before launching the +tool +- Solved issue 0008 + + Playback of images, frame padding ( #### ) solved + + View when finished works regardless of Save being checked +- Solved issue 0019 + + Non-chronological time range is not possible anymore +- Solved issue 0020 + + Added custom frame range, similar to print pages in Word + +## 02 / 05 / 2017 - v1.1.0 +- Solved issue 0014 +- Added plugin validation function +- Added app validation function to validate listed plugins ## 24 / 04 / 2017 - v1.0.2 Fixed issue with storing presets and recent playblasts diff --git a/capture_gui/app.py b/capture_gui/app.py index e46e29a..55deeb5 100644 --- a/capture_gui/app.py +++ b/capture_gui/app.py @@ -37,12 +37,13 @@ class PreviewWidget(QtWidgets.QWidget): preview_width = 320 preview_height = 180 - def __init__(self, options_getter, parent=None): + def __init__(self, options_getter, validator, parent=None): QtWidgets.QWidget.__init__(self, parent=parent) # Add attributes self.initialized = False self.options_getter = options_getter + self.validator = validator self.preview = ClickLabel() self.preview.setFixedWidth(self.preview_width) self.preview.setFixedHeight(self.preview_height) @@ -69,6 +70,11 @@ def refresh(self): # 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() @@ -333,6 +339,7 @@ def __init__(self, title, parent=None): # Add separate widgets self.widgetlibrary.addItem("Preview", PreviewWidget(self.get_outputs, + self.validate, parent=self), collapsed=True) @@ -362,7 +369,13 @@ def __init__(self, title, parent=None): def apply(self): """Run capture action with current settings""" + valid = self.validate() + if not valid: + return + options = self.get_outputs() + if options["frame"] is not None: + options["raw_frame_numbers"] = True filename = options.get("filename", None) self.playblast_start.emit(options) @@ -412,6 +425,7 @@ def add_plugin(self, plugin): return widget = plugin(parent=self) + widget.initialize() widget.options_changed.connect(self.on_widget_settings_changed) self.playblast_finished.connect(widget.on_playblast_finished) @@ -433,6 +447,29 @@ def add_plugin(self, plugin): group_layout.addWidget(widget) layout.addWidget(group_widget) + def validate(self): + """ + Check if the outputs of the widgets are good + :return: + """ + + 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 the settings for a capture as currently set in the Application. diff --git a/capture_gui/lib.py b/capture_gui/lib.py index 23b2f7a..2bb00b8 100644 --- a/capture_gui/lib.py +++ b/capture_gui/lib.py @@ -155,7 +155,18 @@ def _fix_playblast_output_path(filepath): # Fix: playblast not returning correct filename (with extension) # Lets assume the most recently modified file is the correct one. if not os.path.exists(filepath): - files = glob.glob("{}.*".format(filepath)) + directory = os.path.dirname(filepath) + filename = os.path.basename(filepath) + # check if the filepath is has frame based filename + # example : capture.####.png + parts = filename.split(".") + if len(parts) == 3: + query = os.path.join(directory, "{}.*.{}".format(parts[0], + parts[-1])) + files = glob.glob(query) + else: + files = glob.glob("{}.*".format(filepath)) + if not files: raise RuntimeError("Couldn't find playblast from: " "{0}".format(filepath)) diff --git a/capture_gui/plugin.py b/capture_gui/plugin.py index e5953f0..7d08793 100644 --- a/capture_gui/plugin.py +++ b/capture_gui/plugin.py @@ -54,10 +54,21 @@ class Plugin(QtWidgets.QWidget): options_changed = QtCore.Signal() label_changed = QtCore.Signal(str) order = 0 + highlight = "border: 1px solid red;" + validate_state = True def on_playblast_finished(self, options): pass + def validate(self): + """ + Ensure outputs of the widget are possible, when errors are raised it + will return a message with what has caused the error + :return: + """ + errors = [] + return errors + def get_outputs(self): """Return the options as set in this plug-in widget. diff --git a/capture_gui/plugins/cameraplugin.py b/capture_gui/plugins/cameraplugin.py index 754dbfc..b26ff40 100644 --- a/capture_gui/plugins/cameraplugin.py +++ b/capture_gui/plugins/cameraplugin.py @@ -40,6 +40,7 @@ def __init__(self, parent=None): self.cameras.currentIndexChanged.connect(self.options_changed) self.options_changed.connect(self.on_update_label) + self.options_changed.connect(self.validate) # Force update of the label self.set_active_cam() @@ -64,6 +65,19 @@ def select_camera(self, cam): self.cameras.setCurrentIndex(i) return + def validate(self): + + errors = [] + camera = self.cameras.currentText() + if not cmds.objExists(camera): + errors.append("{} : Selected camera '{}' " + "does not exist!".format(self.id, camera)) + self.cameras.setStyleSheet(self.highlight) + else: + self.cameras.setStyleSheet("") + + return errors + def get_outputs(self): """Return currently selected camera from combobox.""" diff --git a/capture_gui/plugins/ioplugin.py b/capture_gui/plugins/ioplugin.py index a9430ac..c0e1a67 100644 --- a/capture_gui/plugins/ioplugin.py +++ b/capture_gui/plugins/ioplugin.py @@ -177,12 +177,14 @@ def get_outputs(self): :rtype: dict """ - output = {"filename": None} + output = {"filename": None, + "viewer": self.open_viewer.isChecked()} + use_default = self.use_default.isChecked() save = self.save_file.isChecked() # run playblast, don't copy to dir if not save: - return + return output # run playblast, copy file to given directory # get directory from inputs @@ -198,7 +200,6 @@ def get_outputs(self): path = lib.default_output() output["filename"] = path - output["viewer"] = self.open_viewer.isChecked() return output diff --git a/capture_gui/plugins/timeplugin.py b/capture_gui/plugins/timeplugin.py index 5247dc2..3d77735 100644 --- a/capture_gui/plugins/timeplugin.py +++ b/capture_gui/plugins/timeplugin.py @@ -1,5 +1,6 @@ import sys import logging +import re import maya.OpenMaya as om from capture_gui.vendor.Qt import QtCore, QtWidgets @@ -10,6 +11,64 @@ log = logging.getLogger("Time Range") +def parse_frames(string): + """Parse the resulting frames list from a frame list string. + + Examples + >>> parse_frames("0-3;30") + [0, 1, 2, 3, 30] + >>> parse_frames("0,2,4,-10") + [0, 2, 4, -10] + >>> parse_frames("-10--5,-2") + [-10, -9, -8, -7, -6, -5, -2] + + Args: + string (str): The string to parse for frames. + + Returns: + list: A list of frames + + """ + + result = list() + + if not string: + return result + + if not re.match("^[-0-9,; ]*$", string): + raise ValueError("Invalid symbols in frame string: {}".format(string)) + + for raw in re.split(";|,", string): + + # Skip empty elements + value = raw.strip().replace(" ", "") + if not value: + if raw: + raise ValueError("Empty frame entry: '{0}'".format(raw)) + continue + + # Check for sequences (1-20) including negatives (-10--8) + sequence = re.search("(-?[0-9]+)-(-?[0-9]+)", value) + + # Sequence + if sequence: + start, end = sequence.groups() + frames = range(int(start), int(end) + 1) + result.extend(frames) + + # Single frame + else: + try: + frame = int(value) + except ValueError: + raise ValueError("Invalid frame description: " + "'{0}'".format(value)) + + result.append(frame) + + return result + + class TimePlugin(capture_gui.plugin.Plugin): """Widget for time based options""" @@ -19,7 +78,8 @@ class TimePlugin(capture_gui.plugin.Plugin): RangeTimeSlider = "Time Slider" RangeStartEnd = "Start/End" - CurrentFrame = "CurrentFrame" + CurrentFrame = "Current Frame" + CustomFrames = "Custom Frames" def __init__(self, parent=None): super(TimePlugin, self).__init__(parent=parent) @@ -33,16 +93,33 @@ def __init__(self, parent=None): self.mode = QtWidgets.QComboBox() self.mode.addItems([self.RangeTimeSlider, self.RangeStartEnd, - self.CurrentFrame]) + self.CurrentFrame, + self.CustomFrames]) + frame_input_height = 20 self.start = QtWidgets.QSpinBox() self.start.setRange(-sys.maxint, sys.maxint) + self.start.setFixedHeight(frame_input_height) self.end = QtWidgets.QSpinBox() self.end.setRange(-sys.maxint, sys.maxint) + self.end.setFixedHeight(frame_input_height) + + # unique frames field + self.custom_frames = QtWidgets.QLineEdit() + self.custom_frames.setPlaceholderText("1-20,25;50;75,100-150") + self.custom_frames.setVisible(False) + self.custom_frames.setFixedHeight(frame_input_height) self._layout.addWidget(self.mode) self._layout.addWidget(self.start) self._layout.addWidget(self.end) + self._layout.addWidget(self.custom_frames) + + # Connect callbacks to ensure start is never higher then end + # and the end is never lower than start + self.end.valueChanged.connect(self._ensure_start) + self.start.valueChanged.connect(self._ensure_end) + self.custom_frames.textChanged.connect(self.validate) self.on_mode_changed() # force enabled state refresh @@ -50,10 +127,16 @@ def __init__(self, parent=None): self.start.valueChanged.connect(self.on_mode_changed) self.end.valueChanged.connect(self.on_mode_changed) + def _ensure_start(self, value): + self.start.setValue(min(self.start.value(), value)) + + def _ensure_end(self, value): + self.end.setValue(max(self.end.value(), value)) + def on_mode_changed(self, emit=True): """ Update the GUI when the user updated the time range or settings - + :param emit: Whether to emit the options changed signal :type emit: bool @@ -65,16 +148,31 @@ def on_mode_changed(self, emit=True): start, end = capture_gui.lib.get_time_slider_range() self.start.setEnabled(False) self.end.setEnabled(False) + self.start.setVisible(True) + self.end.setVisible(True) + self.custom_frames.setVisible(False) mode_values = int(start), int(end) elif mode == self.RangeStartEnd: self.start.setEnabled(True) self.end.setEnabled(True) + self.start.setVisible(True) + self.end.setVisible(True) + self.custom_frames.setVisible(False) mode_values = self.start.value(), self.end.value() + + elif mode == self.CustomFrames: + self.start.setVisible(False) + self.end.setVisible(False) + self.custom_frames.setVisible(True) + mode_values = self.custom_frames.text() else: self.start.setEnabled(False) self.end.setEnabled(False) - mode_values = "({})".format( - int(capture_gui.lib.get_current_frame())) + self.start.setVisible(True) + self.end.setVisible(True) + self.custom_frames.setVisible(False) + currentframe = int(capture_gui.lib.get_current_frame()) + mode_values = "({})".format(currentframe) # Update label self.label = "Time Range {}".format(mode_values) @@ -83,15 +181,35 @@ def on_mode_changed(self, emit=True): if emit: self.options_changed.emit() + def validate(self): + errors = [] + + if self.mode.currentText() == self.CustomFrames: + + # Reset + self.custom_frames.setStyleSheet("") + + try: + parse_frames(self.custom_frames.text()) + except ValueError as exc: + errors.append("{} : Invalid frame description: " + "{}".format(self.id, exc)) + self.custom_frames.setStyleSheet(self.highlight) + + return errors + def get_outputs(self, panel=""): """ Get the options of the Time Widget - :param panel: + :param panel: name of the panel + :type panel: str + :return: the settings in a dictionary :rtype: dict """ mode = self.mode.currentText() + frames = None if mode == self.RangeTimeSlider: start, end = capture_gui.lib.get_time_slider_range() @@ -105,28 +223,37 @@ def get_outputs(self, panel=""): start = frame end = frame + elif mode == self.CustomFrames: + frames = parse_frames(self.custom_frames.text()) + start = None + end = None else: raise NotImplementedError("Unsupported time range mode: " "{0}".format(mode)) return {"start_frame": start, - "end_frame": end} + "end_frame": end, + "frame": frames} def get_inputs(self, as_preset): return {"time": self.mode.currentText(), "start_frame": self.start.value(), - "end_frame": self.end.value()} + "end_frame": self.end.value(), + "frame": self.custom_frames.text()} def apply_inputs(self, settings): # get values mode = self.mode.findText(settings.get("time", self.RangeTimeSlider)) startframe = settings.get("start_frame", 1) endframe = settings.get("end_frame", 120) + custom_frames = settings.get("frame", None) # set values self.mode.setCurrentIndex(mode) self.start.setValue(int(startframe)) self.end.setValue(int(endframe)) + if custom_frames: + self.custom_frames.setText(custom_frames) def initialize(self): self._register_callbacks() diff --git a/capture_gui/version.py b/capture_gui/version.py index 70d4296..c82386b 100644 --- a/capture_gui/version.py +++ b/capture_gui/version.py @@ -1,6 +1,6 @@ VERSION_MAJOR = 1 -VERSION_MINOR = 0 -VERSION_PATCH = 2 +VERSION_MINOR = 3 +VERSION_PATCH = 0 version = '{}.{}.{}'.format(VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH)