From ccb3a16e804c4a8c95548991db9fe41ab976dc45 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 25 Jan 2026 10:47:11 -0500 Subject: [PATCH 01/30] update gitignore ignore profiling and coverage data --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6fe019a..9800ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__ build/ dist/ env/ +prof/ .venv/ .env/ .vscode/ @@ -24,3 +25,5 @@ ffmpeg *.goutput* *.kate-swp *.code-workspace +.coverage +htmlcov/ \ No newline at end of file From fe443be847300ffa8f694284a5f59aa2bc436cc3 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 25 Jan 2026 10:52:09 -0500 Subject: [PATCH 02/30] F1 opens help window, create appName variable, move undostack class --- src/avp/core.py | 2 +- src/avp/gui/mainwindow.py | 39 +++++++++++++++--------------------- src/avp/gui/presetmanager.py | 4 ++-- src/avp/gui/undostack.py | 16 +++++++++++++++ 4 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 src/avp/gui/undostack.py diff --git a/src/avp/core.py b/src/avp/core.py index 196cd7d..099b0b4 100644 --- a/src/avp/core.py +++ b/src/avp/core.py @@ -12,7 +12,7 @@ from . import toolkit - +appName = "Audio Visualizer Python" log = logging.getLogger("AVP.Core") STDOUT_LOGLVL = logging.WARNING diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py index e7a5fe3..dfa2523 100644 --- a/src/avp/gui/mainwindow.py +++ b/src/avp/gui/mainwindow.py @@ -7,7 +7,7 @@ from PyQt6 import QtCore, QtWidgets, uic import PyQt6.QtWidgets as QtWidgets -from PyQt6.QtGui import QUndoStack, QShortcut +from PyQt6.QtGui import QShortcut from PIL import Image from queue import Queue import sys @@ -16,12 +16,16 @@ import filecmp import time import logging +from textwrap import wrap -from ..core import Core +from ..__init__ import __version__ +from ..core import Core, appName +from .undostack import UndoStack from . import preview_thread from .preview_win import PreviewWindow from .presetmanager import PresetManager from .actions import * +from ..toolkit.ffmpeg import createFfmpegCommand from ..toolkit import ( disableWhenEncoding, disableWhenOpeningProject, @@ -30,25 +34,9 @@ ) -appName = "Audio Visualizer" log = logging.getLogger("AVP.Gui.MainWindow") -class MyQUndoStack(QUndoStack): - # FIXME move this class - @property - def encoding(self): - return self.parent().encoding - - @disableWhenEncoding - def undo(self, *args, **kwargs): - super().undo(*args, **kwargs) - - @disableWhenEncoding - def redo(self, *args, **kwargs): - super().redo(*args, **kwargs) - - class MainWindow(QtWidgets.QMainWindow): """ The MainWindow wraps many Core methods in order to update the GUI @@ -91,7 +79,7 @@ def __init__(self, project, dpi): self.settings = Core.settings # Create stack of undoable user actions - self.undoStack = MyQUndoStack(self) + self.undoStack = UndoStack(self) undoLimit = self.settings.value("pref_undoLimit") self.undoStack.setUndoLimit(undoLimit) @@ -325,7 +313,11 @@ def changedField(): self.drawPreview(True) log.info("Pillow version %s", Image.__version__) - log.info("PyQt version %s (Qt version %s)", QtCore.PYQT_VERSION_STR, QtCore.QT_VERSION_STR) + log.info( + "PyQt version %s (Qt version %s)", + QtCore.PYQT_VERSION_STR, + QtCore.QT_VERSION_STR, + ) # verify Ffmpeg version if not self.core.FFMPEG_BIN: @@ -408,6 +400,7 @@ def changedField(): activated=lambda: self.moveComponent("bottom"), ) + QShortcut("F1", self, self.showHelpWindow) QShortcut("Ctrl+Shift+F", self, self.showFfmpegCommand) QShortcut("Ctrl+Shift+U", self, self.showUndoStack) @@ -762,10 +755,10 @@ def showPreviewImage(self, image): def showUndoStack(self): self.undoDialog.show() - def showFfmpegCommand(self): - from textwrap import wrap - from ..toolkit.ffmpeg import createFfmpegCommand + def showHelpWindow(self): + self.showMessage(msg=f"{appName} v{__version__}") + def showFfmpegCommand(self): command = createFfmpegCommand( self.lineEdit_audioFile.text(), self.lineEdit_outputFile.text(), diff --git a/src/avp/gui/presetmanager.py b/src/avp/gui/presetmanager.py index 980a969..ca0029d 100644 --- a/src/avp/gui/presetmanager.py +++ b/src/avp/gui/presetmanager.py @@ -9,7 +9,7 @@ import logging from ..toolkit import badName -from ..core import Core +from ..core import Core, appName from .actions import * @@ -137,7 +137,7 @@ def openSavePresetDialog(self): currentPreset = selectedComponents[index].currentPreset newName, OK = QtWidgets.QInputDialog.getText( self.parent, - "Audio Visualizer", + appName, "New Preset Name:", QtWidgets.QLineEdit.EchoMode.Normal, currentPreset, diff --git a/src/avp/gui/undostack.py b/src/avp/gui/undostack.py new file mode 100644 index 0000000..fd1a3e9 --- /dev/null +++ b/src/avp/gui/undostack.py @@ -0,0 +1,16 @@ +from PyQt6.QtGui import QUndoStack +from ..toolkit.common import disableWhenEncoding + + +class UndoStack(QUndoStack): + @property + def encoding(self): + return self.parent().encoding + + @disableWhenEncoding + def undo(self, *args, **kwargs): + super().undo(*args, **kwargs) + + @disableWhenEncoding + def redo(self, *args, **kwargs): + super().redo(*args, **kwargs) From bacc47e98536a5c325a019d4c7f8e5077b1baec7 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 25 Jan 2026 10:54:58 -0500 Subject: [PATCH 03/30] fix kaleidoscope effect, increase default Y values by +4 the increased y values allow the cells to continue animating for more than 60 minutes instead of 30 (at default 60f/t) --- src/avp/components/life.py | 47 +++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/src/avp/components/life.py b/src/avp/components/life.py index 9e5e202..50f92a0 100644 --- a/src/avp/components/life.py +++ b/src/avp/components/life.py @@ -17,7 +17,7 @@ class Component(Component): name = "Conway's Game of Life" - version = "2.0.0" + version = "2.0.1" def widget(self, *args): super().widget(*args) @@ -27,26 +27,26 @@ def widget(self, *args): # https://conwaylife.com/wiki/Queen_bee_shuttle self.startingGrid = set( [ - (3, 7), - (3, 8), - (4, 7), - (4, 8), - (8, 7), - (9, 6), - (9, 8), - (10, 5), + (3, 11), + (3, 12), + (4, 11), + (4, 12), + (8, 11), + (9, 10), + (9, 12), (10, 9), - (11, 6), - (11, 7), - (11, 8), - (12, 4), - (12, 5), + (10, 13), + (11, 10), + (11, 11), + (11, 12), + (12, 8), (12, 9), - (12, 10), - (23, 6), - (23, 7), - (24, 6), - (24, 7), + (12, 13), + (12, 14), + (23, 10), + (23, 11), + (24, 10), + (24, 11), ] ) @@ -299,11 +299,6 @@ def addKaleidoscopeEffect(self, frame): flippedImage = frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT) frame.paste(flippedImage, (0, 0), mask=flippedImage) - flippedImage = frame.transpose(Image.Transpose.ROTATE_90) - frame.paste(flippedImage, (0, 0), mask=flippedImage) - - flippedImage = frame.transpose(Image.Transpose.ROTATE_270) - frame.paste(flippedImage, (0, 0), mask=flippedImage) return frame def drawGrid(self, grid, color, spectrumData=None, didntChange=None): @@ -506,10 +501,10 @@ def slantLine(difference): for x, y in grid: drawPtX = x * self.pxWidth - if drawPtX > self.width: + if drawPtX > self.width or drawPtX + self.pxWidth < 0: continue drawPtY = y * self.pxHeight - if drawPtY > self.height: + if drawPtY > self.height or drawPtY + self.pxHeight < 0: continue audioMorphWidth = ( From b7c8f1a5bee12f529d9b411119c22ccd42ebef6d Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 25 Jan 2026 12:14:58 -0500 Subject: [PATCH 04/30] update version number --- pyproject.toml | 2 +- src/avp/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a245e09..2f0647c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "uv_build" name = "audio-visualizer-python" description = "Create audio visualization videos from a GUI or commandline" readme = "README.md" -version = "2.2.0" +version = "2.2.1" requires-python = ">= 3.12" license = "MIT" classifiers=[ diff --git a/src/avp/__init__.py b/src/avp/__init__.py index ea32f26..a88bf10 100644 --- a/src/avp/__init__.py +++ b/src/avp/__init__.py @@ -3,7 +3,7 @@ import logging -__version__ = "2.2.0" +__version__ = "2.2.1" class Logger(logging.getLoggerClass()): From bf889b2fe9c6b063622b765850386a1f82be09f5 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 25 Jan 2026 12:15:43 -0500 Subject: [PATCH 05/30] add minimumWidth to undo history window --- src/avp/gui/mainwindow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py index dfa2523..a0655c7 100644 --- a/src/avp/gui/mainwindow.py +++ b/src/avp/gui/mainwindow.py @@ -90,6 +90,7 @@ def __init__(self, project, dpi): layout = QtWidgets.QVBoxLayout() layout.addWidget(undoView) self.undoDialog.setLayout(layout) + self.undoDialog.setMinimumWidth(int(self.width() / 2)) # Create Preset Manager self.presetManager = PresetManager(self) From a12d84788429d8047c4dbb1180a59cea0109c7b6 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 25 Jan 2026 13:35:34 -0500 Subject: [PATCH 06/30] Classic Visualizer: option to include 64th bar --- src/avp/components/original.py | 10 ++++--- src/avp/components/original.ui | 52 +++++++++++++++++++++++----------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/avp/components/original.py b/src/avp/components/original.py index 64eba4d..1e1d8f8 100644 --- a/src/avp/components/original.py +++ b/src/avp/components/original.py @@ -8,7 +8,7 @@ class Component(Component): name = "Classic Visualizer" - version = "1.0.0" + version = "1.1.0" def names(*args): return ["Original Audio Visualization"] @@ -18,6 +18,7 @@ def properties(self): def widget(self, *args): self.scale = 20 + self.bars = 63 self.y = 0 super().widget(*args) @@ -35,7 +36,8 @@ def widget(self, *args): "layout": self.page.comboBox_visLayout, "scale": self.page.spinBox_scale, "y": self.page.spinBox_y, - "smooth": self.page.spinBox_smooth, + "smooth": self.page.spinBox_sensitivity, + "bars": self.page.spinBox_bars, }, colorWidgets={ "visColor": self.page.pushButton_visColor, @@ -146,7 +148,7 @@ def transformData( def drawBars(self, width, height, spectrum, color, layout): bigYCoord = height - height / 8 smallYCoord = height / 1200 - bigXCoord = width / 64 + bigXCoord = width / (self.bars + 1) middleXCoord = bigXCoord / 2 smallXCoord = bigXCoord / 4 @@ -155,7 +157,7 @@ def drawBars(self, width, height, spectrum, color, layout): r, g, b = color color2 = (r, g, b, 125) - for i in range(0, 63): + for i in range(self.bars): x0 = middleXCoord + i * bigXCoord y0 = bigYCoord + smallXCoord y1 = bigYCoord + smallXCoord - spectrum[i * 4] * smallYCoord - middleXCoord diff --git a/src/avp/components/original.ui b/src/avp/components/original.ui index c7b7e22..cfe9a5c 100644 --- a/src/avp/components/original.ui +++ b/src/avp/components/original.ui @@ -44,10 +44,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -89,10 +89,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -112,7 +112,7 @@ - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows -5000 @@ -131,7 +131,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -158,7 +158,7 @@ - QAbstractSpinBox::PlusMinus + QAbstractSpinBox::ButtonSymbols::PlusMinus 1 @@ -168,13 +168,27 @@ + + + + Sensitivity + + + + + + + 5 + + + - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Expanding + QSizePolicy::Policy::Expanding @@ -189,29 +203,35 @@ - QLayout::SetDefaultConstraint + QLayout::SizeConstraint::SetDefaultConstraint 4 - + - Sensitivity + Bars - + + + 63 + - 5 + 64 + + + 63 - Qt::Horizontal + Qt::Orientation::Horizontal @@ -226,7 +246,7 @@ - Qt::Vertical + Qt::Orientation::Vertical From 34c5751f7e07fab11a6c495d9ed5786765b1bcb7 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 25 Jan 2026 23:27:58 -0500 Subject: [PATCH 07/30] Waveform component: fix #74 - new animation speed option --- src/avp/components/waveform.py | 105 ++++++++-- src/avp/components/waveform.ui | 337 ++++++++++++++++++--------------- 2 files changed, 277 insertions(+), 165 deletions(-) diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py index 7dc0b99..55a3ae6 100644 --- a/src/avp/components/waveform.py +++ b/src/avp/components/waveform.py @@ -1,12 +1,12 @@ -from PIL import Image -from PyQt6 import QtGui, QtCore, QtWidgets +from PIL import Image, ImageChops from PyQt6.QtGui import QColor import os -import math import subprocess import logging +from copy import copy from ..component import Component +from .original import Component as Visualizer from ..toolkit.frame import BlankFrame, scale from ..toolkit import checkOutput from ..toolkit.ffmpeg import ( @@ -23,7 +23,15 @@ class Component(Component): name = "Waveform" - version = "1.0.0" + version = "2.0.0" + + @property + def updateInterval(self): + """How many frames from FFmpeg are ignored between each final frame""" + return 100 - self.speed + + def properties(self): + return [] if self.speed == 100 else ["pcm"] def widget(self, *args): super().widget(*args) @@ -46,6 +54,7 @@ def widget(self, *args): "opacity": self.page.spinBox_opacity, "compress": self.page.checkBox_compress, "mono": self.page.checkBox_mono, + "speed": self.page.spinBox_speed, }, colorWidgets={ "color": self.page.pushButton_color, @@ -65,6 +74,10 @@ def previewRender(self): return frame def preFrameRender(self, **kwargs): + self._fadingImage = None + self._prevImage = None + self._currImage = None + self._lastUpdatedFrame = 0 super().preFrameRender(**kwargs) self.updateChunksize() w, h = scale(self.scale, self.width, self.height, str) @@ -79,11 +92,79 @@ def preFrameRender(self, **kwargs): component=self, debug=True, ) + if self.speed == 100: + return + smoothConstantDown = 0.08 + smoothConstantUp = 0.8 + self.lastSpectrum = None + self.spectrumArray = {} + + for i in range(0, len(self.completeAudioArray), self.sampleSize): + if self.canceled: + break + self.lastSpectrum = Visualizer.transformData( + i, + self.completeAudioArray, + self.sampleSize, + smoothConstantDown, + smoothConstantUp, + self.lastSpectrum, + 20, + ) + self.spectrumArray[i] = copy(self.lastSpectrum) + + progress = int(100 * (i / len(self.completeAudioArray))) + if progress >= 100: + progress = 100 + pStr = "Analyzing audio: " + str(progress) + "%" + self.progressBarSetText.emit(pStr) + self.progressBarUpdate.emit(int(progress)) def frameRender(self, frameNo): if FfmpegVideo.threadError is not None: raise FfmpegVideo.threadError - return self.finalizeFrame(self.video.frame(frameNo)) + newFrame = self.finalizeFrame(self.video.frame(frameNo)) + frameDiff = ( + 0 if self.speed == 100 or frameNo == 0 else frameNo % self.updateInterval + ) + peaks = [ + self.spectrumArray[frameNo * self.sampleSize][i * 4] for i in range(64) + ] + peakValue = 70 - (max(*peaks) - min(*peaks)) + isValidPeak = ( + peakValue > 27 + and frameNo - self._lastUpdatedFrame > self.updateInterval / 2 + ) + if frameDiff == 0 or isValidPeak: + self._lastUpdatedFrame = frameNo + self._fadingImage = self._prevImage + self._prevImage = self._image + self._currImage = newFrame + usualAlpha = 0.0 + (1 / self.updateInterval) * frameDiff + alpha = max( + 0.1 + + ( + 1 + / max( + 10, + peakValue, + ) + ), + usualAlpha, + ) + baseImage = self._prevImage + if self._fadingImage is not None: + # fade away the old previous frame from ages ago + baseImage = ImageChops.blend( + self._prevImage, self._fadingImage, max(0.0, 0.9 - usualAlpha) + ) + blendedImage = ImageChops.blend( + baseImage, + ImageChops.lighter(self._prevImage, newFrame), + alpha, + ) + baseImage.paste(blendedImage, (0, 0), mask=blendedImage) + return Image.alpha_composite(self._currImage, baseImage) def postFrameRender(self): closePipe(self.video.pipe) @@ -162,17 +243,17 @@ def getPreviewFrame(self, width, height): def makeFfmpegFilter(self, preview=False, startPt=0): w, h = scale(self.scale, self.width, self.height, str) if self.amplitude == 0: - amplitude = "lin" - elif self.amplitude == 1: amplitude = "log" - elif self.amplitude == 2: + elif self.amplitude == 1: amplitude = "sqrt" - elif self.amplitude == 3: + elif self.amplitude == 2: amplitude = "cbrt" + elif self.amplitude == 3: + amplitude = "lin" hexcolor = QColor(*self.color).name() opacity = "{0:.1f}".format(self.opacity / 100) genericPreview = self.settings.value("pref_genericPreview") - if self.mode < 3: + if self.mode > 1: filter_ = ( "showwaves=" f'r={str(self.settings.value("outputFrameRate"))}:' @@ -180,10 +261,10 @@ def makeFfmpegFilter(self, preview=False, startPt=0): f'mode={self.page.comboBox_mode.currentText().lower() if self.mode != 3 else "p2p"}:' f"colors={hexcolor}@{opacity}:scale={amplitude}" ) - elif self.mode > 2: + elif self.mode < 2: filter_ = ( f'showfreqs=s={str(self.settings.value("outputWidth"))}x{str(self.settings.value("outputHeight"))}:' - f'mode={"line" if self.mode == 4 else "bar"}:' + f'mode={"line" if self.mode == 0 else "bar"}:' f"colors={hexcolor}@{opacity}" f":ascale={amplitude}:fscale={'log' if self.mono else 'lin'}" ) diff --git a/src/avp/components/waveform.ui b/src/avp/components/waveform.ui index 5473f33..7bf11c0 100644 --- a/src/avp/components/waveform.ui +++ b/src/avp/components/waveform.ui @@ -27,156 +27,144 @@ - - - 4 - + - - - - - - 0 - 0 - - - - - 31 - 0 - - - - Mode - - - - - - - - Cline - - - - - Line - - - - - Point - - - - - Frequency Bar - - - - - Frequency Line - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - + + + + 0 + 0 + + + + + 31 + 0 + + + + Mode + + + + + - - - - 0 - 0 - - - - X - - + + Frequency Line + - - - - 0 - 0 - - - - - 80 - 16777215 - - - - -10000 - - - 10000 - - + + Frequency Bar + - - - - 0 - 0 - - - - Y - - + + Cline + - - - - 0 - 0 - - - - - 80 - 16777215 - - - - - 0 - 0 - - - - -10000 - - - 10000 - - - 0 - - + + Line + - + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + @@ -192,7 +180,7 @@ - Qt::ImhNone + Qt::InputMethodHint::ImhNone @@ -224,7 +212,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -240,14 +228,14 @@ Opacity - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows % @@ -269,14 +257,14 @@ Scale - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows % @@ -320,7 +308,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -341,32 +329,75 @@ - Linear + Logarithmic - Logarithmic + Square root - Square root + Cubic root - Cubic root + Linear + + + + + + Animation Speed + + + + + + + % + + + 10 + + + 100 + + + 10 + + + 50 + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + - Qt::Vertical + Qt::Orientation::Vertical From 6f0e972abbd60f033d67231ae7ccbfef37492b21 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Mon, 26 Jan 2026 19:56:07 -0500 Subject: [PATCH 08/30] move shared visualizer code into toolkit --- src/avp/components/image.py | 37 ++++---------- src/avp/components/life.py | 37 ++++---------- src/avp/components/original.py | 84 ++++-------------------------- src/avp/components/waveform.py | 37 ++++---------- src/avp/toolkit/visualizer.py | 87 ++++++++++++++++++++++++++++++++ tests/test_classic_visualizer.py | 9 ++-- uv.lock | 2 +- 7 files changed, 135 insertions(+), 158 deletions(-) create mode 100644 src/avp/toolkit/visualizer.py diff --git a/src/avp/components/image.py b/src/avp/components/image.py index bada15f..15714e5 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -5,7 +5,7 @@ from ..component import Component from ..toolkit.frame import BlankFrame -from .original import Component as Visualizer +from ..toolkit.visualizer import createSpectrumArray class Component(Component): @@ -75,31 +75,16 @@ def preFrameRender(self, **kwargs): # Trigger creation of new base image self.existingImage = None - smoothConstantDown = 0.08 + 0 - smoothConstantUp = 0.8 - 0 - self.lastSpectrum = None - self.spectrumArray = {} - - for i in range(0, len(self.completeAudioArray), self.sampleSize): - if self.canceled: - break - self.lastSpectrum = Visualizer.transformData( - i, - self.completeAudioArray, - self.sampleSize, - smoothConstantDown, - smoothConstantUp, - self.lastSpectrum, - self.sensitivity, - ) - self.spectrumArray[i] = copy(self.lastSpectrum) - - progress = int(100 * (i / len(self.completeAudioArray))) - if progress >= 100: - progress = 100 - pStr = "Analyzing audio: " + str(progress) + "%" - self.progressBarSetText.emit(pStr) - self.progressBarUpdate.emit(int(progress)) + self.spectrumArray = createSpectrumArray( + self, + self.completeAudioArray, + self.sampleSize, + 0.08, + 0.8, + self.sensitivity, + self.progressBarUpdate, + self.progressBarSetText, + ) def frameRender(self, frameNo): return self.drawFrame( diff --git a/src/avp/components/life.py b/src/avp/components/life.py index 50f92a0..6edea61 100644 --- a/src/avp/components/life.py +++ b/src/avp/components/life.py @@ -9,7 +9,7 @@ from ..component import Component from ..toolkit.frame import BlankFrame, scale -from .original import Component as Visualizer +from ..toolkit.visualizer import createSpectrumArray log = logging.getLogger("AVP.Component.Life") @@ -170,34 +170,19 @@ def previewRender(self): def preFrameRender(self, *args, **kwargs): super().preFrameRender(*args, **kwargs) self.tickGrids = {0: self.startingGrid} - - smoothConstantDown = 0.08 + 0 - smoothConstantUp = 0.8 - 0 - self.lastSpectrum = None - self.spectrumArray = {} if self.sensitivity == 0: return - for i in range(0, len(self.completeAudioArray), self.sampleSize): - if self.canceled: - break - self.lastSpectrum = Visualizer.transformData( - i, - self.completeAudioArray, - self.sampleSize, - smoothConstantDown, - smoothConstantUp, - self.lastSpectrum, - self.sensitivity, - ) - self.spectrumArray[i] = copy(self.lastSpectrum) - - progress = int(100 * (i / len(self.completeAudioArray))) - if progress >= 100: - progress = 100 - pStr = "Analyzing audio: " + str(progress) + "%" - self.progressBarSetText.emit(pStr) - self.progressBarUpdate.emit(int(progress)) + self.spectrumArray = createSpectrumArray( + self, + self.completeAudioArray, + self.sampleSize, + 0.08, + 0.8, + 20, + self.progressBarUpdate, + self.progressBarSetText, + ) def properties(self): if self.customImg and (not self.image or not os.path.exists(self.image)): diff --git a/src/avp/components/original.py b/src/avp/components/original.py index 1e1d8f8..0db4a3e 100644 --- a/src/avp/components/original.py +++ b/src/avp/components/original.py @@ -4,6 +4,7 @@ from ..component import Component from ..toolkit.frame import BlankFrame +from ..toolkit.visualizer import createSpectrumArray class Component(Component): @@ -61,29 +62,16 @@ 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.lastSpectrum = None - self.spectrumArray = {} - - for i in range(0, len(self.completeAudioArray), self.sampleSize): - if self.canceled: - break - self.lastSpectrum = self.transformData( - i, - self.completeAudioArray, - self.sampleSize, - smoothConstantDown, - smoothConstantUp, - self.lastSpectrum, - self.scale, - ) - self.spectrumArray[i] = copy(self.lastSpectrum) - - progress = int(100 * (i / len(self.completeAudioArray))) - if progress >= 100: - progress = 100 - pStr = "Analyzing audio: " + str(progress) + "%" - self.progressBarSetText.emit(pStr) - self.progressBarUpdate.emit(int(progress)) + self.spectrumArray = createSpectrumArray( + self, + self.completeAudioArray, + self.sampleSize, + smoothConstantDown, + smoothConstantUp, + self.scale, + self.progressBarUpdate, + self.progressBarSetText, + ) def frameRender(self, frameNo): arrayNo = frameNo * self.sampleSize @@ -95,56 +83,6 @@ def frameRender(self, frameNo): self.layout, ) - @staticmethod - def transformData( - i, - completeAudioArray, - sampleSize, - smoothConstantDown, - smoothConstantUp, - lastSpectrum, - scale, - ): - if len(completeAudioArray) < (i + sampleSize): - sampleSize = len(completeAudioArray) - i - - window = numpy.hanning(sampleSize) - data = completeAudioArray[i : i + sampleSize][::1] * window - paddedSampleSize = 2048 - paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), "constant") - spectrum = numpy.fft.fft(paddedData) - sample_rate = 44100 - frequencies = numpy.fft.fftfreq(len(spectrum), 1.0 / sample_rate) - - y = abs(spectrum[0 : int(paddedSampleSize / 2) - 1]) - - # filter the noise away - # y[y<80] = 0 - - with numpy.errstate(divide="ignore"): - y = scale * numpy.log10(y) - - y[numpy.isinf(y)] = 0 - - if lastSpectrum is not None: - lastSpectrum[y < lastSpectrum] = y[ - y < lastSpectrum - ] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * ( - 1 - smoothConstantDown - ) - - lastSpectrum[y >= lastSpectrum] = y[ - y >= lastSpectrum - ] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * ( - 1 - smoothConstantUp - ) - else: - lastSpectrum = y - - x = frequencies[0 : int(paddedSampleSize / 2) - 1] - - return lastSpectrum - def drawBars(self, width, height, spectrum, color, layout): bigYCoord = height - height / 8 smallYCoord = height / 1200 diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py index 55a3ae6..3469df3 100644 --- a/src/avp/components/waveform.py +++ b/src/avp/components/waveform.py @@ -6,7 +6,7 @@ from copy import copy from ..component import Component -from .original import Component as Visualizer +from ..toolkit.visualizer import transformData, createSpectrumArray from ..toolkit.frame import BlankFrame, scale from ..toolkit import checkOutput from ..toolkit.ffmpeg import ( @@ -94,31 +94,16 @@ def preFrameRender(self, **kwargs): ) if self.speed == 100: return - smoothConstantDown = 0.08 - smoothConstantUp = 0.8 - self.lastSpectrum = None - self.spectrumArray = {} - - for i in range(0, len(self.completeAudioArray), self.sampleSize): - if self.canceled: - break - self.lastSpectrum = Visualizer.transformData( - i, - self.completeAudioArray, - self.sampleSize, - smoothConstantDown, - smoothConstantUp, - self.lastSpectrum, - 20, - ) - self.spectrumArray[i] = copy(self.lastSpectrum) - - progress = int(100 * (i / len(self.completeAudioArray))) - if progress >= 100: - progress = 100 - pStr = "Analyzing audio: " + str(progress) + "%" - self.progressBarSetText.emit(pStr) - self.progressBarUpdate.emit(int(progress)) + self.spectrumArray = createSpectrumArray( + self, + self.completeAudioArray, + self.sampleSize, + 0.08, + 0.8, + 20, + self.progressBarUpdate, + self.progressBarSetText, + ) def frameRender(self, frameNo): if FfmpegVideo.threadError is not None: diff --git a/src/avp/toolkit/visualizer.py b/src/avp/toolkit/visualizer.py new file mode 100644 index 0000000..c55a3f3 --- /dev/null +++ b/src/avp/toolkit/visualizer.py @@ -0,0 +1,87 @@ +"""Functions used to transform and manipulate audio for use by visualizers""" + +from copy import copy +import numpy + + +def createSpectrumArray( + component, + completeAudioArray, + sampleSize, + smoothConstantDown, + smoothConstantUp, + scale, + progressBarUpdate, + progressBarSetText, +): + lastSpectrum = None + spectrumArray = {} + for i in range(0, len(completeAudioArray), sampleSize): + if component.canceled: + break + lastSpectrum = transformData( + i, + completeAudioArray, + sampleSize, + smoothConstantDown, + smoothConstantUp, + lastSpectrum, + scale, + ) + spectrumArray[i] = copy(lastSpectrum) + + progress = int(100 * (i / len(completeAudioArray))) + if progress >= 100: + progress = 100 + progressText = f"Analyzing audio: {str(progress)}%" + progressBarSetText.emit(progressText) + progressBarUpdate.emit(int(progress)) + return spectrumArray + + +def transformData( + i, + completeAudioArray, + sampleSize, + smoothConstantDown, + smoothConstantUp, + lastSpectrum, + scale, +): + if len(completeAudioArray) < (i + sampleSize): + sampleSize = len(completeAudioArray) - i + + window = numpy.hanning(sampleSize) + data = completeAudioArray[i : i + sampleSize][::1] * window + paddedSampleSize = 2048 + paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), "constant") + spectrum = numpy.fft.fft(paddedData) + sample_rate = 44100 + frequencies = numpy.fft.fftfreq(len(spectrum), 1.0 / sample_rate) + + y = abs(spectrum[0 : int(paddedSampleSize / 2) - 1]) + + # filter the noise away + # y[y<80] = 0 + + with numpy.errstate(divide="ignore"): + y = scale * numpy.log10(y) + + y[numpy.isinf(y)] = 0 + + if lastSpectrum is not None: + lastSpectrum[y < lastSpectrum] = y[ + y < lastSpectrum + ] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * ( + 1 - smoothConstantDown + ) + + lastSpectrum[y >= lastSpectrum] = y[ + y >= lastSpectrum + ] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * (1 - smoothConstantUp) + else: + lastSpectrum = y + + x = frequencies[0 : int(paddedSampleSize / 2) - 1] + + return lastSpectrum diff --git a/tests/test_classic_visualizer.py b/tests/test_classic_visualizer.py index e301263..6264644 100644 --- a/tests/test_classic_visualizer.py +++ b/tests/test_classic_visualizer.py @@ -1,4 +1,5 @@ from avp.command import Command +from avp.toolkit.visualizer import transformData from pytestqt import qtbot from pytest import fixture from . import audioData, MockSignal, imageDataSum @@ -31,13 +32,9 @@ def test_comp_classic_removed(coreWithClassicComp): def test_comp_classic_drawBars(coreWithClassicComp, audioData): """Call drawBars after creating audio spectrum data manually.""" - spectrumArray = { - 0: coreWithClassicComp.selectedComponents[0].transformData( - 0, audioData[0], sampleSize, 0.08, 0.8, None, 20 - ) - } + spectrumArray = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)} for i in range(sampleSize, len(audioData[0]), sampleSize): - spectrumArray[i] = coreWithClassicComp.selectedComponents[0].transformData( + spectrumArray[i] = transformData( i, audioData[0], sampleSize, diff --git a/uv.lock b/uv.lock index a3d6bfb..edc9406 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "audio-visualizer-python" -version = "2.2.0" +version = "2.2.1" source = { editable = "." } dependencies = [ { name = "numpy" }, From f53875393027ecb0ca46e69e2217794a9fbabed8 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Mon, 26 Jan 2026 19:58:27 -0500 Subject: [PATCH 09/30] Waveform component: compress audio by default --- src/avp/components/waveform.ui | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/avp/components/waveform.ui b/src/avp/components/waveform.ui index 7bf11c0..434ba62 100644 --- a/src/avp/components/waveform.ui +++ b/src/avp/components/waveform.ui @@ -289,6 +289,9 @@ Compress + + true + From 06b95c3bb4e58bafc3e97da1004dc90e0bd12607 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Mon, 26 Jan 2026 19:59:00 -0500 Subject: [PATCH 10/30] Waveform component: fix 100% animation speed --- src/avp/components/waveform.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py index 3469df3..16a9e32 100644 --- a/src/avp/components/waveform.py +++ b/src/avp/components/waveform.py @@ -109,9 +109,9 @@ def frameRender(self, frameNo): if FfmpegVideo.threadError is not None: raise FfmpegVideo.threadError newFrame = self.finalizeFrame(self.video.frame(frameNo)) - frameDiff = ( - 0 if self.speed == 100 or frameNo == 0 else frameNo % self.updateInterval - ) + if self.speed == 100: + return newFrame + frameDiff = 0 if frameNo == 0 else frameNo % self.updateInterval peaks = [ self.spectrumArray[frameNo * self.sampleSize][i * 4] for i in range(64) ] From 36663e92852168a71526e0a3462175392809afff Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Mon, 26 Jan 2026 21:05:26 -0500 Subject: [PATCH 11/30] new components receive random color --- src/avp/component.py | 8 +++++++- src/avp/components/color.ui | 2 +- src/avp/components/life.ui | 2 +- src/avp/components/original.py | 2 -- src/avp/components/original.ui | 6 +++++- src/avp/components/text.ui | 2 +- src/avp/components/waveform.py | 2 -- src/avp/gui/mainwindow.py | 4 ++++ src/avp/toolkit/common.py | 9 +++++++-- tests/test_text_comp.py | 3 ++- 10 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/avp/component.py b/src/avp/component.py index 6c5e381..416e6b6 100644 --- a/src/avp/component.py +++ b/src/avp/component.py @@ -18,6 +18,7 @@ setWidgetValue, connectWidget, rgbFromString, + randomColor, blockSignals, ) @@ -635,9 +636,14 @@ def 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:-2]) self._colorWidgets[attr].clicked.connect(func) self._colorWidgets[attr].setStyleSheet( - "QPushButton {" "background-color : #FFFFFF; outline: none; }" + "QPushButton { background-color : #%s; outline: none; }" + % QColor(*(rgbFromString(colorText) if colorText else rndColor)) ) if kwarg == "relativeWidgets": diff --git a/src/avp/components/color.ui b/src/avp/components/color.ui index c1713fb..f5d0b29 100644 --- a/src/avp/components/color.ui +++ b/src/avp/components/color.ui @@ -74,7 +74,7 @@ - 0,0,0 + 12 diff --git a/src/avp/components/life.ui b/src/avp/components/life.ui index a0c8999..0070fa4 100644 --- a/src/avp/components/life.ui +++ b/src/avp/components/life.ui @@ -68,7 +68,7 @@ - 255,255,255 + diff --git a/src/avp/components/original.py b/src/avp/components/original.py index 0db4a3e..0da78dc 100644 --- a/src/avp/components/original.py +++ b/src/avp/components/original.py @@ -29,8 +29,6 @@ def widget(self, *args): self.page.comboBox_visLayout.addItem("Top") self.page.comboBox_visLayout.setCurrentIndex(0) - self.page.lineEdit_visColor.setText("255,255,255") - self.trackWidgets( { "visColor": self.page.lineEdit_visColor, diff --git a/src/avp/components/original.ui b/src/avp/components/original.ui index cfe9a5c..8dbdaa2 100644 --- a/src/avp/components/original.ui +++ b/src/avp/components/original.ui @@ -84,7 +84,11 @@ - + + + + + diff --git a/src/avp/components/text.ui b/src/avp/components/text.ui index b62e0ed..f56e959 100644 --- a/src/avp/components/text.ui +++ b/src/avp/components/text.ui @@ -428,7 +428,7 @@ Qt::NoFocus - 255,255,255 + diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py index 16a9e32..e10dec2 100644 --- a/src/avp/components/waveform.py +++ b/src/avp/components/waveform.py @@ -37,8 +37,6 @@ def widget(self, *args): super().widget(*args) self._image = BlankFrame(self.width, self.height) - self.page.lineEdit_color.setText("255,255,255") - if hasattr(self.parent, "lineEdit_audioFile"): self.parent.lineEdit_audioFile.textChanged.connect(self.update) diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py index a0655c7..c49b919 100644 --- a/src/avp/gui/mainwindow.py +++ b/src/avp/gui/mainwindow.py @@ -416,6 +416,10 @@ def changedField(): if not self.core.selectedComponents: self.core.insertComponent(0, 0, self) self.core.insertComponent(1, 1, self) + self.core.selectedComponents[0].page.lineEdit_visColor.setText( + "255,255,255" + ) + self.core.selectedComponents[1].page.lineEdit_color1.setText("0,0,0") def __repr__(self): return ( diff --git a/src/avp/toolkit/common.py b/src/avp/toolkit/common.py index e35aba2..c77dbdb 100644 --- a/src/avp/toolkit/common.py +++ b/src/avp/toolkit/common.py @@ -4,7 +4,7 @@ from PyQt6 import QtWidgets import string -import os +import random import sys import subprocess import logging @@ -136,7 +136,8 @@ def rgbFromString(string): raise ValueError return tup except: - return (255, 255, 255) + log.warning("Error parsing color. Generated random color.") + return randomColor() def formatTraceback(tb=None): @@ -190,3 +191,7 @@ def getWidgetValue(widget): return widget.isChecked() elif type(widget) == QtWidgets.QComboBox: return widget.currentIndex() + + +def randomColor(): + return (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) diff --git a/tests/test_text_comp.py b/tests/test_text_comp.py index 23dd1fd..a6ec705 100644 --- a/tests/test_text_comp.py +++ b/tests/test_text_comp.py @@ -18,8 +18,9 @@ def test_comp_text_renderFrame_resize(coreWithTextComp): comp = coreWithTextComp.selectedComponents[0] comp.parent.settings.setValue("outputWidth", 1920) comp.parent.settings.setValue("outputHeight", 1080) - comp.parent.core.updateComponent(0) comp.titleFont = QFont("Noto Sans") + comp.parent.core.updateComponent(0) + comp.page.lineEdit_textColor.setText("255,255,255") image = comp.frameRender(0) assert imageDataSum(image) == 2957069 From 24c982809c4acda702e0bfff150f173f5d65abee Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Mon, 26 Jan 2026 22:18:05 -0500 Subject: [PATCH 12/30] update to Qt 6 --- src/avp/components/color.ui | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/avp/components/color.ui b/src/avp/components/color.ui index f5d0b29..c36bdd8 100644 --- a/src/avp/components/color.ui +++ b/src/avp/components/color.ui @@ -74,7 +74,7 @@ - + 12 @@ -84,10 +84,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -176,7 +176,7 @@ Width - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter @@ -246,10 +246,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -370,7 +370,7 @@ -1 - QComboBox::AdjustToContentsOnFirstShow + QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow @@ -422,10 +422,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Minimum + QSizePolicy::Policy::Minimum @@ -461,7 +461,7 @@ -1 0 561 - 31 + 34 @@ -503,7 +503,7 @@ End - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -523,7 +523,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -543,7 +543,7 @@ -1 -1 561 - 31 + 34 @@ -559,7 +559,7 @@ Start - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -588,7 +588,7 @@ End - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -617,14 +617,14 @@ Centre - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::PlusMinus + QAbstractSpinBox::ButtonSymbols::PlusMinus -10000 @@ -640,7 +640,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal From 3706b215b0c4cad84859139b4f6384821bcab27d Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Mon, 26 Jan 2026 22:54:07 -0500 Subject: [PATCH 13/30] fix pushbutton stylesheet --- src/avp/component.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/avp/component.py b/src/avp/component.py index 416e6b6..592cbf3 100644 --- a/src/avp/component.py +++ b/src/avp/component.py @@ -642,8 +642,11 @@ def pickColor_(): self._trackedWidgets[attr].setText(str(rndColor)[1:-2]) self._colorWidgets[attr].clicked.connect(func) self._colorWidgets[attr].setStyleSheet( - "QPushButton { background-color : #%s; outline: none; }" - % QColor(*(rgbFromString(colorText) if colorText else rndColor)) + "QPushButton {" + "background-color : %s; outline: none; }" + % QColor( + *rgbFromString(colorText) if colorText else rndColor + ).name() ) if kwarg == "relativeWidgets": From b28fd2dae98e1148bf5fe4a4054da0ce53959f37 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Mon, 26 Jan 2026 22:56:00 -0500 Subject: [PATCH 14/30] fix #92: replace ok/cancel with save/discard/cancel --- src/avp/gui/mainwindow.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py index c49b919..c514a1b 100644 --- a/src/avp/gui/mainwindow.py +++ b/src/avp/gui/mainwindow.py @@ -416,10 +416,12 @@ def changedField(): if not self.core.selectedComponents: self.core.insertComponent(0, 0, self) self.core.insertComponent(1, 1, self) + # set colors to white and black to match classic appearance of program self.core.selectedComponents[0].page.lineEdit_visColor.setText( "255,255,255" ) self.core.selectedComponents[1].page.lineEdit_color1.setText("0,0,0") + self.undoStack.clear() def __repr__(self): return ( @@ -897,7 +899,9 @@ def clear(self): @disableWhenEncoding def createNewProject(self, prompt=True): if prompt: - self.openSaveChangesDialog("starting a new project") + ch = self.openSaveChangesDialog("starting a new project") + if ch is None: + return self.clear() self.currentProject = None @@ -917,6 +921,7 @@ def saveCurrentProject(self): def openSaveChangesDialog(self, phrase): success = True + ch = True if self.autosaveExists(identical=False): ch = self.showMessage( msg="You have unsaved changes in project '%s'. " @@ -926,9 +931,9 @@ def openSaveChangesDialog(self, phrase): ) if ch: success = self.saveProjectChanges() - - if success and os.path.exists(self.autosavePath): + if ch is not None and success and os.path.exists(self.autosavePath): os.remove(self.autosavePath) + return success and ch def openSaveProjectDialog(self): filename, _ = QtWidgets.QFileDialog.getSaveFileName( @@ -965,10 +970,12 @@ def openProject(self, filepath, prompt=True): ): return - self.clear() # ask to save any changes that are about to get deleted if prompt: - self.openSaveChangesDialog("opening another project") + ch = self.openSaveChangesDialog("opening another project") + if ch is None: + return + self.clear() self.currentProject = filepath self.settings.setValue("currentProject", filepath) @@ -992,15 +999,21 @@ def showMessage(self, **kwargs): msg.setDetailedText(kwargs["detail"] if "detail" in kwargs else None) if "showCancel" in kwargs and kwargs["showCancel"]: msg.setStandardButtons( - QtWidgets.QMessageBox.StandardButton.Ok + QtWidgets.QMessageBox.StandardButton.Save + | QtWidgets.QMessageBox.StandardButton.Discard | QtWidgets.QMessageBox.StandardButton.Cancel ) else: msg.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok) ch = msg.exec() - if ch == 1024: + if ch == 1024 or ch == 2048: + # OK or Save return True - return False + elif ch > 8000000: + # Discard + return False + # Cancel + return None @disableWhenEncoding def componentContextMenu(self, QPos): From 73ed65a090cef62c1a9c0b0fab69f527edb78793 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 27 Jan 2026 21:10:30 -0500 Subject: [PATCH 15/30] remove obsolete PaintColor subclass --- src/avp/components/color.py | 10 +++++----- src/avp/components/text.py | 2 +- src/avp/toolkit/frame.py | 18 +----------------- 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/src/avp/components/color.py b/src/avp/components/color.py index 1f32c23..cb0960a 100644 --- a/src/avp/components/color.py +++ b/src/avp/components/color.py @@ -2,7 +2,7 @@ import logging from ..component import Component -from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor +from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter log = logging.getLogger("AVP.Components.Color") @@ -152,13 +152,13 @@ def drawFrame(self, width, height): elif self.spread == 2: spread = QtGui.QGradient.Spread.RepeatSpread brush.setSpread(spread) - brush.setColorAt(0.0, PaintColor(*self.color1)) + brush.setColorAt(0.0, QtGui.QColor(*self.color1)) if self.trans: - brush.setColorAt(1.0, PaintColor(0, 0, 0, 0)) + brush.setColorAt(1.0, QtGui.QColor(0, 0, 0, 0)) elif self.fillType == 1 and self.stretch: - brush.setColorAt(0.2, PaintColor(*self.color2)) + brush.setColorAt(0.2, QtGui.QColor(*self.color2)) else: - brush.setColorAt(1.0, PaintColor(*self.color2)) + brush.setColorAt(1.0, QtGui.QColor(*self.color2)) image.setBrush(brush) image.drawRect(self.x, self.y, self.sizeWidth, self.sizeHeight) diff --git a/src/avp/components/text.py b/src/avp/components/text.py index 40c981a..e0d6df2 100644 --- a/src/avp/components/text.py +++ b/src/avp/components/text.py @@ -173,7 +173,7 @@ def addText(self, width, height): path.addText(x, y, font, self.title) path = outliner.createStroke(path) image.setPen(QtCore.Qt.PenStyle.NoPen) - image.setBrush(PaintColor(*self.strokeColor)) + image.setBrush(QtGui.QColor(*self.strokeColor)) image.drawPath(path) image.setFont(font) diff --git a/src/avp/toolkit/frame.py b/src/avp/toolkit/frame.py index 94537a6..1fccccb 100644 --- a/src/avp/toolkit/frame.py +++ b/src/avp/toolkit/frame.py @@ -30,7 +30,7 @@ def __init__(self, width, height): def setPen(self, penStyle): if type(penStyle) is tuple: - super().setPen(PaintColor(*penStyle)) + super().setPen(QtGui.QColor(*penStyle)) else: super().setPen(penStyle) @@ -45,24 +45,8 @@ def finalize(self): buffer.close() self.end() return frame - imBytes = self.image.bits().asstring(self.image.byteCount()) - frame = Image.frombytes( - "RGBA", (self.image.width(), self.image.height()), imBytes - ) - self.end() - return frame - -class PaintColor(QtGui.QColor): - """ - Subclass of QtGui.QColor with an added scale() method - Previously this class reversed the painter colour to solve - hardware issues related to endianness, - but Qt appears to deal with this itself nowadays - """ - def __init__(self, r, g, b, a=255): - super().__init__(r, g, b, a) def scale(scalePercent, width, height, returntype=None): From 56810e18d4a5acb2804f3efb1fc6173be2a1949f Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 27 Jan 2026 21:12:08 -0500 Subject: [PATCH 16/30] mv common shadow code into addShadow func --- src/avp/components/life.py | 19 +++++-------------- src/avp/components/text.py | 8 ++------ src/avp/toolkit/frame.py | 9 ++++++++- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/avp/components/life.py b/src/avp/components/life.py index 6edea61..a062617 100644 --- a/src/avp/components/life.py +++ b/src/avp/components/life.py @@ -8,7 +8,7 @@ from ..component import Component -from ..toolkit.frame import BlankFrame, scale +from ..toolkit.frame import BlankFrame, scale, addShadow from ..toolkit.visualizer import createSpectrumArray @@ -163,7 +163,8 @@ def updateGridSize(self): def previewRender(self): image = self.drawGrid(self.startingGrid, self.color) image = self.addKaleidoscopeEffect(image) - image = self.addShadow(image) + if self.shadow: + image = addShadow(image, 5.00, -2, 2) image = self.addGridLines(image) return image @@ -241,21 +242,11 @@ def frameRender(self, frameNo): if not self.customImg: image = Image.alpha_composite(previousGridImage, image) image = self.addKaleidoscopeEffect(image) - image = self.addShadow(image) + if self.shadow: + image = addShadow(image, 5.00, -2, 2) image = self.addGridLines(image) return image - def addShadow(self, frame): - if not self.shadow: - return frame - - shadImg = ImageEnhance.Contrast(frame).enhance(0.0) - shadImg = shadImg.filter(ImageFilter.GaussianBlur(5.00)) - shadImg = ImageChops.offset(shadImg, -2, 2) - shadImg.paste(frame, box=(0, 0), mask=frame) - frame = shadImg - return frame - def addGridLines(self, frame): if not self.showGrid: return frame diff --git a/src/avp/components/text.py b/src/avp/components/text.py index e0d6df2..0f02880 100644 --- a/src/avp/components/text.py +++ b/src/avp/components/text.py @@ -5,7 +5,7 @@ import logging from ..component import Component -from ..toolkit.frame import FramePainter, PaintColor +from ..toolkit.frame import FramePainter, addShadow log = logging.getLogger("AVP.Components.Text") @@ -183,11 +183,7 @@ def addText(self, width, height): # turn QImage into Pillow frame frame = image.finalize() if self.shadow: - shadImg = ImageEnhance.Contrast(frame).enhance(0.0) - shadImg = shadImg.filter(ImageFilter.GaussianBlur(self.shadBlur)) - shadImg = ImageChops.offset(shadImg, self.shadX, self.shadY) - shadImg.paste(frame, box=(0, 0), mask=frame) - frame = shadImg + frame = addShadow(frame, self.shadBlur, self.shadX, self.shadY) return frame diff --git a/src/avp/toolkit/frame.py b/src/avp/toolkit/frame.py index 1fccccb..2859556 100644 --- a/src/avp/toolkit/frame.py +++ b/src/avp/toolkit/frame.py @@ -3,7 +3,7 @@ """ from PyQt6 import QtGui -from PIL import Image +from PIL import Image, ImageEnhance, ImageChops, ImageFilter from PIL.ImageQt import ImageQt from PyQt6 import QtCore import sys @@ -47,6 +47,13 @@ def finalize(self): return frame +def addShadow(frame, blurRadius, blurOffsetX, blurOffsetY): + shadImg = ImageEnhance.Contrast(frame).enhance(0.0) + shadImg = shadImg.filter(ImageFilter.GaussianBlur(blurRadius)) + shadImg = ImageChops.offset(shadImg, blurOffsetX, blurOffsetY) + frame = shadImg.paste(frame, box=(0, 0), mask=frame) + frame = shadImg + return frame def scale(scalePercent, width, height, returntype=None): From 13273a94c035842a1a73b35f6aa5a3fcb6c76a8c Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 27 Jan 2026 21:51:53 -0500 Subject: [PATCH 17/30] add 3rd option of ok/cancel back to showMessage the 3 options are: - ok - ok/cancel - save/discard/cancel --- src/avp/gui/mainwindow.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py index c514a1b..5a051fd 100644 --- a/src/avp/gui/mainwindow.py +++ b/src/avp/gui/mainwindow.py @@ -927,7 +927,7 @@ def openSaveChangesDialog(self, phrase): msg="You have unsaved changes in project '%s'. " "Save before %s?" % (os.path.basename(self.currentProject)[:-4], phrase), - showCancel=True, + showDiscard=True, ) if ch: success = self.saveProjectChanges() @@ -997,12 +997,17 @@ def showMessage(self, **kwargs): else QtWidgets.QMessageBox.Icon.Information ) msg.setDetailedText(kwargs["detail"] if "detail" in kwargs else None) - if "showCancel" in kwargs and kwargs["showCancel"]: + if "showDiscard" in kwargs and kwargs["showDiscard"]: msg.setStandardButtons( QtWidgets.QMessageBox.StandardButton.Save | QtWidgets.QMessageBox.StandardButton.Discard | QtWidgets.QMessageBox.StandardButton.Cancel ) + elif "showCancel" in kwargs and kwargs["showCancel"]: + msg.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Ok + | QtWidgets.QMessageBox.StandardButton.Cancel + ) else: msg.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok) ch = msg.exec() From b3b9219128fb4bd8b95b4bf71dc23e3071dd6f19 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 27 Jan 2026 21:53:19 -0500 Subject: [PATCH 18/30] Image component: add shadow option --- src/avp/components/image.py | 13 +++++++++++-- src/avp/components/image.ui | 10 ++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index 15714e5..c46408a 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -4,13 +4,13 @@ from copy import copy from ..component import Component -from ..toolkit.frame import BlankFrame +from ..toolkit.frame import BlankFrame, addShadow from ..toolkit.visualizer import createSpectrumArray class Component(Component): name = "Image" - version = "2.0.0" + version = "2.1.0" def widget(self, *args): super().widget(*args) @@ -35,6 +35,7 @@ def widget(self, *args): "mirror": self.page.checkBox_mirror, "respondToAudio": self.page.checkBox_respondToAudio, "sensitivity": self.page.spinBox_sensitivity, + "shadow": self.page.checkBox_shadow, }, presetNames={ "imagePath": "image", @@ -124,9 +125,15 @@ def drawFrame(self, width, height, dynamicScale): self.existingImage = image # Respond to audio + shadX = 3 + shadY = -1 + shadBlur = 4.00 scale = 0 if dynamicScale is not None: scale = dynamicScale[36 * 4] / 4 + shadX += int(scale / 2) + shadY += int(scale / 2) + shadBlur += scale / 8 image = ImageOps.contain( image, ( @@ -146,6 +153,8 @@ def drawFrame(self, width, height, dynamicScale): ) if self.rotate != 0: frame = frame.rotate(self.rotate) + if self.shadow: + frame = addShadow(frame, shadBlur, shadX, shadY) return frame diff --git a/src/avp/components/image.ui b/src/avp/components/image.ui index 72593a3..b53c4b0 100644 --- a/src/avp/components/image.ui +++ b/src/avp/components/image.ui @@ -306,6 +306,16 @@ + + + + Qt::LayoutDirection::RightToLeft + + + Shadow + + + From 599e7735f4e708c639b1384fd21bf589201271dd Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 27 Jan 2026 22:27:40 -0500 Subject: [PATCH 19/30] small test of rgbFromString --- src/avp/toolkit/common.py | 6 +++--- tests/test_toolkit_common.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/avp/toolkit/common.py b/src/avp/toolkit/common.py index c77dbdb..32a280f 100644 --- a/src/avp/toolkit/common.py +++ b/src/avp/toolkit/common.py @@ -135,9 +135,9 @@ def rgbFromString(string): if i > 255 or i < 0: raise ValueError return tup - except: - log.warning("Error parsing color. Generated random color.") - return randomColor() + except Exception as e: + log.warning("Could not parse '%s' as a color (encountered %s).", string, type(e).__name__) + return (255, 255, 255) def formatTraceback(tb=None): diff --git a/tests/test_toolkit_common.py b/tests/test_toolkit_common.py index d903842..8e9dca2 100644 --- a/tests/test_toolkit_common.py +++ b/tests/test_toolkit_common.py @@ -1,6 +1,27 @@ +from pytest import fixture from pytestqt import qtbot from avp.command import Command -from avp.toolkit import blockSignals +from avp.toolkit import blockSignals, rgbFromString + + +@fixture +def gotWarning(): + """Check if a function called log.warning""" + import avp.toolkit.common as tk + warning = False + def gotWarning(): + nonlocal warning + return warning + class log: + def warning(self, *args): + nonlocal warning + warning = True + oldLog = tk.log + tk.log = log() + try: + yield gotWarning + finally: + tk.log = oldLog def test_blockSignals(qtbot): @@ -11,3 +32,13 @@ def test_blockSignals(qtbot): with blockSignals(comp.page.spinBox_scale): assert comp.page.spinBox_scale.signalsBlocked() == True assert comp.page.spinBox_scale.signalsBlocked() == False + + +def test_rgbFromString(gotWarning): + assert rgbFromString("255,255,255") == (255, 255, 255) + assert not gotWarning() + + +def test_rgbFromString_error(gotWarning): + assert rgbFromString("255,255,256") == (255, 255, 255) + assert gotWarning() \ No newline at end of file From 3bee82df0c69f1a2d9c13522714a976e003130ca Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 27 Jan 2026 23:44:25 -0500 Subject: [PATCH 20/30] fix color tuple string --- src/avp/component.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/avp/component.py b/src/avp/component.py index 592cbf3..6fba834 100644 --- a/src/avp/component.py +++ b/src/avp/component.py @@ -639,7 +639,7 @@ def pickColor_(): colorText = self._trackedWidgets[attr].text() if colorText == "": rndColor = randomColor() - self._trackedWidgets[attr].setText(str(rndColor)[1:-2]) + self._trackedWidgets[attr].setText(str(rndColor)[1:-1]) self._colorWidgets[attr].clicked.connect(func) self._colorWidgets[attr].setStyleSheet( "QPushButton {" From 6aeda1426394c568c06695ff548b4459343a12f7 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 27 Jan 2026 23:45:11 -0500 Subject: [PATCH 21/30] test another way to get comp names from CLI --- tests/test_commandline_parser.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_commandline_parser.py b/tests/test_commandline_parser.py index d092072..77836ce 100644 --- a/tests/test_commandline_parser.py +++ b/tests/test_commandline_parser.py @@ -51,6 +51,11 @@ def test_commandline_parses_classic_by_alias(qtbot): assert command.parseCompName("original") == "Classic Visualizer" -def test_commandline_parses_conway_by_name(qtbot): +def test_commandline_parses_conway_by_short_name(qtbot): command = Command() assert command.parseCompName("conway") == "Conway's Game of Life" + + +def test_commandline_parses_image_by_name(qtbot): + command = Command() + assert command.parseCompName("image") == "Image" From 58a7143f64d17c80707073560f498a65996efea3 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 27 Jan 2026 23:46:28 -0500 Subject: [PATCH 22/30] rename component tests, add some more --- tests/test_comp_color.py | 22 +++++++++++++++++++ ...{test_image_comp.py => test_comp_image.py} | 0 tests/test_comp_life.py | 21 ++++++++++++++++++ ...ic_visualizer.py => test_comp_original.py} | 0 tests/test_comp_spectrum.py | 20 +++++++++++++++++ .../{test_text_comp.py => test_comp_text.py} | 4 +++- tests/test_comp_waveform.py | 21 ++++++++++++++++++ 7 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 tests/test_comp_color.py rename tests/{test_image_comp.py => test_comp_image.py} (100%) create mode 100644 tests/test_comp_life.py rename tests/{test_classic_visualizer.py => test_comp_original.py} (100%) create mode 100644 tests/test_comp_spectrum.py rename tests/{test_text_comp.py => test_comp_text.py} (90%) create mode 100644 tests/test_comp_waveform.py diff --git a/tests/test_comp_color.py b/tests/test_comp_color.py new file mode 100644 index 0000000..6b82e4c --- /dev/null +++ b/tests/test_comp_color.py @@ -0,0 +1,22 @@ +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture +from . import imageDataSum + + +@fixture +def coreWithColorComp(qtbot): + """Fixture providing a Command object with Color component added""" + command = Command() + command.settings.setValue("outputHeight", 1080) + command.settings.setValue("outputWidth", 1920) + command.core.insertComponent(0, command.core.moduleIndexFor("Color"), command) + yield command.core + + +def test_comp_color_set_color(coreWithColorComp): + "Set imagePath of Image component" + comp = coreWithColorComp.selectedComponents[0] + comp.page.lineEdit_color1.setText("111,111,111") + image = comp.previewRender() + assert imageDataSum(image) == 1219276800 diff --git a/tests/test_image_comp.py b/tests/test_comp_image.py similarity index 100% rename from tests/test_image_comp.py rename to tests/test_comp_image.py diff --git a/tests/test_comp_life.py b/tests/test_comp_life.py new file mode 100644 index 0000000..537dbff --- /dev/null +++ b/tests/test_comp_life.py @@ -0,0 +1,21 @@ +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture +from . import imageDataSum + + +@fixture +def coreWithLifeComp(qtbot): + """Fixture providing a Command object with Waveform component added""" + command = Command() + command.settings.setValue("outputHeight", 1080) + command.settings.setValue("outputWidth", 1920) + command.core.insertComponent(0, command.core.moduleIndexFor("Conway's Game of Life"), command) + yield command.core + + +def test_comp_life_previewRender(coreWithLifeComp): + comp = coreWithLifeComp.selectedComponents[0] + comp.page.lineEdit_color.setText("111,111,111") + image = comp.previewRender() + assert imageDataSum(image) == 339814246 diff --git a/tests/test_classic_visualizer.py b/tests/test_comp_original.py similarity index 100% rename from tests/test_classic_visualizer.py rename to tests/test_comp_original.py diff --git a/tests/test_comp_spectrum.py b/tests/test_comp_spectrum.py new file mode 100644 index 0000000..44fb257 --- /dev/null +++ b/tests/test_comp_spectrum.py @@ -0,0 +1,20 @@ +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture +from . import imageDataSum + + +@fixture +def coreWithSpectrumComp(qtbot): + """Fixture providing a Command object with Spectrum component added""" + command = Command() + command.settings.setValue("outputHeight", 1080) + command.settings.setValue("outputWidth", 1920) + command.core.insertComponent(0, command.core.moduleIndexFor("Spectrum"), command) + yield command.core + + +def test_comp_waveform_previewRender(coreWithSpectrumComp): + comp = coreWithSpectrumComp.selectedComponents[0] + image = comp.previewRender() + assert imageDataSum(image) == 71992628 diff --git a/tests/test_text_comp.py b/tests/test_comp_text.py similarity index 90% rename from tests/test_text_comp.py rename to tests/test_comp_text.py index a6ec705..cfcd086 100644 --- a/tests/test_text_comp.py +++ b/tests/test_comp_text.py @@ -30,6 +30,8 @@ def test_comp_text_renderFrame(coreWithTextComp): comp = coreWithTextComp.selectedComponents[0] comp.parent.settings.setValue("outputWidth", 1280) comp.parent.settings.setValue("outputHeight", 720) + comp.titleFont = QFont("Noto Sans") comp.parent.core.updateComponent(0) + comp.page.lineEdit_textColor.setText("255,255,255") image = comp.frameRender(0) - assert imageDataSum(image) == 1412293 or 1379298 + assert imageDataSum(image) == 1412293 \ No newline at end of file diff --git a/tests/test_comp_waveform.py b/tests/test_comp_waveform.py new file mode 100644 index 0000000..f5b66bb --- /dev/null +++ b/tests/test_comp_waveform.py @@ -0,0 +1,21 @@ +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture +from . import imageDataSum + + +@fixture +def coreWithWaveformComp(qtbot): + """Fixture providing a Command object with Waveform component added""" + command = Command() + command.settings.setValue("outputHeight", 1080) + command.settings.setValue("outputWidth", 1920) + command.core.insertComponent(0, command.core.moduleIndexFor("Waveform"), command) + yield command.core + + +def test_comp_waveform_previewRender(coreWithWaveformComp): + comp = coreWithWaveformComp.selectedComponents[0] + comp.page.lineEdit_color.setText("111,111,111") + image = comp.previewRender() + assert imageDataSum(image) == 19989288 From a52dccf3591f786604f83fdf330cc44dd7ece335 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 28 Jan 2026 12:38:10 -0500 Subject: [PATCH 23/30] Image component: scale shadow based on resolution --- src/avp/components/image.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index c46408a..e012cec 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -125,15 +125,16 @@ def drawFrame(self, width, height, dynamicScale): self.existingImage = image # Respond to audio - shadX = 3 - shadY = -1 - shadBlur = 4.00 + resolutionFactor = height / 1080 + shadX = int(resolutionFactor * 1) + shadY = int(resolutionFactor * -1) + shadBlur = resolutionFactor * 3.50 scale = 0 if dynamicScale is not None: scale = dynamicScale[36 * 4] / 4 - shadX += int(scale / 2) - shadY += int(scale / 2) - shadBlur += scale / 8 + shadX += int((scale / 4) * resolutionFactor) + shadY += int((scale / 2) * resolutionFactor) + shadBlur += (scale / 8) * resolutionFactor image = ImageOps.contain( image, ( From d55d92eeb513ef7d7bee67161839f97ac52424b3 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 28 Jan 2026 12:38:53 -0500 Subject: [PATCH 24/30] catch AttributeError if previewRender returns None --- src/avp/gui/preview_thread.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/avp/gui/preview_thread.py b/src/avp/gui/preview_thread.py index 1d78516..a59652a 100644 --- a/src/avp/gui/preview_thread.py +++ b/src/avp/gui/preview_thread.py @@ -65,17 +65,18 @@ def process(self): component.unlockSize() frame = Image.alpha_composite(frame, newFrame) - except ValueError as e: + except (AttributeError, ValueError) as e: errMsg = ( "Bad frame returned by %s's preview renderer. " - "%s. New frame size was %s*%s; should be %s*%s." + "%s. New frame %s." % ( str(component), str(e).capitalize(), - 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) From 5f65cb914fe0d48f4a6a7675b0c6a41b5ef03196 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 28 Jan 2026 14:39:39 -0500 Subject: [PATCH 25/30] Text component: fix blur radius only able to increase the relativeWidgets system causes QDoubleSpinbox to only allow increases, because it really only works with integeres, so I changed the blur radius into a normal QSpinBox. I noted where the problem exists within component.py for future reference. This commit also removes an unneeded VerticalLayout from the ui file --- src/avp/component.py | 1 + src/avp/components/text.py | 5 +- src/avp/components/text.ui | 1213 ++++++++++++++++++------------------ 3 files changed, 609 insertions(+), 610 deletions(-) diff --git a/src/avp/component.py b/src/avp/component.py index 6fba834..5906ab1 100644 --- a/src/avp/component.py +++ b/src/avp/component.py @@ -807,6 +807,7 @@ def updateRelativeWidget(self, 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, diff --git a/src/avp/components/text.py b/src/avp/components/text.py index 0f02880..bee117e 100644 --- a/src/avp/components/text.py +++ b/src/avp/components/text.py @@ -26,7 +26,6 @@ def widget(self, *args): self.page.comboBox_textAlign.addItem("Right") self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) self.page.spinBox_fontSize.setValue(int(self.fontSize)) - self.page.lineEdit_title.setText(self.title) self.page.pushButton_center.clicked.connect(self.centerXY) self.page.fontComboBox_titleFont.currentFontChanged.connect( @@ -35,7 +34,7 @@ def widget(self, *args): # The QFontComboBox must be connected directly to the Qt Signal # which triggers the preview to update. # This unfortunately makes changing the font into a non-undoable action. - # Must be something broken in the conversion to a ComponentAction + # Fix requires updating ComponentAction to handle fonts self.trackWidgets( { @@ -183,7 +182,7 @@ def addText(self, width, height): # turn QImage into Pillow frame frame = image.finalize() if self.shadow: - frame = addShadow(frame, self.shadBlur, self.shadX, self.shadY) + frame = addShadow(frame, self.shadBlur / 10, self.shadX, self.shadY) return frame diff --git a/src/avp/components/text.ui b/src/avp/components/text.ui index f56e959..ce961fe 100644 --- a/src/avp/components/text.ui +++ b/src/avp/components/text.ui @@ -15,646 +15,645 @@ - - - 6 - - - QLayout::SetDefaultConstraint - + + + + + Title + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Text + + + + + + + + 0 + 0 + + + + Font + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + + + + - 4 + 0 - - - - - Title - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - Testing New GUI - - - - - - - - 0 - 0 - - - - Font - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - + + + + 0 + 0 + + + + Text Layout + + + + + + + + 0 + 0 + + + + + 100 + 16777215 + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Center Text + + - - + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 50 + 16777215 + + + + + 0 + 0 + + + 0 - - - - - 0 - 0 - - - - Text Layout - - - - - - - - 0 - 0 - - - - - 100 - 16777215 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - Center Text - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - X - - - - - - - - 0 - 0 - - - - - 50 - 16777215 - - - - - 0 - 0 - - - - 0 - - - 999999999 - - - 0 - - - - - - - - 0 - 0 - - - - Y - - - - - - - - 0 - 0 - - - - - 50 - 16777215 - - - - 999999999 - - - - + + 999999999 + + + 0 + + - - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - Text Color - - - - - - - - 0 - 0 - - - - - 32 - 32 - - - - - - - - 32 - 32 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - Font Size - - - - - - - - 0 - 0 - - - - - - - - - - 1 - - - 500 - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - Font Style - - - - - - - - Normal - - - - - Semi-Bold - - - - - Bold - - - - - Italic - - - - - Bold Italic - - - - - Faux Italic - - - - - Small Caps - - - - - + + + + 0 + 0 + + + + Y + + - - - - - - 0 - 0 - - - - - 0 - 16777215 - - - - Qt::NoFocus - - - - - - - - - - - 0 - 0 - - - - Stroke - - - - - - - - 0 - 0 - - - - px - - - - - - - - 0 - 0 - - - - Stroke Color - - - - - - - - 0 - 0 - - - - - 0 - 16777215 - - - - Qt::NoFocus - - - 0,0,0 - - - - - - - - 0 - 0 - - - - - 32 - 32 - - - - - - - - 32 - 32 - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - + + + + 0 + 0 + + + + + 50 + 16777215 + + + + 999999999 + + + + + + + + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + Text Color + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Font Size + + + + + + + + 0 + 0 + + + + + + + + + + 1 + + + 500 + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Font Style + + - + - - - - 0 - 0 - - - - Shadow - - + + Normal + - - - - 0 - 0 - - - - Shadow Offset - - + + Semi-Bold + - - - - 0 - 0 - - - - -1000 - - - 1000 - - - -4 - - + + Bold + - - - - 0 - 0 - - - - -1000 - - - 1000 - - - 8 - - + + Italic + - - - - 0 - 0 - - - - Shadow Blur - - + + Bold Italic + - - - - 0 - 0 - - - - 99.000000000000000 - - - 0.100000000000000 - - - 5.000000000000000 - - + + Faux Italic + - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 40 - 20 - - - + + Small Caps + - + + + + + + + + + + + 0 + 0 + + + + + 0 + 16777215 + + + + Qt::FocusPolicy::NoFocus + + + + + + + + + + + 0 + 0 + + + + Stroke + + + + + + + + 0 + 0 + + + + px + + + + + + + + 0 + 0 + + + + Stroke Color + + + + + + + + 0 + 0 + + + + + 0 + 16777215 + + + + Qt::FocusPolicy::NoFocus + + + 0,0,0 + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::MinimumExpanding + + + + 40 + 20 + + + + + + + + + + + + + 0 + 0 + + + + Shadow + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Preferred + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Shadow Offset + + + + + + + + 0 + 0 + + + + -1000 + + + 1000 + + + 2 + + + + + + + + 0 + 0 + + + + -1000 + + + 1000 + + + -2 + + + + + + + + 0 + 0 + + + + Shadow Blur + + + + + + + + 0 + 0 + + + + QAbstractSpinBox::ButtonSymbols::PlusMinus + + + QAbstractSpinBox::CorrectionMode::CorrectToPreviousValue + + + 1000 + + + QAbstractSpinBox::StepType::DefaultStepType + + + 35 + + + 10 + + - Qt::Vertical + Qt::Orientation::Vertical From 5d5063dbcc8a2549811d9ab96aa424e7cf58bf5b Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 28 Jan 2026 14:40:05 -0500 Subject: [PATCH 26/30] remove unnecessary QVBoxLayout --- src/avp/components/video.ui | 325 ++++++++++++++++++------------------ 1 file changed, 159 insertions(+), 166 deletions(-) diff --git a/src/avp/components/video.ui b/src/avp/components/video.ui index 08d15d3..72ecb0c 100644 --- a/src/avp/components/video.ui +++ b/src/avp/components/video.ui @@ -27,168 +27,161 @@ - - - 4 - + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Video + + + + + + + + 1 + 0 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + + 32 + 32 + + + + ... + + + + 32 + 32 + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + - - - - - - 0 - 0 - - - - - 31 - 0 - - - - Video - - - - - - - - 1 - 0 - - - - - - - - - 0 - 0 - - - - - 1 - 0 - - - - - 32 - 32 - - - - ... - - - - 32 - 32 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - X - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - -10000 - - - 10000 - - - - - - - - 0 - 0 - - - - Y - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - - 0 - 0 - - - - -10000 - - - 10000 - - - 0 - - - - + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + @@ -204,7 +197,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -227,14 +220,14 @@ Scale - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows % @@ -296,7 +289,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -311,7 +304,7 @@ - Qt::Vertical + Qt::Orientation::Vertical From 5136b4098ff3cfeea359ce662fd849adc0da5c66 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 28 Jan 2026 14:41:00 -0500 Subject: [PATCH 27/30] paste shadow at x,y instead of using offset method --- src/avp/toolkit/frame.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/avp/toolkit/frame.py b/src/avp/toolkit/frame.py index 2859556..829b05b 100644 --- a/src/avp/toolkit/frame.py +++ b/src/avp/toolkit/frame.py @@ -50,8 +50,7 @@ def finalize(self): def addShadow(frame, blurRadius, blurOffsetX, blurOffsetY): shadImg = ImageEnhance.Contrast(frame).enhance(0.0) shadImg = shadImg.filter(ImageFilter.GaussianBlur(blurRadius)) - shadImg = ImageChops.offset(shadImg, blurOffsetX, blurOffsetY) - frame = shadImg.paste(frame, box=(0, 0), mask=frame) + frame = shadImg.paste(frame, box=(-blurOffsetX, -blurOffsetY), mask=frame) frame = shadImg return frame From d6fc41f735c70f184d6342c3df3027bb0d2769a6 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 28 Jan 2026 15:01:01 -0500 Subject: [PATCH 28/30] fix tests due to shadow change --- tests/test_comp_life.py | 6 ++++-- tests/test_comp_text.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_comp_life.py b/tests/test_comp_life.py index 537dbff..ad78e52 100644 --- a/tests/test_comp_life.py +++ b/tests/test_comp_life.py @@ -10,7 +10,9 @@ def coreWithLifeComp(qtbot): command = Command() command.settings.setValue("outputHeight", 1080) command.settings.setValue("outputWidth", 1920) - command.core.insertComponent(0, command.core.moduleIndexFor("Conway's Game of Life"), command) + command.core.insertComponent( + 0, command.core.moduleIndexFor("Conway's Game of Life"), command + ) yield command.core @@ -18,4 +20,4 @@ def test_comp_life_previewRender(coreWithLifeComp): comp = coreWithLifeComp.selectedComponents[0] comp.page.lineEdit_color.setText("111,111,111") image = comp.previewRender() - assert imageDataSum(image) == 339814246 + assert imageDataSum(image) == 339785512 diff --git a/tests/test_comp_text.py b/tests/test_comp_text.py index cfcd086..b56566b 100644 --- a/tests/test_comp_text.py +++ b/tests/test_comp_text.py @@ -22,7 +22,7 @@ def test_comp_text_renderFrame_resize(coreWithTextComp): comp.parent.core.updateComponent(0) comp.page.lineEdit_textColor.setText("255,255,255") image = comp.frameRender(0) - assert imageDataSum(image) == 2957069 + assert imageDataSum(image) == 3345068 def test_comp_text_renderFrame(coreWithTextComp): @@ -34,4 +34,4 @@ def test_comp_text_renderFrame(coreWithTextComp): comp.parent.core.updateComponent(0) comp.page.lineEdit_textColor.setText("255,255,255") image = comp.frameRender(0) - assert imageDataSum(image) == 1412293 \ No newline at end of file + assert imageDataSum(image) == 1602965 From 2b48521e9a7633441123ccbfc4f55e3eb56807b1 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 28 Jan 2026 17:23:49 -0500 Subject: [PATCH 29/30] don't print warning in connectWidget due to QFontComboBox --- src/avp/toolkit/common.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/avp/toolkit/common.py b/src/avp/toolkit/common.py index 32a280f..a6195ed 100644 --- a/src/avp/toolkit/common.py +++ b/src/avp/toolkit/common.py @@ -136,7 +136,11 @@ def rgbFromString(string): raise ValueError return tup except Exception as e: - log.warning("Could not parse '%s' as a color (encountered %s).", string, type(e).__name__) + log.warning( + "Could not parse '%s' as a color (encountered %s).", + string, + type(e).__name__, + ) return (255, 255, 255) @@ -151,6 +155,7 @@ def formatTraceback(tb=None): def connectWidget(widget, func): + unsupportedWidgets = ["QtWidgets.QFontComboBox"] if type(widget) == QtWidgets.QLineEdit: widget.textChanged.connect(func) elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox: @@ -159,6 +164,10 @@ def connectWidget(widget, func): widget.stateChanged.connect(func) elif type(widget) == QtWidgets.QComboBox: widget.currentIndexChanged.connect(func) + elif type(widget) in unsupportedWidgets: + log.info( + "Could not connect %s using connectWidget()", str(widget.__class__.__name__) + ) else: log.warning("Failed to connect %s ", str(widget.__class__.__name__)) return False From 6f38518d5e4a46bfdb056d32c4058e55078a0c28 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 28 Jan 2026 17:25:23 -0500 Subject: [PATCH 30/30] hopefully fix tests not really the best fix but... testing fonts and ffmpeg properly will take more work --- tests/test_comp_text.py | 42 ++++++++++++++++++++++--------------- tests/test_comp_waveform.py | 10 +++------ 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/tests/test_comp_text.py b/tests/test_comp_text.py index b56566b..e389ff9 100644 --- a/tests/test_comp_text.py +++ b/tests/test_comp_text.py @@ -1,7 +1,7 @@ from avp.command import Command from PyQt6.QtGui import QFont from pytestqt import qtbot -from pytest import fixture +from pytest import fixture, mark from . import audioData, MockSignal, imageDataSum @@ -13,25 +13,33 @@ def coreWithTextComp(qtbot): yield command.core -def test_comp_text_renderFrame_resize(coreWithTextComp): - """Call renderFrame of Title Text component added to Command object.""" - comp = coreWithTextComp.selectedComponents[0] - comp.parent.settings.setValue("outputWidth", 1920) - comp.parent.settings.setValue("outputHeight", 1080) - comp.titleFont = QFont("Noto Sans") - comp.parent.core.updateComponent(0) +def setTextSettings(comp): + comp.page.spinBox_fontSize.setValue(40) + comp.page.checkBox_shadow.setChecked(True) + comp.page.spinBox_shadBlur.setValue(0) + comp.page.spinBox_shadX.setValue(2) + comp.page.spinBox_shadY.setValue(-2) + comp.page.fontComboBox_titleFont.setCurrentFont(QFont("Noto Sans")) comp.page.lineEdit_textColor.setText("255,255,255") - image = comp.frameRender(0) - assert imageDataSum(image) == 3345068 -def test_comp_text_renderFrame(coreWithTextComp): +@mark.parametrize( + "width, height", + ((1920, 1080), (1280, 720)), +) +def test_comp_text_renderFrame(coreWithTextComp, width, height): """Call renderFrame of Title Text component added to Command object.""" comp = coreWithTextComp.selectedComponents[0] - comp.parent.settings.setValue("outputWidth", 1280) - comp.parent.settings.setValue("outputHeight", 720) - comp.titleFont = QFont("Noto Sans") - comp.parent.core.updateComponent(0) - comp.page.lineEdit_textColor.setText("255,255,255") + comp.parent.settings.setValue("outputWidth", width) + comp.parent.settings.setValue("outputHeight", height) + setTextSettings(comp) + comp.centerXY() image = comp.frameRender(0) - assert imageDataSum(image) == 1602965 + assert comp.titleFont.family() == "Noto Sans" + assert comp.xPosition == width / 2 + assert image.width == width + assert comp.fontSize == 40 + assert comp.shadX == 2 + assert comp.shadY == -2 + assert comp.shadBlur == 0 + assert imageDataSum(image) == 727403 or 738586 diff --git a/tests/test_comp_waveform.py b/tests/test_comp_waveform.py index f5b66bb..a71040b 100644 --- a/tests/test_comp_waveform.py +++ b/tests/test_comp_waveform.py @@ -1,21 +1,17 @@ from avp.command import Command from pytestqt import qtbot from pytest import fixture -from . import imageDataSum @fixture def coreWithWaveformComp(qtbot): """Fixture providing a Command object with Waveform component added""" command = Command() - command.settings.setValue("outputHeight", 1080) - command.settings.setValue("outputWidth", 1920) command.core.insertComponent(0, command.core.moduleIndexFor("Waveform"), command) yield command.core -def test_comp_waveform_previewRender(coreWithWaveformComp): +def test_comp_waveform_setColor(coreWithWaveformComp): comp = coreWithWaveformComp.selectedComponents[0] - comp.page.lineEdit_color.setText("111,111,111") - image = comp.previewRender() - assert imageDataSum(image) == 19989288 + comp.page.lineEdit_color.setText("255,255,255") + assert comp.color == (255, 255, 255)