aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authormartin2022-05-01 22:41:20 +0200
committerGitHub2022-05-01 22:41:20 +0200
commit4c5aa37aa6f41d909153a2b7d522db6d7582659a (patch)
tree326aa67921439defcb8c25ea5f770feb63e878a4 /src
parent4a3ff8bfce622de0e5affa312d50557b5d336371 (diff)
parent820358a79a87b214139eb7693ce80e96be79e3d8 (diff)
Merge pull request #69 from djfun/feature-newgui
GUI Redesign with Component System
Diffstat (limited to 'src')
-rw-r--r--src/__init__.py36
-rw-r--r--src/__main__.py5
-rw-r--r--src/command.py263
-rw-r--r--src/component.py924
-rw-r--r--src/components/__init__.py1
-rw-r--r--src/components/__template__.ui119
-rw-r--r--src/components/color.py160
-rw-r--r--src/components/color.ui666
-rw-r--r--src/components/image.py126
-rw-r--r--src/components/image.ui388
-rw-r--r--src/components/life.py475
-rw-r--r--src/components/life.ui405
-rw-r--r--src/components/original.py203
-rw-r--r--src/components/original.ui193
-rw-r--r--src/components/sound.py73
-rw-r--r--src/components/sound.ui172
-rw-r--r--src/components/spectrum.py316
-rw-r--r--src/components/spectrum.ui946
-rw-r--r--src/components/text.py203
-rw-r--r--src/components/text.ui671
-rw-r--r--src/components/video.py226
-rw-r--r--src/components/video.ui328
-rw-r--r--src/components/waveform.py215
-rw-r--r--src/components/waveform.ui383
-rw-r--r--src/core.py614
-rw-r--r--src/encoder-options.json130
-rw-r--r--src/gui/__init__.py0
-rw-r--r--src/gui/actions.py191
-rw-r--r--src/gui/background.pngbin0 -> 45367 bytes
-rw-r--r--src/gui/mainwindow.py1057
-rw-r--r--src/gui/mainwindow.ui835
-rw-r--r--src/gui/presetmanager.py361
-rw-r--r--src/gui/presetmanager.ui150
-rw-r--r--src/gui/preview_thread.py86
-rw-r--r--src/gui/preview_win.py61
-rw-r--r--src/main.py53
-rw-r--r--src/tests/__init__.py36
-rw-r--r--src/tests/data/test.jpgbin0 -> 48766 bytes
-rw-r--r--src/tests/data/test.oggbin0 -> 30043 bytes
-rw-r--r--src/tests/data/test.pngbin0 -> 220 bytes
-rw-r--r--src/tests/test_commandline_parser.py39
-rw-r--r--src/tests/test_core_init.py19
-rw-r--r--src/toolkit/__init__.py1
-rw-r--r--src/toolkit/common.py193
-rw-r--r--src/toolkit/ffmpeg.py492
-rw-r--r--src/toolkit/frame.py104
-rw-r--r--src/video_thread.py419
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>&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
+&lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
+p, li { white-space: pre-wrap; }
+&lt;/style&gt;&lt;/head&gt;&lt;body style=&quot; font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;&quot;&gt;
+&lt;p style=&quot; margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;&lt;span style=&quot; font-weight:600;&quot;&gt;Click the preview window to place a cell. Right-click to remove.&lt;/span&gt;&lt;/p&gt;
+&lt;p style=&quot; margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;- A cell with less than 2 neighbours will die from underpopulation&lt;/p&gt;
+&lt;p style=&quot; margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;- A cell with more than 3 neighbours will die from overpopulation.&lt;/p&gt;
+&lt;p style=&quot; margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;- An empty space surrounded by 3 live cells will cause reproduction.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</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
new file mode 100644
index 0000000..fb58593
--- /dev/null
+++ b/src/gui/background.png
Binary files differ
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>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-size:10pt; font-style:italic;&quot;&gt;Right-click components in the main window to create presets&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</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
new file mode 100644
index 0000000..86266d9
--- /dev/null
+++ b/src/tests/data/test.jpg
Binary files differ
diff --git a/src/tests/data/test.ogg b/src/tests/data/test.ogg
new file mode 100644
index 0000000..46af76c
--- /dev/null
+++ b/src/tests/data/test.ogg
Binary files differ
diff --git a/src/tests/data/test.png b/src/tests/data/test.png
new file mode 100644
index 0000000..f1ffd4a
--- /dev/null
+++ b/src/tests/data/test.png
Binary files differ
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()