aboutsummaryrefslogtreecommitdiff
path: root/src/avp/libcomponent/component.py
diff options
context:
space:
mode:
authorBrianna Rainey2026-02-12 15:38:54 -0500
committerGitHub2026-02-12 15:38:54 -0500
commitf03a3a686c7304588dd434322c73506531e53595 (patch)
treeee41d920873e9a77c41f4a65857af019e71a4754 /src/avp/libcomponent/component.py
parent48a9105eab94e64101470402427564203e1d8970 (diff)
v2.2.4 - Quiet FFmpeg; add "invert" option to Classic Vis; fix CLI parsing for Image component (#96)
* change noisiness of terminal output ffmpeg no longer prints everything into the terminal unless we're in `--verbose` mode. percentage progress text stays on one line while not in verbose mode. * Added hint to run `avp --verbose` if `avp --log` is run with no avp_debug.log file present * Classic Visualizer: add invert option * Image component: fix path commandline option * Image component: restrict file formats in CLI to match GUI * Color component: add tooltip to color2 picker (second color of gradients) * change tests to work with pytest-xdist avp core stores its config (location of `settings.ini`) in temp directories if using multiple workers to run tests, so they don't interfere with each other. when using a single worker, the `tests/data/config` directory is still used * check alt comp names when parsing cmdline * rename `original.py` to `classic.py` * move `component.py` into subpackage * rename comp_original to comp_classic * show traceback if renderFrame() raises exception * do not try to insert non-existent components from project files * add "composite" property for components if a component returns "composite" then it will receive a frame to draw on during calls to previewRender and frameRender * more tests of projects, actions, waveform, spectrum, image, color, classic * do not change presetDir to "projects" within PresetManager
Diffstat (limited to 'src/avp/libcomponent/component.py')
-rw-r--r--src/avp/libcomponent/component.py583
1 files changed, 583 insertions, 0 deletions
diff --git a/src/avp/libcomponent/component.py b/src/avp/libcomponent/component.py
new file mode 100644
index 0000000..1f81e07
--- /dev/null
+++ b/src/avp/libcomponent/component.py
@@ -0,0 +1,583 @@
+"""
+Base classes for components to import. Read comments for some documentation
+on making a valid component.
+"""
+
+from PyQt6 import uic, QtCore, QtWidgets
+from PyQt6.QtGui import QColor
+import os
+import math
+import logging
+from copy import copy
+
+from .metaclass import ComponentMetaclass
+from .actions import ComponentUpdate
+from .exceptions import ComponentError
+from ..toolkit.frame import BlankFrame
+
+from ..toolkit import (
+ getWidgetValue,
+ setWidgetValue,
+ rgbFromString,
+ randomColor,
+ blockSignals,
+)
+
+log = logging.getLogger("AVP.BaseComponent")
+
+
+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 with certain meanings:
+ `static`: non-animated
+ `audio`: has extra sound to add
+ `error`: bad configuration
+ `pcm`: request raw audio data
+ `composite`: request frame to draw on
+ """
+ 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():
+ colorText = self._trackedWidgets[attr].text()
+ if colorText == "":
+ rndColor = randomColor()
+ self._trackedWidgets[attr].setText(str(rndColor)[1:-1])
+ self._colorWidgets[attr].clicked.connect(func)
+ self._colorWidgets[attr].setStyleSheet(
+ "QPushButton {"
+ "background-color : %s; outline: none; }"
+ % QColor(
+ *rgbFromString(colorText) if colorText else rndColor
+ ).name()
+ )
+
+ 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()
+ # TODO alpha channel is not actually shown in most color picker widgets?
+ dialog.setOption(
+ QtWidgets.QColorDialog.ColorDialogOption.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
+ # TODO QDoubleSpinBox doesn't work with relativeWidgets because of this
+ 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))