From f03a3a686c7304588dd434322c73506531e53595 Mon Sep 17 00:00:00 2001
From: Brianna Rainey
Date: Thu, 12 Feb 2026 15:38:54 -0500
Subject: 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---
src/avp/__init__.py | 2 +-
src/avp/cli.py | 4 +-
src/avp/command.py | 50 +-
src/avp/component.py | 978 -------------------------------------
src/avp/components/classic.py | 210 ++++++++
src/avp/components/classic.ui | 274 +++++++++++
src/avp/components/color.py | 4 +-
src/avp/components/color.ui | 3 +
src/avp/components/image.py | 16 +-
src/avp/components/life.py | 7 +-
src/avp/components/original.py | 185 -------
src/avp/components/original.ui | 267 ----------
src/avp/components/sound.py | 6 +-
src/avp/components/spectrum.py | 9 +-
src/avp/components/text.py | 10 +-
src/avp/components/video.py | 8 +-
src/avp/components/waveform.py | 8 +-
src/avp/core.py | 12 +-
src/avp/gui/actions.py | 10 +-
src/avp/gui/mainwindow.py | 46 +-
src/avp/gui/presetmanager.py | 4 +-
src/avp/gui/preview_thread.py | 16 +-
src/avp/libcomponent/__init__.py | 4 +
src/avp/libcomponent/actions.py | 104 ++++
src/avp/libcomponent/component.py | 583 ++++++++++++++++++++++
src/avp/libcomponent/exceptions.py | 63 +++
src/avp/libcomponent/metaclass.py | 257 ++++++++++
src/avp/toolkit/ffmpeg.py | 44 +-
src/avp/toolkit/visualizer.py | 4 +
src/avp/video_thread.py | 43 +-
30 files changed, 1669 insertions(+), 1562 deletions(-)
delete mode 100644 src/avp/component.py
create mode 100644 src/avp/components/classic.py
create mode 100644 src/avp/components/classic.ui
delete mode 100644 src/avp/components/original.py
delete mode 100644 src/avp/components/original.ui
create mode 100644 src/avp/libcomponent/__init__.py
create mode 100644 src/avp/libcomponent/actions.py
create mode 100644 src/avp/libcomponent/component.py
create mode 100644 src/avp/libcomponent/exceptions.py
create mode 100644 src/avp/libcomponent/metaclass.py
(limited to 'src/avp')
diff --git a/src/avp/__init__.py b/src/avp/__init__.py
index 8783660..184afda 100644
--- a/src/avp/__init__.py
+++ b/src/avp/__init__.py
@@ -3,7 +3,7 @@ import os
import logging
-__version__ = "2.2.3"
+__version__ = "2.2.4"
class Logger(logging.getLoggerClass()):
diff --git a/src/avp/cli.py b/src/avp/cli.py
index 0176f76..7d58fe1 100644
--- a/src/avp/cli.py
+++ b/src/avp/cli.py
@@ -40,10 +40,8 @@ def main() -> int:
screen = app.primaryScreen()
if screen is None:
dpi = None
- log.error("Could not detect DPI")
else:
dpi = screen.physicalDotsPerInchX()
- log.info("Detected screen DPI: %s", dpi)
# Launch program
if mode == "commandline":
@@ -53,6 +51,8 @@ def main() -> int:
mode = main.parseArgs()
log.debug("Finished creating command object")
+ log.info(f"QApplication Platform: {QApplication.platformName()}")
+ log.info(f"Detected screen DPI: {dpi}")
# Both branches here may occur in one execution:
# Commandline parsing could change mode back to GUI
if mode == "GUI":
diff --git a/src/avp/command.py b/src/avp/command.py
index 870391b..b6700a5 100644
--- a/src/avp/command.py
+++ b/src/avp/command.py
@@ -14,7 +14,8 @@ import signal
import shutil
import logging
-from . import core, __version__
+from . import __version__
+from .core import Core
log = logging.getLogger("AVP.Commandline")
@@ -29,11 +30,11 @@ class Command(QtCore.QObject):
def __init__(self):
super().__init__()
- self.core = core.Core()
- core.Core.mode = "commandline"
+ self.core = Core()
+ Core.mode = "commandline"
self.dataDir = self.core.dataDir
self.canceled = False
- self.settings = core.Core.settings
+ self.settings = Core.settings
# ctrl-c stops the export thread
signal.signal(signal.SIGINT, self.stopVideo)
@@ -71,9 +72,10 @@ class Command(QtCore.QObject):
help="copy and shorten recent log files into ~/avp_log.txt",
)
debugCommands.add_argument(
- "--verbose", "-v",
+ "--verbose",
+ "-v",
action="store_true",
- help="create bigger logfiles while program is running",
+ help="send log messages and ffmpeg output to stdout, and create more verbose log files (good to use before --log)",
)
# project/GUI options
@@ -101,8 +103,8 @@ class Command(QtCore.QObject):
args = parser.parse_args()
if args.verbose:
- core.STDOUT_LOGLVL = logging.DEBUG
- core.Core.makeLogger(deleteOldLogs=False, fileLogLvl=logging.DEBUG)
+ Core.stdoutLogLvl = logging.DEBUG
+ Core.makeLogger(deleteOldLogs=False, fileLogLvl=logging.DEBUG)
if args.log:
self.createLogFile()
@@ -168,7 +170,7 @@ class Command(QtCore.QObject):
return "commandline"
elif args.no_preview:
- core.Core.previewEnabled = False
+ Core.previewEnabled = False
elif (
args.projpath is None
@@ -203,20 +205,18 @@ class Command(QtCore.QObject):
@QtCore.pyqtSlot(str)
def progressBarSetText(self, value):
- if "Export " in value:
- # Don't duplicate completion/failure messages
+ if "Export " in value or time.time() - self.lastProgressUpdate < 0.1:
+ # Don't duplicate completion/failure messages or send too many messages
return
- if (
- not value.startswith("Exporting")
- and time.time() - self.lastProgressUpdate >= 0.05
- ):
+
+ if not value.endswith("%"):
# 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
+ elif log.getEffectiveLevel() > logging.INFO:
+ # if ffmpeg isn't printing export progress for us,
+ # then overwrite previous message with the next one
+ # if this text is our main export progress
+ print(f"{value}\r", end="")
self.lastProgressUpdate = time.time()
@QtCore.pyqtSlot()
@@ -224,6 +224,7 @@ class Command(QtCore.QObject):
self.quit(0)
def quit(self, code):
+ print()
quit(code)
def showMessage(self, **kwargs):
@@ -242,12 +243,14 @@ class Command(QtCore.QObject):
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
+ for altName, moduleIndex in self.core.altCompNames:
+ if name.title() in altName:
+ return self.core.compNames[moduleIndex]
compFileNames = [
os.path.splitext(os.path.basename(mod.__file__))[0]
@@ -281,16 +284,17 @@ class Command(QtCore.QObject):
print("Log file could not be created (too many exist).")
return
try:
- shutil.copy(os.path.join(core.Core.logDir, "avp_debug.log"), filename)
+ shutil.copy(os.path.join(Core.logDir, "avp_debug.log"), filename)
with open(filename, "a") as f:
f.write(f"{'='*60} debug log ends {'='*60}\n")
except FileNotFoundError:
+ print("No debug log was found. Run `avp --verbose` before `avp --log`.")
with open(filename, "w") as f:
f.write(f"{'='*60} no debug log {'='*60}\n")
def concatenateLogs(logPattern):
nonlocal filename
- renderLogs = glob.glob(os.path.join(core.Core.logDir, logPattern))
+ renderLogs = glob.glob(os.path.join(Core.logDir, logPattern))
with open(filename, "a") as fw:
for renderLog in renderLogs:
with open(renderLog, "r") as fr:
diff --git a/src/avp/component.py b/src/avp/component.py
deleted file mode 100644
index 5906ab1..0000000
--- a/src/avp/component.py
+++ /dev/null
@@ -1,978 +0,0 @@
-"""
-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, QUndoCommand
-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,
- randomColor,
- 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():
- 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))
-
-
-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(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 self.parent.mergeUndo:
- if len(self.modifiedVals) == 1:
- attr, val = self.modifiedVals.popitem()
- self.id_ = sum([ord(letter) for letter in attr[-14:]])
- self.modifiedVals[attr] = val
- return
- 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/avp/components/classic.py b/src/avp/components/classic.py
new file mode 100644
index 0000000..72089af
--- /dev/null
+++ b/src/avp/components/classic.py
@@ -0,0 +1,210 @@
+import numpy
+from PIL import Image, ImageDraw
+
+from ..libcomponent import BaseComponent
+from ..toolkit.frame import BlankFrame, FloodFrame
+from ..toolkit.visualizer import createSpectrumArray
+
+
+class Component(BaseComponent):
+ name = "Classic Visualizer"
+ version = "1.2.0"
+
+ def names(*args):
+ return ["Original"]
+
+ def properties(self):
+ props = ["pcm"]
+ if self.invert:
+ props.append("composite")
+ return props
+
+ def widget(self, *args):
+ self.scale = 20
+ self.bars = 63
+ 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.trackWidgets(
+ {
+ "visColor": self.page.lineEdit_visColor,
+ "layout": self.page.comboBox_visLayout,
+ "scale": self.page.spinBox_scale,
+ "y": self.page.spinBox_y,
+ "smooth": self.page.spinBox_sensitivity,
+ "bars": self.page.spinBox_bars,
+ "invert": self.page.checkBox_invert,
+ },
+ colorWidgets={
+ "visColor": self.page.pushButton_visColor,
+ },
+ relativeWidgets=[
+ "y",
+ ],
+ )
+
+ def previewRender(self, frame=None):
+ 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,
+ frame,
+ )
+
+ def preFrameRender(self, **kwargs):
+ super().preFrameRender(**kwargs)
+ smoothConstantDown = 0.08 if not self.smooth else self.smooth / 15
+ smoothConstantUp = 0.8 if not self.smooth else self.smooth / 15
+ self.spectrumArray = createSpectrumArray(
+ self,
+ self.completeAudioArray,
+ self.sampleSize,
+ smoothConstantDown,
+ smoothConstantUp,
+ self.scale,
+ self.progressBarUpdate,
+ self.progressBarSetText,
+ )
+
+ def frameRender(self, frameNo, frame=None):
+ arrayNo = frameNo * self.sampleSize
+ return self.drawBars(
+ self.width,
+ self.height,
+ self.spectrumArray[arrayNo],
+ self.visColor,
+ self.layout,
+ frame,
+ )
+
+ def drawBars(self, width, height, spectrum, color, layout, frame):
+ bigYCoord = height - height / 8
+ smallYCoord = height / 1200
+ bigXCoord = width / (self.bars + 1)
+ middleXCoord = bigXCoord / 2
+ smallXCoord = bigXCoord / 4
+
+ imTop = BlankFrame(width, height)
+ draw = ImageDraw.Draw(imTop)
+ r, g, b = color
+ color2 = (r, g, b, 125)
+
+ for i in range(self.bars):
+ # draw outline behind rectangles if not inverted
+ if frame is None:
+ x0 = middleXCoord + i * bigXCoord
+ y0 = bigYCoord + smallXCoord
+ x1 = middleXCoord + i * bigXCoord + bigXCoord
+ y1 = (
+ bigYCoord
+ + smallXCoord
+ - spectrum[i * 4] * smallYCoord
+ - middleXCoord
+ )
+ selection = (
+ x0,
+ y0 if y0 < y1 else y1,
+ x1 if x1 > x0 else x0,
+ y1 if y0 < y1 else y0,
+ )
+ draw.rectangle(
+ selection,
+ fill=color2,
+ )
+
+ x0 = middleXCoord + smallXCoord + i * bigXCoord
+ y0 = bigYCoord
+ x1 = middleXCoord + smallXCoord + i * bigXCoord + middleXCoord
+ y1 = bigYCoord - spectrum[i * 4] * smallYCoord
+ selection = (
+ x0,
+ y0 if y0 < y1 else y1,
+ x1 if x1 > x0 else x0,
+ y1 if y0 < y1 else y0,
+ )
+ # fill rectangle if not inverted
+ draw.rectangle(
+ selection,
+ fill=color if frame is None else (0, 0, 0, 0),
+ outline=color,
+ width=int(x1 - x0),
+ )
+
+ imBottom = imTop.transpose(Image.Transpose.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)
+
+ if frame is None:
+ return im
+ f = FloodFrame(width, height, color)
+ f.paste(frame, (0, 0), mask=im)
+ return f
+
+ 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/avp/components/classic.ui b/src/avp/components/classic.ui
new file mode 100644
index 0000000..1ae7faa
--- /dev/null
+++ b/src/avp/components/classic.ui
@@ -0,0 +1,274 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 178
+
+
+
+
+ 180
+ 0
+
+
+
+ Form
+
+
+ -
+
+
+ 4
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Layout
+
+
+
+ -
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+ QSizePolicy::Policy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+ Color
+
+
+
+ -
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+ 32
+ 32
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+ QSizePolicy::Policy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+ Y
+
+
+
+ -
+
+
+ QAbstractSpinBox::ButtonSymbols::UpDownArrows
+
+
+ -5000
+
+
+ 5000
+
+
+ 10
+
+
+ 0
+
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ 4
+
+
-
+
+
+ Scale
+
+
+
+ -
+
+
+ QAbstractSpinBox::ButtonSymbols::PlusMinus
+
+
+ 1
+
+
+ 20
+
+
+
+ -
+
+
+ Sensitivity
+
+
+
+ -
+
+
+ 5
+
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+ QSizePolicy::Policy::Expanding
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ QLayout::SizeConstraint::SetDefaultConstraint
+
+
+ 4
+
+
-
+
+
+ Bars
+
+
+
+ -
+
+
+ 63
+
+
+ 64
+
+
+ 63
+
+
+
+ -
+
+
+ Invert
+
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ Qt::Orientation::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/components/color.py b/src/avp/components/color.py
index cb0960a..826f37f 100644
--- a/src/avp/components/color.py
+++ b/src/avp/components/color.py
@@ -1,14 +1,14 @@
from PyQt6 import QtGui
import logging
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter
log = logging.getLogger("AVP.Components.Color")
-class Component(Component):
+class Component(BaseComponent):
name = "Color"
version = "1.0.0"
diff --git a/src/avp/components/color.ui b/src/avp/components/color.ui
index c36bdd8..788adb9 100644
--- a/src/avp/components/color.ui
+++ b/src/avp/components/color.ui
@@ -124,6 +124,9 @@
32
+
+ End color of gradient. Disabled if fill is solid.
+
diff --git a/src/avp/components/image.py b/src/avp/components/image.py
index e012cec..a082092 100644
--- a/src/avp/components/image.py
+++ b/src/avp/components/image.py
@@ -1,14 +1,13 @@
from PIL import Image, ImageOps, ImageEnhance
from PyQt6 import QtWidgets
import os
-from copy import copy
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import BlankFrame, addShadow
from ..toolkit.visualizer import createSpectrumArray
-class Component(Component):
+class Component(BaseComponent):
name = "Image"
version = "2.1.0"
@@ -177,17 +176,22 @@ class Component(Component):
self.mergeUndo = True
def command(self, arg):
+ def fail():
+ print("Not a supported image format")
+ quit(1)
+
if "=" in arg:
key, arg = arg.split("=", 1)
if key == "path" and os.path.exists(arg):
+ if f"*{os.path.splitext(arg)[1]}" not in self.core.imageFormats:
+ fail()
try:
Image.open(arg)
self.page.lineEdit_image.setText(arg)
- self.page.checkBox_stretch.setChecked(True)
+ self.page.comboBox_resizeMode.setCurrentIndex(2)
return
except OSError as e:
- print("Not a supported image format")
- quit(1)
+ fail()
super().command(arg)
def commandHelp(self):
diff --git a/src/avp/components/life.py b/src/avp/components/life.py
index a062617..374b299 100644
--- a/src/avp/components/life.py
+++ b/src/avp/components/life.py
@@ -1,13 +1,12 @@
from PyQt6 import QtCore, QtWidgets
from PyQt6.QtGui import QUndoCommand
-from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter, ImageOps
+from PIL import Image, ImageDraw
import os
-from copy import copy
import math
import logging
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import BlankFrame, scale, addShadow
from ..toolkit.visualizer import createSpectrumArray
@@ -15,7 +14,7 @@ from ..toolkit.visualizer import createSpectrumArray
log = logging.getLogger("AVP.Component.Life")
-class Component(Component):
+class Component(BaseComponent):
name = "Conway's Game of Life"
version = "2.0.1"
diff --git a/src/avp/components/original.py b/src/avp/components/original.py
deleted file mode 100644
index 0da78dc..0000000
--- a/src/avp/components/original.py
+++ /dev/null
@@ -1,185 +0,0 @@
-import numpy
-from PIL import Image, ImageDraw
-from copy import copy
-
-from ..component import Component
-from ..toolkit.frame import BlankFrame
-from ..toolkit.visualizer import createSpectrumArray
-
-
-class Component(Component):
- name = "Classic Visualizer"
- version = "1.1.0"
-
- def names(*args):
- return ["Original Audio Visualization"]
-
- def properties(self):
- return ["pcm"]
-
- def widget(self, *args):
- self.scale = 20
- self.bars = 63
- 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.trackWidgets(
- {
- "visColor": self.page.lineEdit_visColor,
- "layout": self.page.comboBox_visLayout,
- "scale": self.page.spinBox_scale,
- "y": self.page.spinBox_y,
- "smooth": self.page.spinBox_sensitivity,
- "bars": self.page.spinBox_bars,
- },
- 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)
- smoothConstantDown = 0.08 if not self.smooth else self.smooth / 15
- smoothConstantUp = 0.8 if not self.smooth else self.smooth / 15
- self.spectrumArray = createSpectrumArray(
- self,
- self.completeAudioArray,
- self.sampleSize,
- smoothConstantDown,
- smoothConstantUp,
- self.scale,
- self.progressBarUpdate,
- self.progressBarSetText,
- )
-
- def frameRender(self, frameNo):
- arrayNo = frameNo * self.sampleSize
- return self.drawBars(
- self.width,
- self.height,
- self.spectrumArray[arrayNo],
- self.visColor,
- self.layout,
- )
-
- def drawBars(self, width, height, spectrum, color, layout):
- bigYCoord = height - height / 8
- smallYCoord = height / 1200
- bigXCoord = width / (self.bars + 1)
- middleXCoord = bigXCoord / 2
- smallXCoord = bigXCoord / 4
-
- imTop = BlankFrame(width, height)
- draw = ImageDraw.Draw(imTop)
- r, g, b = color
- color2 = (r, g, b, 125)
-
- for i in range(self.bars):
- x0 = middleXCoord + i * bigXCoord
- y0 = bigYCoord + smallXCoord
- y1 = bigYCoord + smallXCoord - spectrum[i * 4] * smallYCoord - middleXCoord
- x1 = middleXCoord + i * bigXCoord + bigXCoord
- draw.rectangle(
- (
- x0,
- y0 if y0 < y1 else y1,
- x1 if x1 > x0 else x0,
- y1 if y0 < y1 else y0,
- ),
- fill=color2,
- )
-
- x0 = middleXCoord + smallXCoord + i * bigXCoord
- y0 = bigYCoord
- x1 = middleXCoord + smallXCoord + i * bigXCoord + middleXCoord
- y1 = bigYCoord - spectrum[i * 4] * smallYCoord
- draw.rectangle(
- (
- x0,
- y0 if y0 < y1 else y1,
- x1 if x1 > x0 else x0,
- y1 if y0 < y1 else y0,
- ),
- fill=color,
- )
-
- imBottom = imTop.transpose(Image.Transpose.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/avp/components/original.ui b/src/avp/components/original.ui
deleted file mode 100644
index 8dbdaa2..0000000
--- a/src/avp/components/original.ui
+++ /dev/null
@@ -1,267 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 178
-
-
-
-
- 180
- 0
-
-
-
- Form
-
-
- -
-
-
- 4
-
-
-
-
-
-
- 0
- 0
-
-
-
- Layout
-
-
-
- -
-
-
- -
-
-
- Qt::Orientation::Horizontal
-
-
- QSizePolicy::Policy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
- Color
-
-
-
- -
-
-
-
- 32
- 32
-
-
-
-
-
-
-
- 32
- 32
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
- Qt::Orientation::Horizontal
-
-
- QSizePolicy::Policy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
- Y
-
-
-
- -
-
-
- QAbstractSpinBox::ButtonSymbols::UpDownArrows
-
-
- -5000
-
-
- 5000
-
-
- 10
-
-
- 0
-
-
-
- -
-
-
- Qt::Orientation::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
- 4
-
-
-
-
-
- Scale
-
-
-
- -
-
-
- QAbstractSpinBox::ButtonSymbols::PlusMinus
-
-
- 1
-
-
- 20
-
-
-
- -
-
-
- Sensitivity
-
-
-
- -
-
-
- 5
-
-
-
- -
-
-
- Qt::Orientation::Horizontal
-
-
- QSizePolicy::Policy::Expanding
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
- QLayout::SizeConstraint::SetDefaultConstraint
-
-
- 4
-
-
-
-
-
- Bars
-
-
-
- -
-
-
- 63
-
-
- 64
-
-
- 63
-
-
-
- -
-
-
- Qt::Orientation::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
- Qt::Orientation::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
diff --git a/src/avp/components/sound.py b/src/avp/components/sound.py
index 2df8e38..c212870 100644
--- a/src/avp/components/sound.py
+++ b/src/avp/components/sound.py
@@ -1,11 +1,11 @@
-from PyQt6 import QtGui, QtCore, QtWidgets
+from PyQt6 import QtWidgets
import os
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import BlankFrame
-class Component(Component):
+class Component(BaseComponent):
name = "Sound"
version = "1.0.0"
diff --git a/src/avp/components/spectrum.py b/src/avp/components/spectrum.py
index 062ebc7..0446865 100644
--- a/src/avp/components/spectrum.py
+++ b/src/avp/components/spectrum.py
@@ -1,14 +1,11 @@
from PIL import Image
-from PyQt6 import QtGui, QtCore, QtWidgets
import os
-import math
import subprocess
-import time
import logging
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import BlankFrame, scale
-from ..toolkit import checkOutput, connectWidget
+from ..toolkit import connectWidget
from ..toolkit.ffmpeg import (
openPipe,
closePipe,
@@ -21,7 +18,7 @@ from ..toolkit.ffmpeg import (
log = logging.getLogger("AVP.Components.Spectrum")
-class Component(Component):
+class Component(BaseComponent):
name = "Spectrum"
version = "1.0.1"
diff --git a/src/avp/components/text.py b/src/avp/components/text.py
index bee117e..d248772 100644
--- a/src/avp/components/text.py
+++ b/src/avp/components/text.py
@@ -1,16 +1,14 @@
-from PIL import ImageEnhance, ImageFilter, ImageChops
-from PyQt6.QtGui import QColor, QFont
-from PyQt6 import QtGui, QtCore, QtWidgets
-import os
+from PyQt6.QtGui import QFont
+from PyQt6 import QtGui, QtCore
import logging
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import FramePainter, addShadow
log = logging.getLogger("AVP.Components.Text")
-class Component(Component):
+class Component(BaseComponent):
name = "Title Text"
version = "1.0.1"
diff --git a/src/avp/components/video.py b/src/avp/components/video.py
index 65a05af..1e9b788 100644
--- a/src/avp/components/video.py
+++ b/src/avp/components/video.py
@@ -1,20 +1,18 @@
from PIL import Image
-from PyQt6 import QtGui, QtCore, QtWidgets
+from PyQt6 import QtWidgets
import os
-import math
import subprocess
import logging
-from ..component import Component
+from ..libcomponent import BaseComponent
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):
+class Component(BaseComponent):
name = "Video"
version = "1.0.0"
diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py
index e10dec2..bfebc30 100644
--- a/src/avp/components/waveform.py
+++ b/src/avp/components/waveform.py
@@ -3,12 +3,10 @@ from PyQt6.QtGui import QColor
import os
import subprocess
import logging
-from copy import copy
-from ..component import Component
-from ..toolkit.visualizer import transformData, createSpectrumArray
+from ..libcomponent import BaseComponent
+from ..toolkit.visualizer import createSpectrumArray
from ..toolkit.frame import BlankFrame, scale
-from ..toolkit import checkOutput
from ..toolkit.ffmpeg import (
openPipe,
closePipe,
@@ -21,7 +19,7 @@ from ..toolkit.ffmpeg import (
log = logging.getLogger("AVP.Components.Waveform")
-class Component(Component):
+class Component(BaseComponent):
name = "Waveform"
version = "2.0.0"
diff --git a/src/avp/core.py b/src/avp/core.py
index 347a5dd..c8e070b 100644
--- a/src/avp/core.py
+++ b/src/avp/core.py
@@ -14,7 +14,6 @@ from . import toolkit
appName = "Audio Visualizer Python"
log = logging.getLogger("AVP.Core")
-STDOUT_LOGLVL = logging.WARNING
class Core:
@@ -26,6 +25,8 @@ class Core:
This class also stores constants as class variables.
"""
+ stdoutLogLvl = logging.WARNING
+
def __init__(self):
self.importComponents()
self.selectedComponents = []
@@ -77,7 +78,10 @@ class Core:
compPos = len(self.selectedComponents)
if len(self.selectedComponents) > 50:
return -1
- if type(component) is int:
+ if component is None:
+ log.warning("Tried to insert non-existent component")
+ return -1
+ elif 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))
@@ -197,7 +201,7 @@ class Core:
)
continue
if i == -1:
- loader.showMessage(msg="Too many components!")
+ loader.showMessage(msg="Invalid components!")
break
try:
@@ -554,7 +558,7 @@ class Core:
def makeLogger(deleteOldLogs=False, fileLogLvl=None):
# send critical log messages to stdout
logStream = logging.StreamHandler()
- logStream.setLevel(STDOUT_LOGLVL)
+ logStream.setLevel(Core.stdoutLogLvl)
streamFormatter = logging.Formatter("<%(name)s> %(levelname)s: %(message)s")
logStream.setFormatter(streamFormatter)
log = logging.getLogger("AVP")
diff --git a/src/avp/gui/actions.py b/src/avp/gui/actions.py
index 654b2a0..6a01bdd 100644
--- a/src/avp/gui/actions.py
+++ b/src/avp/gui/actions.py
@@ -1,5 +1,5 @@
"""
-QCommand classes for every undoable user action performed in the MainWindow
+QUndoCommand classes for every undoable user action performed in the MainWindow
"""
from PyQt6.QtGui import QUndoCommand
@@ -13,9 +13,9 @@ from ..core import Core
log = logging.getLogger("AVP.Gui.Actions")
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# COMPONENT ACTIONS
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
class AddComponent(QUndoCommand):
@@ -107,9 +107,9 @@ class MoveComponent(QUndoCommand):
self.do(self.newRow, self.row)
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# PRESET ACTIONS
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
class ClearPreset(QUndoCommand):
diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py
index 3221783..fac1e41 100644
--- a/src/avp/gui/mainwindow.py
+++ b/src/avp/gui/mainwindow.py
@@ -25,7 +25,7 @@ from . import preview_thread
from .preview_win import PreviewWindow
from .presetmanager import PresetManager
from .actions import *
-from ..toolkit.ffmpeg import createFfmpegCommand
+from ..toolkit.ffmpeg import createFfmpegCommand, checkFfmpegVersion
from ..toolkit import (
disableWhenEncoding,
disableWhenOpeningProject,
@@ -330,26 +330,13 @@ class MainWindow(QtWidgets.QMainWindow):
)
else:
if not self.settings.value("ffmpegMsgShown"):
- try:
- with open(os.devnull, "w") as f:
- ffmpegVers = checkOutput(
- [self.core.FFMPEG_BIN, "-version"], stderr=f
- )
- ffmpegVers = str(ffmpegVers).split()[2].split(".", 1)[0]
- if ffmpegVers.startswith("n"):
- ffmpegVers = ffmpegVers[1:]
- goodVersion = int(ffmpegVers) > 3
- 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)
+ ffmpegGoodVersion, ffmpegVersionNum = checkFfmpegVersion()
+ if not ffmpegGoodVersion:
+ self.showMessage(
+ msg="The version of FFmpeg ({ffmpegVersionNum}) is "
+ "not recognized. Some features may not work as expected."
+ )
+ self.settings.setValue("ffmpegMsgShown", True)
# Hotkeys for projects
@@ -734,6 +721,23 @@ class MainWindow(QtWidgets.QMainWindow):
self.progressLabel.setText(value)
else:
self.progressBar_createVideo.setFormat(value)
+ if log.getEffectiveLevel() > logging.INFO:
+ # if ffmpeg is quiet, print progress ourselves
+ if any(
+ [
+ value.startswith("Export C"),
+ value.startswith("Analyzing"),
+ value.startswith("Loading"),
+ ]
+ ):
+ # Don't duplicate completion/failure messages or send too many messages
+ return
+ elif not value.startswith("Exporting"):
+ print(value)
+ else:
+ # overwrite previous message with next one
+ # if the text is our main export progress
+ print(f"\r{value}", end="")
def updateResolution(self):
resIndex = int(self.comboBox_resolution.currentIndex())
diff --git a/src/avp/gui/presetmanager.py b/src/avp/gui/presetmanager.py
index ca0029d..bdcff91 100644
--- a/src/avp/gui/presetmanager.py
+++ b/src/avp/gui/presetmanager.py
@@ -25,9 +25,7 @@ class PresetManager(QtWidgets.QDialog):
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.settings.setValue("presetDir", os.path.join(parent.dataDir, "presets"))
self.findPresets()
diff --git a/src/avp/gui/preview_thread.py b/src/avp/gui/preview_thread.py
index a59652a..8507f45 100644
--- a/src/avp/gui/preview_thread.py
+++ b/src/avp/gui/preview_thread.py
@@ -61,7 +61,10 @@ class Worker(QtCore.QObject):
for component in reversed(components):
try:
component.lockSize(width, height)
- newFrame = component.previewRender()
+ if "composite" in component.properties():
+ newFrame = component.previewRender(frame)
+ else:
+ newFrame = component.previewRender()
component.unlockSize()
frame = Image.alpha_composite(frame, newFrame)
@@ -72,11 +75,12 @@ class Worker(QtCore.QObject):
% (
str(component),
str(e).capitalize(),
- "is None" if newFrame is None else "size was %s*%s; should be %s*%s" % (
- newFrame.width,
- newFrame.height,
- width,
- height),
+ (
+ "is None"
+ if newFrame is None
+ else "size was %s*%s; should be %s*%s"
+ % (newFrame.width, newFrame.height, width, height)
+ ),
)
)
log.critical(errMsg)
diff --git a/src/avp/libcomponent/__init__.py b/src/avp/libcomponent/__init__.py
new file mode 100644
index 0000000..5b04b45
--- /dev/null
+++ b/src/avp/libcomponent/__init__.py
@@ -0,0 +1,4 @@
+from .component import Component as BaseComponent
+from .exceptions import ComponentError
+
+__all__ = [BaseComponent, ComponentError]
diff --git a/src/avp/libcomponent/actions.py b/src/avp/libcomponent/actions.py
new file mode 100644
index 0000000..f534685
--- /dev/null
+++ b/src/avp/libcomponent/actions.py
@@ -0,0 +1,104 @@
+"""
+QUndoCommand class for generic undoable user actions performed to a BaseComponent
+
+See `../life.py` for an example of a component that uses a custom QUndoCommand
+"""
+
+from PyQt6.QtGui import QUndoCommand
+from copy import copy
+import logging
+
+log = logging.getLogger("AVP.ComponentHandler")
+
+
+class ComponentUpdate(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 self.parent.mergeUndo:
+ if len(self.modifiedVals) == 1:
+ attr, val = self.modifiedVals.popitem()
+ self.id_ = sum([ord(letter) for letter in attr[-14:]])
+ self.modifiedVals[attr] = val
+ return
+ 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/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))
diff --git a/src/avp/libcomponent/exceptions.py b/src/avp/libcomponent/exceptions.py
new file mode 100644
index 0000000..5498414
--- /dev/null
+++ b/src/avp/libcomponent/exceptions.py
@@ -0,0 +1,63 @@
+import time
+import sys
+import logging
+
+from ..toolkit import formatTraceback
+
+
+log = logging.getLogger("AVP.ComponentHandler")
+
+
+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()
+
+ 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)
diff --git a/src/avp/libcomponent/metaclass.py b/src/avp/libcomponent/metaclass.py
new file mode 100644
index 0000000..e8ad949
--- /dev/null
+++ b/src/avp/libcomponent/metaclass.py
@@ -0,0 +1,257 @@
+import os
+import logging
+from PyQt6 import QtCore
+
+from .exceptions import ComponentError
+from ..toolkit import connectWidget
+from ..toolkit.frame import BlankFrame
+
+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)
diff --git a/src/avp/toolkit/ffmpeg.py b/src/avp/toolkit/ffmpeg.py
index 5aedff3..93aa725 100644
--- a/src/avp/toolkit/ffmpeg.py
+++ b/src/avp/toolkit/ffmpeg.py
@@ -11,7 +11,7 @@ import signal
from queue import PriorityQueue
import logging
-from .. import core
+from ..core import Core
from .common import checkOutput, pipeWrapper
@@ -19,7 +19,7 @@ log = logging.getLogger("AVP.Toolkit.Ffmpeg")
class FfmpegVideo:
- """Opens a pipe to ffmpeg and stores a buffer of raw video frames."""
+ """Opens an input pipe to ffmpeg and stores a buffer of raw video frames."""
# error from the thread used to fill the buffer
threadError = None
@@ -53,7 +53,7 @@ class FfmpegVideo:
kwargs["filter_"] = None
self.command = [
- core.Core.FFMPEG_BIN,
+ Core.FFMPEG_BIN,
"-thread_queue_size",
"512",
"-r",
@@ -98,11 +98,11 @@ class FfmpegVideo:
self.frameBuffer.task_done()
def fillBuffer(self):
- from ..component import ComponentError
+ from ..libcomponent import ComponentError
- if core.Core.logEnabled:
+ if Core.logEnabled:
logFilename = os.path.join(
- core.Core.logDir, "render_%s.log" % str(self.component.compPos)
+ Core.logDir, "render_%s.log" % str(self.component.compPos)
)
log.debug("Creating ffmpeg process (log at %s)", logFilename)
with open(logFilename, "w") as logf:
@@ -176,7 +176,7 @@ def findFfmpeg():
if getattr(sys, "frozen", False):
# The application is frozen
- bin = os.path.join(core.Core.wd, bin)
+ bin = os.path.join(Core.wd, bin)
with open(os.devnull, "w") as f:
try:
@@ -187,7 +187,9 @@ def findFfmpeg():
return bin
-def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
+def createFfmpegCommand(
+ inputFile, outputFile, components, duration=-1, logLevel="info"
+):
"""
Constructs the major ffmpeg command used to export the video
"""
@@ -195,7 +197,6 @@ def createFfmpegCommand(inputFile, outputFile, components, 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)
@@ -243,6 +244,8 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
ffmpegCommand = [
Core.FFMPEG_BIN,
+ "-loglevel",
+ logLevel,
"-thread_queue_size",
"512",
"-y", # overwrite the output file if it already exists.
@@ -415,7 +418,7 @@ def createAudioFilterCommand(extraAudio, duration):
def testAudioStream(filename):
"""Test if an audio stream definitely exists"""
audioTestCommand = [
- core.Core.FFMPEG_BIN,
+ Core.FFMPEG_BIN,
"-i",
filename,
"-vn",
@@ -433,7 +436,7 @@ def testAudioStream(filename):
def getAudioDuration(filename):
"""Try to get duration of audio file as float, or False if not possible"""
- command = [core.Core.FFMPEG_BIN, "-i", filename]
+ command = [Core.FFMPEG_BIN, "-i", filename]
try:
fileInfo = checkOutput(command, stderr=subprocess.STDOUT)
@@ -473,7 +476,7 @@ def readAudioFile(filename, videoWorker):
return
command = [
- core.Core.FFMPEG_BIN,
+ Core.FFMPEG_BIN,
"-i",
filename,
"-f",
@@ -498,7 +501,7 @@ def readAudioFile(filename, videoWorker):
progress = 0
lastPercent = None
while True:
- if core.Core.canceled:
+ if Core.canceled:
return
# read 2 seconds of audio
progress += 4
@@ -543,3 +546,18 @@ def exampleSound(style="white", extra="apulsator=offset_l=0.35:offset_r=0.67"):
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 "")
+
+
+def checkFfmpegVersion():
+ try:
+ with open(os.devnull, "w") as f:
+ ffmpegVers = checkOutput([Core.FFMPEG_BIN, "-version"], stderr=f)
+ ffmpegVers = str(ffmpegVers).split()[2].split(".", 1)[0]
+ if ffmpegVers.startswith("n"):
+ ffmpegVers = ffmpegVers[1:]
+ versionNum = int(ffmpegVers)
+ goodVersion = versionNum > 3
+ except Exception:
+ versionNum = -1
+ goodVersion = False
+ return goodVersion, versionNum
diff --git a/src/avp/toolkit/visualizer.py b/src/avp/toolkit/visualizer.py
index c55a3f3..6477559 100644
--- a/src/avp/toolkit/visualizer.py
+++ b/src/avp/toolkit/visualizer.py
@@ -14,6 +14,7 @@ def createSpectrumArray(
progressBarUpdate,
progressBarSetText,
):
+ lastProgress = 0
lastSpectrum = None
spectrumArray = {}
for i in range(0, len(completeAudioArray), sampleSize):
@@ -33,9 +34,12 @@ def createSpectrumArray(
progress = int(100 * (i / len(completeAudioArray)))
if progress >= 100:
progress = 100
+ if progress == lastProgress:
+ continue
progressText = f"Analyzing audio: {str(progress)}%"
progressBarSetText.emit(progressText)
progressBarUpdate.emit(int(progress))
+ lastProgress = progress
return spectrumArray
diff --git a/src/avp/video_thread.py b/src/avp/video_thread.py
index 967d2fe..ecd8c4c 100644
--- a/src/avp/video_thread.py
+++ b/src/avp/video_thread.py
@@ -12,16 +12,16 @@ from PyQt6 import QtCore, QtGui
from PyQt6.QtCore import pyqtSignal, pyqtSlot
from PIL import Image
from PIL.ImageQt import ImageQt
+
import numpy
import subprocess as sp
import sys
import os
-import time
import signal
import logging
-from .component import ComponentError
-from .toolkit.frame import Checkerboard
+from .libcomponent import ComponentError
+from .toolkit import formatTraceback
from .toolkit.ffmpeg import (
openPipe,
readAudioFile,
@@ -61,7 +61,11 @@ class Worker(QtCore.QObject):
def createFfmpegCommand(self, duration):
try:
ffmpegCommand = createFfmpegCommand(
- self.inputFile, self.outputFile, self.components, duration
+ self.inputFile,
+ self.outputFile,
+ self.components,
+ duration,
+ "info" if log.getEffectiveLevel() < logging.WARNING else "error",
)
except sp.CalledProcessError as e:
# FIXME video_thread should own this error signal, not components
@@ -111,6 +115,7 @@ class Worker(QtCore.QObject):
Also prerenders "static" components like text and merges them if possible
"""
self.staticComponents = {}
+ self.compositeComponents = set()
# Call preFrameRender on each component
canceledByComponent = False
@@ -160,6 +165,8 @@ class Worker(QtCore.QObject):
if "static" in compProps:
log.info("Saving static frame from #%s %s", compNo, comp)
self.staticComponents[compNo] = comp.frameRender(0).copy()
+ elif compNo > 0 and "composite" in compProps:
+ self.compositeComponents.add(compNo)
# Check if any errors occured
log.debug("Checking if a component wishes to cancel the export...")
@@ -208,9 +215,11 @@ class Worker(QtCore.QObject):
self.closePipe()
self.cancelExport()
self.error = True
- msg = "A call to renderFrame in the video thread failed critically."
- log.critical(msg)
- comp._error.emit(msg, str(e))
+ msg = f"{comp.name} renderFrame({int(audioI / self.sampleSize)}) raised an exception."
+ tb = formatTraceback()
+ details = f"{e.__class__.__name__}: {str(e)}\n\n{tb}"
+ log.critical(f"{msg}\n{details}")
+ comp._error.emit(msg, details)
bgI = int(audioI / self.sampleSize)
frame = None
@@ -230,6 +239,9 @@ class Worker(QtCore.QObject):
frame, self.staticComponents[layerNo]
)
+ elif layerNo in self.compositeComponents:
+ # component that uses previous frame to draw
+ frame = Image.alpha_composite(frame, comp.frameRender(bgI, frame))
else:
# animated component
if frame is None: # bottom-most layer
@@ -309,9 +321,9 @@ class Worker(QtCore.QObject):
log.critical("Out_Pipe to FFmpeg couldn't be created!", exc_info=True)
raise
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# START CREATING THE VIDEO
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
progressBarValue = 0
self.progressBarUpdate.emit(progressBarValue)
# Begin piping into ffmpeg!
@@ -335,16 +347,13 @@ class Worker(QtCore.QObject):
completion = (audioI / self.audioArrayLen) * 100
if progressBarValue + 1 <= completion:
progressBarValue = numpy.floor(completion).astype(int)
+ msg = "Exporting video: %s%%" % str(int(progressBarValue))
self.progressBarUpdate.emit(progressBarValue)
- self.progressBarSetText.emit(
- "Exporting video: %s%%" % str(int(progressBarValue))
- )
+ self.progressBarSetText.emit(msg)
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# Finished creating the video!
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-
- numpy.seterr(all="print")
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
self.closePipe()
@@ -363,7 +372,7 @@ class Worker(QtCore.QObject):
if self.error:
self.failExport()
else:
- print("Export Complete")
+ print("\nExport Complete")
self.progressBarUpdate.emit(100)
self.progressBarSetText.emit("Export Complete")
--
cgit v1.2.3