aboutsummaryrefslogtreecommitdiff
path: root/src/component.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/component.py')
-rw-r--r--src/component.py532
1 files changed, 395 insertions, 137 deletions
diff --git a/src/component.py b/src/component.py
index adb170e..03023e7 100644
--- a/src/component.py
+++ b/src/component.py
@@ -1,33 +1,224 @@
'''
- Base classes for components to import.
+ Base classes for components to import. Read comments for some documentation
+ on making a valid component.
'''
from PyQt5 import uic, QtCore, QtWidgets
import os
+import time
+from toolkit.frame import BlankFrame
-class Component(QtCore.QObject):
+
+class ComponentMetaclass(type(QtCore.QObject)):
+ '''
+ Checks the validity of each Component class imported, and
+ mutates some attributes for easier use by the core program.
+ 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:
+ 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='):
+ from presetmanager import getPresetDir
+ _, preset = arg.split('=', 1)
+ path = os.path.join(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 __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]
+
+ # if parents[0] == QtCore.QObject: else:
+ decorate = (
+ 'names', # Class methods
+ 'error', 'audio', 'properties', # Properties
+ 'preFrameRender', 'previewRender',
+ 'frameRender', 'command',
+ )
+
+ # Auto-decorate methods
+ for key in decorate:
+ if key not in attrs:
+ continue
+
+ if key in ('names'):
+ attrs[key] = classmethod(attrs[key])
+
+ if key in ('audio'):
+ attrs[key] = property(attrs[key])
+
+ if key == 'command':
+ attrs[key] = cls.commandWrapper(attrs[key])
+
+ if key in ('previewRender', 'frameRender'):
+ attrs[key] = cls.renderWrapper(attrs[key])
+
+ if key == 'preFrameRender':
+ attrs[key] = cls.initializationWrapper(attrs[key])
+
+ if key == 'properties':
+ attrs[key] = cls.propertiesWrapper(attrs[key])
+
+ if key == 'error':
+ attrs[key] = cls.errorWrapper(attrs[key])
+
+ # Turn version string into a number
+ try:
+ if 'version' not in attrs:
+ print(
+ 'No version attribute in %s. Defaulting to 1' %
+ attrs['name'])
+ attrs['version'] = 1
+ else:
+ attrs['version'] = int(attrs['version'].split('.')[0])
+ except ValueError:
+ print('%s component has an invalid version string:\n%s' % (
+ attrs['name'], str(attrs['version'])))
+ except KeyError:
+ print('%s component has no version string.' % attrs['name'])
+ else:
+ return super().__new__(cls, name, parents, attrs)
+ quit(1)
+
+
+class Component(QtCore.QObject, metaclass=ComponentMetaclass):
'''
- A class for components to inherit. Read comments for documentation
- on making a valid component. All subclasses must implement this signal:
- modified = QtCore.pyqtSignal(int, bool)
+ 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.currentPreset = None
- self.canceled = False
self.moduleIndex = moduleIndex
self.compPos = compPos
self.core = core
+ self.currentPreset = None
+
+ self._trackedWidgets = {}
+ self._presetNames = {}
+ self._commandArgs = {}
+ self._lockedProperties = None
+ self._lockedError = None
+
+ # Stop lengthy processes in response to this variable
+ self.canceled = False
def __str__(self):
- return self.__doc__
+ return self.__class__.name
+
+ def __repr__(self):
+ return '%s\n%s\n%s' % (
+ self.__class__.name, str(self.__class__.version), self.savePreset()
+ )
+
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # Critical Methods
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+
+ def previewRender(self):
+ image = BlankFrame(self.width, self.height)
+ return image
- def version(self):
+ def preFrameRender(self, **kwargs):
'''
- Change this number to identify new versions of a component
+ Must call super() when subclassing
+ Triggered only before a video is exported (video_thread.py)
+ self.worker = the video thread worker
+ 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)
'''
- return 1
+ 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):
'''
@@ -40,163 +231,230 @@ class Component(QtCore.QObject):
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 cancel(self):
+ def audio(self):
'''
- Stop any lengthy process in response to this variable
+ 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
'''
- self.canceled = True
- def reset(self):
- self.canceled = False
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # 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/colour) and initialize
+ '''
+ self.parent = parent
+ self.settings = parent.settings
+ self.page = self.loadUi(self.__class__.ui)
+
+ # Connect widget signals
+ widgets = {
+ 'lineEdit': self.page.findChildren(QtWidgets.QLineEdit),
+ 'checkBox': self.page.findChildren(QtWidgets.QCheckBox),
+ 'spinBox': self.page.findChildren(QtWidgets.QSpinBox),
+ 'comboBox': self.page.findChildren(QtWidgets.QComboBox),
+ }
+ widgets['spinBox'].extend(
+ self.page.findChildren(QtWidgets.QDoubleSpinBox)
+ )
+ for widget in widgets['lineEdit']:
+ widget.textChanged.connect(self.update)
+ for widget in widgets['checkBox']:
+ widget.stateChanged.connect(self.update)
+ for widget in widgets['spinBox']:
+ widget.valueChanged.connect(self.update)
+ for widget in widgets['comboBox']:
+ widget.currentIndexChanged.connect(self.update)
def update(self):
'''
- Read your widget values from self.page, then call super().update()
+ Reads all tracked widget values into instance attributes
+ and tells the MainWindow that the component was modified.
+ Call super() at the END if you need to subclass this.
'''
- self.parent.drawPreview()
- saveValueStore = self.savePreset()
- saveValueStore['preset'] = self.currentPreset
- self.modified.emit(self.compPos, saveValueStore)
+ for attr, widget in self._trackedWidgets.items():
+ if type(widget) == QtWidgets.QLineEdit:
+ setattr(self, attr, widget.text())
+ elif type(widget) == QtWidgets.QSpinBox \
+ or type(widget) == QtWidgets.QDoubleSpinBox:
+ setattr(self, attr, widget.value())
+ elif type(widget) == QtWidgets.QCheckBox:
+ setattr(self, attr, widget.isChecked())
+ elif type(widget) == QtWidgets.QComboBox:
+ setattr(self, attr, widget.currentIndex())
+ if not self.core.openingProject:
+ self.parent.drawPreview()
+ saveValueStore = self.savePreset()
+ saveValueStore['preset'] = self.currentPreset
+ self.modified.emit(self.compPos, saveValueStore)
- def loadPreset(self, presetDict, presetName):
+ def loadPreset(self, presetDict, presetName=None):
'''
- Subclasses take (presetDict, presetName=None) as args.
- Must use super().loadPreset(presetDict, presetName) first,
+ 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():
+ val = presetDict[
+ attr if attr not in self._presetNames
+ else self._presetNames[attr]
+ ]
+ 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)
- def preFrameRender(self, **kwargs):
+ def savePreset(self):
+ saveValueStore = {}
+ for attr, widget in self._trackedWidgets.items():
+ saveValueStore[
+ attr if attr not in self._presetNames
+ else self._presetNames[attr]
+ ] = getattr(self, attr)
+ return saveValueStore
+
+ def commandHelp(self):
+ '''Help text as string for this component's commandline arguments'''
+
+ def command(self, arg=''):
'''
- Triggered only before a video is exported (video_thread.py)
- self.worker = the video thread worker
- 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)
+ 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.
'''
- for key, value in kwargs.items():
- setattr(self, key, value)
+ print(
+ self.__class__.name, 'Usage:\n'
+ 'Open a preset for this component:\n'
+ ' "preset=Preset Name"'
+ )
+ self.commandHelp()
+ quit(0)
+
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # "Private" Methods
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
- def command(self, arg):
+ def trackWidgets(self, trackDict, **kwargs):
'''
- Configure a component using argument from the commandline.
- Use super().command(arg) at the end of a subclass's method,
- if no arguments are found in that method first
+ 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
+
+ NOTE: Any kwarg key set to None will selectively disable tracking.
'''
- 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)
- else:
- print(
- self.__doc__, 'Usage:\n'
- 'Open a preset for this component:\n'
- ' "preset=Preset Name"')
- self.commandHelp()
- quit(0)
+ self._trackedWidgets = trackDict
+ for kwarg in kwargs:
+ try:
+ if kwarg in ('presetNames', 'commandArgs'):
+ setattr(self, '_%s' % kwarg, kwargs[kwarg])
+ else:
+ raise ComponentError(
+ self, 'Nonsensical keywords to trackWidgets.')
+ except ComponentError:
+ continue
- def commandHelp(self):
- '''Print help text for this Component's commandline arguments'''
-
- def pickColor(self):
- '''
- Use color picker to get color input from the user,
- and return this as an RGB string and QPushButton stylesheet.
- In a subclass apply stylesheet to any color selection widgets
- '''
- 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()
- return RGBstring, btnStyle
- else:
- return None, None
+ def lockProperties(self, propList):
+ self._lockedProperties = propList
- def RGBFromString(self, 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 lockError(self, msg):
+ self._lockedError = msg
+
+ def unlockProperties(self):
+ self._lockedProperties = None
+
+ def unlockError(self):
+ self._lockedError = 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))
- '''
- ### Reference methods for creating a new component
- ### (Inherit from this class and define these)
+ @property
+ def width(self):
+ return int(self.settings.value('outputWidth'))
- def widget(self, parent):
- self.parent = parent
- page = self.loadUi('example.ui')
- # --- connect widget signals here ---
- self.page = page
- return page
-
- def previewRender(self, previewWorker):
- width = int(previewWorker.core.settings.value('outputWidth'))
- height = int(previewWorker.core.settings.value('outputHeight'))
- from frame import BlankFrame
- image = BlankFrame(width, height)
- return image
+ @property
+ def height(self):
+ return int(self.settings.value('outputHeight'))
- def frameRender(self, layerNo, frameNo):
- audioArrayIndex = frameNo * self.sampleSize
- width = int(self.worker.core.settings.value('outputWidth'))
- height = int(self.worker.core.settings.value('outputHeight'))
- from frame import BlankFrame
- image = BlankFrame(width, height)
- return image
+ def cancel(self):
+ '''Stop any lengthy process in response to this variable.'''
+ self.canceled = True
- 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
- \'''
+ def reset(self):
+ self.canceled = False
+ self.unlockProperties()
+ self.unlockError()
- @classmethod
- def names(cls):
- \'''
- Alternative names for renaming a component between project files.
- \'''
- return []
- '''
+class ComponentError(RuntimeError):
+ '''Gives the MainWindow a traceback to display, and cancels the export.'''
+
+ prevErrors = []
+ lastTime = time.time()
+
+ def __init__(self, caller, name):
+ print('##### ComponentError by %s: %s' % (caller.name, name))
+ 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 < 0.2:
+ # Don't create multiple windows for quickly repeated messages
+ return
+ ComponentError.lastTime = time.time()
+
+ from toolkit import formatTraceback
+ import sys
+ if sys.exc_info()[0] is not None:
+ string = (
+ "%s component's %s encountered %s %s." % (
+ caller.__class__.name,
+ name,
+ 'an' if any([
+ sys.exc_info()[0].__name__.startswith(vowel)
+ for vowel in ('A', 'I')
+ ]) else 'a',
+ sys.exc_info()[0].__name__,
+ )
+ )
+ detail = formatTraceback(sys.exc_info()[2])
+ else:
+ string = name
+ detail = "Methods:\n%s" % (
+ "\n".join(
+ [m for m in dir(caller) if not m.startswith('_')]
+ )
+ )
-class BadComponentInit(Exception):
- def __init__(self, arg, name):
- string = '''################################
-Mandatory argument "%s" not specified
- in %s instance initialization
-###################################'''
- print(string % (arg, name))
- quit()
+ super().__init__(string)
+ caller.lockError(string)
+ caller._error.emit(string, detail)