From 219bf2555a72f7d24fed11509d1dc99d46fbf8fa Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Mon, 2 Feb 2026 21:38:27 -0500 Subject: [PATCH 01/26] more tests of actions, waveform, spectrum, projects --- pyproject.toml | 2 +- src/avp/__init__.py | 2 +- tests/__init__.py | 11 ++++ tests/test_comp_original.py | 9 +--- tests/test_comp_spectrum.py | 17 +++++- tests/test_comp_waveform.py | 19 ++++++- ...ack.py => test_mainwindow_comp_actions.py} | 2 + tests/test_mainwindow_list_actions.py | 52 +++++++++++++++++++ tests/test_mainwindow_projects.py | 8 +++ uv.lock | 2 +- 10 files changed, 111 insertions(+), 13 deletions(-) rename tests/{test_mainwindow_undostack.py => test_mainwindow_comp_actions.py} (96%) create mode 100644 tests/test_mainwindow_list_actions.py diff --git a/pyproject.toml b/pyproject.toml index 2d604f5..a382882 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.3" +version = "2.2.4" requires-python = ">= 3.12" license = "MIT" classifiers=[ 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 logging -__version__ = "2.2.3" +__version__ = "2.2.4" class Logger(logging.getLoggerClass()): diff --git a/tests/__init__.py b/tests/__init__.py index bb35f72..816b911 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -52,6 +52,17 @@ def initCore(): Core.storeSettings(testDataDir) +def preFrameRender(audioData, comp): + """Prepares a component for calls to frameRender()""" + comp.preFrameRender( + audioFile=getTestDataPath("inputfiles/test.ogg"), + completeAudioArray=audioData[0], + sampleSize=1470, + progressBarSetText=MockSignal(), + progressBarUpdate=MockSignal(), + ) + + class MockSignal: """Pretends to be a pyqtSignal""" diff --git a/tests/test_comp_original.py b/tests/test_comp_original.py index 8cd00a4..df0396d 100644 --- a/tests/test_comp_original.py +++ b/tests/test_comp_original.py @@ -2,7 +2,7 @@ from avp.toolkit.visualizer import transformData from pytestqt import qtbot from pytest import fixture -from . import audioData, command, MockSignal, imageDataSum +from . import audioData, command, imageDataSum, preFrameRender, audioData sampleSize = 1470 # 44100 / 30 = 1470 @@ -51,12 +51,7 @@ def test_comp_classic_drawBars(coreWithClassicComp, audioData): def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData): """Call drawBars after creating audio spectrum data using preFrameRender.""" comp = coreWithClassicComp.selectedComponents[0] - comp.preFrameRender( - completeAudioArray=audioData[0], - sampleSize=sampleSize, - progressBarSetText=MockSignal(), - progressBarUpdate=MockSignal(), - ) + preFrameRender(audioData, comp) image = comp.drawBars( 1920, 1080, diff --git a/tests/test_comp_spectrum.py b/tests/test_comp_spectrum.py index 870185c..5dd4e2d 100644 --- a/tests/test_comp_spectrum.py +++ b/tests/test_comp_spectrum.py @@ -1,7 +1,12 @@ from avp.command import Command from pytestqt import qtbot from pytest import fixture -from . import imageDataSum, command +from . import ( + imageDataSum, + command, + preFrameRender, + audioData, +) @fixture @@ -13,7 +18,15 @@ def coreWithSpectrumComp(qtbot, command): yield command.core -def test_comp_waveform_previewRender(coreWithSpectrumComp): +def test_comp_spectrum_previewRender(coreWithSpectrumComp): comp = coreWithSpectrumComp.selectedComponents[0] image = comp.previewRender() assert imageDataSum(image) == 71992628 + + +def test_comp_spectrum_renderFrame(coreWithSpectrumComp, audioData): + comp = coreWithSpectrumComp.selectedComponents[0] + preFrameRender(audioData, comp) + image = comp.frameRender(0) + comp.postFrameRender() + assert imageDataSum(image) == 117 diff --git a/tests/test_comp_waveform.py b/tests/test_comp_waveform.py index eb5800d..a051851 100644 --- a/tests/test_comp_waveform.py +++ b/tests/test_comp_waveform.py @@ -1,11 +1,13 @@ from pytestqt import qtbot from pytest import fixture -from . import command +from . import command, imageDataSum, audioData, preFrameRender @fixture def coreWithWaveformComp(qtbot, command): """Fixture providing a Command object with Waveform component added""" + command.settings.setValue("outputWidth", 1920) + command.settings.setValue("outputHeight", 1080) command.core.insertComponent(0, command.core.moduleIndexFor("Waveform"), command) yield command.core @@ -14,3 +16,18 @@ def test_comp_waveform_setColor(coreWithWaveformComp): comp = coreWithWaveformComp.selectedComponents[0] comp.page.lineEdit_color.setText("255,255,255") assert comp.color == (255, 255, 255) + + +def test_comp_waveform_previewRender(coreWithWaveformComp): + comp = coreWithWaveformComp.selectedComponents[0] + comp.page.lineEdit_color.setText("255,255,255") + assert imageDataSum(comp.previewRender()) == 36114120 + + +def test_comp_waveform_renderFrame(coreWithWaveformComp, audioData): + comp = coreWithWaveformComp.selectedComponents[0] + comp.page.lineEdit_color.setText("255,255,255") + preFrameRender(audioData, comp) + image = comp.frameRender(0) + comp.postFrameRender() + assert imageDataSum(image) == 8331360 diff --git a/tests/test_mainwindow_undostack.py b/tests/test_mainwindow_comp_actions.py similarity index 96% rename from tests/test_mainwindow_undostack.py rename to tests/test_mainwindow_comp_actions.py index ceaf87e..5d3cc7a 100644 --- a/tests/test_mainwindow_undostack.py +++ b/tests/test_mainwindow_comp_actions.py @@ -1,3 +1,5 @@ +"""Tests of MainWindow undoing certain ComponentActions (changes to component settings)""" + from pytest import fixture from pytestqt import qtbot from avp.gui.mainwindow import MainWindow diff --git a/tests/test_mainwindow_list_actions.py b/tests/test_mainwindow_list_actions.py new file mode 100644 index 0000000..5f8bde4 --- /dev/null +++ b/tests/test_mainwindow_list_actions.py @@ -0,0 +1,52 @@ +"""Tests of `actions.py` - MainWindow component list manipulation via undoable actions""" + +from PyQt6 import QtCore +import os +from pytest import fixture +from pytestqt import qtbot +from . import getTestDataPath, window + + +def test_mainwindow_addComponent(qtbot, window): + window.compMenu.actions()[0].trigger() + assert len(window.core.selectedComponents) == 1 + + +def test_mainwindow_removeComponent(qtbot, window): + window.compMenu.actions()[0].trigger() # add component + window.pushButton_removeComponent.click() # remove it + assert len(window.core.selectedComponents) == 0 + + +def test_mainwindow_moveComponent(qtbot, window): + # add first two components from menu + window.compMenu.actions()[0].trigger() + window.compMenu.actions()[1].trigger() + comp0 = window.core.selectedComponents[0].ui + window.pushButton_listMoveDown.click() + # check if 0 is now 1 + assert window.core.selectedComponents[1].ui == comp0 + + +def test_mainwindow_addComponent_undo(qtbot, window): + window.compMenu.actions()[0].trigger() + window.undoStack.undo() + assert len(window.core.selectedComponents) == 0 + + +def test_mainwindow_removeComponent_undo(qtbot, window): + window.compMenu.actions()[0].trigger() # add component + window.pushButton_removeComponent.click() # remove it + window.undoStack.undo() + assert len(window.core.selectedComponents) == 1 + + +def test_mainwindow_moveComponent_undo(qtbot, window): + # add first two components from menu + window.compMenu.actions()[0].trigger() + window.compMenu.actions()[1].trigger() + comp0 = window.core.selectedComponents[0].ui + window.pushButton_listMoveDown.click() + window.undoStack.undo() + # check if 0 is still 0 after undo + assert window.core.selectedComponents[1].ui != comp0 diff --git a/tests/test_mainwindow_projects.py b/tests/test_mainwindow_projects.py index 6b49799..ce88250 100644 --- a/tests/test_mainwindow_projects.py +++ b/tests/test_mainwindow_projects.py @@ -2,9 +2,17 @@ import os from pytest import fixture from pytestqt import qtbot +from avp.gui.mainwindow import MainWindow from . import getTestDataPath, window +def test_mainwindow_init_with_project(qtbot): + window = MainWindow(getTestDataPath("config/projects/testproject.avp"), None) + qtbot.addWidget(window) + assert window.core.selectedComponents[0].name == "Classic Visualizer" + assert window.core.selectedComponents[1].name == "Color" + + def test_mainwindow_clear(qtbot, window): """MainWindow.clear() gives us a clean slate""" assert len(window.core.selectedComponents) == 0 diff --git a/uv.lock b/uv.lock index e403a68..461e725 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "audio-visualizer-python" -version = "2.2.3" +version = "2.2.4" source = { editable = "." } dependencies = [ { name = "numpy" }, From a4b3142103f9fa1f5354b650e33a18203f76662e Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 3 Feb 2026 22:24:07 -0500 Subject: [PATCH 02/26] test classic comp commandline, presets --- tests/test_comp_original.py | 75 ++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/tests/test_comp_original.py b/tests/test_comp_original.py index df0396d..a108327 100644 --- a/tests/test_comp_original.py +++ b/tests/test_comp_original.py @@ -1,13 +1,29 @@ from avp.command import Command from avp.toolkit.visualizer import transformData from pytestqt import qtbot -from pytest import fixture +from pytest import fixture, mark from . import audioData, command, imageDataSum, preFrameRender, audioData sampleSize = 1470 # 44100 / 30 = 1470 +def createSpectrumArray(audioData): + """Creates enough `spectrumArray` for one call to Component.drawBars()""" + spectrumArray = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)} + for i in range(sampleSize, len(audioData[0]), sampleSize): + spectrumArray[i] = transformData( + i, + audioData[0], + sampleSize, + 0.08, + 0.8, + spectrumArray[i - sampleSize].copy(), + 20, + ) + return spectrumArray + + @fixture def coreWithClassicComp(qtbot, command): """Fixture providing a Command object with Classic Visualizer component added""" @@ -28,24 +44,14 @@ def test_comp_classic_removed(coreWithClassicComp): assert len(coreWithClassicComp.selectedComponents) == 0 -def test_comp_classic_drawBars(coreWithClassicComp, audioData): +@mark.parametrize("layout", (0, 1, 2, 3)) +def test_comp_classic_drawBars(coreWithClassicComp, audioData, layout): """Call drawBars after creating audio spectrum data manually.""" - - spectrumArray = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)} - for i in range(sampleSize, len(audioData[0]), sampleSize): - spectrumArray[i] = transformData( - i, - audioData[0], - sampleSize, - 0.08, - 0.8, - spectrumArray[i - sampleSize].copy(), - 20, - ) - image = coreWithClassicComp.selectedComponents[0].drawBars( - 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), 0 - ) - assert imageDataSum(image) == 37872316 + spectrumArray = createSpectrumArray(audioData) + comp = coreWithClassicComp.selectedComponents[0] + image = comp.drawBars(1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), layout) + imageSize = 37872316 + assert imageDataSum(image) == imageSize if layout < 2 else imageSize / 2 def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData): @@ -60,3 +66,36 @@ def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioDa 0, ) assert imageDataSum(image) == 37872316 + + +def test_comp_classic_command_layout(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + comp.command("layout=top") + assert comp.layout == 3 + + +def test_comp_classic_command_color(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + comp.command("color=111,111,111") + assert comp.visColor == (111, 111, 111) + + +def test_comp_classic_command_preset(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + saveValueStore = comp.savePreset() + saveValueStore["preset"] = "testPreset" + coreWithClassicComp.createPresetFile( + comp.name, comp.version, "testPreset", saveValueStore + ) + comp.command("preset=testPreset") + assert comp.currentPreset == "testPreset" + + +def test_comp_classic_loadPreset(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + comp.scale = 99 + saveValueStore = comp.savePreset() + saveValueStore["preset"] = "testPreset" + comp.scale = 20 + comp.loadPreset(saveValueStore, "testPreset") + assert comp.scale == 99 From 7a6f60547666af2137b37e2e6e4403f20c0a7277 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 3 Feb 2026 22:25:05 -0500 Subject: [PATCH 03/26] test comp gradient --- tests/test_comp_color.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_comp_color.py b/tests/test_comp_color.py index 48b07ff..2aa1f2c 100644 --- a/tests/test_comp_color.py +++ b/tests/test_comp_color.py @@ -14,8 +14,18 @@ def coreWithColorComp(qtbot, command): def test_comp_color_set_color(coreWithColorComp): - "Set imagePath of Image component" + """Set imagePath of Image component""" comp = coreWithColorComp.selectedComponents[0] comp.page.lineEdit_color1.setText("111,111,111") image = comp.previewRender() assert imageDataSum(image) == 1219276800 + + +def test_comp_color_gradient(coreWithColorComp): + """Test changing fill type to a gradient""" + comp = coreWithColorComp.selectedComponents[0] + comp.page.comboBox_fill.setCurrentIndex(1) + comp.page.lineEdit_color1.setText("0,0,0") + comp.page.lineEdit_color2.setText("255,255,255") + image = comp.previewRender() + assert imageDataSum(image) == 1849285965 From 55587cf2a2ef50da66a431abf31bdc5a62d51b7a Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 3 Feb 2026 22:25:20 -0500 Subject: [PATCH 04/26] Color component: add tooltip to color2 picker --- src/avp/components/color.ui | 3 +++ 1 file changed, 3 insertions(+) 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. + From afd6c6e14c8793f99a1954ae98c62f3324b89aa3 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 3 Feb 2026 22:28:14 -0500 Subject: [PATCH 05/26] ignore test presets --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5f7cabb..68c7fcb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ prof/ .env/ .vscode/ tests/data/config/log/ +tests/data/config/presets/ tests/data/config/settings.ini tests/data/config/autosave.avp *.mkv From eeacce8182d10912752a13c8693998bba2b0a4df Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 11:54:27 -0500 Subject: [PATCH 06/26] do not change presetDir to "projects" --- src/avp/gui/presetmanager.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 @@ def __init__(self, parent): 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() From 9a509b77f49599db2167fa315bdc92ef4a42e45d Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 14:00:39 -0500 Subject: [PATCH 07/26] 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. Also added hint to run `avp --verbose` if `avp --log` is run with no avp_debug.log file present --- src/avp/command.py | 27 ++++++++++++++------------- src/avp/gui/mainwindow.py | 17 +++++++++++++++++ src/avp/toolkit/ffmpeg.py | 8 ++++++-- src/avp/toolkit/visualizer.py | 4 ++++ src/avp/video_thread.py | 23 ++++++++++++----------- 5 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/avp/command.py b/src/avp/command.py index 870391b..1e4228a 100644 --- a/src/avp/command.py +++ b/src/avp/command.py @@ -71,9 +71,10 @@ def parseArgs(self): 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 @@ -203,20 +204,18 @@ def stopVideo(self, *args): @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 +223,7 @@ def videoCreated(self): self.quit(0) def quit(self, code): + print() quit(code) def showMessage(self, **kwargs): @@ -285,6 +285,7 @@ def getFilename(): 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") diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py index 3221783..d10fc73 100644 --- a/src/avp/gui/mainwindow.py +++ b/src/avp/gui/mainwindow.py @@ -734,6 +734,23 @@ def progressBarSetText(self, value): 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/toolkit/ffmpeg.py b/src/avp/toolkit/ffmpeg.py index 5aedff3..122eef3 100644 --- a/src/avp/toolkit/ffmpeg.py +++ b/src/avp/toolkit/ffmpeg.py @@ -19,7 +19,7 @@ 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 @@ -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 """ @@ -243,6 +245,8 @@ def error(): ffmpegCommand = [ Core.FFMPEG_BIN, + "-loglevel", + logLevel, "-thread_queue_size", "512", "-y", # overwrite the output file if it already exists. 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..5bf571e 100644 --- a/src/avp/video_thread.py +++ b/src/avp/video_thread.py @@ -61,7 +61,11 @@ def __init__(self, parent, inputFile, outputFile, components): 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 @@ -309,9 +313,9 @@ def createVideo(self): 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 +339,13 @@ def createVideo(self): 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 +364,7 @@ def createVideo(self): if self.error: self.failExport() else: - print("Export Complete") + print("\nExport Complete") self.progressBarUpdate.emit(100) self.progressBarSetText.emit("Export Complete") From 3b5309b96106a959a71da2d8cdbee223ac6b1b2a Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 14:01:24 -0500 Subject: [PATCH 08/26] fix ascii art in comments --- src/avp/component.py | 16 ++++++++-------- src/avp/gui/actions.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/avp/component.py b/src/avp/component.py index 5906ab1..e539c37 100644 --- a/src/avp/component.py +++ b/src/avp/component.py @@ -340,9 +340,9 @@ def __repr__(self): pprint.pformat(preset), ) - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Render Methods - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def previewRender(self): image = BlankFrame(self.width, self.height) @@ -371,9 +371,9 @@ def frameRender(self, frameNo): def postFrameRender(self): pass - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Properties - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def properties(self): """ @@ -403,9 +403,9 @@ def audio(self): https://ffmpeg.org/ffmpeg-filters.html """ - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Idle Methods - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def widget(self, parent): """ @@ -510,9 +510,9 @@ def command(self, arg=""): self.commandHelp() quit(0) - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # "Private" Methods - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def _preUpdate(self): """Happens before subclass update()""" for attr in self._relativeWidgets: diff --git a/src/avp/gui/actions.py b/src/avp/gui/actions.py index 654b2a0..61920c8 100644 --- a/src/avp/gui/actions.py +++ b/src/avp/gui/actions.py @@ -13,9 +13,9 @@ log = logging.getLogger("AVP.Gui.Actions") -# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # COMPONENT ACTIONS -# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ class AddComponent(QUndoCommand): @@ -107,9 +107,9 @@ def undo(self): self.do(self.newRow, self.row) -# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # PRESET ACTIONS -# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ class ClearPreset(QUndoCommand): From 23854395da9312ccf49db230faa155550398b3e9 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 14:04:51 -0500 Subject: [PATCH 09/26] 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 --- tests/__init__.py | 38 +++++++++++++++++++++++++++---- tests/test_commandline_export.py | 3 ++- tests/test_core_init.py | 8 +++---- tests/test_mainwindow_projects.py | 11 ++++----- tests/test_toolkit_ffmpeg.py | 10 ++++---- 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 816b911..4042883 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,6 @@ import os +import shutil +import tempfile import numpy from avp.core import Core @@ -8,10 +10,19 @@ from pytest import fixture +PYTEST_XDIST_WORKER_COUNT = os.environ.get("PYTEST_XDIST_WORKER_COUNT", 0) + + +@fixture +def settings(): + """Doesn't instantiate core: just calls a static method to store `settings.ini`""" + initCore() + yield None + + @fixture def audioData(): """Fixture that gives a tuple of (completeAudioArray, duration)""" - # Core.storeSettings() needed to store ffmpeg bin location initCore() soundFile = getTestDataPath("inputfiles/test.ogg") yield readAudioFile(soundFile, MockVideoWorker()) @@ -28,6 +39,8 @@ def command(qtbot): @fixture def window(qtbot): initCore() + # patch out any modal dialog that might happen + MainWindow.showMessage = lambda self, msg, **kwargs: print(msg) window = MainWindow(None, None) window.clear() qtbot.addWidget(window) @@ -43,13 +56,30 @@ def getTestDataPath(filename=""): def initCore(): - testDataDir = getTestDataPath("config") + """ + Initializes the Core by creating `settings.ini` + Returns the temp directory path where settings.ini was created + or None if multiple pytest workers are not enabled. + """ + try: + numWorkers = int(PYTEST_XDIST_WORKER_COUNT) + except ValueError: + numWorkers = 0 + if numWorkers > 0: + # use temporary directories for multiple workers + # so they don't interfere with each other + configDir = tempfile.mkdtemp(prefix="avp-config-") + else: + # use test data path so we can easily see it after + # a failed test, and help us understand the config + configDir = getTestDataPath("config") unwanted = ["autosave.avp", "settings.ini"] for file in unwanted: - filename = os.path.join(testDataDir, "autosave.avp") + filename = os.path.join(configDir, "autosave.avp") if os.path.exists(filename): os.remove(filename) - Core.storeSettings(testDataDir) + Core.storeSettings(configDir) + return configDir if numWorkers > 0 else None def preFrameRender(audioData, comp): diff --git a/tests/test_commandline_export.py b/tests/test_commandline_export.py index 6d7f068..6eb533d 100644 --- a/tests/test_commandline_export.py +++ b/tests/test_commandline_export.py @@ -8,13 +8,14 @@ def test_commandline_classic_export(qtbot, command): """Run Qt event loop and create a video in the system /tmp or /temp""" soundFile = getTestDataPath("inputfiles/test.ogg") - outputDir = tempfile.mkdtemp(prefix="avp-test-") + outputDir = tempfile.mkdtemp(prefix="avp-export-") outputFilename = os.path.join(outputDir, "output.mp4") sys.argv = [ "", "-c", "0", "classic", + "color=255,255,255", "-i", soundFile, "-o", diff --git a/tests/test_core_init.py b/tests/test_core_init.py index e1f2dbb..30477ef 100644 --- a/tests/test_core_init.py +++ b/tests/test_core_init.py @@ -1,10 +1,9 @@ import os from avp.core import Core -from . import getTestDataPath, initCore +from . import getTestDataPath, settings -def test_component_names(): - initCore() +def test_component_names(settings): core = Core() assert core.compNames == [ "Classic Visualizer", @@ -19,8 +18,7 @@ def test_component_names(): ] -def test_moduleindex(): - initCore() +def test_moduleindex(settings): core = Core() assert core.moduleIndexFor("Classic Visualizer") == 0 diff --git a/tests/test_mainwindow_projects.py b/tests/test_mainwindow_projects.py index ce88250..a6df476 100644 --- a/tests/test_mainwindow_projects.py +++ b/tests/test_mainwindow_projects.py @@ -3,10 +3,10 @@ from pytest import fixture from pytestqt import qtbot from avp.gui.mainwindow import MainWindow -from . import getTestDataPath, window +from . import getTestDataPath, window, settings -def test_mainwindow_init_with_project(qtbot): +def test_mainwindow_init_with_project(qtbot, settings): window = MainWindow(getTestDataPath("config/projects/testproject.avp"), None) qtbot.addWidget(window) assert window.core.selectedComponents[0].name == "Classic Visualizer" @@ -19,11 +19,8 @@ def test_mainwindow_clear(qtbot, window): def test_mainwindow_presetDir_in_tests(qtbot, window): - # FIXME presetDir gets set to projectDir for some reason - assert ( - os.path.basename(os.path.dirname(window.core.settings.value("presetDir"))) - == "config" - ) + """`presetDir` is the filepath on which "Import Preset" file picker opens""" + assert os.path.basename(window.core.settings.value("presetDir")) == "presets" def test_mainwindow_openProject(qtbot, window): diff --git a/tests/test_toolkit_ffmpeg.py b/tests/test_toolkit_ffmpeg.py index 363eba1..cc56495 100644 --- a/tests/test_toolkit_ffmpeg.py +++ b/tests/test_toolkit_ffmpeg.py @@ -1,8 +1,6 @@ import pytest -from avp.core import Core -from avp.command import Command from avp.toolkit.ffmpeg import createFfmpegCommand -from . import audioData, getTestDataPath, initCore +from . import audioData, getTestDataPath, command def test_readAudioFile_data(audioData): @@ -14,14 +12,14 @@ def test_readAudioFile_duration(audioData): @pytest.mark.parametrize("width, height", ((1920, 1080), (1280, 720))) -def test_createFfmpegCommand(width, height): - initCore() - command = Command() +def test_createFfmpegCommand(command, width, height): command.settings.setValue("outputWidth", width) command.settings.setValue("outputHeight", height) ffmpegCmd = createFfmpegCommand("test.ogg", "/tmp", command.core.selectedComponents) assert ffmpegCmd == [ "ffmpeg", + "-loglevel", + "info", "-thread_queue_size", "512", "-y", From c36932aba0d0341e09d1f701ba97899f0060b6d6 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 14:32:56 -0500 Subject: [PATCH 10/26] style change: rename core.Core to Core --- src/avp/command.py | 19 ++++++++++--------- src/avp/core.py | 5 +++-- src/avp/toolkit/ffmpeg.py | 19 +++++++++---------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/avp/command.py b/src/avp/command.py index 1e4228a..c47e9ed 100644 --- a/src/avp/command.py +++ b/src/avp/command.py @@ -14,7 +14,8 @@ 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) @@ -102,8 +103,8 @@ def parseArgs(self): 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() @@ -169,7 +170,7 @@ def parseArgs(self): return "commandline" elif args.no_preview: - core.Core.previewEnabled = False + Core.previewEnabled = False elif ( args.projpath is None @@ -281,7 +282,7 @@ def getFilename(): 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: @@ -291,7 +292,7 @@ def getFilename(): 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/core.py b/src/avp/core.py index 347a5dd..b3a88f4 100644 --- a/src/avp/core.py +++ b/src/avp/core.py @@ -14,7 +14,6 @@ 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 = [] @@ -554,7 +555,7 @@ def loadDefaultSettings(cls): 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/toolkit/ffmpeg.py b/src/avp/toolkit/ffmpeg.py index 122eef3..fe52a82 100644 --- a/src/avp/toolkit/ffmpeg.py +++ b/src/avp/toolkit/ffmpeg.py @@ -11,7 +11,7 @@ from queue import PriorityQueue import logging -from .. import core +from ..core import Core from .common import checkOutput, pipeWrapper @@ -53,7 +53,7 @@ def __init__(self, **kwargs): kwargs["filter_"] = None self.command = [ - core.Core.FFMPEG_BIN, + Core.FFMPEG_BIN, "-thread_queue_size", "512", "-r", @@ -100,9 +100,9 @@ def frame(self, num): def fillBuffer(self): from ..component 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: @@ -197,7 +197,6 @@ def createFfmpegCommand( 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) @@ -419,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", @@ -437,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) @@ -477,7 +476,7 @@ def readAudioFile(filename, videoWorker): return command = [ - core.Core.FFMPEG_BIN, + Core.FFMPEG_BIN, "-i", filename, "-f", @@ -502,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 From fa517bccc26350576e1843dc8eb2c4336c9c1c34 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 14:48:55 -0500 Subject: [PATCH 11/26] check alt comp names when parsing cmdline --- src/avp/command.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/avp/command.py b/src/avp/command.py index c47e9ed..b6700a5 100644 --- a/src/avp/command.py +++ b/src/avp/command.py @@ -243,12 +243,14 @@ def drawPreview(self, *args): 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] From e1e5ce310acb478784b50731fb0b648a452dbfbc Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 14:49:03 -0500 Subject: [PATCH 12/26] rename `original.py` to `classic.py` --- src/avp/components/{original.py => classic.py} | 4 ++-- src/avp/components/{original.ui => classic.ui} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/avp/components/{original.py => classic.py} (98%) rename src/avp/components/{original.ui => classic.ui} (100%) diff --git a/src/avp/components/original.py b/src/avp/components/classic.py similarity index 98% rename from src/avp/components/original.py rename to src/avp/components/classic.py index 0da78dc..61248a1 100644 --- a/src/avp/components/original.py +++ b/src/avp/components/classic.py @@ -9,10 +9,10 @@ class Component(Component): name = "Classic Visualizer" - version = "1.1.0" + version = "1.1.1" def names(*args): - return ["Original Audio Visualization"] + return ["Original"] def properties(self): return ["pcm"] diff --git a/src/avp/components/original.ui b/src/avp/components/classic.ui similarity index 100% rename from src/avp/components/original.ui rename to src/avp/components/classic.ui From 687d188af6ebc48b5d363229caaed0d51042e9cf Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 15:12:42 -0500 Subject: [PATCH 13/26] move `component.py` into subpackage --- src/avp/components/classic.py | 4 +- src/avp/components/color.py | 4 +- src/avp/components/image.py | 4 +- src/avp/components/life.py | 4 +- src/avp/components/sound.py | 4 +- src/avp/components/spectrum.py | 4 +- src/avp/components/text.py | 4 +- src/avp/components/video.py | 4 +- src/avp/components/waveform.py | 4 +- src/avp/libcomponent/__init__.py | 4 + src/avp/libcomponent/actions.py | 104 ++++++ src/avp/{ => libcomponent}/component.py | 414 +----------------------- src/avp/libcomponent/exceptions.py | 63 ++++ src/avp/libcomponent/metaclass.py | 257 +++++++++++++++ src/avp/toolkit/ffmpeg.py | 2 +- src/avp/video_thread.py | 2 +- 16 files changed, 456 insertions(+), 426 deletions(-) create mode 100644 src/avp/libcomponent/__init__.py create mode 100644 src/avp/libcomponent/actions.py rename src/avp/{ => libcomponent}/component.py (60%) create mode 100644 src/avp/libcomponent/exceptions.py create mode 100644 src/avp/libcomponent/metaclass.py diff --git a/src/avp/components/classic.py b/src/avp/components/classic.py index 61248a1..6dc0534 100644 --- a/src/avp/components/classic.py +++ b/src/avp/components/classic.py @@ -2,12 +2,12 @@ from PIL import Image, ImageDraw from copy import copy -from ..component import Component +from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame from ..toolkit.visualizer import createSpectrumArray -class Component(Component): +class Component(BaseComponent): name = "Classic Visualizer" version = "1.1.1" 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/image.py b/src/avp/components/image.py index e012cec..3c834f9 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -3,12 +3,12 @@ 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" diff --git a/src/avp/components/life.py b/src/avp/components/life.py index a062617..532bf8b 100644 --- a/src/avp/components/life.py +++ b/src/avp/components/life.py @@ -7,7 +7,7 @@ import logging -from ..component import Component +from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, scale, addShadow from ..toolkit.visualizer import createSpectrumArray @@ -15,7 +15,7 @@ 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/sound.py b/src/avp/components/sound.py index 2df8e38..44e03e1 100644 --- a/src/avp/components/sound.py +++ b/src/avp/components/sound.py @@ -1,11 +1,11 @@ from PyQt6 import QtGui, QtCore, 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..fdb3a7e 100644 --- a/src/avp/components/spectrum.py +++ b/src/avp/components/spectrum.py @@ -6,7 +6,7 @@ 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.ffmpeg import ( @@ -21,7 +21,7 @@ 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..5b63433 100644 --- a/src/avp/components/text.py +++ b/src/avp/components/text.py @@ -4,13 +4,13 @@ import os 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..619a729 100644 --- a/src/avp/components/video.py +++ b/src/avp/components/video.py @@ -5,7 +5,7 @@ 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 @@ -14,7 +14,7 @@ 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..8742004 100644 --- a/src/avp/components/waveform.py +++ b/src/avp/components/waveform.py @@ -5,7 +5,7 @@ import logging from copy import copy -from ..component import Component +from ..libcomponent import BaseComponent from ..toolkit.visualizer import transformData, createSpectrumArray from ..toolkit.frame import BlankFrame, scale from ..toolkit import checkOutput @@ -21,7 +21,7 @@ log = logging.getLogger("AVP.Components.Waveform") -class Component(Component): +class Component(BaseComponent): name = "Waveform" version = "2.0.0" 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/component.py b/src/avp/libcomponent/component.py similarity index 60% rename from src/avp/component.py rename to src/avp/libcomponent/component.py index e539c37..83e8e74 100644 --- a/src/avp/component.py +++ b/src/avp/libcomponent/component.py @@ -4,274 +4,26 @@ """ from PyQt6 import uic, QtCore, QtWidgets -from PyQt6.QtGui import QColor, QUndoCommand +from PyQt6.QtGui import QColor import os -import sys import math -import time import logging from copy import copy -from .toolkit.frame import BlankFrame -from .toolkit import ( +from .metaclass import ComponentMetaclass +from .actions import ComponentUpdate +from .exceptions import ComponentError +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) +log = logging.getLogger("AVP.BaseComponent") class Component(QtCore.QObject, metaclass=ComponentMetaclass): @@ -826,153 +578,3 @@ 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/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 fe52a82..776a412 100644 --- a/src/avp/toolkit/ffmpeg.py +++ b/src/avp/toolkit/ffmpeg.py @@ -98,7 +98,7 @@ def frame(self, num): self.frameBuffer.task_done() def fillBuffer(self): - from ..component import ComponentError + from ..libcomponent import ComponentError if Core.logEnabled: logFilename = os.path.join( diff --git a/src/avp/video_thread.py b/src/avp/video_thread.py index 5bf571e..5b75e53 100644 --- a/src/avp/video_thread.py +++ b/src/avp/video_thread.py @@ -20,7 +20,7 @@ import signal import logging -from .component import ComponentError +from .libcomponent import ComponentError from .toolkit.frame import Checkerboard from .toolkit.ffmpeg import ( openPipe, From cd2014970d151b5303cf2f11a8e9bdacb2ce9eea Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 16:21:16 -0500 Subject: [PATCH 14/26] rename comp_original to comp_classic --- tests/{test_comp_original.py => test_comp_classic.py} | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename tests/{test_comp_original.py => test_comp_classic.py} (96%) diff --git a/tests/test_comp_original.py b/tests/test_comp_classic.py similarity index 96% rename from tests/test_comp_original.py rename to tests/test_comp_classic.py index a108327..cd255a2 100644 --- a/tests/test_comp_original.py +++ b/tests/test_comp_classic.py @@ -1,8 +1,7 @@ -from avp.command import Command from avp.toolkit.visualizer import transformData from pytestqt import qtbot from pytest import fixture, mark -from . import audioData, command, imageDataSum, preFrameRender, audioData +from . import audioData, command, imageDataSum, preFrameRender sampleSize = 1470 # 44100 / 30 = 1470 From dc879d8d41e62a2de93e1518f04a89118f235e21 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 16:21:30 -0500 Subject: [PATCH 15/26] clean-up unused imports --- src/avp/components/classic.py | 1 - src/avp/components/image.py | 1 - src/avp/components/life.py | 3 +-- src/avp/components/sound.py | 2 +- src/avp/components/spectrum.py | 5 +---- src/avp/components/text.py | 6 ++---- src/avp/components/video.py | 4 +--- src/avp/components/waveform.py | 4 +--- src/avp/gui/actions.py | 2 +- tests/__init__.py | 1 - 10 files changed, 8 insertions(+), 21 deletions(-) diff --git a/src/avp/components/classic.py b/src/avp/components/classic.py index 6dc0534..4a757da 100644 --- a/src/avp/components/classic.py +++ b/src/avp/components/classic.py @@ -1,6 +1,5 @@ import numpy from PIL import Image, ImageDraw -from copy import copy from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame diff --git a/src/avp/components/image.py b/src/avp/components/image.py index 3c834f9..c5e8bcf 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -1,7 +1,6 @@ from PIL import Image, ImageOps, ImageEnhance from PyQt6 import QtWidgets import os -from copy import copy from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, addShadow diff --git a/src/avp/components/life.py b/src/avp/components/life.py index 532bf8b..374b299 100644 --- a/src/avp/components/life.py +++ b/src/avp/components/life.py @@ -1,8 +1,7 @@ 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 diff --git a/src/avp/components/sound.py b/src/avp/components/sound.py index 44e03e1..c212870 100644 --- a/src/avp/components/sound.py +++ b/src/avp/components/sound.py @@ -1,4 +1,4 @@ -from PyQt6 import QtGui, QtCore, QtWidgets +from PyQt6 import QtWidgets import os from ..libcomponent import BaseComponent diff --git a/src/avp/components/spectrum.py b/src/avp/components/spectrum.py index fdb3a7e..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 ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, scale -from ..toolkit import checkOutput, connectWidget +from ..toolkit import connectWidget from ..toolkit.ffmpeg import ( openPipe, closePipe, diff --git a/src/avp/components/text.py b/src/avp/components/text.py index 5b63433..d248772 100644 --- a/src/avp/components/text.py +++ b/src/avp/components/text.py @@ -1,7 +1,5 @@ -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 ..libcomponent import BaseComponent diff --git a/src/avp/components/video.py b/src/avp/components/video.py index 619a729..1e9b788 100644 --- a/src/avp/components/video.py +++ b/src/avp/components/video.py @@ -1,14 +1,12 @@ from PIL import Image -from PyQt6 import QtGui, QtCore, QtWidgets +from PyQt6 import QtWidgets import os -import math import subprocess import logging 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") diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py index 8742004..bfebc30 100644 --- a/src/avp/components/waveform.py +++ b/src/avp/components/waveform.py @@ -3,12 +3,10 @@ import os import subprocess import logging -from copy import copy from ..libcomponent import BaseComponent -from ..toolkit.visualizer import transformData, createSpectrumArray +from ..toolkit.visualizer import createSpectrumArray from ..toolkit.frame import BlankFrame, scale -from ..toolkit import checkOutput from ..toolkit.ffmpeg import ( openPipe, closePipe, diff --git a/src/avp/gui/actions.py b/src/avp/gui/actions.py index 61920c8..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 diff --git a/tests/__init__.py b/tests/__init__.py index 4042883..b08a6bd 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,4 @@ import os -import shutil import tempfile import numpy From fe0775a82fcc6c5e43f50d777b0d181526ad6465 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 18:01:14 -0500 Subject: [PATCH 16/26] traceback if renderFrame() raises exception --- src/avp/video_thread.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/avp/video_thread.py b/src/avp/video_thread.py index 5b75e53..53ff2b8 100644 --- a/src/avp/video_thread.py +++ b/src/avp/video_thread.py @@ -12,16 +12,16 @@ 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 .libcomponent import ComponentError -from .toolkit.frame import Checkerboard +from .toolkit import formatTraceback from .toolkit.ffmpeg import ( openPipe, readAudioFile, @@ -212,9 +212,11 @@ def err(): 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 From 3ad9909fc7ab246beb195a06e73040ba51cadade Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 18:01:56 -0500 Subject: [PATCH 17/26] do not try to insert non-existent components --- src/avp/core.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/avp/core.py b/src/avp/core.py index b3a88f4..c8e070b 100644 --- a/src/avp/core.py +++ b/src/avp/core.py @@ -78,7 +78,10 @@ def insertComponent(self, compPos, component, loader): 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)) @@ -198,7 +201,7 @@ def openProject(self, loader, filepath): ) continue if i == -1: - loader.showMessage(msg="Too many components!") + loader.showMessage(msg="Invalid components!") break try: From 795fa3e7983b2e2ed971e26413dffb2debe337f1 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 21:59:36 -0500 Subject: [PATCH 18/26] 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 --- src/avp/gui/preview_thread.py | 16 ++++++++++------ src/avp/libcomponent/component.py | 9 ++++++--- src/avp/video_thread.py | 6 ++++++ 3 files changed, 22 insertions(+), 9 deletions(-) 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 @@ def process(self): 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 @@ def process(self): % ( 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/component.py b/src/avp/libcomponent/component.py index 83e8e74..1f81e07 100644 --- a/src/avp/libcomponent/component.py +++ b/src/avp/libcomponent/component.py @@ -129,9 +129,12 @@ def postFrameRender(self): 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 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 [] diff --git a/src/avp/video_thread.py b/src/avp/video_thread.py index 53ff2b8..ecd8c4c 100644 --- a/src/avp/video_thread.py +++ b/src/avp/video_thread.py @@ -115,6 +115,7 @@ def preFrameRender(self): Also prerenders "static" components like text and merges them if possible """ self.staticComponents = {} + self.compositeComponents = set() # Call preFrameRender on each component canceledByComponent = False @@ -164,6 +165,8 @@ def preFrameRender(self): 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...") @@ -236,6 +239,9 @@ def err(): 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 From bd0babd9091f75cf3c261b9e37d67e53db8852e3 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 4 Feb 2026 22:00:25 -0500 Subject: [PATCH 19/26] Classic Visualizer: add invert option --- src/avp/components/classic.py | 74 +++++++++++++++++++++++------------ src/avp/components/classic.ui | 9 ++++- tests/test_comp_classic.py | 5 ++- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/avp/components/classic.py b/src/avp/components/classic.py index 4a757da..72089af 100644 --- a/src/avp/components/classic.py +++ b/src/avp/components/classic.py @@ -2,19 +2,22 @@ from PIL import Image, ImageDraw from ..libcomponent import BaseComponent -from ..toolkit.frame import BlankFrame +from ..toolkit.frame import BlankFrame, FloodFrame from ..toolkit.visualizer import createSpectrumArray class Component(BaseComponent): name = "Classic Visualizer" - version = "1.1.1" + version = "1.2.0" def names(*args): return ["Original"] def properties(self): - return ["pcm"] + props = ["pcm"] + if self.invert: + props.append("composite") + return props def widget(self, *args): self.scale = 20 @@ -36,6 +39,7 @@ def widget(self, *args): "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, @@ -45,14 +49,19 @@ def widget(self, *args): ], ) - def previewRender(self): + 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 + self.width, + self.height, + spectrum, + self.visColor, + self.layout, + frame, ) def preFrameRender(self, **kwargs): @@ -70,7 +79,7 @@ def preFrameRender(self, **kwargs): self.progressBarSetText, ) - def frameRender(self, frameNo): + def frameRender(self, frameNo, frame=None): arrayNo = frameNo * self.sampleSize return self.drawBars( self.width, @@ -78,9 +87,10 @@ def frameRender(self, frameNo): self.spectrumArray[arrayNo], self.visColor, self.layout, + frame, ) - def drawBars(self, width, height, spectrum, color, layout): + def drawBars(self, width, height, spectrum, color, layout, frame): bigYCoord = height - height / 8 smallYCoord = height / 1200 bigXCoord = width / (self.bars + 1) @@ -93,32 +103,44 @@ def drawBars(self, width, height, spectrum, color, layout): 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( - ( + # 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, - ), - fill=color2, - ) + ) + 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( - ( - x0, - y0 if y0 < y1 else y1, - x1 if x1 > x0 else x0, - y1 if y0 < y1 else y0, - ), - fill=color, + 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) @@ -145,7 +167,11 @@ def drawBars(self, width, height, spectrum, color, layout): y = self.y - int(height / 100 * 10) im.paste(imBottom, (0, y), mask=imBottom) - return im + 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: diff --git a/src/avp/components/classic.ui b/src/avp/components/classic.ui index 8dbdaa2..1ae7faa 100644 --- a/src/avp/components/classic.ui +++ b/src/avp/components/classic.ui @@ -86,7 +86,7 @@ - + @@ -232,6 +232,13 @@ + + + + Invert + + + diff --git a/tests/test_comp_classic.py b/tests/test_comp_classic.py index cd255a2..a942d89 100644 --- a/tests/test_comp_classic.py +++ b/tests/test_comp_classic.py @@ -48,7 +48,9 @@ def test_comp_classic_drawBars(coreWithClassicComp, audioData, layout): """Call drawBars after creating audio spectrum data manually.""" spectrumArray = createSpectrumArray(audioData) comp = coreWithClassicComp.selectedComponents[0] - image = comp.drawBars(1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), layout) + image = comp.drawBars( + 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), layout, None + ) imageSize = 37872316 assert imageDataSum(image) == imageSize if layout < 2 else imageSize / 2 @@ -63,6 +65,7 @@ def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioDa coreWithClassicComp.selectedComponents[0].spectrumArray[sampleSize * 4], (0, 0, 0), 0, + None, ) assert imageDataSum(image) == 37872316 From 0c0132523d437691050e2ddcc414017d1c291ddb Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Fri, 6 Feb 2026 22:38:25 -0500 Subject: [PATCH 20/26] Image component: fix path commandline option --- src/avp/components/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index c5e8bcf..f709fe5 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -182,7 +182,7 @@ def command(self, arg): 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") From e595647cbff54ac77d674473e67f2f968534abcd Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Mon, 9 Feb 2026 21:45:20 -0500 Subject: [PATCH 21/26] document color difference on X11 vs Wayland --- tests/test_comp_waveform.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_comp_waveform.py b/tests/test_comp_waveform.py index a051851..fda3b83 100644 --- a/tests/test_comp_waveform.py +++ b/tests/test_comp_waveform.py @@ -1,7 +1,10 @@ +from PyQt6.QtWidgets import QApplication from pytestqt import qtbot from pytest import fixture from . import command, imageDataSum, audioData, preFrameRender +PLATFORM = QApplication.platformName() + @fixture def coreWithWaveformComp(qtbot, command): @@ -21,7 +24,11 @@ def test_comp_waveform_setColor(coreWithWaveformComp): def test_comp_waveform_previewRender(coreWithWaveformComp): comp = coreWithWaveformComp.selectedComponents[0] comp.page.lineEdit_color.setText("255,255,255") - assert imageDataSum(comp.previewRender()) == 36114120 + if PLATFORM == "wayland": + # Wayland gives slightly different colors + assert imageDataSum(comp.previewRender()) == 36114120 + else: + assert imageDataSum(comp.previewRender()) == 37210620 def test_comp_waveform_renderFrame(coreWithWaveformComp, audioData): From 5b0e123d6438212ec9058159672809c277f9bb4c Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 11 Feb 2026 16:08:12 -0500 Subject: [PATCH 22/26] Image component: restrict file formats in CLI to match GUI --- src/avp/components/image.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index f709fe5..80aee51 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -176,17 +176,22 @@ def pickImage(self): 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 os.path.splitext(arg)[1] not in self.core.imageFormats: + fail() try: Image.open(arg) self.page.lineEdit_image.setText(arg) 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): From 044b1f30942f37b6a46dff328335782a1b90ca2d Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 11 Feb 2026 21:31:29 -0500 Subject: [PATCH 23/26] move ffmpeg version check into function --- src/avp/gui/mainwindow.py | 29 ++++++++--------------------- src/avp/toolkit/ffmpeg.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py index d10fc73..fac1e41 100644 --- a/src/avp/gui/mainwindow.py +++ b/src/avp/gui/mainwindow.py @@ -25,7 +25,7 @@ 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 @@ def changedField(): ) 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 diff --git a/src/avp/toolkit/ffmpeg.py b/src/avp/toolkit/ffmpeg.py index 776a412..93aa725 100644 --- a/src/avp/toolkit/ffmpeg.py +++ b/src/avp/toolkit/ffmpeg.py @@ -546,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 From 86f7a750ce24222b9e0c5702967932e1032806cf Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 11 Feb 2026 21:33:16 -0500 Subject: [PATCH 24/26] move log messages to after CLI parsing this ensures that these log messages will show up or not show up as appropriate for the arguments provided to avp, instead of happening too early --- src/avp/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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": From 4cf229ee1cf7eb6ae986940568791a34d7738c10 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 11 Feb 2026 21:40:56 -0500 Subject: [PATCH 25/26] Waveform test: replace e595647 with actual fix I did think FFmpeg version was the more logical reason for this bug, but I somehow tricked myself into thinking the previous change made sense :) --- tests/test_comp_waveform.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_comp_waveform.py b/tests/test_comp_waveform.py index fda3b83..d295dbe 100644 --- a/tests/test_comp_waveform.py +++ b/tests/test_comp_waveform.py @@ -1,10 +1,8 @@ -from PyQt6.QtWidgets import QApplication from pytestqt import qtbot from pytest import fixture +from avp.toolkit.ffmpeg import checkFfmpegVersion from . import command, imageDataSum, audioData, preFrameRender -PLATFORM = QApplication.platformName() - @fixture def coreWithWaveformComp(qtbot, command): @@ -24,8 +22,10 @@ def test_comp_waveform_setColor(coreWithWaveformComp): def test_comp_waveform_previewRender(coreWithWaveformComp): comp = coreWithWaveformComp.selectedComponents[0] comp.page.lineEdit_color.setText("255,255,255") - if PLATFORM == "wayland": - # Wayland gives slightly different colors + _, version = checkFfmpegVersion() + if version > 6: + # FFmpeg 8 has different colors from 6 + # TODO check version 7 assert imageDataSum(comp.previewRender()) == 36114120 else: assert imageDataSum(comp.previewRender()) == 37210620 From dbe892b6617f89fd7d47e207e50207f086cbc18e Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Thu, 12 Feb 2026 15:12:56 -0500 Subject: [PATCH 26/26] test image component path argument --- src/avp/components/image.py | 2 +- tests/test_comp_image.py | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index 80aee51..a082092 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -183,7 +183,7 @@ def fail(): if "=" in arg: key, arg = arg.split("=", 1) if key == "path" and os.path.exists(arg): - if os.path.splitext(arg)[1] not in self.core.imageFormats: + if f"*{os.path.splitext(arg)[1]}" not in self.core.imageFormats: fail() try: Image.open(arg) diff --git a/tests/test_comp_image.py b/tests/test_comp_image.py index c580d5a..b221df4 100644 --- a/tests/test_comp_image.py +++ b/tests/test_comp_image.py @@ -1,3 +1,4 @@ +import os from avp.command import Command from pytestqt import qtbot from pytest import fixture @@ -5,6 +6,7 @@ sampleSize = 1470 # 44100 / 30 = 1470 +testFile = "inputfiles/test.jpg" @fixture @@ -19,7 +21,7 @@ def coreWithImageComp(qtbot, command): def test_comp_image_set_path(coreWithImageComp): "Set imagePath of Image component" comp = coreWithImageComp.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") + comp.imagePath = getTestDataPath(testFile) image = comp.previewRender() assert imageDataSum(image) == 463711601 @@ -27,7 +29,7 @@ def test_comp_image_set_path(coreWithImageComp): def test_comp_image_scale_50_1080p(coreWithImageComp): """Image component stretches image to 50% at 1080p""" comp = coreWithImageComp.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") + comp.imagePath = getTestDataPath(testFile) image = comp.previewRender() sum = imageDataSum(image) comp.page.spinBox_scale.setValue(50) @@ -37,7 +39,7 @@ def test_comp_image_scale_50_1080p(coreWithImageComp): def test_comp_image_scale_50_720p(coreWithImageComp): """Image component stretches image to 50% at 720p""" comp = coreWithImageComp.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") + comp.imagePath = getTestDataPath(testFile) comp.page.spinBox_scale.setValue(50) image = comp.previewRender() sum = imageDataSum(image) @@ -47,3 +49,12 @@ def test_comp_image_scale_50_720p(coreWithImageComp): assert image.width == 1920 assert newImage.width == 1280 assert imageDataSum(comp.previewRender()) == sum + + +def test_comp_image_command_path(coreWithImageComp): + """Image component accepts commandline argument: + `path=test.jpg`""" + imgPath = os.path.realpath(getTestDataPath(testFile)) + comp = coreWithImageComp.selectedComponents[0] + comp.command(f"path={imgPath}") + assert comp.imagePath == imgPath