diff options
Diffstat (limited to 'src')
47 files changed, 12338 insertions, 0 deletions
diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..08131ce --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,36 @@ +import sys +import os +import logging + + +__version__ = '2.0.0rc6' + + +class Logger(logging.getLoggerClass()): + ''' + Custom Logger class to handle custom VERBOSE log level. + Levels used in this program are as follows: + VERBOSE Annoyingly frequent debug messages (e.g, in loops) + DEBUG Ordinary debug information + INFO Expected events that are expensive or irreversible + WARNING A non-fatal error or suspicious behaviour + ERROR Any error that would interrupt the user + CRITICAL Things that really shouldn't happen at all + ''' + def __init__(self, name, level=logging.NOTSET): + super().__init__(name, level) + logging.addLevelName(5, "VERBOSE") + + def verbose(self, msg, *args, **kwargs): + if self.isEnabledFor(5): + self._log(5, msg, args, **kwargs) +logging.setLoggerClass(Logger) +logging.VERBOSE = 5 + + +if getattr(sys, 'frozen', False): + # frozen + wd = os.path.dirname(sys.executable) +else: + # unfrozen + wd = os.path.dirname(os.path.realpath(__file__)) diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..3206bc8 --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,5 @@ +# Allows for launching with python3 -m avp + +from .main import main + +main() diff --git a/src/command.py b/src/command.py new file mode 100644 index 0000000..bf7941a --- /dev/null +++ b/src/command.py @@ -0,0 +1,263 @@ +''' + When using commandline mode, this module's object handles interpreting + the arguments and giving them to Core, which tracks the main program state. + Then it immediately exports a video. +''' +from PyQt5 import QtCore +import argparse +import os +import sys +import time +import signal +import shutil +import logging + +from . import core + + +log = logging.getLogger('AVP.Commandline') + + +class Command(QtCore.QObject): + """ + This replaces the GUI MainWindow when in commandline mode. + """ + + createVideo = QtCore.pyqtSignal() + + def __init__(self): + super().__init__() + self.core = core.Core() + core.Core.mode = 'commandline' + self.dataDir = self.core.dataDir + self.canceled = False + self.settings = core.Core.settings + + # ctrl-c stops the export thread + signal.signal(signal.SIGINT, self.stopVideo) + + def parseArgs(self): + self.parser = argparse.ArgumentParser( + description='Create a visualization for an audio file', + epilog='EXAMPLE COMMAND: main.py myvideotemplate.avp ' + '-i ~/Music/song.mp3 -o ~/video.mp4 ' + '-c 0 image path=~/Pictures/thisWeeksPicture.jpg ' + '-c 1 video "preset=My Logo" -c 2 vis layout=classic' + ) + self.parser.add_argument( + '-i', '--input', metavar='SOUND', + help='input audio file' + ) + self.parser.add_argument( + '-o', '--output', metavar='OUTPUT', + help='output video file' + ) + self.parser.add_argument( + '--export-project', action='store_true', + help='ignore -i and -o, use input and output from project file' + ) + self.parser.add_argument( + '--test', action='store_true', + help='run tests, generate logfiles, then exit' + ) + self.parser.add_argument( + '--debug', action='store_true', + help='create bigger logfiles while program is running' + ) + + # optional arguments + self.parser.add_argument( + 'projpath', metavar='path-to-project', + help='open a project file (.avp)', nargs='?') + self.parser.add_argument( + '-c', '--comp', metavar=('LAYER', 'ARG'), + help='first arg must be component NAME to insert at LAYER.' + '"help" for information about possible args for a component.', + nargs='*', action='append') + + self.args = self.parser.parse_args() + + if self.args.debug: + core.FILE_LOGLVL = logging.DEBUG + core.STDOUT_LOGLVL = logging.DEBUG + core.Core.makeLogger() + + if self.args.test: + self.runTests() + quit(0) + + if self.args.projpath: + projPath = self.args.projpath + if not os.path.dirname(projPath): + projPath = os.path.join( + self.settings.value("projectDir"), + projPath + ) + if not projPath.endswith('.avp'): + projPath += '.avp' + success = self.core.openProject(self, projPath) + if not success: + quit(1) + self.core.selectedComponents = list( + reversed(self.core.selectedComponents)) + self.core.componentListChanged() + + if self.args.comp: + for comp in self.args.comp: + pos = comp[0] + name = comp[1] + args = comp[2:] + try: + pos = int(pos) + except ValueError: + print(pos, 'is not a layer number.') + quit(1) + realName = self.parseCompName(name) + if not realName: + print(name, 'is not a valid component name.') + quit(1) + modI = self.core.moduleIndexFor(realName) + i = self.core.insertComponent(pos, modI, self) + for arg in args: + self.core.selectedComponents[i].command(arg) + + if self.args.export_project and self.args.projpath: + errcode, data = self.core.parseAvFile(projPath) + for key, value in data['WindowFields']: + if 'outputFile' in key: + output = value + if not os.path.dirname(value): + output = os.path.join( + os.path.expanduser('~'), + output + ) + if 'audioFile' in key: + input = value + self.createAudioVisualisation(input, output) + return "commandline" + + elif self.args.input and self.args.output: + self.createAudioVisualisation(self.args.input, self.args.output) + return "commandline" + + elif 'help' not in sys.argv and self.args.projpath is None and '--debug' not in sys.argv: + self.parser.print_help() + quit(1) + + return "GUI" + + def createAudioVisualisation(self, input, output): + self.core.selectedComponents = list( + reversed(self.core.selectedComponents)) + self.core.componentListChanged() + self.worker = self.core.newVideoWorker( + self, input, output + ) + # quit(0) after video is created + self.worker.videoCreated.connect(self.videoCreated) + self.lastProgressUpdate = time.time() + self.worker.progressBarSetText.connect(self.progressBarSetText) + self.createVideo.emit() + + def stopVideo(self, *args): + self.worker.error = True + self.worker.cancelExport() + self.worker.cancel() + + @QtCore.pyqtSlot(str) + def progressBarSetText(self, value): + if 'Export ' in value: + # Don't duplicate completion/failure messages + return + if not value.startswith('Exporting') \ + and time.time() - self.lastProgressUpdate >= 0.05: + # Show most messages very often + print(value) + elif time.time() - self.lastProgressUpdate >= 2.0: + # Give user time to read ffmpeg's output during the export + print('##### %s' % value) + else: + return + self.lastProgressUpdate = time.time() + + @QtCore.pyqtSlot() + def videoCreated(self): + quit(0) + + def showMessage(self, **kwargs): + print(kwargs['msg']) + if 'detail' in kwargs: + print(kwargs['detail']) + + @QtCore.pyqtSlot(str, str) + def videoThreadError(self, msg, detail): + print(msg) + print(detail) + quit(1) + + def drawPreview(self, *args): + pass + + def parseCompName(self, name): + '''Deduces a proper component name out of a commandline arg''' + + if name.title() in self.core.compNames: + return name.title() + for compName in self.core.compNames: + if name.capitalize() in compName: + return compName + + compFileNames = [ + os.path.splitext( + os.path.basename(mod.__file__) + )[0] + for mod in self.core.modules + ] + for i, compFileName in enumerate(compFileNames): + if name.lower() in compFileName: + return self.core.compNames[i] + return + + return None + + def runTests(self): + core.FILE_LOGLVL = logging.DEBUG + core.Core.makeLogger() + from . import tests + test_report = os.path.join(core.Core.logDir, "test_report.log") + tests.run(test_report) + + # Print test report into terminal + with open(test_report, "r") as f: + output = f.readlines() + test_output = "".join(output) + print(test_output) + + # Choose a numbered location to put the output file + logNumber = 0 + def getFilename(): + """Get a numbered filename for the final test report""" + nonlocal logNumber + name = os.path.join(os.path.expanduser('~'), "avp_test_report") + while True: + possibleName = f"{name}{logNumber:0>2}.txt" + if os.path.exists(possibleName) and logNumber < 100: + logNumber += 1 + continue + break + return possibleName + + # Copy latest debug log to chosen test report location + filename = getFilename() + if logNumber == 100: + print("Test Report could not be created.") + return + try: + shutil.copy(os.path.join(core.Core.logDir, "avp_debug.log"), filename) + except FileNotFoundError: + print("No debug log found.") + # Append actual test report to debug log + with open(filename, "a") as f: + f.write(f"{'='*59} debug log ends {'='*59}\n") + f.write(test_output) + print(f"Test Report created at {filename}") diff --git a/src/component.py b/src/component.py new file mode 100644 index 0000000..1e10f66 --- /dev/null +++ b/src/component.py @@ -0,0 +1,924 @@ +''' + Base classes for components to import. Read comments for some documentation + on making a valid component. +''' +from PyQt5 import uic, QtCore, QtWidgets +from PyQt5.QtGui import QColor +import os +import sys +import math +import time +import logging +from copy import copy + +from .toolkit.frame import BlankFrame +from .toolkit import ( + getWidgetValue, setWidgetValue, connectWidget, rgbFromString, blockSignals +) + + +log = logging.getLogger('AVP.ComponentHandler') + + +class ComponentMetaclass(type(QtCore.QObject)): + ''' + Checks the validity of each Component class and mutates some attrs. + E.g., takes only major version from version string & decorates methods + ''' + + def initializationWrapper(func): + def initializationWrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except Exception: + try: + raise ComponentError(self, 'initialization process') + except ComponentError: + return + return initializationWrapper + + def renderWrapper(func): + def renderWrapper(self, *args, **kwargs): + try: + log.verbose( + '### %s #%s renders a preview frame ###', + self.__class__.name, str(self.compPos), + ) + return func(self, *args, **kwargs) + except Exception as e: + try: + if e.__class__.__name__.startswith('Component'): + raise + else: + raise ComponentError(self, 'renderer') + except ComponentError: + return BlankFrame() + return renderWrapper + + def commandWrapper(func): + '''Intercepts the command() method to check for global args''' + def commandWrapper(self, arg): + if arg.startswith('preset='): + _, preset = arg.split('=', 1) + path = os.path.join(self.core.getPresetDir(self), preset) + if not os.path.exists(path): + print('Couldn\'t locate preset "%s"' % preset) + quit(1) + else: + print('Opening "%s" preset on layer %s' % ( + preset, self.compPos) + ) + self.core.openPreset(path, self.compPos, preset) + # Don't call the component's command() method + return + else: + return func(self, arg) + return commandWrapper + + def propertiesWrapper(func): + '''Intercepts the usual properties if the properties are locked.''' + def propertiesWrapper(self): + if self._lockedProperties is not None: + return self._lockedProperties + else: + try: + return func(self) + except Exception: + try: + raise ComponentError(self, 'properties') + except ComponentError: + return [] + return propertiesWrapper + + def errorWrapper(func): + '''Intercepts the usual error message if it is locked.''' + def errorWrapper(self): + if self._lockedError is not None: + return self._lockedError + else: + return func(self) + return errorWrapper + + def loadPresetWrapper(func): + '''Wraps loadPreset to handle the self.openingPreset boolean''' + class openingPreset: + def __init__(self, comp): + self.comp = comp + + def __enter__(self): + self.comp.openingPreset = True + + def __exit__(self, *args): + self.comp.openingPreset = False + + def presetWrapper(self, *args): + with openingPreset(self): + try: + return func(self, *args) + except Exception: + try: + raise ComponentError(self, 'preset loader') + except ComponentError: + return + return presetWrapper + + def updateWrapper(func): + ''' + Calls _preUpdate before every subclass update(). + Afterwards, for non-user updates, calls _autoUpdate(). + For undoable updates triggered by the user, calls _userUpdate() + ''' + class wrap: + def __init__(self, comp, auto): + self.comp = comp + self.auto = auto + + def __enter__(self): + self.comp._preUpdate() + + def __exit__(self, *args): + if self.auto or self.comp.openingPreset \ + or not hasattr(self.comp.parent, 'undoStack'): + log.verbose('Automatic update') + self.comp._autoUpdate() + else: + log.verbose('User update') + self.comp._userUpdate() + + def updateWrapper(self, **kwargs): + auto = kwargs['auto'] if 'auto' in kwargs else False + with wrap(self, auto): + try: + return func(self) + except Exception: + try: + raise ComponentError(self, 'update method') + except ComponentError: + return + return updateWrapper + + def widgetWrapper(func): + '''Connects all widgets to update method after the subclass's method''' + class wrap: + def __init__(self, comp): + self.comp = comp + + def __enter__(self): + pass + + def __exit__(self, *args): + for widgetList in self.comp._allWidgets.values(): + for widget in widgetList: + log.verbose('Connecting %s', str( + widget.__class__.__name__)) + connectWidget(widget, self.comp.update) + + def widgetWrapper(self, *args, **kwargs): + auto = kwargs['auto'] if 'auto' in kwargs else False + with wrap(self): + try: + return func(self, *args, **kwargs) + except Exception: + try: + raise ComponentError(self, 'widget creation') + except ComponentError: + return + return widgetWrapper + + def __new__(cls, name, parents, attrs): + if 'ui' not in attrs: + # Use module name as ui filename by default + attrs['ui'] = '%s.ui' % os.path.splitext( + attrs['__module__'].split('.')[-1] + )[0] + + decorate = ( + 'names', # Class methods + 'error', 'audio', 'properties', # Properties + 'preFrameRender', 'previewRender', + 'loadPreset', 'command', + 'update', 'widget', + ) + + # Auto-decorate methods + for key in decorate: + if key not in attrs: + continue + if key in ('names'): + attrs[key] = classmethod(attrs[key]) + elif key in ('audio'): + attrs[key] = property(attrs[key]) + elif key == 'command': + attrs[key] = cls.commandWrapper(attrs[key]) + elif key == 'previewRender': + attrs[key] = cls.renderWrapper(attrs[key]) + elif key == 'preFrameRender': + attrs[key] = cls.initializationWrapper(attrs[key]) + elif key == 'properties': + attrs[key] = cls.propertiesWrapper(attrs[key]) + elif key == 'error': + attrs[key] = cls.errorWrapper(attrs[key]) + elif key == 'loadPreset': + attrs[key] = cls.loadPresetWrapper(attrs[key]) + elif key == 'update': + attrs[key] = cls.updateWrapper(attrs[key]) + elif key == 'widget' and parents[0] != QtCore.QObject: + attrs[key] = cls.widgetWrapper(attrs[key]) + + # Turn version string into a number + try: + if 'version' not in attrs: + log.error( + 'No version attribute in %s. Defaulting to 1', + attrs['name']) + attrs['version'] = 1 + else: + attrs['version'] = int(attrs['version'].split('.')[0]) + except ValueError: + log.critical( + '%s component has an invalid version string:\n%s', + attrs['name'], str(attrs['version']) + ) + except KeyError: + log.critical('%s component has no version string.', attrs['name']) + else: + return super().__new__(cls, name, parents, attrs) + quit(1) + + +class Component(QtCore.QObject, metaclass=ComponentMetaclass): + ''' + The base class for components to inherit. + ''' + + name = 'Component' + # ui = 'name_Of_Non_Default_Ui_File' + + version = '1.0.0' + # The major version (before the first dot) is used to determine + # preset compatibility; the rest is ignored so it can be non-numeric. + + modified = QtCore.pyqtSignal(int, dict) + _error = QtCore.pyqtSignal(str, str) + + def __init__(self, moduleIndex, compPos, core): + super().__init__() + self.moduleIndex = moduleIndex + self.compPos = compPos + self.core = core + + # STATUS VARIABLES + self.currentPreset = None + self._allWidgets = {} + self._trackedWidgets = {} + self._presetNames = {} + self._commandArgs = {} + self._colorWidgets = {} + self._colorFuncs = {} + self._relativeWidgets = {} + # Pixel values stored as floats + self._relativeValues = {} + # Maximum values of spinBoxes at 1080p (Core.resolutions[0]) + self._relativeMaximums = {} + + # LOCKING VARIABLES + self.openingPreset = False + self.mergeUndo = True + self._lockedProperties = None + self._lockedError = None + self._lockedSize = None + # If set to a dict, values are used as basis to update relative widgets + self.oldAttrs = None + # Stop lengthy processes in response to this variable + self.canceled = False + + def __str__(self): + return self.__class__.name + + def __repr__(self): + import pprint + try: + preset = self.savePreset() + except Exception as e: + preset = '%s occurred while saving preset' % str(e) + + return ( + 'Component(module %s, pos %s) (%s)\n' + 'Name: %s v%s\nPreset: %s' % ( + self.moduleIndex, self.compPos, + object.__repr__(self), + self.__class__.name, str(self.__class__.version), + pprint.pformat(preset) + ) + ) + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Render Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def previewRender(self): + image = BlankFrame(self.width, self.height) + return image + + def preFrameRender(self, **kwargs): + ''' + Must call super() when subclassing + Triggered only before a video is exported (video_thread.py) + self.audioFile = filepath to the main input audio file + self.completeAudioArray = a list of audio samples + self.sampleSize = number of audio samples per video frame + self.progressBarUpdate = signal to set progress bar number + self.progressBarSetText = signal to set progress bar text + Use the latter two signals to update the MainWindow if needed + for a long initialization procedure (i.e., for a visualizer) + ''' + for key, value in kwargs.items(): + setattr(self, key, value) + + def frameRender(self, frameNo): + audioArrayIndex = frameNo * self.sampleSize + image = BlankFrame(self.width, self.height) + return image + + def postFrameRender(self): + pass + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Properties + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def properties(self): + ''' + Return a list of properties to signify if your component is + non-animated ('static'), returns sound ('audio'), or has + encountered an error in configuration ('error'). + ''' + return [] + + def error(self): + ''' + Return a string containing an error message, or None for a default. + Or tuple of two strings for a message with details. + Alternatively use lockError(msgString) within properties() + to skip this method entirely. + ''' + return + + def audio(self): + ''' + Return audio to mix into master as a tuple with two elements: + The first element can be: + - A string (path to audio file), + - Or an object that returns audio data through a pipe + The second element must be a dictionary of ffmpeg filters/options + to apply to the input stream. See the filter docs for ideas: + https://ffmpeg.org/ffmpeg-filters.html + ''' + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Idle Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def widget(self, parent): + ''' + Call super().widget(*args) to create the component widget + which also auto-connects any common widgets (e.g., checkBoxes) + to self.update(). Then in a subclass connect special actions + (e.g., pushButtons to select a file) and initialize + ''' + self.parent = parent + self.settings = parent.settings + log.verbose( + 'Creating UI for %s #%s\'s widget', + self.__class__.name, self.compPos + ) + self.page = self.loadUi(self.__class__.ui) + + # Find all normal widgets which will be connected after subclass method + self._allWidgets = { + 'lineEdit': self.page.findChildren(QtWidgets.QLineEdit), + 'checkBox': self.page.findChildren(QtWidgets.QCheckBox), + 'spinBox': self.page.findChildren(QtWidgets.QSpinBox), + 'comboBox': self.page.findChildren(QtWidgets.QComboBox), + } + self._allWidgets['spinBox'].extend( + self.page.findChildren(QtWidgets.QDoubleSpinBox) + ) + + def update(self): + ''' + Starting point for a component update. A subclass should override + this method, and the base class will then magically insert a call + to either _autoUpdate() or _userUpdate() at the end. + ''' + + def loadPreset(self, presetDict, presetName=None): + ''' + Subclasses should take (presetDict, *args) as args. + Must use super().loadPreset(presetDict, *args) first, + then update self.page widgets using the preset dict. + ''' + self.currentPreset = presetName \ + if presetName is not None else presetDict['preset'] + for attr, widget in self._trackedWidgets.items(): + key = attr if attr not in self._presetNames \ + else self._presetNames[attr] + try: + val = presetDict[key] + except KeyError as e: + log.info( + '%s missing value %s. Outdated preset?', + self.currentPreset, str(e) + ) + val = getattr(self, key) + + if attr in self._colorWidgets: + widget.setText('%s,%s,%s' % val) + btnStyle = ( + "QPushButton { background-color : %s; outline: none; }" + % QColor(*val).name() + ) + self._colorWidgets[attr].setStyleSheet(btnStyle) + elif attr in self._relativeWidgets: + self._relativeValues[attr] = val + pixelVal = self.pixelValForAttr(attr, val) + setWidgetValue(widget, pixelVal) + else: + setWidgetValue(widget, val) + + def savePreset(self): + saveValueStore = {} + for attr, widget in self._trackedWidgets.items(): + presetAttrName = ( + attr if attr not in self._presetNames + else self._presetNames[attr] + ) + if attr in self._relativeWidgets: + try: + val = self._relativeValues[attr] + except AttributeError: + val = self.floatValForAttr(attr) + else: + val = getattr(self, attr) + + saveValueStore[presetAttrName] = val + return saveValueStore + + def commandHelp(self): + '''Help text as string for this component's commandline arguments''' + + def command(self, arg=''): + ''' + Configure a component using an arg from the commandline. This is + never called if global args like 'preset=' are found in the arg. + So simply check for any non-global args in your component and + call super().command() at the end to get a Help message. + ''' + print( + self.__class__.name, 'Usage:\n' + 'Open a preset for this component:\n' + ' "preset=Preset Name"' + ) + self.commandHelp() + quit(0) + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # "Private" Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + def _preUpdate(self): + '''Happens before subclass update()''' + for attr in self._relativeWidgets: + self.updateRelativeWidget(attr) + + def _userUpdate(self): + '''Happens after subclass update() for an undoable update by user.''' + oldWidgetVals = { + attr: copy(getattr(self, attr)) + for attr in self._trackedWidgets + } + newWidgetVals = { + attr: getWidgetValue(widget) + if attr not in self._colorWidgets else rgbFromString(widget.text()) + for attr, widget in self._trackedWidgets.items() + } + modifiedWidgets = { + attr: val + for attr, val in newWidgetVals.items() + if val != oldWidgetVals[attr] + } + if modifiedWidgets: + action = ComponentUpdate(self, oldWidgetVals, modifiedWidgets) + self.parent.undoStack.push(action) + + def _autoUpdate(self): + '''Happens after subclass update() for an internal component update.''' + newWidgetVals = { + attr: getWidgetValue(widget) + for attr, widget in self._trackedWidgets.items() + } + self.setAttrs(newWidgetVals) + self._sendUpdateSignal() + + def setAttrs(self, attrDict): + ''' + Sets attrs (linked to trackedWidgets) in this component to + the values in the attrDict. Mutates certain widget values if needed + ''' + for attr, val in attrDict.items(): + if attr in self._colorWidgets: + # Color Widgets must have a tuple & have a button to update + if type(val) is tuple: + rgbTuple = val + else: + rgbTuple = rgbFromString(val) + btnStyle = ( + "QPushButton { background-color : %s; outline: none; }" + % QColor(*rgbTuple).name()) + self._colorWidgets[attr].setStyleSheet(btnStyle) + setattr(self, attr, rgbTuple) + + else: + # Normal tracked widget + setattr(self, attr, val) + log.verbose('Setting %s self.%s to %s' % ( + self.__class__.name, attr, val)) + + def setWidgetValues(self, attrDict): + ''' + Sets widgets defined by keys in trackedWidgets in this preset to + the values in the attrDict. + ''' + affectedWidgets = [ + self._trackedWidgets[attr] for attr in attrDict + ] + with blockSignals(affectedWidgets): + for attr, val in attrDict.items(): + widget = self._trackedWidgets[attr] + if attr in self._colorWidgets: + val = '%s,%s,%s' % val + setWidgetValue(widget, val) + + def _sendUpdateSignal(self): + if not self.core.openingProject: + self.parent.drawPreview() + saveValueStore = self.savePreset() + saveValueStore['preset'] = self.currentPreset + self.modified.emit(self.compPos, saveValueStore) + + def trackWidgets(self, trackDict, **kwargs): + ''' + Name widgets to track in update(), savePreset(), loadPreset(), and + command(). Requires a dict of attr names as keys, widgets as values + + Optional args: + 'presetNames': preset variable names to replace attr names + 'commandArgs': arg keywords that differ from attr names + 'colorWidgets': identify attr as RGB tuple & update button CSS + 'relativeWidgets': change value proportionally to resolution + + NOTE: Any kwarg key set to None will selectively disable tracking. + ''' + self._trackedWidgets = trackDict + for kwarg in kwargs: + try: + if kwarg in ( + 'presetNames', + 'commandArgs', + 'colorWidgets', + 'relativeWidgets', + ): + setattr(self, '_{}'.format(kwarg), kwargs[kwarg]) + else: + raise ComponentError( + self, 'Nonsensical keywords to trackWidgets.') + except ComponentError: + continue + + if kwarg == 'colorWidgets': + def makeColorFunc(attr): + def pickColor_(): + self.mergeUndo = False + self.pickColor( + self._trackedWidgets[attr], + self._colorWidgets[attr] + ) + self.mergeUndo = True + return pickColor_ + self._colorFuncs = { + attr: makeColorFunc(attr) for attr in kwargs[kwarg] + } + for attr, func in self._colorFuncs.items(): + self._colorWidgets[attr].clicked.connect(func) + self._colorWidgets[attr].setStyleSheet( + "QPushButton {" + "background-color : #FFFFFF; outline: none; }" + ) + + if kwarg == 'relativeWidgets': + # store maximum values of spinBoxes to be scaled appropriately + for attr in kwargs[kwarg]: + self._relativeMaximums[attr] = \ + self._trackedWidgets[attr].maximum() + self.updateRelativeWidgetMaximum(attr) + setattr( + self, attr, getWidgetValue(self._trackedWidgets[attr]) + ) + + self._preUpdate() + self._autoUpdate() + + def pickColor(self, textWidget, button): + '''Use color picker to get color input from the user.''' + dialog = QtWidgets.QColorDialog() + dialog.setOption(QtWidgets.QColorDialog.ShowAlphaChannel, True) + color = dialog.getColor() + if color.isValid(): + RGBstring = '%s,%s,%s' % ( + str(color.red()), str(color.green()), str(color.blue())) + btnStyle = "QPushButton{background-color: %s; outline: none;}" \ + % color.name() + textWidget.setText(RGBstring) + button.setStyleSheet(btnStyle) + + def lockProperties(self, propList): + self._lockedProperties = propList + + def lockError(self, msg): + self._lockedError = msg + + def lockSize(self, w, h): + self._lockedSize = (w, h) + + def unlockProperties(self): + self._lockedProperties = None + + def unlockError(self): + self._lockedError = None + + def unlockSize(self): + self._lockedSize = None + + def loadUi(self, filename): + '''Load a Qt Designer ui file to use for this component's widget''' + return uic.loadUi(os.path.join(self.core.componentsPath, filename)) + + @property + def width(self): + if self._lockedSize is None: + return int(self.settings.value('outputWidth')) + else: + return self._lockedSize[0] + + @property + def height(self): + if self._lockedSize is None: + return int(self.settings.value('outputHeight')) + else: + return self._lockedSize[1] + + def cancel(self): + '''Stop any lengthy process in response to this variable.''' + self.canceled = True + + def reset(self): + self.canceled = False + self.unlockProperties() + self.unlockError() + + def relativeWidgetAxis(func): + def relativeWidgetAxis(self, attr, *args, **kwargs): + hasVerticalWords = ( + lambda attr: + 'height' in attr.lower() or + 'ypos' in attr.lower() or + attr == 'y' + ) + if 'axis' not in kwargs: + axis = self.width + if hasVerticalWords(attr): + axis = self.height + kwargs['axis'] = axis + if 'axis' in kwargs and type(kwargs['axis']) is tuple: + axis = kwargs['axis'][0] + if hasVerticalWords(attr): + axis = kwargs['axis'][1] + kwargs['axis'] = axis + return func(self, attr, *args, **kwargs) + return relativeWidgetAxis + + @relativeWidgetAxis + def pixelValForAttr(self, attr, val=None, **kwargs): + if val is None: + val = self._relativeValues[attr] + if val > 50.0: + log.warning( + '%s #%s attempted to set %s to dangerously high number %s', + self.__class__.name, self.compPos, attr, val + ) + val = 50.0 + result = math.ceil(kwargs['axis'] * val) + log.verbose( + 'Converting %s: f%s to px%s using axis %s', + attr, val, result, kwargs['axis'] + ) + return result + + @relativeWidgetAxis + def floatValForAttr(self, attr, val=None, **kwargs): + if val is None: + val = self._trackedWidgets[attr].value() + return val / kwargs['axis'] + + def setRelativeWidget(self, attr, floatVal): + '''Set a relative widget using a float''' + pixelVal = self.pixelValForAttr(attr, floatVal) + with blockSignals(self._trackedWidgets[attr]): + self._trackedWidgets[attr].setValue(pixelVal) + self.update(auto=True) + + def getOldAttr(self, attr): + ''' + Returns previous state of this attr. Used to determine whether + a relative widget must be updated. Required because undoing/redoing + can make determining the 'previous' value tricky. + ''' + if self.oldAttrs is not None: + return self.oldAttrs[attr] + else: + try: + return getattr(self, attr) + except AttributeError: + log.error('Using visible values instead of oldAttrs') + return self._trackedWidgets[attr].value() + + def updateRelativeWidget(self, attr): + '''Called by _preUpdate() for each relativeWidget before each update''' + oldUserValue = self.getOldAttr(attr) + newUserValue = self._trackedWidgets[attr].value() + newRelativeVal = self.floatValForAttr(attr, newUserValue) + + if attr in self._relativeValues: + oldRelativeVal = self._relativeValues[attr] + if oldUserValue == newUserValue \ + and oldRelativeVal != newRelativeVal: + # Float changed without pixel value changing, which + # means the pixel value needs to be updated + log.debug( + 'Updating %s #%s\'s relative widget: %s', + self.__class__.name, self.compPos, attr) + with blockSignals(self._trackedWidgets[attr]): + self.updateRelativeWidgetMaximum(attr) + pixelVal = self.pixelValForAttr(attr, oldRelativeVal) + self._trackedWidgets[attr].setValue(pixelVal) + + if attr not in self._relativeValues \ + or oldUserValue != newUserValue: + self._relativeValues[attr] = newRelativeVal + + def updateRelativeWidgetMaximum(self, attr): + maxRes = int(self.core.resolutions[0].split('x')[0]) + newMaximumValue = self.width * ( + self._relativeMaximums[attr] / + maxRes + ) + self._trackedWidgets[attr].setMaximum(int(newMaximumValue)) + + +class ComponentError(RuntimeError): + '''Gives the MainWindow a traceback to display, and cancels the export.''' + + prevErrors = [] + lastTime = time.time() + + def __init__(self, caller, name, msg=None): + if msg is None and sys.exc_info()[0] is not None: + msg = str(sys.exc_info()[1]) + else: + msg = 'Unknown error.' + log.error("ComponentError by %s's %s: %s" % ( + caller.name, name, msg)) + + # Don't create multiple windows for quickly repeated messages + if len(ComponentError.prevErrors) > 1: + ComponentError.prevErrors.pop() + ComponentError.prevErrors.insert(0, name) + curTime = time.time() + if name in ComponentError.prevErrors[1:] \ + and curTime - ComponentError.lastTime < 1.0: + return + ComponentError.lastTime = time.time() + + from .toolkit import formatTraceback + if sys.exc_info()[0] is not None: + string = ( + "%s component (#%s): %s encountered %s %s: %s" % ( + caller.__class__.name, + str(caller.compPos), + name, + 'an' if any([ + sys.exc_info()[0].__name__.startswith(vowel) + for vowel in ('A', 'I', 'U', 'O', 'E') + ]) else 'a', + sys.exc_info()[0].__name__, + str(sys.exc_info()[1]) + ) + ) + detail = formatTraceback(sys.exc_info()[2]) + else: + string = name + detail = "Attributes:\n%s" % ( + "\n".join( + [m for m in dir(caller) if not m.startswith('_')] + ) + ) + + super().__init__(string) + caller.lockError(string) + caller._error.emit(string, detail) + + +class ComponentUpdate(QtWidgets.QUndoCommand): + '''Command object for making a component action undoable''' + def __init__(self, parent, oldWidgetVals, modifiedVals): + super().__init__( + 'change %s component #%s' % ( + parent.name, parent.compPos + ) + ) + self.undone = False + self.res = (int(parent.width), int(parent.height)) + self.parent = parent + self.oldWidgetVals = { + attr: copy(val) + if attr not in self.parent._relativeWidgets + else self.parent.floatValForAttr(attr, val, axis=self.res) + for attr, val in oldWidgetVals.items() + if attr in modifiedVals + } + self.modifiedVals = { + attr: val + if attr not in self.parent._relativeWidgets + else self.parent.floatValForAttr(attr, val, axis=self.res) + for attr, val in modifiedVals.items() + } + + # Because relative widgets change themselves every update based on + # their previous value, we must store ALL their values in case of undo + self.relativeWidgetValsAfterUndo = { + attr: copy(getattr(self.parent, attr)) + for attr in self.parent._relativeWidgets + } + + # Determine if this update is mergeable + self.id_ = -1 + if len(self.modifiedVals) == 1 and self.parent.mergeUndo: + attr, val = self.modifiedVals.popitem() + self.id_ = sum([ord(letter) for letter in attr[-14:]]) + self.modifiedVals[attr] = val + else: + log.warning( + '%s component settings changed at once. (%s)', + len(self.modifiedVals), repr(self.modifiedVals) + ) + + def id(self): + '''If 2 consecutive updates have same id, Qt will call mergeWith()''' + return self.id_ + + def mergeWith(self, other): + self.modifiedVals.update(other.modifiedVals) + return True + + def setWidgetValues(self, attrDict): + ''' + Mask the component's usual method to handle our + relative widgets in case the resolution has changed. + ''' + newAttrDict = { + attr: val if attr not in self.parent._relativeWidgets + else self.parent.pixelValForAttr(attr, val) + for attr, val in attrDict.items() + } + self.parent.setWidgetValues(newAttrDict) + + def redo(self): + if self.undone: + log.info('Redoing component update') + self.parent.oldAttrs = self.relativeWidgetValsAfterUndo + self.setWidgetValues(self.modifiedVals) + self.parent.update(auto=True) + self.parent.oldAttrs = None + if not self.undone: + self.relativeWidgetValsAfterRedo = { + attr: copy(getattr(self.parent, attr)) + for attr in self.parent._relativeWidgets + } + self.parent._sendUpdateSignal() + + def undo(self): + log.info('Undoing component update') + self.undone = True + self.parent.oldAttrs = self.relativeWidgetValsAfterRedo + self.setWidgetValues(self.oldWidgetVals) + self.parent.update(auto=True) + self.parent.oldAttrs = None diff --git a/src/components/__init__.py b/src/components/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/components/__init__.py @@ -0,0 +1 @@ + diff --git a/src/components/__template__.ui b/src/components/__template__.ui new file mode 100644 index 0000000..301a2b7 --- /dev/null +++ b/src/components/__template__.ui @@ -0,0 +1,119 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_6"> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_5"> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_4"> + <item> + <spacer name="horizontalSpacer_4"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/color.py b/src/components/color.py new file mode 100644 index 0000000..8d0edd2 --- /dev/null +++ b/src/components/color.py @@ -0,0 +1,160 @@ +from PyQt5 import QtGui +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor + + +log = logging.getLogger('AVP.Components.Color') + + +class Component(Component): + name = 'Color' + version = '1.0.0' + + def widget(self, *args): + self.x = 0 + self.y = 0 + super().widget(*args) + + # disable color #2 until non-default 'fill' option gets changed + self.page.lineEdit_color2.setDisabled(True) + self.page.pushButton_color2.setDisabled(True) + self.page.spinBox_width.setValue( + int(self.settings.value("outputWidth"))) + self.page.spinBox_height.setValue( + int(self.settings.value("outputHeight"))) + + self.fillLabels = [ + 'Solid', + 'Linear Gradient', + 'Radial Gradient', + ] + for label in self.fillLabels: + self.page.comboBox_fill.addItem(label) + self.page.comboBox_fill.setCurrentIndex(0) + + self.trackWidgets({ + 'x': self.page.spinBox_x, + 'y': self.page.spinBox_y, + 'sizeWidth': self.page.spinBox_width, + 'sizeHeight': self.page.spinBox_height, + 'trans': self.page.checkBox_trans, + 'spread': self.page.comboBox_spread, + 'stretch': self.page.checkBox_stretch, + 'RG_start': self.page.spinBox_radialGradient_start, + 'LG_start': self.page.spinBox_linearGradient_start, + 'RG_end': self.page.spinBox_radialGradient_end, + 'LG_end': self.page.spinBox_linearGradient_end, + 'RG_centre': self.page.spinBox_radialGradient_spread, + 'fillType': self.page.comboBox_fill, + 'color1': self.page.lineEdit_color1, + 'color2': self.page.lineEdit_color2, + }, presetNames={ + 'sizeWidth': 'width', + 'sizeHeight': 'height', + }, colorWidgets={ + 'color1': self.page.pushButton_color1, + 'color2': self.page.pushButton_color2, + }, relativeWidgets=[ + 'x', 'y', + 'sizeWidth', 'sizeHeight', + 'LG_start', 'LG_end', + 'RG_start', 'RG_end', 'RG_centre', + ]) + + def update(self): + fillType = self.page.comboBox_fill.currentIndex() + if fillType == 0: + self.page.lineEdit_color2.setEnabled(False) + self.page.pushButton_color2.setEnabled(False) + self.page.checkBox_trans.setEnabled(False) + self.page.checkBox_stretch.setEnabled(False) + self.page.comboBox_spread.setEnabled(False) + else: + self.page.lineEdit_color2.setEnabled(True) + self.page.pushButton_color2.setEnabled(True) + self.page.checkBox_trans.setEnabled(True) + self.page.checkBox_stretch.setEnabled(True) + self.page.comboBox_spread.setEnabled(True) + if self.page.checkBox_trans.isChecked(): + self.page.lineEdit_color2.setEnabled(False) + self.page.pushButton_color2.setEnabled(False) + self.page.fillWidget.setCurrentIndex(fillType) + + def previewRender(self): + return self.drawFrame(self.width, self.height) + + def properties(self): + return ['static'] + + def frameRender(self, frameNo): + log.debug("Color component is drawing frame #%s", frameNo) + return self.drawFrame(self.width, self.height) + + def drawFrame(self, width, height): + r, g, b = self.color1 + shapeSize = (self.sizeWidth, self.sizeHeight) + # in default state, skip all this logic and return a plain fill + if self.fillType == 0 and shapeSize == (width, height) \ + and self.x == 0 and self.y == 0: + return FloodFrame(width, height, (r, g, b, 255)) + + # Return a solid image at x, y + if self.fillType == 0: + frame = BlankFrame(width, height) + image = FloodFrame(self.sizeWidth, self.sizeHeight, (r, g, b, 255)) + frame.paste(image, box=(self.x, self.y)) + return frame + + # Now fills that require using Qt... + elif self.fillType > 0: + image = FramePainter(width, height) + + if self.stretch: + w = width + h = height + else: + w = self.sizeWidth + h = self.sizeWidth + + if self.fillType == 1: # Linear Gradient + brush = QtGui.QLinearGradient( + self.LG_start, + self.LG_start, + self.LG_end+width/3, + self.LG_end) + + elif self.fillType == 2: # Radial Gradient + brush = QtGui.QRadialGradient( + self.RG_start, + self.RG_end, + w, h, + self.RG_centre) + + brush.setSpread(self.spread) + brush.setColorAt(0.0, PaintColor(*self.color1)) + if self.trans: + brush.setColorAt(1.0, PaintColor(0, 0, 0, 0)) + elif self.fillType == 1 and self.stretch: + brush.setColorAt(0.2, PaintColor(*self.color2)) + else: + brush.setColorAt(1.0, PaintColor(*self.color2)) + image.setBrush(brush) + image.drawRect( + self.x, self.y, + self.sizeWidth, self.sizeHeight + ) + + return image.finalize() + + def commandHelp(self): + print('Specify a color:\n color=255,255,255') + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + if key == 'color': + self.page.lineEdit_color1.setText(arg) + return + super().command(arg) diff --git a/src/components/color.ui b/src/components/color.ui new file mode 100644 index 0000000..c1713fb --- /dev/null +++ b/src/components/color.ui @@ -0,0 +1,666 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Color #1</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_color1"> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_color1"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>0,0,0</string> + </property> + <property name="maxLength"> + <number>12</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_textColor_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Color #2</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_color2"> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_color2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>133,133,133</string> + </property> + <property name="maxLength"> + <number>12</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_7"> + <property name="leftMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label_xTitleAlign_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Width</string> + </property> + <property name="alignment"> + <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_width"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>0</number> + </property> + <property name="maximum"> + <number>19200</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Height</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_height"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="maximum"> + <number>10800</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_7"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_x"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <property name="leftMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label_textLayout"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Fill </string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_fill"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="currentIndex"> + <number>-1</number> + </property> + <property name="sizeAdjustPolicy"> + <enum>QComboBox::AdjustToContentsOnFirstShow</enum> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_trans"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Transparent</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_stretch"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Stretch</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_spread"> + <item> + <property name="text"> + <string>Pad</string> + </property> + </item> + <item> + <property name="text"> + <string>Reflect</string> + </property> + </item> + <item> + <property name="text"> + <string>Repeat</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QStackedWidget" name="fillWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Minimum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="lineWidth"> + <number>0</number> + </property> + <property name="currentIndex"> + <number>2</number> + </property> + <widget class="QWidget" name="blank"/> + <widget class="QWidget" name="linearGradient"> + <widget class="QWidget" name="horizontalLayoutWidget"> + <property name="geometry"> + <rect> + <x>-1</x> + <y>0</y> + <width>561</width> + <height>31</height> + </rect> + </property> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QLabel" name="label_xTitleAlign_4"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Start</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_linearGradient_start"> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="singleStep"> + <number>10</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>End</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_linearGradient_end"> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="singleStep"> + <number>10</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + </widget> + <widget class="QWidget" name="radialGradient"> + <widget class="QWidget" name="horizontalLayoutWidget_3"> + <property name="geometry"> + <rect> + <x>-1</x> + <y>-1</y> + <width>561</width> + <height>31</height> + </rect> + </property> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QLabel" name="label_xTitleAlign_6"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Start</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_radialGradient_start"> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="singleStep"> + <number>10</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>End</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_radialGradient_end"> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="singleStep"> + <number>10</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_4"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Centre</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_radialGradient_spread"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::PlusMinus</enum> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="value"> + <number>3</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + </widget> + </widget> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/image.py b/src/components/image.py new file mode 100644 index 0000000..42f9564 --- /dev/null +++ b/src/components/image.py @@ -0,0 +1,126 @@ +from PIL import Image, ImageDraw, ImageEnhance +from PyQt5 import QtGui, QtCore, QtWidgets +import os + +from ..component import Component +from ..toolkit.frame import BlankFrame + + +class Component(Component): + name = 'Image' + version = '1.0.1' + + def widget(self, *args): + super().widget(*args) + self.page.pushButton_image.clicked.connect(self.pickImage) + self.trackWidgets({ + 'imagePath': self.page.lineEdit_image, + 'scale': self.page.spinBox_scale, + 'stretchScale': self.page.spinBox_scale_stretch, + 'rotate': self.page.spinBox_rotate, + 'color': self.page.spinBox_color, + 'xPosition': self.page.spinBox_x, + 'yPosition': self.page.spinBox_y, + 'stretched': self.page.checkBox_stretch, + 'mirror': self.page.checkBox_mirror, + }, presetNames={ + 'imagePath': 'image', + 'xPosition': 'x', + 'yPosition': 'y', + }, relativeWidgets=[ + 'xPosition', 'yPosition', 'scale' + ]) + + def previewRender(self): + return self.drawFrame(self.width, self.height) + + def properties(self): + props = ['static'] + if not os.path.exists(self.imagePath): + props.append('error') + return props + + def error(self): + if not self.imagePath: + return "There is no image selected." + if not os.path.exists(self.imagePath): + return "The image selected does not exist!" + + def frameRender(self, frameNo): + return self.drawFrame(self.width, self.height) + + def drawFrame(self, width, height): + frame = BlankFrame(width, height) + if self.imagePath and os.path.exists(self.imagePath): + scale = self.scale if not self.stretched else self.stretchScale + image = Image.open(self.imagePath) + + # Modify image's appearance + if self.color != 100: + image = ImageEnhance.Color(image).enhance( + float(self.color / 100) + ) + if self.mirror: + image = image.transpose(Image.FLIP_LEFT_RIGHT) + if self.stretched and image.size != (width, height): + image = image.resize((width, height), Image.ANTIALIAS) + if scale != 100: + newHeight = int((image.height / 100) * scale) + newWidth = int((image.width / 100) * scale) + image = image.resize((newWidth, newHeight), Image.ANTIALIAS) + + # Paste image at correct position + frame.paste(image, box=(self.xPosition, self.yPosition)) + if self.rotate != 0: + frame = frame.rotate(self.rotate) + + return frame + + def pickImage(self): + imgDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, "Choose Image", imgDir, + "Image Files (%s)" % " ".join(self.core.imageFormats)) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_image.setText(filename) + self.mergeUndo = True + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + if key == 'path' and os.path.exists(arg): + try: + Image.open(arg) + self.page.lineEdit_image.setText(arg) + self.page.checkBox_stretch.setChecked(True) + return + except OSError as e: + print("Not a supported image format") + quit(1) + super().command(arg) + + def commandHelp(self): + print('Load an image:\n path=/filepath/to/image.png') + + def savePreset(self): + # Maintain the illusion that the scale spinbox is one widget + scaleBox = self.page.spinBox_scale + stretchScaleBox = self.page.spinBox_scale_stretch + if self.page.checkBox_stretch.isChecked(): + scaleBox.setValue(stretchScaleBox.value()) + else: + stretchScaleBox.setValue(scaleBox.value()) + return super().savePreset() + + def update(self): + # Maintain the illusion that the scale spinbox is one widget + scaleBox = self.page.spinBox_scale + stretchScaleBox = self.page.spinBox_scale_stretch + if self.page.checkBox_stretch.isChecked(): + scaleBox.setVisible(False) + stretchScaleBox.setVisible(True) + else: + scaleBox.setVisible(True) + stretchScaleBox.setVisible(False) diff --git a/src/components/image.ui b/src/components/image.ui new file mode 100644 index 0000000..2dad127 --- /dev/null +++ b/src/components/image.ui @@ -0,0 +1,388 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Image</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_image"> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_image"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_x"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>-1000</number> + </property> + <property name="maximum"> + <number>1000</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <item> + <widget class="QCheckBox" name="checkBox_stretch"> + <property name="text"> + <string>Stretch</string> + </property> + <property name="checked"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_10"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QCheckBox" name="checkBox_mirror"> + <property name="text"> + <string>Mirror</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Rotate</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_rotate"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string notr="true">°</string> + </property> + <property name="minimum"> + <number>0</number> + </property> + <property name="maximum"> + <number>359</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>10</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Scale</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>400</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale_stretch"> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>400</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Color</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_color"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>0</number> + </property> + <property name="maximum"> + <number>999</number> + </property> + <property name="singleStep"> + <number>1</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/life.py b/src/components/life.py new file mode 100644 index 0000000..94704bc --- /dev/null +++ b/src/components/life.py @@ -0,0 +1,475 @@ +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtWidgets import QUndoCommand +from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter +import os +import math + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale + + +class Component(Component): + name = 'Conway\'s Game of Life' + version = '1.0.0' + + def widget(self, *args): + super().widget(*args) + self.scale = 32 + self.updateGridSize() + self.startingGrid = set() + self.page.pushButton_pickImage.clicked.connect(self.pickImage) + self.trackWidgets({ + 'tickRate': self.page.spinBox_tickRate, + 'scale': self.page.spinBox_scale, + 'color': self.page.lineEdit_color, + 'shapeType': self.page.comboBox_shapeType, + 'shadow': self.page.checkBox_shadow, + 'customImg': self.page.checkBox_customImg, + 'showGrid': self.page.checkBox_showGrid, + 'image': self.page.lineEdit_image, + }, colorWidgets={ + 'color': self.page.pushButton_color, + }) + self.shiftButtons = ( + self.page.toolButton_up, + self.page.toolButton_down, + self.page.toolButton_left, + self.page.toolButton_right, + ) + + def shiftFunc(i): + def shift(): + self.shiftGrid(i) + return shift + shiftFuncs = [shiftFunc(i) for i in range(len(self.shiftButtons))] + for i, widget in enumerate(self.shiftButtons): + widget.clicked.connect(shiftFuncs[i]) + self.page.spinBox_scale.setValue(self.scale) + self.page.spinBox_scale.valueChanged.connect(self.updateGridSize) + + def pickImage(self): + imgDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, "Choose Image", imgDir, + "Image Files (%s)" % " ".join(self.core.imageFormats)) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_image.setText(filename) + self.mergeUndo = True + + def shiftGrid(self, d): + action = ShiftGrid(self, d) + self.parent.undoStack.push(action) + + def update(self): + self.updateGridSize() + if self.page.checkBox_customImg.isChecked(): + self.page.label_color.setVisible(False) + self.page.lineEdit_color.setVisible(False) + self.page.pushButton_color.setVisible(False) + self.page.label_shape.setVisible(False) + self.page.comboBox_shapeType.setVisible(False) + self.page.label_image.setVisible(True) + self.page.lineEdit_image.setVisible(True) + self.page.pushButton_pickImage.setVisible(True) + else: + self.page.label_color.setVisible(True) + self.page.lineEdit_color.setVisible(True) + self.page.pushButton_color.setVisible(True) + self.page.label_shape.setVisible(True) + self.page.comboBox_shapeType.setVisible(True) + self.page.label_image.setVisible(False) + self.page.lineEdit_image.setVisible(False) + self.page.pushButton_pickImage.setVisible(False) + enabled = (len(self.startingGrid) > 0) + for widget in self.shiftButtons: + widget.setEnabled(enabled) + + def previewClickEvent(self, pos, size, button): + pos = ( + math.ceil((pos[0] / size[0]) * self.gridWidth) - 1, + math.ceil((pos[1] / size[1]) * self.gridHeight) - 1 + ) + action = ClickGrid(self, pos, button) + self.parent.undoStack.push(action) + + def updateGridSize(self): + w, h = self.core.resolutions[-1].split('x') + self.gridWidth = int(int(w) / self.scale) + self.gridHeight = int(int(h) / self.scale) + self.pxWidth = math.ceil(self.width / self.gridWidth) + self.pxHeight = math.ceil(self.height / self.gridHeight) + + def previewRender(self): + return self.drawGrid(self.startingGrid) + + def preFrameRender(self, *args, **kwargs): + super().preFrameRender(*args, **kwargs) + self.progressBarSetText.emit("Computing evolution...") + self.tickGrids = {0: self.startingGrid} + tick = 0 + for frameNo in range( + self.tickRate, self.audioArrayLen, self.sampleSize + ): + if self.parent.canceled: + break + if frameNo % self.tickRate == 0: + tick += 1 + self.tickGrids[tick] = self.gridForTick(tick) + + # update progress bar + progress = int(100*(frameNo/self.audioArrayLen)) + if progress >= 100: + progress = 100 + pStr = "Computing evolution: "+str(progress)+'%' + self.progressBarSetText.emit(pStr) + self.progressBarUpdate.emit(int(progress)) + + def properties(self): + if self.customImg and ( + not self.image or not os.path.exists(self.image) + ): + return ['error'] + return [] + + def error(self): + return "No image selected to represent life." + + def frameRender(self, frameNo): + tick = math.floor(frameNo / self.tickRate) + grid = self.tickGrids[tick] + return self.drawGrid(grid) + + def drawGrid(self, grid): + frame = BlankFrame(self.width, self.height) + + def drawCustomImg(): + try: + img = Image.open(self.image) + except Exception: + return + img = img.resize((self.pxWidth, self.pxHeight), Image.ANTIALIAS) + frame.paste(img, box=(drawPtX, drawPtY)) + + def drawShape(): + drawer = ImageDraw.Draw(frame) + rect = ( + (drawPtX, drawPtY), + (drawPtX + self.pxWidth, drawPtY + self.pxHeight) + ) + shape = self.page.comboBox_shapeType.currentText().lower() + + # Rectangle + if shape == 'rectangle': + drawer.rectangle(rect, fill=self.color) + + # Elliptical + elif shape == 'elliptical': + drawer.ellipse(rect, fill=self.color) + + tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int) + smallerShape = ( + (drawPtX + tenthX + int(tenthX / 4), + drawPtY + tenthY + int(tenthY / 2)), + (drawPtX + self.pxWidth - tenthX - int(tenthX / 4), + drawPtY + self.pxHeight - (tenthY + int(tenthY / 2))) + ) + outlineShape = ( + (drawPtX + int(tenthX / 4), + drawPtY + int(tenthY / 2)), + (drawPtX + self.pxWidth - int(tenthX / 4), + drawPtY + self.pxHeight - int(tenthY / 2)) + ) + # Circle + if shape == 'circle': + drawer.ellipse(outlineShape, fill=self.color) + drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) + + # Lilypad + elif shape == 'lilypad': + drawer.pieslice(smallerShape, 290, 250, fill=self.color) + + # Pac-Man + elif shape == 'pac-man': + drawer.pieslice(outlineShape, 35, 320, fill=self.color) + + hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline + tX, tY = scale(33, self.pxWidth, self.pxHeight, int) # thirdline + qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline + + # Path + if shape == 'path': + drawer.ellipse(rect, fill=self.color) + rects = { + direction: False + for direction in ( + 'up', 'down', 'left', 'right', + ) + } + for cell in self.nearbyCoords(x, y): + if cell not in grid: + continue + if cell[0] == x: + if cell[1] < y: + rects['up'] = True + if cell[1] > y: + rects['down'] = True + if cell[1] == y: + if cell[0] < x: + rects['left'] = True + if cell[0] > x: + rects['right'] = True + + for direction, rect in rects.items(): + if rect: + if direction == 'up': + sect = ( + (drawPtX, drawPtY), + (drawPtX + self.pxWidth, drawPtY + hY) + ) + elif direction == 'down': + sect = ( + (drawPtX, drawPtY + hY), + (drawPtX + self.pxWidth, + drawPtY + self.pxHeight) + ) + elif direction == 'left': + sect = ( + (drawPtX, drawPtY), + (drawPtX + hX, + drawPtY + self.pxHeight) + ) + elif direction == 'right': + sect = ( + (drawPtX + hX, drawPtY), + (drawPtX + self.pxWidth, + drawPtY + self.pxHeight) + ) + drawer.rectangle(sect, fill=self.color) + + # Duck + elif shape == 'duck': + duckHead = ( + (drawPtX + qX, drawPtY + qY), + (drawPtX + int(qX * 3), drawPtY + int(tY * 2)) + ) + duckBeak = ( + (drawPtX + hX, drawPtY + qY), + (drawPtX + self.pxWidth + qX, + drawPtY + int(qY * 3)) + ) + duckWing = ( + (drawPtX, drawPtY + hY), + rect[1] + ) + duckBody = ( + (drawPtX + int(qX / 4), drawPtY + int(qY * 3)), + (drawPtX + int(tX * 2), drawPtY + self.pxHeight) + ) + drawer.ellipse(duckBody, fill=self.color) + drawer.ellipse(duckHead, fill=self.color) + drawer.pieslice(duckWing, 130, 200, fill=self.color) + drawer.pieslice(duckBeak, 145, 200, fill=self.color) + + # Peace + elif shape == 'peace': + line = (( + drawPtX + hX - int(tenthX / 2), drawPtY + int(tenthY / 2)), + (drawPtX + hX + int(tenthX / 2), + drawPtY + self.pxHeight - int(tenthY / 2)) + ) + drawer.ellipse(outlineShape, fill=self.color) + drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) + drawer.rectangle(line, fill=self.color) + + def slantLine(difference): + return ( + (drawPtX + difference), + (drawPtY + self.pxHeight - qY) + ), + ( + (drawPtX + hX), + (drawPtY + hY) + ) + + drawer.line( + slantLine(qX), + fill=self.color, + width=tenthX + ) + drawer.line( + slantLine(self.pxWidth - qX), + fill=self.color, + width=tenthX + ) + + for x, y in grid: + drawPtX = x * self.pxWidth + if drawPtX > self.width: + continue + drawPtY = y * self.pxHeight + if drawPtY > self.height: + continue + + if self.customImg: + drawCustomImg() + else: + drawShape() + + if self.shadow: + shadImg = ImageEnhance.Contrast(frame).enhance(0.0) + shadImg = shadImg.filter(ImageFilter.GaussianBlur(5.00)) + shadImg = ImageChops.offset(shadImg, -2, 2) + shadImg.paste(frame, box=(0, 0), mask=frame) + frame = shadImg + if self.showGrid: + drawer = ImageDraw.Draw(frame) + w, h = scale(0.05, self.width, self.height, int) + for x in range(self.pxWidth, self.width, self.pxWidth): + drawer.rectangle( + ((x, 0), + (x + w, self.height)), + fill=self.color, + ) + for y in range(self.pxHeight, self.height, self.pxHeight): + drawer.rectangle( + ((0, y), + (self.width, y + h)), + fill=self.color, + ) + + return frame + + def gridForTick(self, tick): + '''Given a tick number over 0, returns a new grid set of tuples''' + lastGrid = self.tickGrids[tick - 1] + + def neighbours(x, y): + return { + cell for cell in self.nearbyCoords(x, y) + if cell in lastGrid + } + + newGrid = set() + for x, y in lastGrid: + surrounding = len(neighbours(x, y)) + if surrounding == 2 or surrounding == 3: + newGrid.add((x, y)) + potentialNewCells = { + coordTup for origin in lastGrid + for coordTup in list(self.nearbyCoords(*origin)) + } + for x, y in potentialNewCells: + if (x, y) in newGrid: + continue + surrounding = len(neighbours(x, y)) + if surrounding == 3: + newGrid.add((x, y)) + + return newGrid + + def savePreset(self): + pr = super().savePreset() + pr['GRID'] = sorted(self.startingGrid) + return pr + + def loadPreset(self, pr, *args): + self.startingGrid = set(pr['GRID']) + if self.startingGrid: + for widget in self.shiftButtons: + widget.setEnabled(True) + super().loadPreset(pr, *args) + + def nearbyCoords(self, x, y): + yield x + 1, y + 1 + yield x + 1, y - 1 + yield x - 1, y + 1 + yield x - 1, y - 1 + yield x, y + 1 + yield x, y - 1 + yield x + 1, y + yield x - 1, y + + +class ClickGrid(QUndoCommand): + def __init__(self, comp, pos, id_): + super().__init__( + "click %s component #%s" % (comp.name, comp.compPos)) + self.comp = comp + self.pos = [pos] + self.id_ = id_ + + def id(self): + return self.id_ + + def mergeWith(self, other): + self.pos.extend(other.pos) + return True + + def add(self): + for pos in self.pos[:]: + self.comp.startingGrid.add(pos) + self.comp.update(auto=True) + + def remove(self): + for pos in self.pos[:]: + self.comp.startingGrid.discard(pos) + self.comp.update(auto=True) + + def redo(self): + if self.id_ == 1: # Left-click + self.add() + elif self.id_ == 2: # Right-click + self.remove() + + def undo(self): + if self.id_ == 1: # Left-click + self.remove() + elif self.id_ == 2: # Right-click + self.add() + +class ShiftGrid(QUndoCommand): + def __init__(self, comp, direction): + super().__init__( + "change %s component #%s" % (comp.name, comp.compPos)) + self.comp = comp + self.direction = direction + self.distance = 1 + + def id(self): + return self.direction + + def mergeWith(self, other): + self.distance += other.distance + return True + + def newGrid(self, Xchange, Ychange): + return { + (x + Xchange, y + Ychange) + for x, y in self.comp.startingGrid + } + + def redo(self): + if self.direction == 0: + newGrid = self.newGrid(0, -self.distance) + elif self.direction == 1: + newGrid = self.newGrid(0, self.distance) + elif self.direction == 2: + newGrid = self.newGrid(-self.distance, 0) + elif self.direction == 3: + newGrid = self.newGrid(self.distance, 0) + self.comp.startingGrid = newGrid + self.comp._sendUpdateSignal() + + def undo(self): + if self.direction == 0: + newGrid = self.newGrid(0, self.distance) + elif self.direction == 1: + newGrid = self.newGrid(0, -self.distance) + elif self.direction == 2: + newGrid = self.newGrid(self.distance, 0) + elif self.direction == 3: + newGrid = self.newGrid(-self.distance, 0) + self.comp.startingGrid = newGrid + self.comp._sendUpdateSignal() diff --git a/src/components/life.ui b/src/components/life.ui new file mode 100644 index 0000000..85b2926 --- /dev/null +++ b/src/components/life.ui @@ -0,0 +1,405 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_4"> + <item> + <widget class="QLabel" name="label_5"> + <property name="text"> + <string>Simulation Speed</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_tickRate"> + <property name="suffix"> + <string> frames per tick</string> + </property> + <property name="minimum"> + <number>1</number> + </property> + <property name="maximum"> + <number>30</number> + </property> + <property name="value"> + <number>5</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_color"> + <property name="maximumSize"> + <size> + <width>0</width> + <height>16777215</height> + </size> + </property> + <property name="text"> + <string>0,0,0</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_5"> + <item> + <widget class="QLabel" name="label_7"> + <property name="text"> + <string>Grid Scale</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="minimum"> + <number>22</number> + </property> + <property name="maximum"> + <number>128</number> + </property> + <property name="value"> + <number>32</number> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_customImg"> + <property name="text"> + <string>Custom Image</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_image"> + <property name="text"> + <string>Image</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_image"/> + </item> + <item> + <widget class="QPushButton" name="pushButton_pickImage"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_color"> + <property name="text"> + <string>Color</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_color_3"> + <property name="maximumSize"> + <size> + <width>0</width> + <height>16777215</height> + </size> + </property> + <property name="text"> + <string>0,0,0</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_color"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="default"> + <bool>false</bool> + </property> + <property name="flat"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_shape"> + <property name="text"> + <string>Shape</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_shapeType"> + <item> + <property name="text"> + <string>Path</string> + </property> + </item> + <item> + <property name="text"> + <string>Rectangle</string> + </property> + </item> + <item> + <property name="text"> + <string>Elliptical</string> + </property> + </item> + <item> + <property name="text"> + <string>Circle</string> + </property> + </item> + <item> + <property name="text"> + <string>Lilypad</string> + </property> + </item> + <item> + <property name="text"> + <string>Pac-Man</string> + </property> + </item> + <item> + <property name="text"> + <string>Duck</string> + </property> + </item> + <item> + <property name="text"> + <string>Peace</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_8"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_6"> + <item> + <widget class="QCheckBox" name="checkBox_shadow"> + <property name="text"> + <string>Shadow</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_showGrid"> + <property name="text"> + <string>Show Grid</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <item> + <widget class="QToolButton" name="toolButton_up"> + <property name="text"> + <string>Up</string> + </property> + <property name="arrowType"> + <enum>Qt::UpArrow</enum> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_down"> + <property name="text"> + <string>Down</string> + </property> + <property name="arrowType"> + <enum>Qt::DownArrow</enum> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_left"> + <property name="text"> + <string>Left</string> + </property> + <property name="arrowType"> + <enum>Qt::LeftArrow</enum> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_right"> + <property name="text"> + <string>Right</string> + </property> + <property name="arrowType"> + <enum>Qt::RightArrow</enum> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </item> + <item> + <widget class="QTextBrowser" name="textBrowser"> + <property name="html"> + <string><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Click the preview window to place a cell. Right-click to remove.</span></p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with less than 2 neighbours will die from underpopulation</p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with more than 3 neighbours will die from overpopulation.</p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- An empty space surrounded by 3 live cells will cause reproduction.</p></body></html></string> + </property> + <property name="tabStopWidth"> + <number>80</number> + </property> + <property name="textInteractionFlags"> + <set>Qt::NoTextInteraction</set> + </property> + <property name="openLinks"> + <bool>false</bool> + </property> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/original.py b/src/components/original.py new file mode 100644 index 0000000..80228fe --- /dev/null +++ b/src/components/original.py @@ -0,0 +1,203 @@ +import numpy +from PIL import Image, ImageDraw +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtGui import QColor +import os +import time +from copy import copy + +from ..component import Component +from ..toolkit.frame import BlankFrame + + +class Component(Component): + name = 'Classic Visualizer' + version = '1.0.0' + + def names(*args): + return ['Original Audio Visualization'] + + def properties(self): + return ['pcm'] + + def widget(self, *args): + self.scale = 20 + self.y = 0 + super().widget(*args) + + self.page.comboBox_visLayout.addItem("Classic") + self.page.comboBox_visLayout.addItem("Split") + self.page.comboBox_visLayout.addItem("Bottom") + self.page.comboBox_visLayout.addItem("Top") + self.page.comboBox_visLayout.setCurrentIndex(0) + + self.page.lineEdit_visColor.setText('255,255,255') + + self.trackWidgets({ + 'visColor': self.page.lineEdit_visColor, + 'layout': self.page.comboBox_visLayout, + 'scale': self.page.spinBox_scale, + 'y': self.page.spinBox_y, + }, colorWidgets={ + 'visColor': self.page.pushButton_visColor, + }, relativeWidgets=[ + 'y', + ]) + + def previewRender(self): + spectrum = numpy.fromfunction( + lambda x: float(self.scale)/2500*(x-128)**2, (255,), dtype="int16") + return self.drawBars( + self.width, self.height, spectrum, self.visColor, self.layout + ) + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + self.smoothConstantDown = 0.08 + self.smoothConstantUp = 0.8 + self.lastSpectrum = None + self.spectrumArray = {} + + for i in range(0, len(self.completeAudioArray), self.sampleSize): + if self.canceled: + break + self.lastSpectrum = self.transformData( + i, self.completeAudioArray, self.sampleSize, + self.smoothConstantDown, self.smoothConstantUp, + self.lastSpectrum) + self.spectrumArray[i] = copy(self.lastSpectrum) + + progress = int(100*(i/len(self.completeAudioArray))) + if progress >= 100: + progress = 100 + pStr = "Analyzing audio: "+str(progress)+'%' + self.progressBarSetText.emit(pStr) + self.progressBarUpdate.emit(int(progress)) + + def frameRender(self, frameNo): + arrayNo = frameNo * self.sampleSize + return self.drawBars( + self.width, self.height, + self.spectrumArray[arrayNo], + self.visColor, self.layout) + + def transformData( + self, i, completeAudioArray, sampleSize, + smoothConstantDown, smoothConstantUp, lastSpectrum): + if len(completeAudioArray) < (i + sampleSize): + sampleSize = len(completeAudioArray) - i + + window = numpy.hanning(sampleSize) + data = completeAudioArray[i:i+sampleSize][::1] * window + paddedSampleSize = 2048 + paddedData = numpy.pad( + data, (0, paddedSampleSize - sampleSize), 'constant') + spectrum = numpy.fft.fft(paddedData) + sample_rate = 44100 + frequencies = numpy.fft.fftfreq(len(spectrum), 1./sample_rate) + + y = abs(spectrum[0:int(paddedSampleSize/2) - 1]) + + # filter the noise away + # y[y<80] = 0 + + y = self.scale * numpy.log10(y) + y[numpy.isinf(y)] = 0 + + if lastSpectrum is not None: + lastSpectrum[y < lastSpectrum] = \ + y[y < lastSpectrum] * smoothConstantDown + \ + lastSpectrum[y < lastSpectrum] * (1 - smoothConstantDown) + + lastSpectrum[y >= lastSpectrum] = \ + y[y >= lastSpectrum] * smoothConstantUp + \ + lastSpectrum[y >= lastSpectrum] * (1 - smoothConstantUp) + else: + lastSpectrum = y + + x = frequencies[0:int(paddedSampleSize/2) - 1] + + return lastSpectrum + + def drawBars(self, width, height, spectrum, color, layout): + vH = height-height/8 + bF = width / 64 + bH = bF / 2 + bQ = bF / 4 + imTop = BlankFrame(width, height) + draw = ImageDraw.Draw(imTop) + r, g, b = color + color2 = (r, g, b, 125) + + bP = height / 1200 + + for j in range(0, 63): + draw.rectangle(( + bH + j * bF, vH+bQ, bH + j * bF + bF, vH + bQ - + spectrum[j * 4] * bP - bH), fill=color2) + + draw.rectangle(( + bH + bQ + j * bF, vH, bH + bQ + j * bF + bH, vH - + spectrum[j * 4] * bP), fill=color) + + imBottom = imTop.transpose(Image.FLIP_TOP_BOTTOM) + + im = BlankFrame(width, height) + + if layout == 0: # Classic + y = self.y - int(height/100*43) + im.paste(imTop, (0, y), mask=imTop) + y = self.y + int(height/100*43) + im.paste(imBottom, (0, y), mask=imBottom) + + if layout == 1: # Split + y = self.y + int(height/100*10) + im.paste(imTop, (0, y), mask=imTop) + y = self.y - int(height/100*10) + im.paste(imBottom, (0, y), mask=imBottom) + + if layout == 2: # Bottom + y = self.y + int(height/100*10) + im.paste(imTop, (0, y), mask=imTop) + + if layout == 3: # Top + y = self.y - int(height/100*10) + im.paste(imBottom, (0, y), mask=imBottom) + + return im + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + try: + if key == 'color': + self.page.lineEdit_visColor.setText(arg) + return + elif key == 'layout': + if arg == 'classic': + self.page.comboBox_visLayout.setCurrentIndex(0) + elif arg == 'split': + self.page.comboBox_visLayout.setCurrentIndex(1) + elif arg == 'bottom': + self.page.comboBox_visLayout.setCurrentIndex(2) + elif arg == 'top': + self.page.comboBox_visLayout.setCurrentIndex(3) + return + elif key == 'scale': + arg = int(arg) + self.page.spinBox_scale.setValue(arg) + return + elif key == 'y': + arg = int(arg) + self.page.spinBox_y.setValue(arg) + return + except ValueError: + print('You must enter a number.') + quit(1) + super().command(arg) + + def commandHelp(self): + print('Give a layout name:\n layout=[classic/split/bottom/top]') + print('Specify a color:\n color=255,255,255') + print('Visualizer scale (20 is default):\n scale=number') + print('Y position:\n y=number') diff --git a/src/components/original.ui b/src/components/original.ui new file mode 100644 index 0000000..a4d5119 --- /dev/null +++ b/src/components/original.ui @@ -0,0 +1,193 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>178</height> + </rect> + </property> + <property name="minimumSize"> + <size> + <width>180</width> + <height>0</height> + </size> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <widget class="QLabel" name="label_visLayout"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Layout</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_visLayout"/> + </item> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Scale</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::PlusMinus</enum> + </property> + <property name="minimum"> + <number>1</number> + </property> + <property name="value"> + <number>20</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_visColor"> + <property name="text"> + <string>Color</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_visColor"> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_visColor"/> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="minimum"> + <number>-5000</number> + </property> + <property name="maximum"> + <number>5000</number> + </property> + <property name="singleStep"> + <number>10</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Expanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/sound.py b/src/components/sound.py new file mode 100644 index 0000000..118ea23 --- /dev/null +++ b/src/components/sound.py @@ -0,0 +1,73 @@ +from PyQt5 import QtGui, QtCore, QtWidgets +import os + +from ..component import Component +from ..toolkit.frame import BlankFrame + + +class Component(Component): + name = 'Sound' + version = '1.0.0' + + def widget(self, *args): + super().widget(*args) + self.page.pushButton_sound.clicked.connect(self.pickSound) + self.trackWidgets({ + 'sound': self.page.lineEdit_sound, + 'chorus': self.page.checkBox_chorus, + 'delay': self.page.spinBox_delay, + 'volume': self.page.spinBox_volume, + }, commandArgs={ + 'sound': None, + }) + + def properties(self): + props = ['static', 'audio'] + if not os.path.exists(self.sound): + props.append('error') + return props + + def error(self): + if not self.sound: + return "No audio file selected." + if not os.path.exists(self.sound): + return "The audio file selected no longer exists!" + + def audio(self): + params = {} + if self.delay != 0.0: + params['adelay'] = '=%s' % str(int(self.delay * 1000.00)) + if self.chorus: + params['chorus'] = \ + '=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3' + if self.volume != 1.0: + params['volume'] = '=%s:replaygain_noclip=0' % str(self.volume) + + return (self.sound, params) + + def pickSound(self): + sndDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, "Choose Sound", sndDir, + "Audio Files (%s)" % " ".join(self.core.audioFormats)) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_sound.setText(filename) + self.mergeUndo = True + + def commandHelp(self): + print('Path to audio file:\n path=/filepath/to/sound.ogg') + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + if key == 'path': + if '*%s' % os.path.splitext(arg)[1] \ + not in self.core.audioFormats: + print("Not a supported audio format") + quit(1) + self.page.lineEdit_sound.setText(arg) + return + + super().command(arg) diff --git a/src/components/sound.ui b/src/components/sound.ui new file mode 100644 index 0000000..4c11332 --- /dev/null +++ b/src/components/sound.ui @@ -0,0 +1,172 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Audio File</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_sound"> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_sound"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Volume</string> + </property> + </widget> + </item> + <item> + <widget class="QDoubleSpinBox" name="spinBox_volume"> + <property name="suffix"> + <string>x</string> + </property> + <property name="maximum"> + <double>10.000000000000000</double> + </property> + <property name="singleStep"> + <double>0.100000000000000</double> + </property> + <property name="value"> + <double>1.000000000000000</double> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Delay</string> + </property> + </widget> + </item> + <item> + <widget class="QDoubleSpinBox" name="spinBox_delay"> + <property name="suffix"> + <string>s</string> + </property> + <property name="maximum"> + <double>9999999.990000000223517</double> + </property> + <property name="singleStep"> + <double>0.500000000000000</double> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_chorus"> + <property name="text"> + <string>Chorus</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/spectrum.py b/src/components/spectrum.py new file mode 100644 index 0000000..91f2afb --- /dev/null +++ b/src/components/spectrum.py @@ -0,0 +1,316 @@ +from PIL import Image +from PyQt5 import QtGui, QtCore, QtWidgets +import os +import math +import subprocess +import time +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit import checkOutput, connectWidget +from ..toolkit.ffmpeg import ( + openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound +) + + +log = logging.getLogger('AVP.Components.Spectrum') + + +class Component(Component): + name = 'Spectrum' + version = '1.0.1' + + def widget(self, *args): + self.previewFrame = None + super().widget(*args) + self._image = BlankFrame(self.width, self.height) + self.chunkSize = 4 * self.width * self.height + self.changedOptions = True + self.previewSize = (214, 120) + self.previewPipe = None + + if hasattr(self.parent, 'lineEdit_audioFile'): + # update preview when audio file changes (if genericPreview is off) + self.parent.lineEdit_audioFile.textChanged.connect( + self.update + ) + + self.trackWidgets({ + 'filterType': self.page.comboBox_filterType, + 'window': self.page.comboBox_window, + 'mode': self.page.comboBox_mode, + 'amplitude': self.page.comboBox_amplitude0, + 'amplitude1': self.page.comboBox_amplitude1, + 'amplitude2': self.page.comboBox_amplitude2, + 'display': self.page.comboBox_display, + 'zoom': self.page.spinBox_zoom, + 'tc': self.page.spinBox_tc, + 'x': self.page.spinBox_x, + 'y': self.page.spinBox_y, + 'mirror': self.page.checkBox_mirror, + 'draw': self.page.checkBox_draw, + 'scale': self.page.spinBox_scale, + 'color': self.page.comboBox_color, + 'compress': self.page.checkBox_compress, + 'mono': self.page.checkBox_mono, + 'hue': self.page.spinBox_hue, + }, relativeWidgets=[ + 'x', 'y', + ]) + for widget in self._trackedWidgets.values(): + connectWidget(widget, lambda: self.changed()) + + def changed(self): + self.changedOptions = True + + def update(self): + filterType = self.page.comboBox_filterType.currentIndex() + self.page.stackedWidget.setCurrentIndex(filterType) + if filterType == 3: + self.page.spinBox_hue.setEnabled(False) + else: + self.page.spinBox_hue.setEnabled(True) + if filterType == 2 or filterType == 4: + self.page.checkBox_mono.setEnabled(False) + else: + self.page.checkBox_mono.setEnabled(True) + + def previewRender(self): + changedSize = self.updateChunksize() + if not changedSize \ + and not self.changedOptions \ + and self.previewFrame is not None: + log.debug( + 'Spectrum #%s is reusing old preview frame' % self.compPos) + return self.previewFrame + + frame = self.getPreviewFrame() + self.changedOptions = False + if not frame: + log.warning( + 'Spectrum #%s failed to create a preview frame' % self.compPos) + self.previewFrame = None + return BlankFrame(self.width, self.height) + else: + self.previewFrame = frame + return frame + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + if self.previewPipe is not None: + self.previewPipe.wait() + self.updateChunksize() + w, h = scale(self.scale, self.width, self.height, str) + self.video = FfmpegVideo( + inputPath=self.audioFile, + filter_=self.makeFfmpegFilter(), + width=w, height=h, + chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, component=self, + ) + + def frameRender(self, frameNo): + if FfmpegVideo.threadError is not None: + raise FfmpegVideo.threadError + return self.finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def getPreviewFrame(self): + genericPreview = self.settings.value("pref_genericPreview") + startPt = 0 + if not genericPreview: + inputFile = self.parent.lineEdit_audioFile.text() + if not inputFile or not os.path.exists(inputFile): + return + duration = getAudioDuration(inputFile) + if not duration: + return + startPt = duration / 3 + + command = [ + self.core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-r', self.settings.value("outputFrameRate"), + '-ss', "{0:.3f}".format(startPt), + '-i', + self.core.junkStream + if genericPreview else inputFile, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) + command.extend([ + '-an', + '-s:v', '%sx%s' % scale(self.scale, self.width, self.height, str), + '-codec:v', 'rawvideo', '-', + '-frames:v', '1', + ]) + + if self.core.logEnabled: + logFilename = os.path.join( + self.core.logDir, 'preview_%s.log' % str(self.compPos)) + log.debug('Creating ffmpeg process (log at %s)' % logFilename) + with open(logFilename, 'w') as logf: + logf.write(" ".join(command) + '\n\n') + with open(logFilename, 'a') as logf: + self.previewPipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=logf, bufsize=10**8 + ) + else: + self.previewPipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=10**8 + ) + byteFrame = self.previewPipe.stdout.read(self.chunkSize) + closePipe(self.previewPipe) + + frame = self.finalizeFrame(byteFrame) + return frame + + def makeFfmpegFilter(self, preview=False, startPt=0): + if preview: + w, h = self.previewSize + else: + w, h = (self.width, self.height) + color = self.page.comboBox_color.currentText().lower() + genericPreview = self.settings.value("pref_genericPreview") + + if self.filterType == 0: # Spectrum + if self.amplitude == 0: + amplitude = 'sqrt' + elif self.amplitude == 1: + amplitude = 'cbrt' + elif self.amplitude == 2: + amplitude = '4thrt' + elif self.amplitude == 3: + amplitude = '5thrt' + elif self.amplitude == 4: + amplitude = 'lin' + elif self.amplitude == 5: + amplitude = 'log' + filter_ = ( + 'showspectrum=s=%sx%s:slide=scroll:win_func=%s:' + 'color=%s:scale=%s,' + 'colorkey=color=black:similarity=0.1:blend=0.5' % ( + w, h, + self.page.comboBox_window.currentText(), + color, amplitude, + ) + ) + elif self.filterType == 1: # Histogram + if self.amplitude1 == 0: + amplitude = 'log' + elif self.amplitude1 == 1: + amplitude = 'lin' + if self.display == 0: + display = 'log' + elif self.display == 1: + display = 'sqrt' + elif self.display == 2: + display = 'cbrt' + elif self.display == 3: + display = 'lin' + elif self.display == 4: + display = 'rlog' + filter_ = ( + 'ahistogram=r=%s:s=%sx%s:dmode=separate:ascale=%s:scale=%s' % ( + self.settings.value("outputFrameRate"), + w, h, + amplitude, display + ) + ) + elif self.filterType == 2: # Vector Scope + if self.amplitude2 == 0: + amplitude = 'log' + elif self.amplitude2 == 1: + amplitude = 'sqrt' + elif self.amplitude2 == 2: + amplitude = 'cbrt' + elif self.amplitude2 == 3: + amplitude = 'lin' + m = self.page.comboBox_mode.currentText() + filter_ = ( + 'avectorscope=s=%sx%s:draw=%s:m=%s:scale=%s:zoom=%s' % ( + w, h, + 'line'if self.draw else 'dot', + m, amplitude, str(self.zoom), + ) + ) + elif self.filterType == 3: # Musical Scale + filter_ = ( + 'showcqt=r=%s:s=%sx%s:count=30:text=0:tc=%s,' + 'colorkey=color=black:similarity=0.1:blend=0.5 ' % ( + self.settings.value("outputFrameRate"), + w, h, + str(self.tc), + ) + ) + elif self.filterType == 4: # Phase + filter_ = ( + 'aphasemeter=r=%s:s=%sx%s:video=1 [atrash][vtmp1]; ' + '[atrash] anullsink; ' + '[vtmp1] colorkey=color=black:similarity=0.1:blend=0.5, ' + 'crop=in_w/8:in_h:(in_w/8)*7:0 ' % ( + self.settings.value("outputFrameRate"), + w, h, + ) + ) + + if self.filterType < 2: + exampleSnd = exampleSound('freq') + elif self.filterType == 2 or self.filterType == 4: + exampleSnd = exampleSound('stereo') + elif self.filterType == 3: + exampleSnd = exampleSound('white') + + return [ + '-filter_complex', + '%s%s%s%s [v1]; ' + '[v1] %s%s%s%s%s [v]' % ( + exampleSnd if preview and genericPreview else '[0:a] ', + 'compand=gain=4,' if self.compress else '', + 'aformat=channel_layouts=mono,' + if self.mono and self.filterType not in (2, 4) else '', + filter_, + 'hflip, ' if self.mirror else '', + 'trim=start=%s:end=%s, ' % ( + "{0:.3f}".format(startPt + 12), + "{0:.3f}".format(startPt + 12.5) + ) if preview else '', + 'scale=%sx%s' % scale( + self.scale, self.width, self.height, str), + ', hue=h=%s:s=10' % str(self.hue) + if self.hue > 0 and self.filterType != 3 else '', + ', convolution=-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 ' + '-1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2' + if self.filterType == 3 else '' + ), + '-map', '[v]', + ] + + def updateChunksize(self): + width, height = scale(self.scale, self.width, self.height, int) + oldChunkSize = int(self.chunkSize) + self.chunkSize = 4 * width * height + changed = self.chunkSize != oldChunkSize + return changed + + def finalizeFrame(self, imageData): + try: + image = Image.frombytes( + 'RGBA', + scale(self.scale, self.width, self.height, int), + imageData + ) + self._image = image + except ValueError: + image = self._image + + frame = BlankFrame(self.width, self.height) + frame.paste(image, box=(self.x, self.y)) + return frame diff --git a/src/components/spectrum.ui b/src/components/spectrum.ui new file mode 100644 index 0000000..c6a8a15 --- /dev/null +++ b/src/components/spectrum.ui @@ -0,0 +1,946 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>197</height> + </size> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_5"/> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_4"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Type</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_filterType"> + <item> + <property name="text"> + <string>Spectrum</string> + </property> + </item> + <item> + <property name="text"> + <string>Histogram</string> + </property> + </item> + <item> + <property name="text"> + <string>Vector Scope</string> + </property> + </item> + <item> + <property name="text"> + <string>Musical Scale</string> + </property> + </item> + <item> + <property name="text"> + <string>Phase</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_x"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <item> + <widget class="QCheckBox" name="checkBox_compress"> + <property name="text"> + <string>Compress</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_mono"> + <property name="text"> + <string>Mono</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_mirror"> + <property name="text"> + <string>Mirror</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_11"> + <property name="text"> + <string>Hue</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_hue"> + <property name="suffix"> + <string>° </string> + </property> + <property name="maximum"> + <number>359</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Scale</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>400</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QStackedWidget" name="stackedWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="autoFillBackground"> + <bool>false</bool> + </property> + <property name="frameShape"> + <enum>QFrame::NoFrame</enum> + </property> + <property name="frameShadow"> + <enum>QFrame::Plain</enum> + </property> + <property name="currentIndex"> + <number>0</number> + </property> + <widget class="QWidget" name="page"> + <widget class="QWidget" name="verticalLayoutWidget"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>561</width> + <height>66</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <property name="sizeConstraint"> + <enum>QLayout::SetMaximumSize</enum> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Window</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_window"> + <item> + <property name="text"> + <string>hann</string> + </property> + </item> + <item> + <property name="text"> + <string>gauss</string> + </property> + </item> + <item> + <property name="text"> + <string>tukey</string> + </property> + </item> + <item> + <property name="text"> + <string>dolph</string> + </property> + </item> + <item> + <property name="text"> + <string>cauchy</string> + </property> + </item> + <item> + <property name="text"> + <string>parzen</string> + </property> + </item> + <item> + <property name="text"> + <string>poisson</string> + </property> + </item> + <item> + <property name="text"> + <string>rect</string> + </property> + </item> + <item> + <property name="text"> + <string>bartlett</string> + </property> + </item> + <item> + <property name="text"> + <string>hanning</string> + </property> + </item> + <item> + <property name="text"> + <string>hamming</string> + </property> + </item> + <item> + <property name="text"> + <string>blackman</string> + </property> + </item> + <item> + <property name="text"> + <string>welch</string> + </property> + </item> + <item> + <property name="text"> + <string>flattop</string> + </property> + </item> + <item> + <property name="text"> + <string>bharris</string> + </property> + </item> + <item> + <property name="text"> + <string>bnuttall</string> + </property> + </item> + <item> + <property name="text"> + <string>lanczos</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Amplitude</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_amplitude0"> + <item> + <property name="text"> + <string>Square root</string> + </property> + </item> + <item> + <property name="text"> + <string>Cubic root</string> + </property> + </item> + <item> + <property name="text"> + <string>4thrt</string> + </property> + </item> + <item> + <property name="text"> + <string>5thrt</string> + </property> + </item> + <item> + <property name="text"> + <string>Linear</string> + </property> + </item> + <item> + <property name="text"> + <string>Logarithmic</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_4"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>10</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QLabel" name="label_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Color </string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_color"> + <item> + <property name="text"> + <string>Channel</string> + </property> + </item> + <item> + <property name="text"> + <string>Intensity</string> + </property> + </item> + <item> + <property name="text"> + <string>Rainbow</string> + </property> + </item> + <item> + <property name="text"> + <string>Moreland</string> + </property> + </item> + <item> + <property name="text"> + <string>Nebulae</string> + </property> + </item> + <item> + <property name="text"> + <string>Fire</string> + </property> + </item> + <item> + <property name="text"> + <string>Fiery</string> + </property> + </item> + <item> + <property name="text"> + <string>Fruit</string> + </property> + </item> + <item> + <property name="text"> + <string>Cool</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>10</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <widget class="QWidget" name="page_2"> + <widget class="QWidget" name="verticalLayoutWidget_2"> + <property name="geometry"> + <rect> + <x>-1</x> + <y>-1</y> + <width>561</width> + <height>31</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_4"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QLabel" name="label_6"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Display Scale</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_display"> + <item> + <property name="text"> + <string>Logarithmic</string> + </property> + </item> + <item> + <property name="text"> + <string>Square root</string> + </property> + </item> + <item> + <property name="text"> + <string>Cubic root</string> + </property> + </item> + <item> + <property name="text"> + <string>Linear</string> + </property> + </item> + <item> + <property name="text"> + <string>Reverse Log</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QLabel" name="label_5"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Amplitude</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_amplitude1"> + <item> + <property name="text"> + <string>Logarithmic</string> + </property> + </item> + <item> + <property name="text"> + <string>Linear</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <widget class="QWidget" name="page_3"> + <widget class="QWidget" name="verticalLayoutWidget_3"> + <property name="geometry"> + <rect> + <x>-1</x> + <y>-1</y> + <width>585</width> + <height>64</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_5"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QLabel" name="label_9"> + <property name="text"> + <string>Mode</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_mode"> + <item> + <property name="text"> + <string>lissajous</string> + </property> + </item> + <item> + <property name="text"> + <string>lissajous_xy</string> + </property> + </item> + <item> + <property name="text"> + <string>polar</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QLabel" name="label_7"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Amplitude</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_amplitude2"> + <item> + <property name="text"> + <string>Linear</string> + </property> + </item> + <item> + <property name="text"> + <string>Square root</string> + </property> + </item> + <item> + <property name="text"> + <string>Cubic root</string> + </property> + </item> + <item> + <property name="text"> + <string>Logarithmic</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_6"> + <item> + <widget class="QLabel" name="label_8"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Zoom</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_zoom"> + <property name="minimum"> + <number>1</number> + </property> + <property name="maximum"> + <number>10</number> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_draw"> + <property name="text"> + <string>Line</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <widget class="QWidget" name="page_4"> + <widget class="QWidget" name="verticalLayoutWidget_4"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>561</width> + <height>31</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_6"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_7"> + <item> + <widget class="QLabel" name="label_10"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Timeclamp</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QDoubleSpinBox" name="spinBox_tc"> + <property name="suffix"> + <string>s</string> + </property> + <property name="decimals"> + <number>3</number> + </property> + <property name="minimum"> + <double>0.002000000000000</double> + </property> + <property name="maximum"> + <double>1.000000000000000</double> + </property> + <property name="singleStep"> + <double>0.010000000000000</double> + </property> + <property name="value"> + <double>0.017000000000000</double> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_7"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <widget class="QWidget" name="page_5"> + <widget class="QWidget" name="verticalLayoutWidget_5"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>551</width> + <height>31</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_7"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_11"/> + </item> + </layout> + </widget> + </widget> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>10</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/text.py b/src/components/text.py new file mode 100644 index 0000000..e8c5a9c --- /dev/null +++ b/src/components/text.py @@ -0,0 +1,203 @@ +from PIL import ImageEnhance, ImageFilter, ImageChops +from PyQt5.QtGui import QColor, QFont +from PyQt5 import QtGui, QtCore, QtWidgets +import os +import logging + +from ..component import Component +from ..toolkit.frame import FramePainter, PaintColor + +log = logging.getLogger('AVP.Components.Text') + + +class Component(Component): + name = 'Title Text' + version = '1.0.1' + + def widget(self, *args): + super().widget(*args) + self.title = 'Text' + self.alignment = 1 + self.titleFont = QFont() + self.fontSize = self.height / 13.5 + + self.page.comboBox_textAlign.addItem("Left") + self.page.comboBox_textAlign.addItem("Middle") + self.page.comboBox_textAlign.addItem("Right") + self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) + + self.page.spinBox_fontSize.setValue(int(self.fontSize)) + self.page.lineEdit_title.setText(self.title) + + self.page.pushButton_center.clicked.connect(self.centerXY) + self.page.fontComboBox_titleFont.currentFontChanged.connect( + self.update + ) + + self.trackWidgets({ + 'textColor': self.page.lineEdit_textColor, + 'title': self.page.lineEdit_title, + 'alignment': self.page.comboBox_textAlign, + 'fontSize': self.page.spinBox_fontSize, + 'xPosition': self.page.spinBox_xTextAlign, + 'yPosition': self.page.spinBox_yTextAlign, + 'fontStyle': self.page.comboBox_fontStyle, + 'stroke': self.page.spinBox_stroke, + 'strokeColor': self.page.lineEdit_strokeColor, + 'shadow': self.page.checkBox_shadow, + 'shadX': self.page.spinBox_shadX, + 'shadY': self.page.spinBox_shadY, + 'shadBlur': self.page.spinBox_shadBlur, + }, colorWidgets={ + 'textColor': self.page.pushButton_textColor, + 'strokeColor': self.page.pushButton_strokeColor, + }, relativeWidgets=[ + 'xPosition', 'yPosition', 'fontSize', + 'stroke', 'shadX', 'shadY', 'shadBlur' + ]) + self.centerXY() + + def update(self): + self.titleFont = self.page.fontComboBox_titleFont.currentFont() + if self.page.checkBox_shadow.isChecked(): + self.page.label_shadX.setHidden(False) + self.page.spinBox_shadX.setHidden(False) + self.page.spinBox_shadY.setHidden(False) + self.page.label_shadBlur.setHidden(False) + self.page.spinBox_shadBlur.setHidden(False) + else: + self.page.label_shadX.setHidden(True) + self.page.spinBox_shadX.setHidden(True) + self.page.spinBox_shadY.setHidden(True) + self.page.label_shadBlur.setHidden(True) + self.page.spinBox_shadBlur.setHidden(True) + + def centerXY(self): + self.setRelativeWidget('xPosition', 0.5) + self.setRelativeWidget('yPosition', 0.521) + + def getXY(self): + '''Returns true x, y after considering alignment settings''' + fm = QtGui.QFontMetrics(self.titleFont) + x = self.pixelValForAttr('xPosition') + + if self.alignment == 1: # Middle + offset = int(fm.width(self.title)/2) + x -= offset + if self.alignment == 2: # Right + offset = fm.width(self.title) + x -= offset + + return x, self.yPosition + + def loadPreset(self, pr, *args): + super().loadPreset(pr, *args) + + font = QFont() + font.fromString(pr['titleFont']) + self.page.fontComboBox_titleFont.setCurrentFont(font) + + def savePreset(self): + saveValueStore = super().savePreset() + saveValueStore['titleFont'] = self.titleFont.toString() + return saveValueStore + + def previewRender(self): + return self.addText(self.width, self.height) + + def properties(self): + props = ['static'] + if not self.title: + props.append('error') + return props + + def error(self): + return "No text provided." + + def frameRender(self, frameNo): + return self.addText(self.width, self.height) + + def addText(self, width, height): + font = self.titleFont + font.setPixelSize(self.fontSize) + font.setStyle(QFont.StyleNormal) + font.setWeight(QFont.Normal) + font.setCapitalization(QFont.MixedCase) + if self.fontStyle == 1: + font.setWeight(QFont.DemiBold) + if self.fontStyle == 2: + font.setWeight(QFont.Bold) + elif self.fontStyle == 3: + font.setStyle(QFont.StyleItalic) + elif self.fontStyle == 4: + font.setWeight(QFont.Bold) + font.setStyle(QFont.StyleItalic) + elif self.fontStyle == 5: + font.setStyle(QFont.StyleOblique) + elif self.fontStyle == 6: + font.setCapitalization(QFont.SmallCaps) + + image = FramePainter(width, height) + x, y = self.getXY() + log.debug('Text position translates to %s, %s', x, y) + if self.stroke > 0: + outliner = QtGui.QPainterPathStroker() + outliner.setWidth(self.stroke) + path = QtGui.QPainterPath() + if self.fontStyle == 6: + # PathStroker ignores smallcaps so we need this weird hack + path.addText(x, y, font, self.title[0]) + fm = QtGui.QFontMetrics(font) + newX = x + fm.width(self.title[0]) + strokeFont = self.page.fontComboBox_titleFont.currentFont() + strokeFont.setCapitalization(QFont.SmallCaps) + strokeFont.setPixelSize(int((self.fontSize / 7) * 5)) + strokeFont.setLetterSpacing(QFont.PercentageSpacing, 139) + path.addText(newX, y, strokeFont, self.title[1:]) + else: + path.addText(x, y, font, self.title) + path = outliner.createStroke(path) + image.setPen(QtCore.Qt.NoPen) + image.setBrush(PaintColor(*self.strokeColor)) + image.drawPath(path) + + image.setFont(font) + image.setPen(self.textColor) + image.drawText(x, y, self.title) + + # turn QImage into Pillow frame + frame = image.finalize() + if self.shadow: + shadImg = ImageEnhance.Contrast(frame).enhance(0.0) + shadImg = shadImg.filter(ImageFilter.GaussianBlur(self.shadBlur)) + shadImg = ImageChops.offset(shadImg, self.shadX, self.shadY) + shadImg.paste(frame, box=(0, 0), mask=frame) + frame = shadImg + + return frame + + def commandHelp(self): + print('Enter a string to use as centred white text:') + print(' "title=User Error"') + print('Specify a text color:\n color=255,255,255') + print('Set custom x, y position:\n x=500 y=500') + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + if key == 'color': + self.page.lineEdit_textColor.setText(arg) + return + elif key == 'size': + self.page.spinBox_fontSize.setValue(int(arg)) + return + elif key == 'x': + self.page.spinBox_xTextAlign.setValue(int(arg)) + return + elif key == 'y': + self.page.spinBox_yTextAlign.setValue(int(arg)) + return + elif key == 'title': + self.page.lineEdit_title.setText(arg) + return + super().command(arg) diff --git a/src/components/text.ui b/src/components/text.ui new file mode 100644 index 0000000..b62e0ed --- /dev/null +++ b/src/components/text.ui @@ -0,0 +1,671 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="spacing"> + <number>6</number> + </property> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QLabel" name="label_title"> + <property name="text"> + <string>Title</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_title"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Testing New GUI</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Font</string> + </property> + </widget> + </item> + <item> + <widget class="QFontComboBox" name="fontComboBox_titleFont"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_7"> + <property name="leftMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label_textLayout"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Text Layout</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_textAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>100</width> + <height>16777215</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_center"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Center Text</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_xTextAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>50</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>0</number> + </property> + <property name="maximum"> + <number>999999999</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_yTextAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>50</width> + <height>16777215</height> + </size> + </property> + <property name="maximum"> + <number>999999999</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>16777215</height> + </size> + </property> + <property name="text"> + <string>Text Color</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_8"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_fontSize"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Font Size</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_fontSize"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="suffix"> + <string/> + </property> + <property name="prefix"> + <string/> + </property> + <property name="minimum"> + <number>1</number> + </property> + <property name="maximum"> + <number>500</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_7"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Font Style</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_fontStyle"> + <item> + <property name="text"> + <string>Normal</string> + </property> + </item> + <item> + <property name="text"> + <string>Semi-Bold</string> + </property> + </item> + <item> + <property name="text"> + <string>Bold</string> + </property> + </item> + <item> + <property name="text"> + <string>Italic</string> + </property> + </item> + <item> + <property name="text"> + <string>Bold Italic</string> + </property> + </item> + <item> + <property name="text"> + <string>Faux Italic</string> + </property> + </item> + <item> + <property name="text"> + <string>Small Caps</string> + </property> + </item> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_12"> + <item> + <widget class="QLineEdit" name="lineEdit_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>0</width> + <height>16777215</height> + </size> + </property> + <property name="focusPolicy"> + <enum>Qt::NoFocus</enum> + </property> + <property name="text"> + <string>255,255,255</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Stroke</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_stroke"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="suffix"> + <string>px</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_5"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Stroke Color</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_strokeColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>0</width> + <height>16777215</height> + </size> + </property> + <property name="focusPolicy"> + <enum>Qt::NoFocus</enum> + </property> + <property name="text"> + <string>0,0,0</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_strokeColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QCheckBox" name="checkBox_shadow"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Shadow</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_shadX"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Shadow Offset</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_shadX"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimum"> + <number>-1000</number> + </property> + <property name="maximum"> + <number>1000</number> + </property> + <property name="value"> + <number>-4</number> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_shadY"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimum"> + <number>-1000</number> + </property> + <property name="maximum"> + <number>1000</number> + </property> + <property name="value"> + <number>8</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_shadBlur"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Shadow Blur</string> + </property> + </widget> + </item> + <item> + <widget class="QDoubleSpinBox" name="spinBox_shadBlur"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximum"> + <double>99.000000000000000</double> + </property> + <property name="singleStep"> + <double>0.100000000000000</double> + </property> + <property name="value"> + <double>5.000000000000000</double> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/video.py b/src/components/video.py new file mode 100644 index 0000000..9fffc26 --- /dev/null +++ b/src/components/video.py @@ -0,0 +1,226 @@ +from PIL import Image +from PyQt5 import QtGui, QtCore, QtWidgets +import os +import math +import subprocess +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo +from ..toolkit import checkOutput + + +log = logging.getLogger('AVP.Components.Video') + + +class Component(Component): + name = 'Video' + version = '1.0.0' + + def widget(self, *args): + self.videoPath = '' + self.badAudio = False + self.x = 0 + self.y = 0 + self.loopVideo = False + super().widget(*args) + self._image = BlankFrame(self.width, self.height) + self.page.pushButton_video.clicked.connect(self.pickVideo) + self.trackWidgets({ + 'videoPath': self.page.lineEdit_video, + 'loopVideo': self.page.checkBox_loop, + 'useAudio': self.page.checkBox_useAudio, + 'distort': self.page.checkBox_distort, + 'scale': self.page.spinBox_scale, + 'volume': self.page.spinBox_volume, + 'xPosition': self.page.spinBox_x, + 'yPosition': self.page.spinBox_y, + }, presetNames={ + 'videoPath': 'video', + 'loopVideo': 'loop', + 'xPosition': 'x', + 'yPosition': 'y', + }, relativeWidgets=[ + 'xPosition', 'yPosition', + ]) + + def update(self): + if self.page.checkBox_useAudio.isChecked(): + self.page.label_volume.setEnabled(True) + self.page.spinBox_volume.setEnabled(True) + else: + self.page.label_volume.setEnabled(False) + self.page.spinBox_volume.setEnabled(False) + + def previewRender(self): + self.updateChunksize() + frame = self.getPreviewFrame(self.width, self.height) + if not frame: + return BlankFrame(self.width, self.height) + else: + return frame + + def properties(self): + props = [] + if hasattr(self.parent, 'lineEdit_outputFile'): + outputFile = self.parent.lineEdit_outputFile.text() + else: + outputFile = str(self.parent.args.output) + + if not self.videoPath: + self.lockError("There is no video selected.") + elif not os.path.exists(self.videoPath): + self.lockError("The video selected does not exist!") + elif os.path.realpath(self.videoPath) == os.path.realpath(outputFile): + self.lockError("Input and output paths match.") + + if self.useAudio: + props.append('audio') + if not testAudioStream(self.videoPath) \ + and self.error() is None: + self.lockError( + "Could not identify an audio stream in this video.") + + return props + + def audio(self): + params = {} + if self.volume != 1.0: + params['volume'] = '=%s:replaygain_noclip=0' % str(self.volume) + return (self.videoPath, params) + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + self.updateChunksize() + self.video = FfmpegVideo( + inputPath=self.videoPath, filter_=self.makeFfmpegFilter(), + width=self.width, height=self.height, chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, loopVideo=self.loopVideo, + component=self + ) if os.path.exists(self.videoPath) else None + + def frameRender(self, frameNo): + if FfmpegVideo.threadError is not None: + raise FfmpegVideo.threadError + return self.finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def pickVideo(self): + imgDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, "Choose Video", + imgDir, "Video Files (%s)" % " ".join(self.core.videoFormats) + ) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_video.setText(filename) + self.mergeUndo = True + + def getPreviewFrame(self, width, height): + if not self.videoPath or not os.path.exists(self.videoPath): + return + + command = [ + self.core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-i', self.videoPath, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + command.extend(self.makeFfmpegFilter()) + command.extend([ + '-codec:v', 'rawvideo', '-', + '-ss', '90', + '-frames:v', '1', + ]) + + if self.core.logEnabled: + logFilename = os.path.join( + self.core.logDir, 'preview_%s.log' % str(self.compPos)) + log.debug('Creating ffmpeg process (log at %s)' % logFilename) + with open(logFilename, 'w') as logf: + logf.write(" ".join(command) + '\n\n') + with open(logFilename, 'a') as logf: + pipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=logf, bufsize=10**8 + ) + else: + pipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=10**8 + ) + + byteFrame = pipe.stdout.read(self.chunkSize) + closePipe(pipe) + + frame = self.finalizeFrame(byteFrame) + return frame + + def makeFfmpegFilter(self): + return [ + '-filter_complex', + '[0:v] scale=%s:%s' % scale( + self.scale, self.width, self.height, str), + ] + + def updateChunksize(self): + if self.scale != 100 and not self.distort: + width, height = scale(self.scale, self.width, self.height, int) + else: + width, height = self.width, self.height + self.chunkSize = 4 * width * height + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + if key == 'path' and os.path.exists(arg): + if '*%s' % os.path.splitext(arg)[1] in self.core.videoFormats: + self.page.lineEdit_video.setText(arg) + self.page.spinBox_scale.setValue(100) + self.page.checkBox_loop.setChecked(True) + return + else: + print("Not a supported video format") + quit(1) + elif arg == 'audio': + if not self.page.lineEdit_video.text(): + print("'audio' option must follow a video selection") + quit(1) + self.page.checkBox_useAudio.setChecked(True) + return + super().command(arg) + + def commandHelp(self): + print('Load a video:\n path=/filepath/to/video.mp4') + print('Using audio:\n path=/filepath/to/video.mp4 audio') + + def finalizeFrame(self, imageData): + try: + if self.distort: + image = Image.frombytes( + 'RGBA', + (self.width, self.height), + imageData) + else: + image = Image.frombytes( + 'RGBA', + scale(self.scale, self.width, self.height, int), + imageData) + self._image = image + except ValueError: + # use last good frame + image = self._image + + if self.scale != 100 \ + or self.xPosition != 0 or self.yPosition != 0: + frame = BlankFrame(self.width, self.height) + frame.paste(image, box=(self.xPosition, self.yPosition)) + else: + frame = image + return frame diff --git a/src/components/video.ui b/src/components/video.ui new file mode 100644 index 0000000..08d15d3 --- /dev/null +++ b/src/components/video.ui @@ -0,0 +1,328 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>197</height> + </size> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Video</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_video"> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_video"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_x"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <item> + <widget class="QCheckBox" name="checkBox_loop"> + <property name="text"> + <string>Loop</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QCheckBox" name="checkBox_distort"> + <property name="text"> + <string>Distort by scale</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Scale</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>400</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <item> + <widget class="QCheckBox" name="checkBox_useAudio"> + <property name="text"> + <string>Use Audio</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_volume"> + <property name="text"> + <string>Volume</string> + </property> + </widget> + </item> + <item> + <widget class="QDoubleSpinBox" name="spinBox_volume"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="suffix"> + <string>x</string> + </property> + <property name="minimum"> + <double>0.000000000000000</double> + </property> + <property name="maximum"> + <double>10.000000000000000</double> + </property> + <property name="singleStep"> + <double>0.100000000000000</double> + </property> + <property name="value"> + <double>1.000000000000000</double> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/waveform.py b/src/components/waveform.py new file mode 100644 index 0000000..227f711 --- /dev/null +++ b/src/components/waveform.py @@ -0,0 +1,215 @@ +from PIL import Image +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtGui import QColor +import os +import math +import subprocess +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit import checkOutput +from ..toolkit.ffmpeg import ( + openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound +) + + +log = logging.getLogger('AVP.Components.Waveform') + + +class Component(Component): + name = 'Waveform' + version = '1.0.0' + + def widget(self, *args): + super().widget(*args) + self._image = BlankFrame(self.width, self.height) + + self.page.lineEdit_color.setText('255,255,255') + + if hasattr(self.parent, 'lineEdit_audioFile'): + self.parent.lineEdit_audioFile.textChanged.connect( + self.update + ) + + self.trackWidgets({ + 'color': self.page.lineEdit_color, + 'mode': self.page.comboBox_mode, + 'amplitude': self.page.comboBox_amplitude, + 'x': self.page.spinBox_x, + 'y': self.page.spinBox_y, + 'mirror': self.page.checkBox_mirror, + 'scale': self.page.spinBox_scale, + 'opacity': self.page.spinBox_opacity, + 'compress': self.page.checkBox_compress, + 'mono': self.page.checkBox_mono, + }, colorWidgets={ + 'color': self.page.pushButton_color, + }, relativeWidgets=[ + 'x', 'y', + ]) + + def previewRender(self): + self.updateChunksize() + frame = self.getPreviewFrame(self.width, self.height) + if not frame: + return BlankFrame(self.width, self.height) + else: + return frame + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + self.updateChunksize() + w, h = scale(self.scale, self.width, self.height, str) + self.video = FfmpegVideo( + inputPath=self.audioFile, + filter_=self.makeFfmpegFilter(), + width=w, height=h, + chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, component=self, debug=True, + ) + + def frameRender(self, frameNo): + if FfmpegVideo.threadError is not None: + raise FfmpegVideo.threadError + return self.finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def getPreviewFrame(self, width, height): + genericPreview = self.settings.value("pref_genericPreview") + startPt = 0 + if not genericPreview: + inputFile = self.parent.lineEdit_audioFile.text() + if not inputFile or not os.path.exists(inputFile): + return + duration = getAudioDuration(inputFile) + if not duration: + return + startPt = duration / 3 + if startPt + 3 > duration: + startPt += startPt - 3 + + command = [ + self.core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-r', self.settings.value("outputFrameRate"), + '-ss', "{0:.3f}".format(startPt), + '-i', + self.core.junkStream + if genericPreview else inputFile, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) + command.extend([ + '-an', + '-s:v', '%sx%s' % scale(self.scale, self.width, self.height, str), + '-codec:v', 'rawvideo', '-', + '-frames:v', '1', + ]) + if self.core.logEnabled: + logFilename = os.path.join( + self.core.logDir, 'preview_%s.log' % str(self.compPos)) + log.debug('Creating ffmpeg log at %s', logFilename) + with open(logFilename, 'w') as logf: + logf.write(" ".join(command) + '\n\n') + with open(logFilename, 'a') as logf: + pipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=logf, bufsize=10**8 + ) + else: + pipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=10**8 + ) + byteFrame = pipe.stdout.read(self.chunkSize) + closePipe(pipe) + + frame = self.finalizeFrame(byteFrame) + return frame + + def makeFfmpegFilter(self, preview=False, startPt=0): + w, h = scale(self.scale, self.width, self.height, str) + if self.amplitude == 0: + amplitude = 'lin' + elif self.amplitude == 1: + amplitude = 'log' + elif self.amplitude == 2: + amplitude = 'sqrt' + elif self.amplitude == 3: + amplitude = 'cbrt' + hexcolor = QColor(*self.color).name() + opacity = "{0:.1f}".format(self.opacity / 100) + genericPreview = self.settings.value("pref_genericPreview") + if self.mode < 3: + filter_ = ( + 'showwaves=' + 'r=%s:s=%sx%s:mode=%s:colors=%s@%s:scale=%s' % ( + self.settings.value("outputFrameRate"), + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + self.page.comboBox_mode.currentText().lower() + if self.mode != 3 else 'p2p', + hexcolor, opacity, amplitude, + ) + ) + elif self.mode > 2: + filter_ = ( + 'showfreqs=s=%sx%s:mode=%s:colors=%s@%s' + ':ascale=%s:fscale=%s' % ( + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + 'line' if self.mode == 4 else 'bar', + hexcolor, opacity, amplitude, + 'log' if self.mono else 'lin' + ) + ) + + baselineHeight = int(self.height * (4 / 1080)) + return [ + '-filter_complex', + '%s%s%s' + '%s%s%s [v1]; ' + '[v1] scale=%s:%s%s [v]' % ( + exampleSound('wave', extra='') + if preview and genericPreview else '[0:a] ', + 'compand=gain=4,' if self.compress else '', + 'aformat=channel_layouts=mono,' + if self.mono and self.mode < 3 else '', + filter_, + ', drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=%s:color=%s@%s' % ( + baselineHeight, hexcolor, opacity, + ) if self.mode < 2 else '', + ', hflip' if self.mirror else'', + w, h, + ', trim=duration=%s' % "{0:.3f}".format(startPt + 3) + if preview else '', + ), + '-map', '[v]', + ] + + def updateChunksize(self): + width, height = scale(self.scale, self.width, self.height, int) + self.chunkSize = 4 * width * height + + def finalizeFrame(self, imageData): + try: + image = Image.frombytes( + 'RGBA', + scale(self.scale, self.width, self.height, int), + imageData + ) + self._image = image + except ValueError: + image = self._image + if self.scale != 100 \ + or self.x != 0 or self.y != 0: + frame = BlankFrame(self.width, self.height) + frame.paste(image, box=(self.x, self.y)) + else: + frame = image + return frame diff --git a/src/components/waveform.ui b/src/components/waveform.ui new file mode 100644 index 0000000..5473f33 --- /dev/null +++ b/src/components/waveform.ui @@ -0,0 +1,383 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>197</height> + </size> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Mode</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_mode"> + <item> + <property name="text"> + <string>Cline</string> + </property> + </item> + <item> + <property name="text"> + <string>Line</string> + </property> + </item> + <item> + <property name="text"> + <string>Point</string> + </property> + </item> + <item> + <property name="text"> + <string>Frequency Bar</string> + </property> + </item> + <item> + <property name="text"> + <string>Frequency Line</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_x"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Color</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_color"> + <property name="inputMethodHints"> + <set>Qt::ImhNone</set> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_color"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="default"> + <bool>false</bool> + </property> + <property name="flat"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_4"> + <property name="text"> + <string>Opacity</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_opacity"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>0</number> + </property> + <property name="maximum"> + <number>100</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Scale</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>400</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <item> + <widget class="QCheckBox" name="checkBox_compress"> + <property name="text"> + <string>Compress</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_mono"> + <property name="text"> + <string>Mono</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_mirror"> + <property name="text"> + <string>Mirror</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="text"> + <string>Amplitude</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_amplitude"> + <item> + <property name="text"> + <string>Linear</string> + </property> + </item> + <item> + <property name="text"> + <string>Logarithmic</string> + </property> + </item> + <item> + <property name="text"> + <string>Square root</string> + </property> + </item> + <item> + <property name="text"> + <string>Cubic root</string> + </property> + </item> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/core.py b/src/core.py new file mode 100644 index 0000000..77b0894 --- /dev/null +++ b/src/core.py @@ -0,0 +1,614 @@ +''' + Home to the Core class which tracks program state. Used by GUI & commandline + to create a list of components and create a video thread to export. +''' +from PyQt5 import QtCore, QtGui, uic +import sys +import os +import json +from importlib import import_module +import logging + +from . import toolkit + + +log = logging.getLogger('AVP.Core') +STDOUT_LOGLVL = logging.WARNING +FILE_LIBLOGLVL = logging.WARNING +FILE_LOGLVL = logging.INFO + + +class Core: + ''' + MainWindow and Command module both use an instance of this class + to store the core program state. This object tracks the components, + talks to the components, handles opening/creating project files + and presets, and creates the video thread to export. + This class also stores constants as class variables. + ''' + + def __init__(self): + self.importComponents() + self.selectedComponents = [] + self.savedPresets = {} # copies of presets to detect modification + self.openingProject = False + + def __repr__(self): + return "\n=~=~=~=\n".join( + [repr(comp) for comp in self.selectedComponents] + ) + + def importComponents(self): + def findComponents(): + for f in os.listdir(Core.componentsPath): + name, ext = os.path.splitext(f) + if name.startswith("__"): + continue + elif ext == '.py': + yield name + log.debug('Importing component modules') + self.modules = [ + import_module('.components.%s' % name, __package__) + for name in findComponents() + ] + # store canonical module names and indexes + self.moduleIndexes = [i for i in range(len(self.modules))] + self.compNames = [mod.Component.name for mod in self.modules] + # alphabetize modules by Component name + sortedModules = sorted(zip(self.compNames, self.modules)) + self.compNames = [y[0] for y in sortedModules] + self.modules = [y[1] for y in sortedModules] + + # store alternative names for modules + self.altCompNames = [] + for i, mod in enumerate(self.modules): + if hasattr(mod.Component, 'names'): + for name in mod.Component.names(): + self.altCompNames.append((name, i)) + + def componentListChanged(self): + for i, component in enumerate(self.selectedComponents): + component.compPos = i + + def insertComponent(self, compPos, component, loader): + ''' + Creates a new component using these args: + (compPos, component obj or moduleIndex, MWindow/Command/Core obj) + ''' + if compPos < 0 or compPos > len(self.selectedComponents): + compPos = len(self.selectedComponents) + if len(self.selectedComponents) > 50: + return -1 + if type(component) is int: + # create component using module index in self.modules + moduleIndex = int(component) + log.debug( + 'Creating new component from module #%s', str(moduleIndex)) + component = self.modules[moduleIndex].Component( + moduleIndex, compPos, self + ) + component.widget(loader) + else: + moduleIndex = -1 + log.debug( + 'Inserting previously-created %s component', component.name) + + component._error.connect( + loader.videoThreadError + ) + self.selectedComponents.insert( + compPos, + component + ) + if hasattr(loader, 'insertComponent'): + loader.insertComponent(compPos) + + self.componentListChanged() + self.updateComponent(compPos) + return compPos + + def moveComponent(self, startI, endI): + comp = self.selectedComponents.pop(startI) + self.selectedComponents.insert(endI, comp) + + self.componentListChanged() + return endI + + def removeComponent(self, i): + self.selectedComponents.pop(i) + self.componentListChanged() + + def clearComponents(self): + self.selectedComponents = list() + self.componentListChanged() + + def updateComponent(self, i): + log.debug( + 'Auto-updating %s #%s', + self.selectedComponents[i], str(i)) + self.selectedComponents[i].update(auto=True) + + def moduleIndexFor(self, compName): + try: + index = self.compNames.index(compName) + return self.moduleIndexes[index] + except ValueError: + for altName, modI in self.altCompNames: + if altName == compName: + return self.moduleIndexes[modI] + + def clearPreset(self, compIndex): + self.selectedComponents[compIndex].currentPreset = None + + def openPreset(self, filepath, compIndex, presetName): + '''Applies a preset to a specific component''' + saveValueStore = self.getPreset(filepath) + if not saveValueStore: + return False + comp = self.selectedComponents[compIndex] + comp.loadPreset( + saveValueStore, + presetName + ) + + self.savedPresets[presetName] = dict(saveValueStore) + return True + + def getPreset(self, filepath): + '''Returns the preset dict stored at this filepath''' + if not os.path.exists(filepath): + return False + with open(filepath, 'r') as f: + for line in f: + saveValueStore = toolkit.presetFromString(line.strip()) + break + return saveValueStore + + def getPresetDir(self, comp): + '''Get the preset subdir for a particular version of a component''' + return os.path.join(Core.presetDir, comp.name, str(comp.version)) + + def openProject(self, loader, filepath): + ''' loader is the object calling this method which must have + its own showMessage(**kwargs) method for displaying errors. + ''' + if not os.path.exists(filepath): + loader.showMessage(msg='Project file not found.') + return + + errcode, data = self.parseAvFile(filepath) + if errcode == 0: + self.openingProject = True + try: + if hasattr(loader, 'window'): + for widget, value in data['WindowFields']: + widget = eval('loader.%s' % widget) + with toolkit.blockSignals(widget): + toolkit.setWidgetValue(widget, value) + + for key, value in data['Settings']: + Core.settings.setValue(key, value) + for tup in data['Components']: + name, vers, preset = tup + clearThis = False + modified = False + + # add loaded named presets to savedPresets dict + if 'preset' in preset and preset['preset'] is not None: + nam = preset['preset'] + filepath2 = os.path.join( + Core.presetDir, name, str(vers), nam) + origSaveValueStore = self.getPreset(filepath2) + if origSaveValueStore: + self.savedPresets[nam] = dict(origSaveValueStore) + modified = not origSaveValueStore == preset + else: + # saved preset was renamed or deleted + clearThis = True + + # create the actual component object & get its index + i = self.insertComponent( + -1, + self.moduleIndexFor(name), + loader + ) + if i == -1: + loader.showMessage(msg="Too many components!") + break + + try: + if 'preset' in preset and preset['preset'] is not None: + self.selectedComponents[i].loadPreset( + preset + ) + else: + self.selectedComponents[i].loadPreset( + preset, + preset['preset'] + ) + except KeyError as e: + log.warning('%s missing value: %s' % ( + self.selectedComponents[i], e) + ) + + if clearThis: + self.clearPreset(i) + if hasattr(loader, 'updateComponentTitle'): + loader.updateComponentTitle(i, modified) + self.openingProject = False + return True + except Exception: + errcode = 1 + data = sys.exc_info() + + if errcode == 1: + typ, value, tb = data + if typ.__name__ == 'KeyError': + # probably just an old version, still loadable + log.warning('Project file missing value: %s' % value) + return + if hasattr(loader, 'createNewProject'): + loader.createNewProject(prompt=False) + msg = '%s: %s\n\n' % (typ.__name__, value) + msg += toolkit.formatTraceback(tb) + loader.showMessage( + msg="Project file '%s' is corrupted." % filepath, + showCancel=False, + icon='Warning', + detail=msg) + self.openingProject = False + return False + + def parseAvFile(self, filepath): + ''' + Parses an avp (project) or avl (preset package) file. + Returns dictionary with section names as the keys, each one + contains a list of tuples: (compName, version, compPresetDict) + ''' + log.debug('Parsing av file: %s', filepath) + validSections = ( + 'Components', + 'Settings', + 'WindowFields' + ) + data = {sect: [] for sect in validSections} + try: + with open(filepath, 'r') as f: + def parseLine(line): + '''Decides if a file line is a section header''' + line = line.strip() + newSection = '' + + if line.startswith('[') and line.endswith(']') \ + and line[1:-1] in validSections: + newSection = line[1:-1] + + return line, newSection + + section = '' + i = 0 + for line in f: + line, newSection = parseLine(line) + if newSection: + section = str(newSection) + continue + if line and section == 'Components': + if i == 0: + lastCompName = str(line) + i += 1 + elif i == 1: + lastCompVers = str(line) + i += 1 + elif i == 2: + lastCompPreset = toolkit.presetFromString(line) + data[section].append(( + lastCompName, + lastCompVers, + lastCompPreset + )) + i = 0 + elif line and section: + key, value = line.split('=', 1) + data[section].append((key, value.strip())) + + return 0, data + except Exception: + return 1, sys.exc_info() + + def importPreset(self, filepath): + errcode, data = self.parseAvFile(filepath) + returnList = [] + if errcode == 0: + name, vers, preset = data['Components'][0] + presetName = preset['preset'] \ + if preset['preset'] else os.path.basename(filepath)[:-4] + newPath = os.path.join( + Core.presetDir, + name, + vers, + presetName + ) + if os.path.exists(newPath): + return False, newPath + preset['preset'] = presetName + self.createPresetFile( + name, vers, presetName, preset + ) + return True, presetName + elif errcode == 1: + # TODO: an error message + return False, '' + + def exportPreset(self, exportPath, compName, vers, origName): + internalPath = os.path.join( + Core.presetDir, compName, str(vers), origName + ) + if not os.path.exists(internalPath): + return + if os.path.exists(exportPath): + os.remove(exportPath) + with open(internalPath, 'r') as f: + internalData = [line for line in f] + try: + saveValueStore = toolkit.presetFromString(internalData[0].strip()) + self.createPresetFile( + compName, vers, + origName, saveValueStore, + exportPath + ) + return True + except Exception: + return False + + def createPresetFile( + self, compName, vers, presetName, saveValueStore, filepath=''): + '''Create a preset file (.avl) at filepath using args. + Or if filepath is empty, create an internal preset using args''' + if not filepath: + dirname = os.path.join(Core.presetDir, compName, str(vers)) + if not os.path.exists(dirname): + os.makedirs(dirname) + filepath = os.path.join(dirname, presetName) + internal = True + else: + if not filepath.endswith('.avl'): + filepath += '.avl' + internal = False + + with open(filepath, 'w') as f: + if not internal: + f.write('[Components]\n') + f.write('%s\n' % compName) + f.write('%s\n' % str(vers)) + f.write(toolkit.presetToString(saveValueStore)) + + def createProjectFile(self, filepath, window=None): + '''Create a project file (.avp) using the current program state''' + log.info('Creating %s', filepath) + settingsKeys = [ + 'componentDir', + 'inputDir', + 'outputDir', + 'presetDir', + 'projectDir', + ] + try: + if not filepath.endswith(".avp"): + filepath += '.avp' + if os.path.exists(filepath): + os.remove(filepath) + + with open(filepath, 'w') as f: + f.write('[Components]\n') + for comp in self.selectedComponents: + saveValueStore = comp.savePreset() + saveValueStore['preset'] = comp.currentPreset + f.write('%s\n' % str(comp)) + f.write('%s\n' % str(comp.version)) + f.write('%s\n' % toolkit.presetToString(saveValueStore)) + + f.write('\n[Settings]\n') + for key in Core.settings.allKeys(): + if key in settingsKeys: + f.write('%s=%s\n' % (key, Core.settings.value(key))) + + if window: + f.write('\n[WindowFields]\n') + f.write( + 'lineEdit_audioFile=%s\n' + 'lineEdit_outputFile=%s\n' % ( + window.lineEdit_audioFile.text(), + window.lineEdit_outputFile.text() + ) + ) + return True + except Exception: + return False + + def newVideoWorker(self, loader, audioFile, outputPath): + '''loader is MainWindow or Command object which must own the thread''' + from . import video_thread + self.videoThread = QtCore.QThread(loader) + videoWorker = video_thread.Worker( + loader, audioFile, outputPath, self.selectedComponents + ) + videoWorker.moveToThread(self.videoThread) + videoWorker.videoCreated.connect(self.stopVideoThread) + + self.videoThread.start() + return videoWorker + + def stopVideoThread(self): + self.videoThread.quit() + self.videoThread.wait() + + def cancel(self): + Core.canceled = True + + def reset(self): + Core.canceled = False + + @classmethod + def storeSettings(cls): + '''Store settings/paths to directories as class variables''' + from .__init__ import wd + from .toolkit.ffmpeg import findFfmpeg + + cls.wd = wd + dataDir = QtCore.QStandardPaths.writableLocation( + QtCore.QStandardPaths.AppConfigLocation + ) + # Windows: C:/Users/<USER>/AppData/Local/audio-visualizer + # macOS: ~/Library/Preferences/audio-visualizer + # Linux: ~/.config/audio-visualizer + with open(os.path.join(wd, 'encoder-options.json')) as json_file: + encoderOptions = json.load(json_file) + + # Locate FFmpeg + ffmpegBin = findFfmpeg() + if not ffmpegBin: + print("Could not find FFmpeg") + + settings = { + 'canceled': False, + 'FFMPEG_BIN': ffmpegBin, + 'dataDir': dataDir, + 'settings': QtCore.QSettings( + os.path.join(dataDir, 'settings.ini'), + QtCore.QSettings.IniFormat), + 'presetDir': os.path.join(dataDir, 'presets'), + 'componentsPath': os.path.join(wd, 'components'), + 'junkStream': os.path.join(wd, 'gui', 'background.png'), + 'encoderOptions': encoderOptions, + 'resolutions': [ + '1920x1080', + '1280x720', + '854x480', + ], + 'logDir': os.path.join(dataDir, 'log'), + 'logEnabled': False, + } + + settings['videoFormats'] = toolkit.appendUppercase([ + '*.mp4', + '*.mov', + '*.mkv', + '*.avi', + '*.webm', + '*.flv', + ]) + settings['audioFormats'] = toolkit.appendUppercase([ + '*.mp3', + '*.wav', + '*.ogg', + '*.fla', + '*.flac', + '*.aac', + ]) + settings['imageFormats'] = toolkit.appendUppercase([ + '*.png', + '*.jpg', + '*.tif', + '*.tiff', + '*.gif', + '*.bmp', + '*.ico', + '*.xbm', + '*.xpm', + ]) + + # Register all settings as class variables + for classvar, val in settings.items(): + setattr(cls, classvar, val) + + cls.loadDefaultSettings() + if not os.path.exists(cls.dataDir): + os.makedirs(cls.dataDir) + for neededDirectory in ( + cls.presetDir, cls.logDir, cls.settings.value("projectDir")): + if not os.path.exists(neededDirectory): + os.mkdir(neededDirectory) + cls.makeLogger(deleteOldLogs=True) + + @classmethod + def loadDefaultSettings(cls): + # settings that get saved into the ini file + cls.defaultSettings = { + "outputWidth": 1280, + "outputHeight": 720, + "outputFrameRate": 30, + "outputAudioCodec": "AAC", + "outputAudioBitrate": "192", + "outputVideoCodec": "H264", + "outputVideoBitrate": "2500", + "outputVideoFormat": "yuv420p", + "outputPreset": "medium", + "outputFormat": "mp4", + "outputContainer": "MP4", + "projectDir": os.path.join(cls.dataDir, 'projects'), + "pref_insertCompAtTop": True, + "pref_genericPreview": True, + "pref_undoLimit": 10, + } + + for parm, value in cls.defaultSettings.items(): + if cls.settings.value(parm) is None: + cls.settings.setValue(parm, value) + + # Allow manual editing of prefs. (Surprisingly necessary as Qt seems to + # store True as 'true' but interprets a manually-added 'true' as str.) + for key in cls.settings.allKeys(): + if not key.startswith('pref_'): + continue + val = cls.settings.value(key) + try: + val = int(val) + except ValueError: + if val == 'true': + val = True + elif val == 'false': + val = False + cls.settings.setValue(key, val) + + @staticmethod + def makeLogger(deleteOldLogs=False): + # send critical log messages to stdout + logStream = logging.StreamHandler() + logStream.setLevel(STDOUT_LOGLVL) + streamFormatter = logging.Formatter( + '<%(name)s> %(levelname)s: %(message)s' + ) + logStream.setFormatter(streamFormatter) + log = logging.getLogger('AVP') + log.addHandler(logStream) + + if FILE_LOGLVL is not None: + # write log files as well! + Core.logEnabled = True + logFilename = os.path.join(Core.logDir, 'avp_debug.log') + libLogFilename = os.path.join(Core.logDir, 'global_debug.log') + + if deleteOldLogs: + for log_ in (logFilename, libLogFilename): + if os.path.exists(log_): + os.remove(log_) + + logFile = logging.FileHandler(logFilename, delay=True) + logFile.setLevel(FILE_LOGLVL) + libLogFile = logging.FileHandler(libLogFilename, delay=True) + libLogFile.setLevel(FILE_LIBLOGLVL) + fileFormatter = logging.Formatter( + '[%(asctime)s] %(threadName)-10.10s %(name)-23.23s %(levelname)s: ' + '%(message)s' + ) + logFile.setFormatter(fileFormatter) + libLogFile.setFormatter(fileFormatter) + + libLog = logging.getLogger() + log.addHandler(logFile) + libLog.addHandler(libLogFile) + # lowest level must be explicitly set on the root Logger + libLog.setLevel(0) + +# always store settings in class variables even if a Core object is not created +Core.storeSettings() diff --git a/src/encoder-options.json b/src/encoder-options.json new file mode 100644 index 0000000..78bc940 --- /dev/null +++ b/src/encoder-options.json @@ -0,0 +1,130 @@ +{ + "containers":[ + { + "name": "MP4", + "container": "mp4", + "default-vcodec": "H264", + "default-acodec": "AAC", + "video-codecs": [ + "H264", + "H264 (nvenc)", + "MPEG4" + ], + "audio-codecs": [ + "AAC", + "AC3", + "MP3" + ] + }, + { + "name": "MOV", + "container": "mov", + "default-vcodec": "H264", + "default-acodec": "AAC", + "video-codecs": [ + "H264", + "H264 (nvenc)", + "MPEG4", + "XVID" + ], + "audio-codecs": [ + "AAC", + "AC3", + "MP3", + "PCM s16 LE" + ] + }, + { + "name": "MKV", + "container": "matroska", + "default-vcodec": "H264", + "default-acodec": "AAC", + "video-codecs": [ + "H264", + "H264 (nvenc)", + "MPEG4", + "MPEG2", + "DV", + "WMV" + ], + "audio-codecs": [ + "AAC", + "AC3", + "MP3", + "PCM s16 LE", + "WMA" + ] + }, + { + "name": "AVI", + "container": "avi", + "default-vcodec": "H264", + "default-acodec": "AAC", + "video-codecs": [ + "H264", + "H264 (nvenc)", + "MPEG4", + "MPEG2", + "DV", + "WMV" + ], + "audio-codecs": [ + "AAC", + "AC3", + "MP3", + "PCM s16 LE", + "WMA" + ] + }, + { + "name": "WEBM", + "container": "webm", + "default-vcodec": "VP9", + "default-acodec": "Vorbis", + "video-codecs": [ + "VP9", + "VP8" + ], + "audio-codecs": [ + "Vorbis" + ] + }, + { + "name": "FLV", + "container": "flv", + "default-vcodec": "FLV", + "default-acodec": "Vorbis", + "video-codecs": [ + "Sorenson (flv)", + "H264", + "H264 (nvenc)", + "MPEG4" + ], + "audio-codecs": [ + "MP3", + "PCM s16 LE", + "Vorbis" + ] + } + ], + "video-codecs":{ + "H264": ["libx264"], + "H264 (nvenc)": ["h264_nvenc", "nvenc_h264"], + "MPEG4": ["mpeg4"], + "VP9": ["libvpx-vp9"], + "VP8": ["libvpx"], + "XVID": ["libxvid"], + "Sorenson (flv)": ["flv"], + "MPEG2": ["mp2video"], + "DV": ["dvvideo"], + "WMV": ["wmv2"] + }, + "audio-codecs": { + "AAC": ["libfdk_aac", "aac"], + "AC3": ["ac3"], + "MP3": ["libmp3lame"], + "PCM s16 LE": ["pcm_s16le"], + "WMA": ["wmav2"], + "Vorbis": ["libvorbis"] + } +}
\ No newline at end of file diff --git a/src/gui/__init__.py b/src/gui/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/gui/__init__.py diff --git a/src/gui/actions.py b/src/gui/actions.py new file mode 100644 index 0000000..afb980a --- /dev/null +++ b/src/gui/actions.py @@ -0,0 +1,191 @@ +''' + QCommand classes for every undoable user action performed in the MainWindow +''' +from PyQt5.QtWidgets import QUndoCommand +import os +from copy import copy + +from ..core import Core + + +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# COMPONENT ACTIONS +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + +class AddComponent(QUndoCommand): + def __init__(self, parent, compI, moduleI): + super().__init__( + "create new %s component" % + parent.core.modules[moduleI].Component.name + ) + self.parent = parent + self.moduleI = moduleI + self.compI = compI + self.comp = None + + def redo(self): + if self.comp is None: + self.parent.core.insertComponent( + self.compI, self.moduleI, self.parent) + else: + # inserting previously-created component + self.parent.core.insertComponent( + self.compI, self.comp, self.parent) + + def undo(self): + self.comp = self.parent.core.selectedComponents[self.compI] + self.parent._removeComponent(self.compI) + + +class RemoveComponent(QUndoCommand): + def __init__(self, parent, selectedRows): + super().__init__('remove component') + self.parent = parent + componentList = self.parent.listWidget_componentList + self.selectedRows = [ + componentList.row(selected) for selected in selectedRows + ] + self.components = [ + parent.core.selectedComponents[i] for i in self.selectedRows + ] + + def redo(self): + self.parent._removeComponent(self.selectedRows[0]) + + def undo(self): + componentList = self.parent.listWidget_componentList + for index, comp in zip(self.selectedRows, self.components): + self.parent.core.insertComponent( + index, comp, self.parent + ) + self.parent.drawPreview() + + +class MoveComponent(QUndoCommand): + def __init__(self, parent, row, newRow, tag): + super().__init__("move component %s" % tag) + self.parent = parent + self.row = row + self.newRow = newRow + self.id_ = ord(tag[0]) + + def id(self): + '''If 2 consecutive updates have same id, Qt will call mergeWith()''' + return self.id_ + + def mergeWith(self, other): + self.newRow = other.newRow + return True + + def do(self, rowa, rowb): + componentList = self.parent.listWidget_componentList + + page = self.parent.pages.pop(rowa) + self.parent.pages.insert(rowb, page) + + item = componentList.takeItem(rowa) + componentList.insertItem(rowb, item) + + stackedWidget = self.parent.stackedWidget + widget = stackedWidget.removeWidget(page) + stackedWidget.insertWidget(rowb, page) + componentList.setCurrentRow(rowb) + stackedWidget.setCurrentIndex(rowb) + self.parent.core.moveComponent(rowa, rowb) + self.parent.drawPreview(True) + + def redo(self): + self.do(self.row, self.newRow) + + def undo(self): + self.do(self.newRow, self.row) + + +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# PRESET ACTIONS +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + +class ClearPreset(QUndoCommand): + def __init__(self, parent, compI): + super().__init__("clear preset") + self.parent = parent + self.compI = compI + self.component = self.parent.core.selectedComponents[compI] + self.store = self.component.savePreset() + self.store['preset'] = self.component.currentPreset + + def redo(self): + self.parent.core.clearPreset(self.compI) + self.parent.updateComponentTitle(self.compI, False) + + def undo(self): + self.parent.core.selectedComponents[self.compI].loadPreset(self.store) + self.parent.updateComponentTitle(self.compI, self.store) + + +class OpenPreset(QUndoCommand): + def __init__(self, parent, presetName, compI): + super().__init__("open %s preset" % presetName) + self.parent = parent + self.presetName = presetName + self.compI = compI + + comp = self.parent.core.selectedComponents[compI] + self.store = comp.savePreset() + self.store['preset'] = copy(comp.currentPreset) + + def redo(self): + self.parent._openPreset(self.presetName, self.compI) + + def undo(self): + self.parent.core.selectedComponents[self.compI].loadPreset( + self.store) + self.parent.parent.updateComponentTitle(self.compI, self.store) + + +class RenamePreset(QUndoCommand): + def __init__(self, parent, path, oldName, newName): + super().__init__('rename preset') + self.parent = parent + self.path = path + self.oldName = oldName + self.newName = newName + + def redo(self): + self.parent.renamePreset(self.path, self.oldName, self.newName) + + def undo(self): + self.parent.renamePreset(self.path, self.newName, self.oldName) + + +class DeletePreset(QUndoCommand): + def __init__(self, parent, compName, vers, presetFile): + self.parent = parent + self.preset = (compName, vers, presetFile) + self.path = os.path.join( + Core.presetDir, compName, str(vers), presetFile + ) + self.store = self.parent.core.getPreset(self.path) + self.presetName = self.store['preset'] + super().__init__('delete %s preset (%s)' % (self.presetName, compName)) + self.loadedPresets = [ + i for i, comp in enumerate(self.parent.core.selectedComponents) + if self.presetName == str(comp.currentPreset) + ] + + def redo(self): + os.remove(self.path) + for i in self.loadedPresets: + self.parent.core.clearPreset(i) + self.parent.parent.updateComponentTitle(i, False) + self.parent.findPresets() + self.parent.drawPresetList() + + def undo(self): + self.parent.createNewPreset(*self.preset, self.store) + selectedComponents = self.parent.core.selectedComponents + for i in self.loadedPresets: + selectedComponents[i].currentPreset = self.presetName + self.parent.parent.updateComponentTitle(i) + self.parent.findPresets() + self.parent.drawPresetList() diff --git a/src/gui/background.png b/src/gui/background.png Binary files differnew file mode 100644 index 0000000..fb58593 --- /dev/null +++ b/src/gui/background.png diff --git a/src/gui/mainwindow.py b/src/gui/mainwindow.py new file mode 100644 index 0000000..f6de763 --- /dev/null +++ b/src/gui/mainwindow.py @@ -0,0 +1,1057 @@ +''' + When using GUI mode, this module's object (the main window) takes + user input to construct a program state (stored in the Core object). + This shows a preview of the video being created and allows for saving + projects and exporting the video at a later time. +''' +from PyQt5 import QtCore, QtGui, QtWidgets, uic +import PyQt5.QtWidgets as QtWidgets +from PIL import Image +from queue import Queue +import sys +import os +import signal +import filecmp +import time +import logging + +from ..core import Core +from . import preview_thread +from .preview_win import PreviewWindow +from .presetmanager import PresetManager +from .actions import * +from ..toolkit import ( + disableWhenEncoding, disableWhenOpeningProject, checkOutput, blockSignals +) + + +log = logging.getLogger('AVP.Gui.MainWindow') + + +class MainWindow(QtWidgets.QMainWindow): + ''' + The MainWindow wraps many Core methods in order to update the GUI + accordingly. E.g., instead of self.core.openProject(), it will use + self.openProject() and update the window titlebar within the wrapper. + + MainWindow manages the autosave feature, although Core has the + primary functions for opening and creating project files. + ''' + + createVideo = QtCore.pyqtSignal() + newTask = QtCore.pyqtSignal(list) # for the preview window + processTask = QtCore.pyqtSignal() + + def __init__(self, project): + super().__init__() + log.debug( + 'Main thread id: {}'.format(int(QtCore.QThread.currentThreadId()))) + uic.loadUi(os.path.join(Core.wd, "gui", "mainwindow.ui"), self) + desk = QtWidgets.QDesktopWidget() + dpi = desk.physicalDpiX() + log.info("Detected screen DPI: %s", dpi) + + self.resize( + int(self.width() * + (dpi / 96)), + int(self.height() * + (dpi / 96)) + ) + + self.core = Core() + Core.mode = 'GUI' + # widgets of component settings + self.pages = [] + self.lastAutosave = time.time() + # list of previous five autosave times, used to reduce update spam + self.autosaveTimes = [] + self.autosaveCooldown = 0.2 + self.encoding = False + + # Find settings created by Core object + self.dataDir = Core.dataDir + self.presetDir = Core.presetDir + self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') + self.settings = Core.settings + + # Create stack of undoable user actions + self.undoStack = QtWidgets.QUndoStack(self) + undoLimit = self.settings.value("pref_undoLimit") + self.undoStack.setUndoLimit(undoLimit) + + # Create Preset Manager + self.presetManager = PresetManager(self) + + # Create the preview window and its thread, queues, and timers + log.debug('Creating preview window') + self.previewWindow = PreviewWindow(self, os.path.join( + Core.wd, 'gui', "background.png")) + self.verticalLayout_previewWrapper.addWidget(self.previewWindow) + + log.debug('Starting preview thread') + self.previewQueue = Queue() + self.previewThread = QtCore.QThread(self) + self.previewWorker = preview_thread.Worker( + self.core, + self.settings, + self.previewQueue + ) + self.previewWorker.moveToThread(self.previewThread) + self.newTask.connect(self.previewWorker.createPreviewImage) + self.processTask.connect(self.previewWorker.process) + self.previewWorker.error.connect(self.previewWindow.threadError) + self.previewWorker.imageCreated.connect(self.showPreviewImage) + self.previewThread.start() + self.previewThread.finished.connect(lambda: log.info('Preview thread finished.')) + + timeout = 500 + log.debug( + 'Preview timer set to trigger when idle for %sms' % str(timeout) + ) + self.timer = QtCore.QTimer(self) + self.timer.timeout.connect(self.processTask.emit) + self.timer.start(timeout) + + # Begin decorating the window and connecting events + componentList = self.listWidget_componentList + + # Undo Feature + def toggleUndoButtonEnabled(*_): + """ Enable/disable undo button depending on whether UndoStack contains Actions """ + try: + undoButton.setEnabled(self.undoStack.count()) + except RuntimeError: + # program is probably in midst of exiting + pass + + style = self.pushButton_undo.style() + undoButton = self.pushButton_undo + undoButton.setIcon( + style.standardIcon(QtWidgets.QStyle.SP_FileDialogBack) + ) + undoButton.clicked.connect(self.undoStack.undo) + undoButton.setEnabled(False) + self.undoStack.cleanChanged.connect(toggleUndoButtonEnabled) + self.undoMenu = QtWidgets.QMenu() + self.undoMenu.addAction( + self.undoStack.createUndoAction(self) + ) + self.undoMenu.addAction( + self.undoStack.createRedoAction(self) + ) + action = self.undoMenu.addAction('Show History...') + action.triggered.connect( + lambda _: self.showUndoStack() + ) + undoButton.setMenu(self.undoMenu) + # end of Undo Feature + + style = self.pushButton_listMoveUp.style() + self.pushButton_listMoveUp.setIcon( + style.standardIcon(QtWidgets.QStyle.SP_ArrowUp) + ) + style = self.pushButton_listMoveDown.style() + self.pushButton_listMoveDown.setIcon( + style.standardIcon(QtWidgets.QStyle.SP_ArrowDown) + ) + style = self.pushButton_removeComponent.style() + self.pushButton_removeComponent.setIcon( + style.standardIcon(QtWidgets.QStyle.SP_DialogDiscardButton) + ) + + if sys.platform == 'darwin': + log.debug( + 'Darwin detected: showing progress label below progress bar') + self.progressBar_createVideo.setTextVisible(False) + else: + self.progressLabel.setHidden(True) + + self.toolButton_selectAudioFile.clicked.connect( + self.openInputFileDialog) + + self.toolButton_selectOutputFile.clicked.connect( + self.openOutputFileDialog) + + def changedField(): + self.autosave() + self.updateWindowTitle() + + self.lineEdit_audioFile.textChanged.connect(changedField) + self.lineEdit_outputFile.textChanged.connect(changedField) + + self.progressBar_createVideo.setValue(0) + + self.pushButton_createVideo.clicked.connect( + self.createAudioVisualisation) + + self.pushButton_Cancel.clicked.connect(self.stopVideo) + + for i, container in enumerate(Core.encoderOptions['containers']): + self.comboBox_videoContainer.addItem(container['name']) + if container['name'] == self.settings.value('outputContainer'): + selectedContainer = i + + self.comboBox_videoContainer.setCurrentIndex(selectedContainer) + self.comboBox_videoContainer.currentIndexChanged.connect( + self.updateCodecs + ) + + self.updateCodecs() + + for i in range(self.comboBox_videoCodec.count()): + codec = self.comboBox_videoCodec.itemText(i) + if codec == self.settings.value('outputVideoCodec'): + self.comboBox_videoCodec.setCurrentIndex(i) + + for i in range(self.comboBox_audioCodec.count()): + codec = self.comboBox_audioCodec.itemText(i) + if codec == self.settings.value('outputAudioCodec'): + self.comboBox_audioCodec.setCurrentIndex(i) + + self.comboBox_videoCodec.currentIndexChanged.connect( + self.updateCodecSettings + ) + + self.comboBox_audioCodec.currentIndexChanged.connect( + self.updateCodecSettings + ) + + vBitrate = int(self.settings.value('outputVideoBitrate')) + aBitrate = int(self.settings.value('outputAudioBitrate')) + + self.spinBox_vBitrate.setValue(vBitrate) + self.spinBox_aBitrate.setValue(aBitrate) + self.spinBox_vBitrate.valueChanged.connect(self.updateCodecSettings) + self.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings) + + # Make component buttons + self.compMenu = QtWidgets.QMenu() + for i, comp in enumerate(self.core.modules): + action = self.compMenu.addAction(comp.Component.name) + action.triggered.connect( + lambda _, item=i: self.addComponent(0, item) + ) + + self.pushButton_addComponent.setMenu(self.compMenu) + + componentList.dropEvent = self.dragComponent + componentList.itemSelectionChanged.connect( + self.changeComponentWidget + ) + componentList.itemSelectionChanged.connect( + self.presetManager.clearPresetListSelection + ) + self.pushButton_removeComponent.clicked.connect( + lambda: self.removeComponent() + ) + + componentList.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + componentList.customContextMenuRequested.connect( + self.componentContextMenu + ) + + currentRes = str(self.settings.value('outputWidth'))+'x' + \ + str(self.settings.value('outputHeight')) + for i, res in enumerate(Core.resolutions): + self.comboBox_resolution.addItem(res) + if res == currentRes: + currentRes = i + self.comboBox_resolution.setCurrentIndex(currentRes) + self.comboBox_resolution.currentIndexChanged.connect( + self.updateResolution + ) + + self.pushButton_listMoveUp.clicked.connect( + lambda: self.moveComponent(-1) + ) + self.pushButton_listMoveDown.clicked.connect( + lambda: self.moveComponent(1) + ) + + # Configure the Projects Menu + self.projectMenu = QtWidgets.QMenu() + self.menuButton_newProject = self.projectMenu.addAction( + "New Project" + ) + self.menuButton_newProject.triggered.connect( + lambda: self.createNewProject() + ) + self.menuButton_openProject = self.projectMenu.addAction( + "Open Project" + ) + self.menuButton_openProject.triggered.connect( + lambda: self.openOpenProjectDialog() + ) + + action = self.projectMenu.addAction("Save Project") + action.triggered.connect(self.saveCurrentProject) + + action = self.projectMenu.addAction("Save Project As") + action.triggered.connect(self.openSaveProjectDialog) + + self.pushButton_projects.setMenu(self.projectMenu) + + # Configure the Presets Button + self.pushButton_presets.clicked.connect( + self.openPresetManager + ) + + self.updateWindowTitle() + log.debug('Showing main window') + self.show() + + if project and project != self.autosavePath: + if not project.endswith('.avp'): + project += '.avp' + # open a project from the commandline + if not os.path.dirname(project): + project = os.path.join( + self.settings.value("projectDir"), project + ) + self.currentProject = project + self.settings.setValue("currentProject", project) + if os.path.exists(self.autosavePath): + os.remove(self.autosavePath) + else: + # open the last currentProject from settings + self.currentProject = self.settings.value("currentProject") + + # delete autosave if it's identical to this project + if self.autosaveExists(identical=True): + os.remove(self.autosavePath) + + if self.currentProject and os.path.exists(self.autosavePath): + ch = self.showMessage( + msg="Restore unsaved changes in project '%s'?" + % os.path.basename(self.currentProject)[:-4], + showCancel=True) + if ch: + self.saveProjectChanges() + else: + os.remove(self.autosavePath) + + self.openProject(self.currentProject, prompt=False) + self.drawPreview(True) + + log.info("Pillow version %s", Image.__version__) + + # verify Ffmpeg version + if not self.core.FFMPEG_BIN: + self.showMessage( + msg="FFmpeg could not be found. This is a critical error. " + "Install FFmpeg, or download it and place the program executable " + "in the same folder as this program.", + icon='Critical' + ) + else: + if not self.settings.value("ffmpegMsgShown"): + try: + with open(os.devnull, "w") as f: + ffmpegVers = checkOutput( + [self.core.FFMPEG_BIN, '-version'], stderr=f + ) + goodVersion = str(ffmpegVers).split()[2].startswith('4') + except Exception: + goodVersion = False + else: + goodVersion = True + + if not goodVersion: + self.showMessage( + msg="You're using an old version of Ffmpeg. " + "Some features may not work as expected." + ) + self.settings.setValue("ffmpegMsgShown", True) + + # Hotkeys for projects + QtWidgets.QShortcut("Ctrl+S", self, self.saveCurrentProject) + QtWidgets.QShortcut("Ctrl+A", self, self.openSaveProjectDialog) + QtWidgets.QShortcut("Ctrl+O", self, self.openOpenProjectDialog) + QtWidgets.QShortcut("Ctrl+N", self, self.createNewProject) + + # Hotkeys for undo/redo + QtWidgets.QShortcut("Ctrl+Z", self, self.undoStack.undo) + QtWidgets.QShortcut("Ctrl+Y", self, self.undoStack.redo) + QtWidgets.QShortcut("Ctrl+Shift+Z", self, self.undoStack.redo) + + # Hotkeys for component list + for inskey in ("Ctrl+T", QtCore.Qt.Key_Insert): + QtWidgets.QShortcut( + inskey, self, + activated=lambda: self.pushButton_addComponent.click() + ) + for delkey in ("Ctrl+R", QtCore.Qt.Key_Delete): + QtWidgets.QShortcut( + delkey, self.listWidget_componentList, + self.removeComponent + ) + QtWidgets.QShortcut( + "Ctrl+Space", self, + activated=lambda: self.listWidget_componentList.setFocus() + ) + QtWidgets.QShortcut( + "Ctrl+Shift+S", self, + self.presetManager.openSavePresetDialog + ) + QtWidgets.QShortcut( + "Ctrl+Shift+C", self, self.presetManager.clearPreset + ) + + QtWidgets.QShortcut( + "Ctrl+Up", self.listWidget_componentList, + activated=lambda: self.moveComponent(-1) + ) + QtWidgets.QShortcut( + "Ctrl+Down", self.listWidget_componentList, + activated=lambda: self.moveComponent(1) + ) + QtWidgets.QShortcut( + "Ctrl+Home", self.listWidget_componentList, + activated=lambda: self.moveComponent('top') + ) + QtWidgets.QShortcut( + "Ctrl+End", self.listWidget_componentList, + activated=lambda: self.moveComponent('bottom') + ) + + QtWidgets.QShortcut( + "Ctrl+Shift+F", self, self.showFfmpegCommand + ) + QtWidgets.QShortcut( + "Ctrl+Shift+U", self, self.showUndoStack + ) + + if log.isEnabledFor(logging.DEBUG): + QtWidgets.QShortcut( + "Ctrl+Alt+Shift+R", self, self.drawPreview + ) + QtWidgets.QShortcut( + "Ctrl+Alt+Shift+A", self, lambda: log.debug(repr(self)) + ) + + # Close MainWindow when receiving Ctrl+C from terminal + signal.signal(signal.SIGINT, lambda *args: self.close()) + + def __repr__(self): + return ( + '%s\n' + '\n%s\n' + '#####\n' + 'Preview thread is %s\n' % ( + super().__repr__(), + "core not initialized" if not hasattr(self, "core") else repr(self.core), + 'live' if hasattr(self, "previewThread") and self.previewThread.isRunning() else 'dead', + ) + ) + + def closeEvent(self, event): + log.info('Ending the preview thread') + self.timer.stop() + self.previewThread.quit() + self.previewThread.wait() + return super().closeEvent(event) + + @disableWhenOpeningProject + def updateWindowTitle(self): + log.debug("Setting main window's title") + appName = 'Audio Visualizer' + try: + if self.currentProject: + appName += ' - %s' % \ + os.path.splitext( + os.path.basename(self.currentProject))[0] + if self.autosaveExists(identical=False): + appName += '*' + except AttributeError: + pass + log.verbose(f'Window title is "{appName}"') + self.setWindowTitle(appName) + + @QtCore.pyqtSlot(int, dict) + def updateComponentTitle(self, pos, presetStore=False): + ''' + Sets component title to modified or unmodified when given boolean. + If given a preset dict, compares it against the component to + determine if it is modified. + A component with no preset is always unmodified. + ''' + if type(presetStore) is dict: + name = presetStore['preset'] + if name is None or name not in self.core.savedPresets: + modified = False + else: + modified = (presetStore != self.core.savedPresets[name]) + + modified = bool(presetStore) + if pos < 0: + pos = len(self.core.selectedComponents)-1 + name = self.core.selectedComponents[pos].name + title = str(name) + if self.core.selectedComponents[pos].currentPreset: + title += ' - %s' % self.core.selectedComponents[pos].currentPreset + if modified: + title += '*' + if type(presetStore) is bool: + log.debug( + 'Forcing %s #%s\'s modified status to %s: %s', + name, pos, modified, title + ) + else: + log.debug( + 'Setting %s #%s\'s title: %s', + name, pos, title + ) + self.listWidget_componentList.item(pos).setText(title) + + def updateCodecs(self): + containerWidget = self.comboBox_videoContainer + vCodecWidget = self.comboBox_videoCodec + aCodecWidget = self.comboBox_audioCodec + index = containerWidget.currentIndex() + name = containerWidget.itemText(index) + self.settings.setValue('outputContainer', name) + + vCodecWidget.clear() + aCodecWidget.clear() + + for container in Core.encoderOptions['containers']: + if container['name'] == name: + for vCodec in container['video-codecs']: + vCodecWidget.addItem(vCodec) + for aCodec in container['audio-codecs']: + aCodecWidget.addItem(aCodec) + + def updateCodecSettings(self): + '''Updates settings.ini to match encoder option widgets''' + vCodecWidget = self.comboBox_videoCodec + vBitrateWidget = self.spinBox_vBitrate + aBitrateWidget = self.spinBox_aBitrate + aCodecWidget = self.comboBox_audioCodec + currentVideoCodec = vCodecWidget.currentIndex() + currentVideoCodec = vCodecWidget.itemText(currentVideoCodec) + currentVideoBitrate = vBitrateWidget.value() + currentAudioCodec = aCodecWidget.currentIndex() + currentAudioCodec = aCodecWidget.itemText(currentAudioCodec) + currentAudioBitrate = aBitrateWidget.value() + self.settings.setValue('outputVideoCodec', currentVideoCodec) + self.settings.setValue('outputAudioCodec', currentAudioCodec) + self.settings.setValue('outputVideoBitrate', currentVideoBitrate) + self.settings.setValue('outputAudioBitrate', currentAudioBitrate) + + @disableWhenOpeningProject + def autosave(self, force=False): + if not self.currentProject: + if os.path.exists(self.autosavePath): + os.remove(self.autosavePath) + elif force or time.time() - self.lastAutosave >= self.autosaveCooldown: + self.core.createProjectFile(self.autosavePath, self) + self.lastAutosave = time.time() + if len(self.autosaveTimes) >= 5: + # Do some math to reduce autosave spam. This gives a smooth + # curve up to 5 seconds cooldown and maintains that for 30 secs + # if a component is continuously updated + timeDiff = self.lastAutosave - self.autosaveTimes.pop() + if not force and timeDiff >= 1.0 \ + and timeDiff <= 10.0: + if self.autosaveCooldown / 4.0 < 0.5: + self.autosaveCooldown += 1.0 + self.autosaveCooldown = ( + 5.0 * (self.autosaveCooldown / 5.0) + ) + (self.autosaveCooldown / 5.0) * 2 + elif force or timeDiff >= self.autosaveCooldown * 5: + self.autosaveCooldown = 0.2 + self.autosaveTimes.insert(0, self.lastAutosave) + else: + log.debug('Autosave rejected by cooldown') + + def autosaveExists(self, identical=True): + '''Determines if creating the autosave should be blocked.''' + try: + if self.currentProject and os.path.exists(self.autosavePath) \ + and filecmp.cmp( + self.autosavePath, self.currentProject) == identical: + log.debug( + 'Autosave found %s to be identical' + % 'not' if not identical else '' + ) + return True + except FileNotFoundError: + log.error( + 'Project file couldn\'t be located: %s', self.currentProject) + return identical + return False + + def saveProjectChanges(self): + '''Overwrites project file with autosave file''' + try: + os.remove(self.currentProject) + os.rename(self.autosavePath, self.currentProject) + return True + except (FileNotFoundError, IsADirectoryError) as e: + self.showMessage( + msg='Project file couldn\'t be saved.', + detail=str(e)) + return False + + def openInputFileDialog(self): + inputDir = self.settings.value("inputDir", os.path.expanduser("~")) + + fileName, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Open Audio File", + inputDir, "Audio Files (%s)" % " ".join(Core.audioFormats)) + + if fileName: + self.settings.setValue("inputDir", os.path.dirname(fileName)) + self.lineEdit_audioFile.setText(fileName) + + def openOutputFileDialog(self): + outputDir = self.settings.value("outputDir", os.path.expanduser("~")) + + fileName, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Set Output Video File", + outputDir, + "Video Files (%s);; All Files (*)" % " ".join( + Core.videoFormats)) + + if fileName: + self.settings.setValue("outputDir", os.path.dirname(fileName)) + self.lineEdit_outputFile.setText(fileName) + + def stopVideo(self): + log.info('Export cancelled') + self.videoWorker.cancel() + self.canceled = True + + def createAudioVisualisation(self): + # create output video if mandatory settings are filled in + audioFile = self.lineEdit_audioFile.text() + outputPath = self.lineEdit_outputFile.text() + + if audioFile and outputPath and self.core.selectedComponents: + if not os.path.dirname(outputPath): + outputPath = os.path.join( + os.path.expanduser("~"), outputPath) + if outputPath and os.path.isdir(outputPath): + self.showMessage( + msg='Chosen filename matches a directory, which ' + 'cannot be overwritten. Please choose a different ' + 'filename or move the directory.', + icon='Warning', + ) + return + else: + if not audioFile or not outputPath: + self.showMessage( + msg="You must select an audio file and output filename." + ) + elif not self.core.selectedComponents: + self.showMessage( + msg="Not enough components." + ) + return + + self.canceled = False + self.progressBarUpdated(-1) + self.videoWorker = self.core.newVideoWorker( + self, audioFile, outputPath + ) + self.videoWorker.progressBarUpdate.connect(self.progressBarUpdated) + self.videoWorker.progressBarSetText.connect( + self.progressBarSetText) + self.videoWorker.imageCreated.connect(self.showPreviewImage) + self.videoWorker.encoding.connect(self.changeEncodingStatus) + self.createVideo.emit() + + @QtCore.pyqtSlot(str, str) + def videoThreadError(self, msg, detail): + try: + self.stopVideo() + except AttributeError as e: + if 'videoWorker' not in str(e): + raise + self.showMessage( + msg=msg, + detail=detail, + icon='Critical', + ) + log.info('%s', repr(self)) + + def changeEncodingStatus(self, status): + self.encoding = status + if status: + self.pushButton_createVideo.setEnabled(False) + self.pushButton_Cancel.setEnabled(True) + self.comboBox_resolution.setEnabled(False) + self.stackedWidget.setEnabled(False) + self.tab_encoderSettings.setEnabled(False) + self.label_audioFile.setEnabled(False) + self.toolButton_selectAudioFile.setEnabled(False) + self.label_outputFile.setEnabled(False) + self.toolButton_selectOutputFile.setEnabled(False) + self.lineEdit_audioFile.setEnabled(False) + self.lineEdit_outputFile.setEnabled(False) + self.pushButton_addComponent.setEnabled(False) + self.pushButton_removeComponent.setEnabled(False) + self.pushButton_listMoveDown.setEnabled(False) + self.pushButton_listMoveUp.setEnabled(False) + self.menuButton_newProject.setEnabled(False) + self.menuButton_openProject.setEnabled(False) + if sys.platform == 'darwin': + self.progressLabel.setHidden(False) + else: + self.listWidget_componentList.setEnabled(False) + else: + self.pushButton_createVideo.setEnabled(True) + self.pushButton_Cancel.setEnabled(False) + self.comboBox_resolution.setEnabled(True) + self.stackedWidget.setEnabled(True) + self.tab_encoderSettings.setEnabled(True) + self.label_audioFile.setEnabled(True) + self.toolButton_selectAudioFile.setEnabled(True) + self.lineEdit_audioFile.setEnabled(True) + self.label_outputFile.setEnabled(True) + self.toolButton_selectOutputFile.setEnabled(True) + self.lineEdit_outputFile.setEnabled(True) + self.pushButton_addComponent.setEnabled(True) + self.pushButton_removeComponent.setEnabled(True) + self.pushButton_listMoveDown.setEnabled(True) + self.pushButton_listMoveUp.setEnabled(True) + self.menuButton_newProject.setEnabled(True) + self.menuButton_openProject.setEnabled(True) + self.listWidget_componentList.setEnabled(True) + self.progressLabel.setHidden(True) + self.drawPreview(True) + + @QtCore.pyqtSlot(int) + def progressBarUpdated(self, value): + self.progressBar_createVideo.setValue(value) + + @QtCore.pyqtSlot(str) + def progressBarSetText(self, value): + if sys.platform == 'darwin': + self.progressLabel.setText(value) + else: + self.progressBar_createVideo.setFormat(value) + + def updateResolution(self): + resIndex = int(self.comboBox_resolution.currentIndex()) + res = Core.resolutions[resIndex].split('x') + changed = res[0] != self.settings.value("outputWidth") + self.settings.setValue('outputWidth', res[0]) + self.settings.setValue('outputHeight', res[1]) + if changed: + for i in range(len(self.core.selectedComponents)): + self.core.updateComponent(i) + + def drawPreview(self, force=False, **kwargs): + '''Use autosave keyword arg to force saving or not saving if needed''' + self.newTask.emit(self.core.selectedComponents) + # self.processTask.emit() + if force or 'autosave' in kwargs: + if force or kwargs['autosave']: + self.autosave(True) + else: + self.autosave() + self.updateWindowTitle() + + @QtCore.pyqtSlot(QtGui.QImage) + def showPreviewImage(self, image): + self.previewWindow.changePixmap(image) + + def showUndoStack(self): + dialog = QtWidgets.QDialog(self) + undoView = QtWidgets.QUndoView(self.undoStack) + layout = QtWidgets.QVBoxLayout() + layout.addWidget(undoView) + dialog.setLayout(layout) + dialog.show() + + def showFfmpegCommand(self): + from textwrap import wrap + from ..toolkit.ffmpeg import createFfmpegCommand + command = createFfmpegCommand( + self.lineEdit_audioFile.text(), + self.lineEdit_outputFile.text(), + self.core.selectedComponents + ) + command = " ".join(command) + log.info(f"FFmpeg command: {command}") + lines = wrap(command, 49) + self.showMessage( + msg=f"Current FFmpeg command:\n\n{' '.join(lines)}" + ) + + def addComponent(self, compPos, moduleIndex): + '''Creates an undoable action that adds a new component.''' + action = AddComponent(self, compPos, moduleIndex) + self.undoStack.push(action) + + def insertComponent(self, index): + '''Triggered by Core to finish initializing a new component.''' + componentList = self.listWidget_componentList + stackedWidget = self.stackedWidget + + componentList.insertItem( + index, + self.core.selectedComponents[index].name) + componentList.setCurrentRow(index) + + # connect to signal that adds an asterisk when modified + self.core.selectedComponents[index].modified.connect( + self.updateComponentTitle) + + self.pages.insert(index, self.core.selectedComponents[index].page) + stackedWidget.insertWidget(index, self.pages[index]) + stackedWidget.setCurrentIndex(index) + + return index + + def removeComponent(self): + componentList = self.listWidget_componentList + selected = componentList.selectedItems() + if selected: + action = RemoveComponent(self, selected) + self.undoStack.push(action) + + def _removeComponent(self, index): + stackedWidget = self.stackedWidget + componentList = self.listWidget_componentList + stackedWidget.removeWidget(self.pages[index]) + componentList.takeItem(index) + self.core.removeComponent(index) + self.pages.pop(index) + self.changeComponentWidget() + self.drawPreview() + + @disableWhenEncoding + def moveComponent(self, change): + '''Moves a component relatively from its current position''' + componentList = self.listWidget_componentList + tag = change + if change == 'top': + change = -componentList.currentRow() + elif change == 'bottom': + change = len(componentList)-componentList.currentRow()-1 + else: + tag = 'down' if change == 1 else 'up' + + row = componentList.currentRow() + newRow = row + change + if newRow > -1 and newRow < componentList.count(): + action = MoveComponent(self, row, newRow, tag) + self.undoStack.push(action) + + def getComponentListMousePos(self, position): + ''' + Given a QPos, returns the component index under the mouse cursor + or -1 if no component is there. + ''' + componentList = self.listWidget_componentList + + modelIndexes = [ + componentList.model().index(i) + for i in range(componentList.count()) + ] + rects = [ + componentList.visualRect(modelIndex) + for modelIndex in modelIndexes + ] + mousePos = [rect.contains(position) for rect in rects] + if not any(mousePos): + # Not clicking a component + mousePos = -1 + else: + mousePos = mousePos.index(True) + log.debug('Click component list row %s' % mousePos) + return mousePos + + @disableWhenEncoding + def dragComponent(self, event): + '''Used as Qt drop event for the component listwidget''' + componentList = self.listWidget_componentList + mousePos = self.getComponentListMousePos(event.pos()) + if mousePos > -1: + change = (componentList.currentRow() - mousePos) * -1 + else: + change = (componentList.count() - componentList.currentRow() - 1) + self.moveComponent(change) + + def changeComponentWidget(self): + selected = self.listWidget_componentList.selectedItems() + if selected: + index = self.listWidget_componentList.row(selected[0]) + self.stackedWidget.setCurrentIndex(index) + + def openPresetManager(self): + '''Preset manager for importing, exporting, renaming, deleting''' + self.presetManager.show_() + + def clear(self): + '''Get a blank slate''' + self.core.clearComponents() + self.listWidget_componentList.clear() + for widget in self.pages: + self.stackedWidget.removeWidget(widget) + self.pages = [] + for field in ( + self.lineEdit_audioFile, + self.lineEdit_outputFile + ): + with blockSignals(field): + field.setText('') + self.progressBarUpdated(0) + self.progressBarSetText('') + self.undoStack.clear() + + @disableWhenEncoding + def createNewProject(self, prompt=True): + if prompt: + self.openSaveChangesDialog('starting a new project') + + self.clear() + self.currentProject = None + self.settings.setValue("currentProject", None) + self.drawPreview(True) + + def saveCurrentProject(self): + if self.currentProject: + self.core.createProjectFile(self.currentProject, self) + try: + os.remove(self.autosavePath) + except FileNotFoundError: + pass + self.updateWindowTitle() + else: + self.openSaveProjectDialog() + + def openSaveChangesDialog(self, phrase): + success = True + if self.autosaveExists(identical=False): + ch = self.showMessage( + msg="You have unsaved changes in project '%s'. " + "Save before %s?" % ( + os.path.basename(self.currentProject)[:-4], + phrase + ), + showCancel=True) + if ch: + success = self.saveProjectChanges() + + if success and os.path.exists(self.autosavePath): + os.remove(self.autosavePath) + + def openSaveProjectDialog(self): + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Create Project File", + self.settings.value("projectDir"), + "Project Files (*.avp)") + if not filename: + return + if not filename.endswith(".avp"): + filename += '.avp' + self.settings.setValue("projectDir", os.path.dirname(filename)) + self.settings.setValue("currentProject", filename) + self.currentProject = filename + self.core.createProjectFile(filename, self) + self.updateWindowTitle() + + @disableWhenEncoding + def openOpenProjectDialog(self): + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Open Project File", + self.settings.value("projectDir"), + "Project Files (*.avp)") + self.openProject(filename) + + def openProject(self, filepath, prompt=True): + if not filepath or not os.path.exists(filepath) \ + or not filepath.endswith('.avp'): + return + + self.clear() + # ask to save any changes that are about to get deleted + if prompt: + self.openSaveChangesDialog('opening another project') + + self.currentProject = filepath + self.settings.setValue("currentProject", filepath) + self.settings.setValue("projectDir", os.path.dirname(filepath)) + # actually load the project using core method + self.core.openProject(self, filepath) + self.drawPreview(autosave=False) + self.updateWindowTitle() + + def showMessage(self, **kwargs): + parent = kwargs['parent'] if 'parent' in kwargs else self + msg = QtWidgets.QMessageBox(parent) + msg.setModal(True) + msg.setText(kwargs['msg']) + msg.setIcon( + eval('QtWidgets.QMessageBox.%s' % kwargs['icon']) + if 'icon' in kwargs else QtWidgets.QMessageBox.Information + ) + msg.setDetailedText(kwargs['detail'] if 'detail' in kwargs else None) + if 'showCancel'in kwargs and kwargs['showCancel']: + msg.setStandardButtons( + QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) + else: + msg.setStandardButtons(QtWidgets.QMessageBox.Ok) + ch = msg.exec_() + if ch == 1024: + return True + return False + + @disableWhenEncoding + def componentContextMenu(self, QPos): + '''Appears when right-clicking the component list''' + componentList = self.listWidget_componentList + self.menu = QtWidgets.QMenu() + parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) + + index = self.getComponentListMousePos(QPos) + if index > -1: + # Show preset menu if clicking a component + self.presetManager.findPresets() + menuItem = self.menu.addAction("Save Preset") + menuItem.triggered.connect( + self.presetManager.openSavePresetDialog + ) + + # submenu for opening presets + try: + presets = self.presetManager.presets[ + str(self.core.selectedComponents[index]) + ] + self.presetSubmenu = QtWidgets.QMenu("Open Preset") + self.menu.addMenu(self.presetSubmenu) + + for version, presetName in presets: + menuItem = self.presetSubmenu.addAction(presetName) + menuItem.triggered.connect( + lambda _, presetName=presetName: + self.presetManager.openPreset(presetName) + ) + except KeyError: + pass + + if self.core.selectedComponents[index].currentPreset: + menuItem = self.menu.addAction("Clear Preset") + menuItem.triggered.connect( + self.presetManager.clearPreset + ) + self.menu.addSeparator() + + # "Add Component" submenu + self.submenu = QtWidgets.QMenu("Add") + self.menu.addMenu(self.submenu) + insertCompAtTop = self.settings.value("pref_insertCompAtTop") + for i, comp in enumerate(self.core.modules): + menuItem = self.submenu.addAction(comp.Component.name) + menuItem.triggered.connect( + lambda _, item=i: self.addComponent( + 0 if insertCompAtTop else index, item + ) + ) + + self.menu.move(parentPosition + QPos) + self.menu.show() diff --git a/src/gui/mainwindow.ui b/src/gui/mainwindow.ui new file mode 100644 index 0000000..cd8454d --- /dev/null +++ b/src/gui/mainwindow.ui @@ -0,0 +1,835 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>MainWindow</class> + <widget class="QMainWindow" name="MainWindow"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>1008</width> + <height>575</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="focusPolicy"> + <enum>Qt::StrongFocus</enum> + </property> + <property name="windowTitle"> + <string>MainWindow</string> + </property> + <widget class="QWidget" name="centralwidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="autoFillBackground"> + <bool>false</bool> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>9</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <spacer name="verticalSpacer_2"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>0</width> + <height>360</height> + </size> + </property> + </spacer> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_previewWrapper"> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <item> + <spacer name="horizontalSpacer_previewSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>420</width> + <height>0</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <property name="leftMargin"> + <number>3</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout_4"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <property name="leftMargin"> + <number>3</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_16"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <item> + <widget class="QPushButton" name="pushButton_undo"> + <property name="text"> + <string>Undo</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>140</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_projects"> + <property name="text"> + <string>Projects</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_presets"> + <property name="text"> + <string>Presets</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>2</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QListWidget" name="listWidget_componentList"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>16777215</height> + </size> + </property> + <property name="acceptDrops"> + <bool>true</bool> + </property> + <property name="frameShape"> + <enum>QFrame::StyledPanel</enum> + </property> + <property name="frameShadow"> + <enum>QFrame::Sunken</enum> + </property> + <property name="lineWidth"> + <number>1</number> + </property> + <property name="tabKeyNavigation"> + <bool>true</bool> + </property> + <property name="dragEnabled"> + <bool>true</bool> + </property> + <property name="dragDropOverwriteMode"> + <bool>false</bool> + </property> + <property name="dragDropMode"> + <enum>QAbstractItemView::InternalMove</enum> + </property> + <property name="defaultDropAction"> + <enum>Qt::MoveAction</enum> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_14"> + <item> + <widget class="QPushButton" name="pushButton_addComponent"> + <property name="text"> + <string>Add</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_removeComponent"> + <property name="text"> + <string>Remove</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_listMoveUp"> + <property name="text"> + <string>Up</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_listMoveDown"> + <property name="text"> + <string>Down</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_7"> + <property name="leftMargin"> + <number>4</number> + </property> + <property name="rightMargin"> + <number>2</number> + </property> + </layout> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <property name="sizeConstraint"> + <enum>QLayout::SetFixedSize</enum> + </property> + <property name="topMargin"> + <number>4</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <item> + <widget class="QTabWidget" name="tabWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>500</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>180</height> + </size> + </property> + <property name="tabPosition"> + <enum>QTabWidget::North</enum> + </property> + <property name="tabShape"> + <enum>QTabWidget::Rounded</enum> + </property> + <property name="currentIndex"> + <number>0</number> + </property> + <widget class="QWidget" name="tab_exportVideo"> + <attribute name="title"> + <string>Export Video</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_10"> + <property name="margin"> + <number>10</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_4"> + <property name="topMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label_audioFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>80</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Audio File</string> + </property> + <property name="alignment"> + <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_audioFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_selectAudioFile"> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_11"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_6"> + <item> + <widget class="QLabel" name="label_outputFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Output File</string> + </property> + <property name="alignment"> + <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_outputFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_selectOutputFile"> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <property name="margin"> + <number>0</number> + </property> + <item> + <widget class="QProgressBar" name="progressBar_createVideo"> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="value"> + <number>24</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>10</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_createVideo"> + <property name="text"> + <string>Create Video</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_Cancel"> + <property name="enabled"> + <bool>false</bool> + </property> + <property name="text"> + <string>Cancel</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QLabel" name="progressLabel"> + <property name="text"> + <string/> + </property> + <property name="scaledContents"> + <bool>true</bool> + </property> + <property name="alignment"> + <set>Qt::AlignCenter</set> + </property> + <property name="indent"> + <number>-1</number> + </property> + </widget> + </item> + </layout> + <zorder></zorder> + <zorder></zorder> + <zorder>progressLabel</zorder> + </widget> + <widget class="QWidget" name="tab_encoderSettings"> + <attribute name="title"> + <string>Encoder Settings</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_9"> + <property name="margin"> + <number>10</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_13"> + <item> + <widget class="QLabel" name="label_videoFormat"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Container</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_videoContainer"> + <property name="minimumSize"> + <size> + <width>150</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>5</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_videoPreset"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Resolution</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_resolution"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <item> + <widget class="QLabel" name="label_videoCodec"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Video Codec</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_videoCodec"> + <property name="minimumSize"> + <size> + <width>150</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_4"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>5</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_resolution"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Video Bitrate (Kbps)</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_vBitrate"> + <property name="maximum"> + <number>99999</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_11"> + <item> + <widget class="QLabel" name="label_audioCodec"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Audio Codec</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_audioCodec"> + <property name="minimumSize"> + <size> + <width>150</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>10</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_audioBitrate"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Audio Bitrate (Kbps)</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_aBitrate"> + <property name="maximum"> + <number>9999</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_5"> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>500</width> + <height>0</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QStackedWidget" name="stackedWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>180</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>180</height> + </size> + </property> + <property name="currentIndex"> + <number>-1</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/gui/presetmanager.py b/src/gui/presetmanager.py new file mode 100644 index 0000000..9cf95b4 --- /dev/null +++ b/src/gui/presetmanager.py @@ -0,0 +1,361 @@ +''' + Preset manager object handles all interactions with presets, including + the context menu accessed from MainWindow. +''' +from PyQt5 import QtCore, QtWidgets, uic +import string +import os +import logging + +from ..toolkit import badName +from ..core import Core +from .actions import * + + +log = logging.getLogger('AVP.Gui.PresetManager') + + +class PresetManager(QtWidgets.QDialog): + def __init__(self, parent): + super().__init__() + uic.loadUi( + os.path.join(Core.wd, 'gui', 'presetmanager.ui'), self) + self.parent = parent + self.core = parent.core + self.settings = parent.settings + self.presetDir = parent.presetDir + if not self.settings.value('presetDir'): + self.settings.setValue( + "presetDir", + os.path.join(parent.dataDir, 'projects')) + + self.findPresets() + + # window + self.lastFilter = '*' + self.presetRows = [] # list of (comp, vers, name) tuples + self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) + + # connect button signals + self.pushButton_delete.clicked.connect( + self.openDeletePresetDialog + ) + self.pushButton_rename.clicked.connect( + self.openRenamePresetDialog + ) + self.pushButton_import.clicked.connect( + self.openImportDialog + ) + self.pushButton_export.clicked.connect( + self.openExportDialog + ) + self.pushButton_close.clicked.connect( + self.close + ) + + # create filter box and preset list + self.drawFilterList() + self.comboBox_filter.currentIndexChanged.connect( + lambda: self.drawPresetList( + self.comboBox_filter.currentText(), + self.lineEdit_search.text() + ) + ) + + # make auto-completion for search bar + self.autocomplete = QtCore.QStringListModel() + completer = QtWidgets.QCompleter() + completer.setModel(self.autocomplete) + self.lineEdit_search.setCompleter(completer) + self.lineEdit_search.textChanged.connect( + lambda: self.drawPresetList( + self.comboBox_filter.currentText(), + self.lineEdit_search.text() + ) + ) + self.drawPresetList('*') + + def show_(self): + '''Open a new preset manager window from the mainwindow''' + self.findPresets() + self.drawFilterList() + self.drawPresetList('*') + self.show() + + def findPresets(self): + log.debug("Searching %s for presets", self.presetDir) + parseList = [] + for dirpath, dirnames, filenames in os.walk(self.presetDir): + # anything without a subdirectory must be a preset folder + if dirnames: + continue + for preset in filenames: + compName = os.path.basename(os.path.dirname(dirpath)) + if compName not in self.core.compNames: + continue + compVers = os.path.basename(dirpath) + try: + parseList.append((compName, int(compVers), preset)) + except ValueError: + continue + self.presets = { + compName: [ + (vers, preset) + for name, vers, preset in parseList + if name == compName + ] + for compName, _, __ in parseList + } + + def drawPresetList(self, compFilter=None, presetFilter=''): + self.listWidget_presets.clear() + if compFilter: + self.lastFilter = str(compFilter) + else: + compFilter = str(self.lastFilter) + self.presetRows = [] + presetNames = [] + for component, presets in self.presets.items(): + if compFilter != '*' and component != compFilter: + continue + for vers, preset in presets: + if not presetFilter or presetFilter in preset: + self.listWidget_presets.addItem( + '%s: %s' % (component, preset) + ) + self.presetRows.append((component, vers, preset)) + if preset not in presetNames: + presetNames.append(preset) + self.autocomplete.setStringList(presetNames) + + def drawFilterList(self): + self.comboBox_filter.clear() + self.comboBox_filter.addItem('*') + for component in self.presets: + self.comboBox_filter.addItem(component) + + def clearPreset(self, compI=None): + '''Functions on mainwindow level from the context menu''' + compI = self.parent.listWidget_componentList.currentRow() + action = ClearPreset(self.parent, compI) + self.parent.undoStack.push(action) + + def openSavePresetDialog(self): + '''Functions on mainwindow level from the context menu''' + selectedComponents = self.core.selectedComponents + componentList = self.parent.listWidget_componentList + + if componentList.currentRow() == -1: + return + while True: + index = componentList.currentRow() + currentPreset = selectedComponents[index].currentPreset + newName, OK = QtWidgets.QInputDialog.getText( + self.parent, + 'Audio Visualizer', + 'New Preset Name:', + QtWidgets.QLineEdit.Normal, + currentPreset + ) + if OK: + if badName(newName): + self.warnMessage(self.parent) + continue + if newName: + if index != -1: + selectedComponents[index].currentPreset = newName + saveValueStore = \ + selectedComponents[index].savePreset() + saveValueStore['preset'] = newName + componentName = str(selectedComponents[index]).strip() + vers = selectedComponents[index].version + self.createNewPreset( + componentName, vers, newName, + saveValueStore, window=self.parent) + self.findPresets() + self.drawPresetList() + self.openPreset(newName, index) + break + + def createNewPreset( + self, compName, vers, filename, saveValueStore, **kwargs): + path = os.path.join(self.presetDir, compName, str(vers), filename) + if self.presetExists(path, **kwargs): + return + self.core.createPresetFile(compName, vers, filename, saveValueStore) + + def presetExists(self, path, **kwargs): + if os.path.exists(path): + window = kwargs.get("window", self) + ch = self.parent.showMessage( + msg="%s already exists! Overwrite it?" % + os.path.basename(path), + showCancel=True, + icon='Warning', + parent=window) + if not ch: + # user clicked cancel + return True + + return False + + def openPreset(self, presetName, compPos=None): + componentList = self.parent.listWidget_componentList + index = compPos if compPos is not None else componentList.currentRow() + if index == -1: + return + action = OpenPreset(self, presetName, index) + self.parent.undoStack.push(action) + + def _openPreset(self, presetName, index): + selectedComponents = self.core.selectedComponents + + componentName = selectedComponents[index].name.strip() + version = selectedComponents[index].version + dirname = os.path.join(self.presetDir, componentName, str(version)) + filepath = os.path.join(dirname, presetName) + self.core.openPreset(filepath, index, presetName) + + self.parent.updateComponentTitle(index) + self.parent.drawPreview() + + def openDeletePresetDialog(self): + row = self.getPresetRow() + if row == -1: + return + comp, vers, name = self.presetRows[row] + ch = self.parent.showMessage( + msg='Really delete %s?' % name, + showCancel=True, + icon='Warning', + parent=self + ) + if not ch: + return + self.deletePreset(comp, vers, name) + + def deletePreset(self, comp, vers, name): + action = DeletePreset(self, comp, vers, name) + self.parent.undoStack.push(action) + + def warnMessage(self, window=None): + self.parent.showMessage( + msg='Preset names must contain only letters, ' + 'numbers, and spaces.', + parent=window if window else self) + + def getPresetRow(self): + row = self.listWidget_presets.currentRow() + if row > -1: + return row + + # check if component selected in MainWindow has preset loaded + componentList = self.parent.listWidget_componentList + compIndex = componentList.currentRow() + if compIndex == -1: + return compIndex + + preset = self.core.selectedComponents[compIndex].currentPreset + if preset is None: + return -1 + else: + rowTuple = ( + self.core.selectedComponents[compIndex].name, + self.core.selectedComponents[compIndex].version, + preset + ) + for i, tup in enumerate(self.presetRows): + if rowTuple == tup: + index = i + break + else: + return -1 + return index + + def openRenamePresetDialog(self): + presetList = self.listWidget_presets + index = self.getPresetRow() + if index == -1: + return + + while True: + newName, OK = QtWidgets.QInputDialog.getText( + self, + 'Preset Manager', + 'Rename Preset:', + QtWidgets.QLineEdit.Normal, + self.presetRows[index][2] + ) + if OK: + if badName(newName): + self.warnMessage() + continue + if newName: + comp, vers, oldName = self.presetRows[index] + path = os.path.join( + self.presetDir, comp, str(vers)) + newPath = os.path.join(path, newName) + if self.presetExists(newPath): + return + action = RenamePreset(self, path, oldName, newName) + self.parent.undoStack.push(action) + break + + def renamePreset(self, path, oldName, newName): + oldPath = os.path.join(path, oldName) + newPath = os.path.join(path, newName) + if os.path.exists(newPath): + os.remove(newPath) + os.rename(oldPath, newPath) + self.findPresets() + self.drawPresetList() + path = os.path.dirname(newPath) + for i, comp in enumerate(self.core.selectedComponents): + if self.core.getPresetDir(comp) == path \ + and comp.currentPreset == oldName: + self.core.openPreset(newPath, i, newName) + self.parent.updateComponentTitle(i, False) + self.parent.drawPreview() + + def openImportDialog(self): + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Import Preset File", + self.settings.value("presetDir"), + "Preset Files (*.avl)") + if filename: + # get installed path & ask user to overwrite if needed + path = '' + while True: + if path: + if self.presetExists(path): + break + else: + if os.path.exists(path): + os.remove(path) + success, path = self.core.importPreset(filename) + if success: + break + + self.findPresets() + self.drawPresetList() + self.settings.setValue("presetDir", os.path.dirname(filename)) + + def openExportDialog(self): + index = self.getPresetRow() + if index == -1: + return + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Export Preset", + self.settings.value("presetDir"), + "Preset Files (*.avl)") + if filename: + comp, vers, name = self.presetRows[index] + if not self.core.exportPreset(filename, comp, vers, name): + self.parent.showMessage( + msg='Couldn\'t export %s.' % filename, + parent=self + ) + self.settings.setValue("presetDir", os.path.dirname(filename)) + + def clearPresetListSelection(self): + self.listWidget_presets.setCurrentRow(-1) diff --git a/src/gui/presetmanager.ui b/src/gui/presetmanager.ui new file mode 100644 index 0000000..5257b1c --- /dev/null +++ b/src/gui/presetmanager.ui @@ -0,0 +1,150 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>presetmanager</class> + <widget class="QWidget" name="presetmanager"> + <property name="windowModality"> + <enum>Qt::NonModal</enum> + </property> + <property name="enabled"> + <bool>true</bool> + </property> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>497</width> + <height>377</height> + </rect> + </property> + <property name="windowTitle"> + <string>Preset Manager</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QLineEdit" name="lineEdit_search"> + <property name="text"> + <string/> + </property> + <property name="placeholderText"> + <string>Filter by name</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_filter"> + <property name="minimumSize"> + <size> + <width>200</width> + <height>0</height> + </size> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QListWidget" name="listWidget_presets"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="tabKeyNavigation"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <item> + <widget class="QPushButton" name="pushButton_import"> + <property name="text"> + <string>Import</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_export"> + <property name="text"> + <string>Export</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_rename"> + <property name="enabled"> + <bool>true</bool> + </property> + <property name="text"> + <string>Rename</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_delete"> + <property name="text"> + <string>Delete</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_4"> + <item alignment="Qt::AlignRight"> + <widget class="QLabel" name="label"> + <property name="text"> + <string><html><head/><body><p><span style=" font-size:10pt; font-style:italic;">Right-click components in the main window to create presets</span></p></body></html></string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_close"> + <property name="text"> + <string>Close</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/gui/preview_thread.py b/src/gui/preview_thread.py new file mode 100644 index 0000000..137864b --- /dev/null +++ b/src/gui/preview_thread.py @@ -0,0 +1,86 @@ +''' + Thread that runs to create QImages for MainWindow's preview label. + Processes a queue of component lists. +''' +from PyQt5 import QtCore, QtGui, uic +from PyQt5.QtCore import pyqtSignal, pyqtSlot +from PIL import Image +from PIL.ImageQt import ImageQt +from queue import Queue, Empty +import os +import logging + +from ..toolkit.frame import Checkerboard +from ..toolkit import disableWhenOpeningProject + + +log = logging.getLogger("AVP.Gui.PreviewThread") + + +class Worker(QtCore.QObject): + + imageCreated = pyqtSignal(QtGui.QImage) + error = pyqtSignal(str) + + def __init__(self, core, settings, queue): + super().__init__() + self.core = core + self.settings = settings + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) + self.queue = queue + self.background = Checkerboard(width, height) + + @disableWhenOpeningProject + @pyqtSlot(list) + def createPreviewImage(self, components): + dic = { + "components": components, + } + self.queue.put(dic) + + @pyqtSlot() + def process(self): + try: + nextPreviewInformation = self.queue.get(block=False) + while self.queue.qsize() >= 2: + try: + self.queue.get(block=False) + except Empty: + continue + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) + if self.background.width != width \ + or self.background.height != height: + self.background = Checkerboard(width, height) + + frame = self.background.copy() + log.info('Creating new preview frame') + components = nextPreviewInformation["components"] + for component in reversed(components): + try: + component.lockSize(width, height) + newFrame = component.previewRender() + component.unlockSize() + frame = Image.alpha_composite( + frame, newFrame + ) + + except ValueError as e: + errMsg = "Bad frame returned by %s's preview renderer. " \ + "%s. New frame size was %s*%s; should be %s*%s." % ( + str(component), str(e).capitalize(), + newFrame.width, newFrame.height, + width, height + ) + log.critical(errMsg) + self.error.emit(errMsg) + break + except RuntimeError as e: + log.error(str(e)) + else: + self.frame = ImageQt(frame) + self.imageCreated.emit(QtGui.QImage(self.frame)) + + except Empty: + True diff --git a/src/gui/preview_win.py b/src/gui/preview_win.py new file mode 100644 index 0000000..d910456 --- /dev/null +++ b/src/gui/preview_win.py @@ -0,0 +1,61 @@ +from PyQt5 import QtCore, QtGui, QtWidgets +import logging + +log = logging.getLogger('AVP.Gui.PreviewWindow') + + +class PreviewWindow(QtWidgets.QLabel): + ''' + Paints the preview QLabel in MainWindow and maintains the aspect ratio + when the window is resized. + ''' + def __init__(self, parent, img): + super().__init__() + self.parent = parent + self.setFrameStyle(QtWidgets.QFrame.StyledPanel) + self.pixmap = QtGui.QPixmap(img) + + def paintEvent(self, event): + size = self.size() + painter = QtGui.QPainter(self) + point = QtCore.QPoint(0, 0) + scaledPix = self.pixmap.scaled( + size, + QtCore.Qt.KeepAspectRatio, + transformMode=QtCore.Qt.SmoothTransformation) + + # start painting the label from left upper corner + point.setX(int((size.width() - scaledPix.width())/2)) + point.setY(int((size.height() - scaledPix.height())/2)) + painter.drawPixmap(point, scaledPix) + + def changePixmap(self, img): + self.pixmap = QtGui.QPixmap(img) + self.repaint() + + def mousePressEvent(self, event): + if self.parent.encoding: + return + + i = self.parent.listWidget_componentList.currentRow() + if i >= 0: + component = self.parent.core.selectedComponents[i] + if not hasattr(component, 'previewClickEvent'): + return + pos = (event.x(), event.y()) + size = (self.width(), self.height()) + butt = event.button() + log.info('Click event for #%s: %s button %s' % ( + i, pos, butt)) + component.previewClickEvent( + pos, size, butt + ) + + @QtCore.pyqtSlot(str) + def threadError(self, msg): + self.parent.showMessage( + msg=msg, + icon='Critical', + parent=self + ) + log.info('%', repr(self.parent)) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..709e5e7 --- /dev/null +++ b/src/main.py @@ -0,0 +1,53 @@ +from PyQt5 import uic, QtWidgets +import sys +import os +import logging +import re +import string + +from .__init__ import wd + + +log = logging.getLogger('AVP.Main') + + +def main(): + # Determine primary mode + proj = None + mode = 'GUI' + if len(sys.argv) > 2: + mode = 'commandline' + elif len(sys.argv) == 2: + if sys.argv[1].startswith('-'): + mode = 'commandline' + else: + # remove unsafe punctuation characters such as \/?*&^%$# + sys.argv[1] = re.sub(f'[{re.escape(string.punctuation)}]', '', sys.argv[1]) + # opening a project file with gui + proj = sys.argv[1] + + # Create Qt Application + app = QtWidgets.QApplication(sys.argv) + app.setApplicationName("audio-visualizer") + # Launch program + if mode == 'commandline': + from .command import Command + + main = Command() + mode = main.parseArgs() + log.debug("Finished creating command object") + + # Both branches here may occur in one execution: + # Commandline parsing could change mode back to GUI + if mode == 'GUI': + from .gui.mainwindow import MainWindow + + mainWindow = MainWindow(proj) + log.debug("Finished creating MainWindow") + mainWindow.raise_() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..062dca7 --- /dev/null +++ b/src/tests/__init__.py @@ -0,0 +1,36 @@ +import pytest +import os +import sys +from ..core import Core +from ..command import Command + + +@pytest.fixture +def core(): + return Core() + + +@pytest.fixture +def command(): + """Like a MainWindow for commandline mode, this owns the Core""" + return Command() + + +def getTestData(filename): + return os.path.join(Core.wd, 'tests', 'data', filename) + + +def run(logFile): + """Run Pytest, which then imports and runs all tests in this module.""" + with open(logFile, "w") as f: + # temporarily redirect stdout to a text file so we capture pytest's output + sys.stdout = f + try: + val = pytest.main([ + os.path.dirname(__file__), + "-s", # disable pytest's internal capturing of stdout etc. + ]) + finally: + sys.stdout = sys.__stdout__ + + return val diff --git a/src/tests/data/test.jpg b/src/tests/data/test.jpg Binary files differnew file mode 100644 index 0000000..86266d9 --- /dev/null +++ b/src/tests/data/test.jpg diff --git a/src/tests/data/test.ogg b/src/tests/data/test.ogg Binary files differnew file mode 100644 index 0000000..46af76c --- /dev/null +++ b/src/tests/data/test.ogg diff --git a/src/tests/data/test.png b/src/tests/data/test.png Binary files differnew file mode 100644 index 0000000..f1ffd4a --- /dev/null +++ b/src/tests/data/test.png diff --git a/src/tests/test_commandline_parser.py b/src/tests/test_commandline_parser.py new file mode 100644 index 0000000..d672441 --- /dev/null +++ b/src/tests/test_commandline_parser.py @@ -0,0 +1,39 @@ +import sys +import pytest +from .__init__ import command + + +def test_commandline_help(command): + sys.argv = ['', '--help'] + with pytest.raises(SystemExit): + command.parseArgs() + + +def test_commandline_help_if_bad_args(command): + sys.argv = ['', '--junk'] + with pytest.raises(SystemExit): + command.parseArgs() + + +def test_commandline_launches_gui_if_debug(command): + sys.argv = ['', '--debug'] + mode = command.parseArgs() + assert mode == "GUI" + + +def test_commandline_launches_gui_if_debug_with_project(command): + sys.argv = ['', 'test', '--debug'] + mode = command.parseArgs() + assert mode == "GUI" + + +def test_commandline_export_creates_audio_visualization(command): + didCallFunction = False + def captureFunction(*args): + nonlocal didCallFunction + didCallFunction = True + + sys.argv = ['', '-c', '0', 'classic', '-i', '_', '-o', '_'] + command.createAudioVisualisation = captureFunction + command.parseArgs() + assert didCallFunction diff --git a/src/tests/test_core_init.py b/src/tests/test_core_init.py new file mode 100644 index 0000000..696533a --- /dev/null +++ b/src/tests/test_core_init.py @@ -0,0 +1,19 @@ +from .__init__ import core + + +def test_component_names(core): + assert core.compNames == [ + 'Classic Visualizer', + 'Color', + "Conway's Game of Life", + 'Image', + 'Sound', + 'Spectrum', + 'Title Text', + 'Video', + 'Waveform', + ] + + +def test_moduleindex(core): + assert core.moduleIndexFor("Classic Visualizer") == 0 diff --git a/src/toolkit/__init__.py b/src/toolkit/__init__.py new file mode 100644 index 0000000..55e5f84 --- /dev/null +++ b/src/toolkit/__init__.py @@ -0,0 +1 @@ +from .common import * diff --git a/src/toolkit/common.py b/src/toolkit/common.py new file mode 100644 index 0000000..2e800eb --- /dev/null +++ b/src/toolkit/common.py @@ -0,0 +1,193 @@ +''' + Common functions +''' +from PyQt5 import QtWidgets +import string +import os +import sys +import subprocess +import logging +from copy import copy +from collections import OrderedDict + + +log = logging.getLogger('AVP.Toolkit.Common') + + +class blockSignals: + ''' + Context manager to temporarily block list of QtWidgets from updating, + and guarantee restoring the previous state afterwards. + ''' + def __init__(self, widgets): + if type(widgets) is dict: + self.widgets = concatDictVals(widgets) + else: + self.widgets = ( + widgets if hasattr(widgets, '__iter__') + else [widgets] + ) + + def __enter__(self): + log.verbose( + 'Blocking signals for %s', + ", ".join([ + str(w.__class__.__name__) for w in self.widgets + ]) + ) + self.oldStates = [w.signalsBlocked() for w in self.widgets] + for w in self.widgets: + w.blockSignals(True) + + def __exit__(self, *args): + log.verbose( + 'Resetting blockSignals to %s', str(bool(sum(self.oldStates)))) + for w, state in zip(self.widgets, self.oldStates): + w.blockSignals(state) + + +def concatDictVals(d): + '''Concatenates all values in given dict into one list.''' + key, value = d.popitem() + d[key] = value + final = copy(value) + if type(final) is not list: + final = [final] + final.extend([val for val in d.values()]) + else: + value.extend([item for val in d.values() for item in val]) + return final + + +def badName(name): + '''Returns whether a name contains non-alphanumeric chars''' + return any([letter in string.punctuation for letter in name]) + + +def alphabetizeDict(dictionary): + '''Alphabetizes a dict into OrderedDict ''' + return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0])) + + +def presetToString(dictionary): + '''Returns string repr of a preset''' + return repr(alphabetizeDict(dictionary)) + + +def presetFromString(string): + '''Turns a string repr of OrderedDict into a regular dict''' + return dict(eval(string)) + + +def appendUppercase(lst): + for form, i in zip(lst, range(len(lst))): + lst.append(form.upper()) + return lst + + +def pipeWrapper(func): + '''A decorator to insert proper kwargs into Popen objects.''' + def pipeWrapper(commandList, **kwargs): + if sys.platform == 'win32': + # Stop CMD window from appearing on Windows + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + kwargs['startupinfo'] = startupinfo + + if 'bufsize' not in kwargs: + kwargs['bufsize'] = 10**8 + if 'stdin' not in kwargs: + kwargs['stdin'] = subprocess.DEVNULL + return func(commandList, **kwargs) + return pipeWrapper + + +@pipeWrapper +def checkOutput(commandList, **kwargs): + return subprocess.check_output(commandList, **kwargs) + + +def disableWhenEncoding(func): + def decorator(self, *args, **kwargs): + if self.encoding: + return + else: + return func(self, *args, **kwargs) + return decorator + + +def disableWhenOpeningProject(func): + def decorator(self, *args, **kwargs): + if self.core.openingProject: + return + else: + return func(self, *args, **kwargs) + return decorator + + +def rgbFromString(string): + '''Turns an RGB string like "255, 255, 255" into a tuple''' + try: + tup = tuple([int(i) for i in string.split(',')]) + if len(tup) != 3: + raise ValueError + for i in tup: + if i > 255 or i < 0: + raise ValueError + return tup + except: + return (255, 255, 255) + + +def formatTraceback(tb=None): + import traceback + if tb is None: + import sys + tb = sys.exc_info()[2] + return 'Traceback:\n%s' % "\n".join(traceback.format_tb(tb)) + + +def connectWidget(widget, func): + if type(widget) == QtWidgets.QLineEdit: + widget.textChanged.connect(func) + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + widget.valueChanged.connect(func) + elif type(widget) == QtWidgets.QCheckBox: + widget.stateChanged.connect(func) + elif type(widget) == QtWidgets.QComboBox: + widget.currentIndexChanged.connect(func) + else: + log.warning('Failed to connect %s ', str(widget.__class__.__name__)) + return False + return True + + +def setWidgetValue(widget, val): + '''Generic setValue method for use with any typical QtWidget''' + log.verbose('Setting %s to %s' % (str(widget.__class__.__name__), val)) + if type(widget) == QtWidgets.QLineEdit: + widget.setText(val) + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + widget.setValue(val) + elif type(widget) == QtWidgets.QCheckBox: + widget.setChecked(val) + elif type(widget) == QtWidgets.QComboBox: + widget.setCurrentIndex(val) + else: + log.warning('Failed to set %s ', str(widget.__class__.__name__)) + return False + return True + + +def getWidgetValue(widget): + if type(widget) == QtWidgets.QLineEdit: + return widget.text() + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + return widget.value() + elif type(widget) == QtWidgets.QCheckBox: + return widget.isChecked() + elif type(widget) == QtWidgets.QComboBox: + return widget.currentIndex() diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py new file mode 100644 index 0000000..1649670 --- /dev/null +++ b/src/toolkit/ffmpeg.py @@ -0,0 +1,492 @@ +''' + Tools for using ffmpeg +''' +import numpy +import sys +import os +import subprocess +import threading +import signal +from queue import PriorityQueue +import logging + +from .. import core +from .common import checkOutput, pipeWrapper + + +log = logging.getLogger('AVP.Toolkit.Ffmpeg') + + +class FfmpegVideo: + '''Opens a pipe to ffmpeg and stores a buffer of raw video frames.''' + + # error from the thread used to fill the buffer + threadError = None + + def __init__(self, **kwargs): + mandatoryArgs = [ + 'inputPath', + 'filter_', + 'width', + 'height', + 'frameRate', # frames per second + 'chunkSize', # number of bytes in one frame + 'parent', # mainwindow object + 'component', # component object + ] + for arg in mandatoryArgs: + setattr(self, arg, kwargs[arg]) + + self.frameNo = -1 + self.currentFrame = 'None' + self.map_ = None + + if 'loopVideo' in kwargs and kwargs['loopVideo']: + self.loopValue = '-1' + else: + self.loopValue = '0' + if 'filter_' in kwargs: + if kwargs['filter_'][0] != '-filter_complex': + kwargs['filter_'].insert(0, '-filter_complex') + else: + kwargs['filter_'] = None + + self.command = [ + core.Core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-r', str(self.frameRate), + '-stream_loop', str(self.loopValue), + '-i', self.inputPath, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + if type(kwargs['filter_']) is list: + self.command.extend( + kwargs['filter_'] + ) + self.command.extend([ + '-codec:v', 'rawvideo', '-', + ]) + + self.frameBuffer = PriorityQueue() + self.frameBuffer.maxsize = self.frameRate + self.finishedFrames = {} + + self.thread = threading.Thread( + target=self.fillBuffer, + name='FFmpeg Frame-Fetcher' + ) + self.thread.daemon = True + self.thread.start() + + def frame(self, num): + while True: + if num in self.finishedFrames: + image = self.finishedFrames.pop(num) + return image + + i, image = self.frameBuffer.get() + self.finishedFrames[i] = image + self.frameBuffer.task_done() + + def fillBuffer(self): + from ..component import ComponentError + if core.Core.logEnabled: + logFilename = os.path.join( + core.Core.logDir, 'render_%s.log' % str(self.component.compPos) + ) + log.debug('Creating ffmpeg process (log at %s)', logFilename) + with open(logFilename, 'w') as logf: + logf.write(" ".join(self.command) + '\n\n') + with open(logFilename, 'a') as logf: + self.pipe = openPipe( + self.command, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=logf, bufsize=10**8 + ) + else: + self.pipe = openPipe( + self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=10**8 + ) + + while True: + if self.parent.canceled: + break + self.frameNo += 1 + + # If we run out of frames, use the last good frame and loop. + try: + if len(self.currentFrame) == 0: + self.frameBuffer.put((self.frameNo-1, self.lastFrame)) + continue + except AttributeError: + FfmpegVideo.threadError = ComponentError( + self.component, 'video', + "Video seemed playable but wasn't." + ) + break + + try: + self.currentFrame = self.pipe.stdout.read(self.chunkSize) + except ValueError as e: + if str(e) == "PyMemoryView_FromBuffer(): info->buf must not be NULL": + log.debug("Ignored 'info->buf must not be NULL' error from FFmpeg pipe") + return + else: + FfmpegVideo.threadError = ComponentError( + self.component, 'video') + + if len(self.currentFrame) != 0: + self.frameBuffer.put((self.frameNo, self.currentFrame)) + self.lastFrame = self.currentFrame + + +@pipeWrapper +def openPipe(commandList, **kwargs): + return subprocess.Popen(commandList, **kwargs) + + +def closePipe(pipe): + pipe.stdout.close() + pipe.send_signal(signal.SIGTERM) + + +def findFfmpeg(): + if sys.platform == "win32": + bin = 'ffmpeg.exe' + else: + bin = 'ffmpeg' + + if getattr(sys, 'frozen', False): + # The application is frozen + bin = os.path.join(core.Core.wd, bin) + + with open(os.devnull, "w") as f: + try: + checkOutput( + [bin, '-version'], stderr=f + ) + except (subprocess.CalledProcessError, FileNotFoundError): + bin = "" + + return bin + + +def createFfmpegCommand(inputFile, outputFile, components, duration=-1): + ''' + Constructs the major ffmpeg command used to export the video + ''' + if duration == -1: + duration = getAudioDuration(inputFile) + safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters + duration = "{0:.3f}".format(duration + 0.1) # used by input sources + Core = core.Core + + # Test if user has libfdk_aac + encoders = checkOutput( + "%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True + ) + encoders = encoders.decode("utf-8") + + acodec = Core.settings.value('outputAudioCodec') + + options = Core.encoderOptions + containerName = Core.settings.value('outputContainer') + vcodec = Core.settings.value('outputVideoCodec') + vbitrate = str(Core.settings.value('outputVideoBitrate'))+'k' + acodec = Core.settings.value('outputAudioCodec') + abitrate = str(Core.settings.value('outputAudioBitrate'))+'k' + + for cont in options['containers']: + if cont['name'] == containerName: + container = cont['container'] + break + + vencoders = options['video-codecs'][vcodec] + aencoders = options['audio-codecs'][acodec] + + def error(): + nonlocal encoders, encoder + log.critical("Selected encoder (%s) is not supported by Ffmpeg. The supported encoders are: %s", encoder, encoders) + return [] + + for encoder in vencoders: + if encoder in encoders: + vencoder = encoder + break + else: + return error() + + for encoder in aencoders: + if encoder in encoders: + aencoder = encoder + break + else: + return error() + + ffmpegCommand = [ + Core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-y', # overwrite the output file if it already exists. + + # INPUT VIDEO + '-f', 'rawvideo', + '-vcodec', 'rawvideo', + '-s', f'{Core.settings.value("outputWidth")}x{Core.settings.value("outputHeight")}', + '-pix_fmt', 'rgba', + '-r', str(Core.settings.value('outputFrameRate')), + '-t', duration, + '-an', # the video input has no sound + '-i', '-', # the video input comes from a pipe + + # INPUT SOUND + '-t', duration, + '-i', inputFile + ] + + extraAudio = [ + comp.audio for comp in components + if 'audio' in comp.properties() + ] + segment = createAudioFilterCommand(extraAudio, safeDuration) + ffmpegCommand.extend(segment) + # Map audio from the filters or the single audio input, and map video from the pipe + ffmpegCommand.extend([ + '-map', '0:v', + '-map', '[a]' if segment else '1:a', + ]) + + ffmpegCommand.extend([ + # OUTPUT + '-vcodec', vencoder, + '-acodec', aencoder, + '-b:v', vbitrate, + '-b:a', abitrate, + '-pix_fmt', Core.settings.value('outputVideoFormat'), + '-preset', Core.settings.value('outputPreset'), + '-f', container + ]) + + if acodec == 'aac': + ffmpegCommand.append('-strict') + ffmpegCommand.append('-2') + + ffmpegCommand.append(outputFile) + return ffmpegCommand + + +def createAudioFilterCommand(extraAudio, duration): + '''Add extra inputs and any needed filters to the main ffmpeg command.''' + # NOTE: Global filters are currently hard-coded here for debugging use + globalFilters = 0 # increase to add global filters + + if not extraAudio and not globalFilters: + return [] + + ffmpegCommand = [] + # Add -i options for extra input files + extraFilters = {} + for streamNo, params in enumerate(reversed(extraAudio)): + extraInputFile, params = params + ffmpegCommand.extend([ + '-t', duration, + # Tell ffmpeg about shorter clips (seemingly not needed) + # streamDuration = getAudioDuration(extraInputFile) + # if streamDuration and streamDuration > float(safeDuration) + # else "{0:.3f}".format(streamDuration), + '-i', extraInputFile + ]) + # Construct dataset of extra filters we'll need to add later + for ffmpegFilter in params: + if streamNo + 2 not in extraFilters: + extraFilters[streamNo + 2] = [] + extraFilters[streamNo + 2].append(( + ffmpegFilter, params[ffmpegFilter] + )) + + # Start creating avfilters! Popen-style, so don't use semicolons; + extraFilterCommand = [] + + if globalFilters <= 0: + # Dictionary of last-used tmp labels for a given stream number + tmpInputs = {streamNo: -1 for streamNo in extraFilters} + else: + # Insert blank entries for global filters into extraFilters + # so the per-stream filters know what input to source later + for streamNo in range(len(extraAudio), 0, -1): + if streamNo + 1 not in extraFilters: + extraFilters[streamNo + 1] = [] + # Also filter the primary audio track + extraFilters[1] = [] + tmpInputs = { + streamNo: globalFilters - 1 + for streamNo in extraFilters + } + + # Add the global filters! + # NOTE: list length must = globalFilters, currently hardcoded + if tmpInputs: + extraFilterCommand.extend([ + '[%s:a] ashowinfo [%stmp0]' % ( + str(streamNo), + str(streamNo) + ) + for streamNo in tmpInputs + ]) + + # Now add the per-stream filters! + for streamNo, paramList in extraFilters.items(): + for param in paramList: + source = '[%s:a]' % str(streamNo) \ + if tmpInputs[streamNo] == -1 else \ + '[%stmp%s]' % ( + str(streamNo), str(tmpInputs[streamNo]) + ) + tmpInputs[streamNo] = tmpInputs[streamNo] + 1 + extraFilterCommand.append( + '%s %s%s [%stmp%s]' % ( + source, param[0], param[1], str(streamNo), + str(tmpInputs[streamNo]) + ) + ) + + # Join all the filters together and combine into 1 stream + extraFilterCommand = "; ".join(extraFilterCommand) + '; ' \ + if tmpInputs else '' + ffmpegCommand.extend([ + '-filter_complex', + extraFilterCommand + + '%s amix=inputs=%s:duration=first [a]' + % ( + "".join([ + '[%stmp%s]' % (str(i), tmpInputs[i]) + if i in extraFilters else '[%s:a]' % str(i) + for i in range(1, len(extraAudio) + 2) + ]), + str(len(extraAudio) + 1) + ), + ]) + return ffmpegCommand + + +def testAudioStream(filename): + '''Test if an audio stream definitely exists''' + audioTestCommand = [ + core.Core.FFMPEG_BIN, + '-i', filename, + '-vn', '-f', 'null', '-' + ] + try: + checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + return False + else: + return True + + +def getAudioDuration(filename): + '''Try to get duration of audio file as float, or False if not possible''' + command = [core.Core.FFMPEG_BIN, '-i', filename] + + try: + fileInfo = checkOutput(command, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as ex: + fileInfo = ex.output + except FileNotFoundError: + # ffmpeg is possibly not installed + return False + + try: + info = fileInfo.decode("utf-8").split('\n') + except UnicodeDecodeError as e: + log.error('Unicode error:', str(e)) + return False + + for line in info: + if 'Duration' in line: + d = line.split(',')[0] + d = d.split(' ')[3] + d = d.split(':') + duration = float(d[0])*3600 + float(d[1])*60 + float(d[2]) + break + else: + # String not found in output + return False + return duration + + +def readAudioFile(filename, videoWorker): + ''' + Creates the completeAudioArray given to components + and used to draw the classic visualizer. + ''' + duration = getAudioDuration(filename) + if not duration: + log.error('Audio file doesn\'t exist or unreadable.') + return + + command = [ + core.Core.FFMPEG_BIN, + '-i', filename, + '-f', 's16le', + '-acodec', 'pcm_s16le', + '-ar', '44100', # ouput will have 44100 Hz + '-ac', '1', # mono (set to '2' for stereo) + '-'] + in_pipe = openPipe( + command, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8 + ) + + completeAudioArray = numpy.empty(0, dtype="int16") + + progress = 0 + lastPercent = None + while True: + if core.Core.canceled: + return + # read 2 seconds of audio + progress += 4 + raw_audio = in_pipe.stdout.read(88200*4) + if len(raw_audio) == 0: + break + audio_array = numpy.fromstring(raw_audio, dtype="int16") + completeAudioArray = numpy.append(completeAudioArray, audio_array) + + percent = int(100*(progress/duration)) + if percent >= 100: + percent = 100 + + if lastPercent != percent: + string = 'Loading audio file: '+str(percent)+'%' + videoWorker.progressBarSetText.emit(string) + videoWorker.progressBarUpdate.emit(percent) + + lastPercent = percent + + in_pipe.kill() + in_pipe.wait() + + # add 0s the end + completeAudioArrayCopy = numpy.zeros( + len(completeAudioArray) + 44100, dtype="int16") + completeAudioArrayCopy[:len(completeAudioArray)] = completeAudioArray + completeAudioArray = completeAudioArrayCopy + + return (completeAudioArray, duration) + + +def exampleSound( + style='white', extra='apulsator=offset_l=0.35:offset_r=0.67'): + '''Help generate an example sound for use in creating a preview''' + + if style == 'white': + src = '-2+random(0)' + elif style == 'freq': + src = 'sin(1000*t*PI*t)' + elif style == 'wave': + src = 'sin(random(0)*2*PI*t)*tan(random(0)*2*PI*t)' + elif style == 'stereo': + src = '0.1*sin(2*PI*(360-2.5/2)*t) : 0.1*sin(2*PI*(360+2.5/2)*t)' + + return "aevalsrc='%s', %s%s" % (src, extra, ', ' if extra else '') diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py new file mode 100644 index 0000000..520bd43 --- /dev/null +++ b/src/toolkit/frame.py @@ -0,0 +1,104 @@ +''' + Common tools for drawing compatible frames in a Component's frameRender() +''' +from PyQt5 import QtGui +from PIL import Image +from PIL.ImageQt import ImageQt +import sys +import os +import math +import logging + +from .. import core + + +log = logging.getLogger('AVP.Toolkit.Frame') + + +class FramePainter(QtGui.QPainter): + ''' + A QPainter for a blank frame, which can be converted into a + Pillow image with finalize() + ''' + def __init__(self, width, height): + image = BlankFrame(width, height) + log.debug("Creating QImage from PIL image object") + self.image = ImageQt(image) + super().__init__(self.image) + + def setPen(self, penStyle): + if type(penStyle) is tuple: + super().setPen(PaintColor(*penStyle)) + else: + super().setPen(penStyle) + + def finalize(self): + log.verbose("Finalizing FramePainter") + imBytes = self.image.bits().asstring(self.image.byteCount()) + frame = Image.frombytes( + 'RGBA', (self.image.width(), self.image.height()), imBytes + ) + self.end() + return frame + + +class PaintColor(QtGui.QColor): + '''Reverse the painter colour if the hardware stores RGB values backward''' + def __init__(self, r, g, b, a=255): + if sys.byteorder == 'big': + super().__init__(r, g, b, a) + else: + super().__init__(b, g, r, a) + + +def scale(scalePercent, width, height, returntype=None): + width = (float(width) / 100.0) * float(scalePercent) + height = (float(height) / 100.0) * float(scalePercent) + if returntype == str: + return (str(math.ceil(width)), str(math.ceil(height))) + elif returntype == int: + return (math.ceil(width), math.ceil(height)) + else: + return (width, height) + + +def defaultSize(framefunc): + '''Makes width/height arguments optional''' + def decorator(*args): + if len(args) < 2: + newArgs = list(args) + if len(args) == 0 or len(args) == 1: + height = int(core.Core.settings.value("outputHeight")) + newArgs.append(height) + if len(args) == 0: + width = int(core.Core.settings.value("outputWidth")) + newArgs.insert(0, width) + args = tuple(newArgs) + return framefunc(*args) + return decorator + + +def FloodFrame(width, height, RgbaTuple): + return Image.new("RGBA", (width, height), RgbaTuple) + + +@defaultSize +def BlankFrame(width, height): + '''The base frame used by each component to start drawing.''' + return FloodFrame(width, height, (0, 0, 0, 0)) + + +@defaultSize +def Checkerboard(width, height): + ''' + A checkerboard to represent transparency to the user. + TODO: Would be cool to generate this image with numpy instead. + ''' + log.debug('Creating new %s*%s checkerboard' % (width, height)) + image = FloodFrame(1920, 1080, (0, 0, 0, 0)) + image.paste(Image.open( + os.path.join(core.Core.wd, 'gui', "background.png")), + (0, 0) + ) + image = image.resize((width, height)) + return image diff --git a/src/video_thread.py b/src/video_thread.py new file mode 100644 index 0000000..5a28beb --- /dev/null +++ b/src/video_thread.py @@ -0,0 +1,419 @@ +''' + Thread created to export a video. It has a slot to begin export using + an input file, output path, and component list. During export multiple + threads are created to render the video as quickly as possible. Signals + are emitted to update MainWindow's progress bar, detail text, and preview. + Export can be cancelled with cancel() +''' +from PyQt5 import QtCore, QtGui +from PyQt5.QtCore import pyqtSignal, pyqtSlot +from PIL import Image +from PIL.ImageQt import ImageQt +import numpy +import subprocess as sp +import sys +import os +from queue import Queue, PriorityQueue +from threading import Thread, Event +import time +import signal +import logging + +from .component import ComponentError +from .toolkit.frame import Checkerboard +from .toolkit.ffmpeg import ( + openPipe, readAudioFile, + getAudioDuration, createFfmpegCommand +) + + +log = logging.getLogger("AVP.VideoThread") + + +class Worker(QtCore.QObject): + + imageCreated = pyqtSignal('QImage') + videoCreated = pyqtSignal() + progressBarUpdate = pyqtSignal(int) + progressBarSetText = pyqtSignal(str) + encoding = pyqtSignal(bool) + + def __init__(self, parent, inputFile, outputFile, components): + super().__init__() + self.core = parent.core + self.settings = parent.settings + self.modules = parent.core.modules + parent.createVideo.connect(self.createVideo) + + #self.parent = parent + self.components = components + self.outputFile = outputFile + self.inputFile = inputFile + + self.hertz = 44100 + self.sampleSize = 1470 # 44100 / 30 = 1470 + self.canceled = False + self.error = False + self.stopped = False + + def renderNode(self): + ''' + Grabs audio data indices at frames to export, from compositeQueue. + Sends it to the components' frameRender methods in layer order + to create subframes & composite them into the final frame. + The resulting frames are collected in the renderQueue + ''' + def err(): + self.closePipe() + self.cancelExport() + self.error = True + msg = 'A render node failed critically.' + log.critical(msg) + comp._error.emit(msg, str(e)) + + while not self.stopped: + audioI = self.compositeQueue.get() + bgI = int(audioI / self.sampleSize) + frame = None + for layerNo, comp in enumerate(reversed((self.components))): + try: + if layerNo in self.staticComponents: + if self.staticComponents[layerNo] is None: + # this layer was merged into a following layer + continue + # static component + if frame is None: # bottom-most layer + frame = self.staticComponents[layerNo] + else: + frame = Image.alpha_composite( + frame, self.staticComponents[layerNo] + ) + + else: + # animated component + if frame is None: # bottom-most layer + frame = comp.frameRender(bgI) + else: + frame = Image.alpha_composite( + frame, comp.frameRender(bgI) + ) + except Exception as e: + err() + + self.renderQueue.put([audioI, frame]) + self.compositeQueue.task_done() + + def renderDispatch(self): + ''' + Places audio data indices in the compositeQueue, to be used + by a renderNode later. All indices are multiples of self.sampleSize + sampleSize * frameNo = audioI, AKA audio data starting at frameNo + ''' + log.debug('Dispatching Frames for Compositing...') + + for audioI in range(0, self.audioArrayLen, self.sampleSize): + self.compositeQueue.put(audioI) + + def previewDispatch(self): + ''' + Grabs frames from the previewQueue, adds them to the checkerboard + and emits a final QImage to the MainWindow for the live preview + ''' + background = Checkerboard(self.width, self.height) + + while not self.stopped: + audioI, frame = self.previewQueue.get() + if time.time() - self.lastPreview >= 0.06 or audioI == 0: + image = Image.alpha_composite(background.copy(), frame) + self.imageCreated.emit(QtGui.QImage(ImageQt(image))) + self.lastPreview = time.time() + + self.previewQueue.task_done() + + @pyqtSlot() + def createVideo(self): + log.debug("Video worker received signal to createVideo") + numpy.seterr(divide='ignore') + self.encoding.emit(True) + self.extraAudio = [] + self.width = int(self.settings.value('outputWidth')) + self.height = int(self.settings.value('outputHeight')) + + self.compositeQueue = Queue() + self.compositeQueue.maxsize = 20 + self.renderQueue = PriorityQueue() + self.renderQueue.maxsize = 20 + self.previewQueue = PriorityQueue() + + self.reset() + progressBarValue = 0 + self.progressBarUpdate.emit(progressBarValue) + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # READ AUDIO, INITIALIZE COMPONENTS, OPEN A PIPE TO FFMPEG + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + log.debug("Determining length of audio...") + if any([ + True if 'pcm' in comp.properties() else False + for comp in self.components + ]): + self.progressBarSetText.emit("Loading audio file...") + audioFileTraits = readAudioFile( + self.inputFile, self + ) + if audioFileTraits is None: + self.cancelExport() + return + self.completeAudioArray, duration = audioFileTraits + self.audioArrayLen = len(self.completeAudioArray) + else: + duration = getAudioDuration(self.inputFile) + self.completeAudioArray = [] + self.audioArrayLen = int( + ((duration * self.hertz) + + self.hertz) - self.sampleSize) + + self.progressBarUpdate.emit(0) + self.progressBarSetText.emit("Starting components...") + canceledByComponent = False + initText = ", ".join([ + "%s) %s" % (num, str(component)) + for num, component in enumerate(reversed(self.components)) + ]) + print('Loaded Components:', initText) + log.info('Calling preFrameRender for %s', initText) + self.staticComponents = {} + for compNo, comp in enumerate(reversed(self.components)): + try: + comp.preFrameRender( + audioFile=self.inputFile, + completeAudioArray=self.completeAudioArray, + audioArrayLen=self.audioArrayLen, + sampleSize=self.sampleSize, + progressBarUpdate=self.progressBarUpdate, + progressBarSetText=self.progressBarSetText + ) + except ComponentError: + log.warning( + '#%s %s encountered an error in its preFrameRender method', + compNo, + comp + ) + + compProps = comp.properties() + if 'error' in compProps or comp._lockedError is not None: + self.cancel() + self.canceled = True + canceledByComponent = True + compError = comp.error() \ + if type(comp.error()) is tuple else (comp.error(), '') + errMsg = ( + "Component #%s (%s) encountered an error!" % ( + str(compNo), comp.name + ) + if comp.error() is None else + 'Export cancelled by component #%s (%s): %s' % ( + str(compNo), + comp.name, + compError[0] + ) + ) + log.error(errMsg) + comp._error.emit(errMsg, compError[1]) + break + if 'static' in compProps: + log.info('Saving static frame from #%s %s', compNo, comp) + self.staticComponents[compNo] = \ + comp.frameRender(0).copy() + + log.debug("Checking if a component wishes to cancel the export...") + if self.canceled: + if canceledByComponent: + log.error( + 'Export cancelled by component #%s (%s): %s', + compNo, + comp.name, + 'No message.' if comp.error() is None else ( + comp.error() if type(comp.error()) is str + else comp.error()[0] + ) + ) + self.cancelExport() + return + + log.info("Merging consecutive static component frames") + for compNo in range(len(self.components)): + if compNo not in self.staticComponents \ + or compNo + 1 not in self.staticComponents: + continue + self.staticComponents[compNo + 1] = Image.alpha_composite( + self.staticComponents.pop(compNo), + self.staticComponents[compNo + 1] + ) + self.staticComponents[compNo] = None + + try: + ffmpegCommand = createFfmpegCommand( + self.inputFile, self.outputFile, self.components, duration + ) + except sp.CalledProcessError as e: + #FIXME video_thread should own this error signal, not components + self.components[0]._error.emit("Ffmpeg could not be found. Is it installed?", str(e)) + self.error = True + return + + cmd = " ".join(ffmpegCommand) + print('###### FFMPEG COMMAND ######\n%s' % cmd) + print('############################') + if not cmd: + #FIXME video_thread should own this error signal, not components + self.components[0]._error.emit("The ffmpeg command could not be generated.", "") + log.critical("Cancelling render process due to failure while generating the ffmpeg command.") + self.failExport() + return + + log.info('Opening pipe to ffmpeg') + log.info(cmd) + try: + self.out_pipe = openPipe( + ffmpegCommand, + stdin=sp.PIPE, stdout=sys.stdout, stderr=sys.stdout + ) + except sp.CalledProcessError: + log.critical('Ffmpeg pipe couldn\'t be created!', exc_info=True) + raise + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # START CREATING THE VIDEO + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + # Make 2 or 3 renderNodes in new threads to create the frames + self.renderThreads = [] + try: + numCpus = len(os.sched_getaffinity(0)) + except Exception: + numCpus = os.cpu_count() + + for i in range(2 if numCpus <= 2 else 3): + self.renderThreads.append( + Thread(target=self.renderNode, name="Render Thread")) + self.renderThreads[i].daemon = True + self.renderThreads[i].start() + + self.dispatchThread = Thread( + target=self.renderDispatch, name="Render Dispatch Thread") + self.dispatchThread.daemon = True + self.dispatchThread.start() + + self.lastPreview = 0.0 + self.previewDispatch = Thread( + target=self.previewDispatch, name="Render Dispatch Thread" + ) + self.previewDispatch.daemon = True + self.previewDispatch.start() + + # Begin piping into ffmpeg! + frameBuffer = {} + progressBarValue = 0 + self.progressBarUpdate.emit(progressBarValue) + self.progressBarSetText.emit("Exporting video...") + if not self.canceled: + for audioI in range( + 0, self.audioArrayLen, self.sampleSize): + while True: + if audioI in frameBuffer or self.canceled: + # if frame's in buffer, pipe it to ffmpeg + break + # else fetch the next frame & add to the buffer + audioI_, frame = self.renderQueue.get() + frameBuffer[audioI_] = frame + self.renderQueue.task_done() + if self.canceled: + break + + try: + self.out_pipe.stdin.write(frameBuffer[audioI].tobytes()) + self.previewQueue.put([audioI, frameBuffer.pop(audioI)]) + except Exception: + break + + # increase progress bar value + completion = (audioI / self.audioArrayLen) * 100 + if progressBarValue + 1 <= completion: + progressBarValue = numpy.floor(completion).astype(int) + self.progressBarUpdate.emit(progressBarValue) + self.progressBarSetText.emit( + "Exporting video: %s%%" % str(int(progressBarValue)) + ) + + numpy.seterr(all='print') + + self.closePipe() + + for comp in reversed(self.components): + comp.postFrameRender() + + if self.canceled: + print("Export Canceled") + try: + os.remove(self.outputFile) + except Exception: + pass + self.progressBarUpdate.emit(0) + self.progressBarSetText.emit('Export Canceled') + else: + if self.error: + self.failExport() + else: + print("Export Complete") + self.progressBarUpdate.emit(100) + self.progressBarSetText.emit('Export Complete') + + self.error = False + self.canceled = False + self.stopped = True + self.encoding.emit(False) + self.videoCreated.emit() + + def closePipe(self): + try: + self.out_pipe.stdin.close() + except BrokenPipeError: + log.error('Broken pipe to ffmpeg!') + if self.out_pipe.stderr is not None: + log.error(self.out_pipe.stderr.read()) + self.out_pipe.stderr.close() + self.error = True + self.out_pipe.wait() + + def cancelExport(self, message='Export Canceled'): + self.progressBarUpdate.emit(0) + self.progressBarSetText.emit(message) + self.encoding.emit(False) + self.videoCreated.emit() + + def failExport(self): + self.cancelExport('Export Failed') + + def updateProgress(self, pStr, pVal): + self.progressBarValue.emit(pVal) + self.progressBarSetText.emit(pStr) + + def cancel(self): + self.canceled = True + self.stopped = True + self.core.cancel() + + for comp in self.components: + comp.cancel() + + try: + self.out_pipe.send_signal(signal.SIGTERM) + except Exception: + pass + + def reset(self): + self.core.reset() + self.canceled = False + for comp in self.components: + comp.reset() |
