From 5ba5dbd99f402037f6fc3190a2bc1a1f6391fe5b Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sat, 17 Jan 2026 20:02:51 -0500 Subject: [PATCH 01/21] qtbot is needed in any test that uses a QObject previously these tests would fail if they ran before qtbot was initialized by another test. I'm now running tests in a random order --- tests/test_commandline_parser.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_commandline_parser.py b/tests/test_commandline_parser.py index 8b07b8c..0583ba4 100644 --- a/tests/test_commandline_parser.py +++ b/tests/test_commandline_parser.py @@ -1,37 +1,38 @@ import sys import pytest from avp.command import Command +from pytestqt import qtbot -def test_commandline_help(): +def test_commandline_help(qtbot): command = Command() sys.argv = ["", "--help"] with pytest.raises(SystemExit): command.parseArgs() -def test_commandline_help_if_bad_args(): +def test_commandline_help_if_bad_args(qtbot): command = Command() sys.argv = ["", "--junk"] with pytest.raises(SystemExit): command.parseArgs() -def test_commandline_launches_gui_if_verbose(): +def test_commandline_launches_gui_if_verbose(qtbot): command = Command() sys.argv = ["", "--verbose"] mode = command.parseArgs() assert mode == "GUI" -def test_commandline_launches_gui_if_verbose_with_project(): +def test_commandline_launches_gui_if_verbose_with_project(qtbot): command = Command() sys.argv = ["", "test", "--verbose"] mode = command.parseArgs() assert mode == "GUI" -def test_commandline_tries_to_export(): +def test_commandline_tries_to_export(qtbot): command = Command() didCallFunction = False From b98e2ec03f568a6f463efd76f1bf205c0659d159 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sat, 17 Jan 2026 22:31:36 -0500 Subject: [PATCH 02/21] add tests for drawBars, readAudioFile, BlankFrame --- tests/__init__.py | 29 ++++++-------- tests/test_classic_visualizer.py | 67 ++++++++++++++++++++++++++++++++ tests/test_toolkit_ffmpeg.py | 9 +++++ tests/test_toolkit_frame.py | 6 +++ 4 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 tests/test_classic_visualizer.py create mode 100644 tests/test_toolkit_ffmpeg.py create mode 100644 tests/test_toolkit_frame.py diff --git a/tests/__init__.py b/tests/__init__.py index d0073ef..1dabd22 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,5 @@ -import pytest import os -import sys +from pytest import fixture def getTestDataPath(filename): @@ -8,20 +7,14 @@ def getTestDataPath(filename): return os.path.join(tests_dir, "data", filename) -def run(logFile): - """Run Pytest, which then imports and runs all tests in this module.""" - os.environ["PYTEST_QT_API"] = "PyQt6" - with open(logFile, "w") as f: - # temporarily redirect stdout to a text file so we capture pytest's output - sys.stdout = f - try: - val = pytest.main( - [ - os.path.dirname(__file__), - "-s", # disable pytest's internal capturing of stdout etc. - ] - ) - finally: - sys.stdout = sys.__stdout__ +@fixture +def audioData(): + from avp.toolkit.ffmpeg import readAudioFile - return val + soundFile = getTestDataPath("test.ogg") + yield readAudioFile(soundFile, None) + + +class mockSignal: + def emit(self, *args): + pass diff --git a/tests/test_classic_visualizer.py b/tests/test_classic_visualizer.py new file mode 100644 index 0000000..e1c0cf1 --- /dev/null +++ b/tests/test_classic_visualizer.py @@ -0,0 +1,67 @@ +from PyQt6.QtCore import pyqtSignal +import numpy +from avp.core import Core +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture +from . import audioData, mockSignal + + +sampleSize = 1470 # 44100 / 30 = 1470 + + +@fixture +def coreWithClassicComp(qtbot): + command = Command() + command.core.insertComponent( + 0, command.core.moduleIndexFor("Classic Visualizer"), command + ) + yield command.core + + +def test_comp_classic_added(coreWithClassicComp): + """Test adding Classic Visualizer to core""" + assert len(coreWithClassicComp.selectedComponents) == 1 + + +def test_comp_classic_removed(coreWithClassicComp): + """Test removing Classic Visualizer from core""" + coreWithClassicComp.removeComponent(0) + assert len(coreWithClassicComp.selectedComponents) == 0 + + +def test_comp_classic_drawBars(coreWithClassicComp, audioData): + lastSpectrum = coreWithClassicComp.selectedComponents[0].transformData( + 0, audioData[0], sampleSize, 0.08, 0.8, None + ) + spectrum = {0: lastSpectrum.copy()} + spectrum[sampleSize] = ( + coreWithClassicComp.selectedComponents[0] + .transformData(0, audioData[0], sampleSize, 0.08, 0.8, spectrum[0]) + .copy() + ) + image = coreWithClassicComp.selectedComponents[0].drawBars( + 1920, 1080, spectrum[0], (0, 0, 0), 0 + ) + data = numpy.asarray(image, dtype="int32") + assert data.sum() == 14654498 + + +def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData): + comp = coreWithClassicComp.selectedComponents[0] + numpy.seterr(divide="ignore") + comp.preFrameRender( + completeAudioArray=audioData[0], + sampleSize=sampleSize, + progressBarSetText=mockSignal(), + progressBarUpdate=mockSignal(), + ) + image = comp.drawBars( + 1920, + 1080, + coreWithClassicComp.selectedComponents[0].spectrumArray[0], + (0, 0, 0), + 0, + ) + data = numpy.asarray(image, dtype="int32") + assert data.sum() == 14654498 diff --git a/tests/test_toolkit_ffmpeg.py b/tests/test_toolkit_ffmpeg.py new file mode 100644 index 0000000..c159806 --- /dev/null +++ b/tests/test_toolkit_ffmpeg.py @@ -0,0 +1,9 @@ +from . import audioData + + +def test_readAudioFile_data(audioData): + assert len(audioData[0]) == 218453 + + +def test_readAudioFile_duration(audioData): + assert audioData[1] == 3.95 diff --git a/tests/test_toolkit_frame.py b/tests/test_toolkit_frame.py new file mode 100644 index 0000000..2908c6c --- /dev/null +++ b/tests/test_toolkit_frame.py @@ -0,0 +1,6 @@ +import numpy +from avp.toolkit.frame import BlankFrame + + +def test_blank_frame(): + assert numpy.asarray(BlankFrame(1920, 1080), dtype="int32").sum() == 0 From 3977de9ac931ee2e1e29cf8c4ed6670f5a23ace1 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 18 Jan 2026 00:09:10 -0500 Subject: [PATCH 03/21] replace numpy.seterr with numpy.errstate --- src/avp/components/original.py | 4 +++- src/avp/video_thread.py | 16 +++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/avp/components/original.py b/src/avp/components/original.py index 1e7ef86..d66419c 100644 --- a/src/avp/components/original.py +++ b/src/avp/components/original.py @@ -117,7 +117,9 @@ def transformData( # filter the noise away # y[y<80] = 0 - y = self.scale * numpy.log10(y) + with numpy.errstate(divide="ignore"): + y = self.scale * numpy.log10(y) + y[numpy.isinf(y)] = 0 if lastSpectrum is not None: diff --git a/src/avp/video_thread.py b/src/avp/video_thread.py index 5d72409..967d2fe 100644 --- a/src/avp/video_thread.py +++ b/src/avp/video_thread.py @@ -253,18 +253,16 @@ def showPreview(self, frame): @pyqtSlot() def createVideo(self): """ - 1. Numpy is set to ignore division errors during this method - 2. Determine length of final video - 3. Call preFrameRender on each component - 4. Create the main FFmpeg command - 5. Open the out_pipe to FFmpeg process - 6. Iterate over the audio data array and call frameRender on the components to get frames - 7. Close the out_pipe - 8. Call postFrameRender on each component + 1. Determine length of final video + 2. Call preFrameRender on each component + 3. Create the main FFmpeg command + 4. Open the out_pipe to FFmpeg process + 5. Iterate over the audio data array and call frameRender on the components to get frames + 6. Close the out_pipe + 7. Call postFrameRender on each component """ log.debug("Video worker received signal to createVideo") log.debug("Video thread id: {}".format(int(QtCore.QThread.currentThreadId()))) - numpy.seterr(divide="ignore") self.encoding.emit(True) self.extraAudio = [] self.width = int(self.settings.value("outputWidth")) From cde87832c12c87a4b19f230a8817205d9264b8aa Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 18 Jan 2026 00:11:15 -0500 Subject: [PATCH 04/21] fix incorrect comment --- src/avp/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/avp/core.py b/src/avp/core.py index 402b532..196cd7d 100644 --- a/src/avp/core.py +++ b/src/avp/core.py @@ -71,7 +71,7 @@ def componentListChanged(self): def insertComponent(self, compPos, component, loader): """ Creates a new component using these args: - (compPos, component obj or moduleIndex, MWindow/Command/Core obj) + (compPos, component obj or moduleIndex, MWindow/Command obj) """ if compPos < 0 or compPos > len(self.selectedComponents): compPos = len(self.selectedComponents) From 0681f3d61db60d64eacb31c0d0a5fe11bbe47f75 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 18 Jan 2026 01:18:39 -0500 Subject: [PATCH 05/21] add MockVideoWorker and imageDataSum --- tests/__init__.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 1dabd22..290a541 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,20 +1,39 @@ import os +import numpy + +# core always has to be imported first +import avp.core +from avp.toolkit.ffmpeg import readAudioFile from pytest import fixture +@fixture +def audioData(): + """Fixture that gives a tuple of (completeAudioArray, duration)""" + soundFile = getTestDataPath("test.ogg") + yield readAudioFile(soundFile, MockVideoWorker()) + + def getTestDataPath(filename): + """Get path to a file in the ./data directory""" tests_dir = os.path.dirname(os.path.abspath(__file__)) return os.path.join(tests_dir, "data", filename) -@fixture -def audioData(): - from avp.toolkit.ffmpeg import readAudioFile - - soundFile = getTestDataPath("test.ogg") - yield readAudioFile(soundFile, None) - +class MockSignal: + """Pretends to be a pyqtSignal""" -class mockSignal: def emit(self, *args): pass + + +class MockVideoWorker: + """Pretends to be a video thread worker""" + + progressBarSetText = MockSignal() + progressBarUpdate = MockSignal() + + +def imageDataSum(image): + """Get sum of raw data of a Pillow Image object""" + return numpy.asarray(image, dtype="int32").sum() From 2964cf7b81be302dd8723f80e60568e55676fd67 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 18 Jan 2026 01:18:47 -0500 Subject: [PATCH 06/21] test further into visualization (less likely to be a false positive) --- tests/test_classic_visualizer.py | 46 +++++++++++++++----------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/tests/test_classic_visualizer.py b/tests/test_classic_visualizer.py index e1c0cf1..242a19a 100644 --- a/tests/test_classic_visualizer.py +++ b/tests/test_classic_visualizer.py @@ -1,10 +1,7 @@ -from PyQt6.QtCore import pyqtSignal -import numpy -from avp.core import Core from avp.command import Command from pytestqt import qtbot from pytest import fixture -from . import audioData, mockSignal +from . import audioData, MockSignal, imageDataSum sampleSize = 1470 # 44100 / 30 = 1470 @@ -12,6 +9,7 @@ @fixture def coreWithClassicComp(qtbot): + """Fixture providing a Command object with Classic Visualizer component added""" command = Command() command.core.insertComponent( 0, command.core.moduleIndexFor("Classic Visualizer"), command @@ -20,48 +18,48 @@ def coreWithClassicComp(qtbot): def test_comp_classic_added(coreWithClassicComp): - """Test adding Classic Visualizer to core""" + """Add Classic Visualizer to core""" assert len(coreWithClassicComp.selectedComponents) == 1 def test_comp_classic_removed(coreWithClassicComp): - """Test removing Classic Visualizer from core""" + """Remove Classic Visualizer from core""" coreWithClassicComp.removeComponent(0) assert len(coreWithClassicComp.selectedComponents) == 0 def test_comp_classic_drawBars(coreWithClassicComp, audioData): - lastSpectrum = coreWithClassicComp.selectedComponents[0].transformData( - 0, audioData[0], sampleSize, 0.08, 0.8, None - ) - spectrum = {0: lastSpectrum.copy()} - spectrum[sampleSize] = ( - coreWithClassicComp.selectedComponents[0] - .transformData(0, audioData[0], sampleSize, 0.08, 0.8, spectrum[0]) - .copy() - ) + """Call drawBars after creating audio spectrum data manually.""" + + spectrumArray = { + 0: coreWithClassicComp.selectedComponents[0].transformData( + 0, audioData[0], sampleSize, 0.08, 0.8, None + ) + } + for i in range(sampleSize, len(audioData[0]), sampleSize): + spectrumArray[i] = coreWithClassicComp.selectedComponents[0].transformData( + i, audioData[0], sampleSize, 0.08, 0.8, spectrumArray[i - sampleSize].copy() + ) image = coreWithClassicComp.selectedComponents[0].drawBars( - 1920, 1080, spectrum[0], (0, 0, 0), 0 + 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), 0 ) - data = numpy.asarray(image, dtype="int32") - assert data.sum() == 14654498 + assert imageDataSum(image) == 37872316 def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData): + """Call drawBars after creating audio spectrum data using preFrameRender.""" comp = coreWithClassicComp.selectedComponents[0] - numpy.seterr(divide="ignore") comp.preFrameRender( completeAudioArray=audioData[0], sampleSize=sampleSize, - progressBarSetText=mockSignal(), - progressBarUpdate=mockSignal(), + progressBarSetText=MockSignal(), + progressBarUpdate=MockSignal(), ) image = comp.drawBars( 1920, 1080, - coreWithClassicComp.selectedComponents[0].spectrumArray[0], + coreWithClassicComp.selectedComponents[0].spectrumArray[sampleSize * 4], (0, 0, 0), 0, ) - data = numpy.asarray(image, dtype="int32") - assert data.sum() == 14654498 + assert imageDataSum(image) == 37872316 From 5e47daecfa6db0776e36462b60ba3319cd088164 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 18 Jan 2026 01:19:12 -0500 Subject: [PATCH 07/21] test FloodFrame function --- tests/test_toolkit_frame.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_toolkit_frame.py b/tests/test_toolkit_frame.py index 2908c6c..9486227 100644 --- a/tests/test_toolkit_frame.py +++ b/tests/test_toolkit_frame.py @@ -1,6 +1,14 @@ import numpy -from avp.toolkit.frame import BlankFrame +from avp.toolkit.frame import BlankFrame, FloodFrame def test_blank_frame(): + """BlankFrame creates a frame of all zeros""" assert numpy.asarray(BlankFrame(1920, 1080), dtype="int32").sum() == 0 + + +def test_flood_frame(): + """FloodFrame given (1, 1, 1, 1) creates a frame of sum 1920 * 1080 * 4""" + assert numpy.asarray(FloodFrame(1920, 1080, (1, 1, 1, 1)), dtype="int32").sum() == ( + 1920 * 1080 * 4 + ) From 06adb767840ccb8e802dfd2e68536d7e67b9379c Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 18 Jan 2026 01:23:49 -0500 Subject: [PATCH 08/21] add failing test for Image component one step towards fixing #89 --- tests/test_image_comp.py | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/test_image_comp.py diff --git a/tests/test_image_comp.py b/tests/test_image_comp.py new file mode 100644 index 0000000..880162e --- /dev/null +++ b/tests/test_image_comp.py @@ -0,0 +1,43 @@ +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture +from . import audioData, MockSignal, imageDataSum, getTestDataPath + + +sampleSize = 1470 # 44100 / 30 = 1470 + + +@fixture +def coreWithImageComp(qtbot): + """Fixture providing a Command object with Image component added""" + command = Command() + command.core.insertComponent(0, command.core.moduleIndexFor("Image"), command) + yield command.core + + +def test_comp_image_set_path(coreWithImageComp): + "Set imagePath of Image component" + comp = coreWithImageComp.selectedComponents[0] + comp.imagePath = getTestDataPath("test.jpg") + image = comp.previewRender() + assert imageDataSum(image) == 463711601 + + +def test_comp_image_stretch_scale_120(coreWithImageComp): + """Image component stretches image to 120%""" + comp = coreWithImageComp.selectedComponents[0] + comp.imagePath = getTestDataPath("test.jpg") + comp.stretched = True + comp.stretchScale = 120 + image = comp.previewRender() + assert imageDataSum(image) == 474484783 + + +def test_comp_image_stretch_scale_undo_redo(coreWithImageComp): + """Image component rapidly changes scale. This test segfaults currently.""" + comp = coreWithImageComp.selectedComponents[0] + comp.imagePath = getTestDataPath("test.jpg") + for i in range(100): + comp.scale = i + comp.previewRender() + assert True From 6e55ce62e7c88c7a077d60469225993544e851fe Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Sun, 18 Jan 2026 10:31:12 -0500 Subject: [PATCH 09/21] test component name CLI parsing --- tests/test_commandline_parser.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_commandline_parser.py b/tests/test_commandline_parser.py index 0583ba4..d092072 100644 --- a/tests/test_commandline_parser.py +++ b/tests/test_commandline_parser.py @@ -44,3 +44,13 @@ def captureFunction(*args): command.createAudioVisualization = captureFunction command.parseArgs() assert didCallFunction + + +def test_commandline_parses_classic_by_alias(qtbot): + command = Command() + assert command.parseCompName("original") == "Classic Visualizer" + + +def test_commandline_parses_conway_by_name(qtbot): + command = Command() + assert command.parseCompName("conway") == "Conway's Game of Life" From 5b04403a4285ae5f8c0d01f0402a04f7c6c660b1 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Mon, 19 Jan 2026 19:40:23 -0500 Subject: [PATCH 10/21] prevent log warning when 1 setting changed --- src/avp/component.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/avp/component.py b/src/avp/component.py index 01d4e44..6c5e381 100644 --- a/src/avp/component.py +++ b/src/avp/component.py @@ -910,11 +910,12 @@ def __init__(self, parent, oldWidgetVals, modifiedVals): # Determine if this update is mergeable self.id_ = -1 - if len(self.modifiedVals) == 1 and self.parent.mergeUndo: - attr, val = self.modifiedVals.popitem() - self.id_ = sum([ord(letter) for letter in attr[-14:]]) - self.modifiedVals[attr] = val - else: + 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), From d334031f8c7e25b8d8b3ca6cc95c1f68a1827f5e Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 20 Jan 2026 20:00:24 -0500 Subject: [PATCH 11/21] correct tests to use widgets when needed --- tests/__init__.py | 2 +- tests/test_image_comp.py | 29 ++++++++++++++++++----------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 290a541..b615681 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -36,4 +36,4 @@ class MockVideoWorker: def imageDataSum(image): """Get sum of raw data of a Pillow Image object""" - return numpy.asarray(image, dtype="int32").sum() + return numpy.asarray(image, dtype="int32").sum(dtype="int32") diff --git a/tests/test_image_comp.py b/tests/test_image_comp.py index 880162e..a4f05e1 100644 --- a/tests/test_image_comp.py +++ b/tests/test_image_comp.py @@ -11,6 +11,8 @@ def coreWithImageComp(qtbot): """Fixture providing a Command object with Image component added""" command = Command() + command.settings.setValue("outputHeight", 1080) + command.settings.setValue("outputWidth", 1920) command.core.insertComponent(0, command.core.moduleIndexFor("Image"), command) yield command.core @@ -23,21 +25,26 @@ def test_comp_image_set_path(coreWithImageComp): assert imageDataSum(image) == 463711601 -def test_comp_image_stretch_scale_120(coreWithImageComp): - """Image component stretches image to 120%""" +def test_comp_image_scale_50_1080p(coreWithImageComp): + """Image component stretches image to 50% at 1080p""" comp = coreWithImageComp.selectedComponents[0] comp.imagePath = getTestDataPath("test.jpg") - comp.stretched = True - comp.stretchScale = 120 image = comp.previewRender() - assert imageDataSum(image) == 474484783 + sum = imageDataSum(image) + comp.page.spinBox_scale.setValue(50) + assert imageDataSum(comp.previewRender()) - sum / 4 < 2000 -def test_comp_image_stretch_scale_undo_redo(coreWithImageComp): - """Image component rapidly changes scale. This test segfaults currently.""" +def test_comp_image_scale_50_720p(coreWithImageComp): + """Image component stretches image to 50% at 720p""" comp = coreWithImageComp.selectedComponents[0] comp.imagePath = getTestDataPath("test.jpg") - for i in range(100): - comp.scale = i - comp.previewRender() - assert True + comp.page.spinBox_scale.setValue(50) + image = comp.previewRender() + sum = imageDataSum(image) + comp.parent.settings.setValue("outputHeight", 720) + comp.parent.settings.setValue("outputWidth", 1280) + newImage = comp.previewRender() + assert image.width == 1920 + assert newImage.width == 1280 + assert imageDataSum(comp.previewRender()) == sum From de52ad193a0448e59bc7726900172127741a6ab2 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 20 Jan 2026 20:02:26 -0500 Subject: [PATCH 12/21] test undo and blockSignals --- tests/test_mainwindow_undostack.py | 37 ++++++++++++++++++++++++++++++ tests/test_toolkit_common.py | 13 +++++++++++ 2 files changed, 50 insertions(+) create mode 100644 tests/test_mainwindow_undostack.py create mode 100644 tests/test_toolkit_common.py diff --git a/tests/test_mainwindow_undostack.py b/tests/test_mainwindow_undostack.py new file mode 100644 index 0000000..3c49339 --- /dev/null +++ b/tests/test_mainwindow_undostack.py @@ -0,0 +1,37 @@ +from pytestqt import qtbot +from avp.gui.mainwindow import MainWindow +from . import getTestDataPath + + +def test_undo_image_scale(qtbot): + """Undo Image component scale setting should undo multiple merged actions.""" + qtbot.addWidget(window := MainWindow(None, None)) + window.settings.setValue("outputWidth", 1920) + window.settings.setValue("outputHeight", 1080) + window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) + comp = window.core.selectedComponents[0] + comp.imagePath = getTestDataPath("test.jpg") + comp.page.spinBox_scale.setValue(100) + for i in range(10, 401): + comp.page.spinBox_scale.setValue(i) + assert comp.scale == 400 + window.undoStack.undo() + assert comp.scale == 10 + window.undoStack.undo() + assert comp.scale == 100 + + +def test_undo_classic_visualizer_sensitivity(qtbot): + """Undo Classic Visualizer component sensitivity setting + should undo multiple merged actions.""" + qtbot.addWidget(window := MainWindow(None, None)) + window.core.insertComponent( + 0, window.core.moduleIndexFor("Classic Visualizer"), window + ) + comp = window.core.selectedComponents[0] + comp.imagePath = getTestDataPath("test.jpg") + for i in range(1, 100): + comp.page.spinBox_scale.setValue(i) + assert comp.scale == 99 + window.undoStack.undo() + assert comp.scale == 20 diff --git a/tests/test_toolkit_common.py b/tests/test_toolkit_common.py new file mode 100644 index 0000000..d903842 --- /dev/null +++ b/tests/test_toolkit_common.py @@ -0,0 +1,13 @@ +from pytestqt import qtbot +from avp.command import Command +from avp.toolkit import blockSignals + + +def test_blockSignals(qtbot): + command = Command() + command.core.insertComponent(0, 0, command) + comp = command.core.selectedComponents[0] + assert comp.page.spinBox_scale.signalsBlocked() == False + with blockSignals(comp.page.spinBox_scale): + assert comp.page.spinBox_scale.signalsBlocked() == True + assert comp.page.spinBox_scale.signalsBlocked() == False From 95b0147248071b8d70d540691faa6aa7b1f3cc3e Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 20 Jan 2026 20:03:34 -0500 Subject: [PATCH 13/21] remove stretch_scale (use scale only) --- src/avp/components/image.py | 29 +++--------------------- src/avp/components/image.ui | 44 ++++++++++++------------------------- 2 files changed, 17 insertions(+), 56 deletions(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index 2393611..8ea1604 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -17,7 +17,6 @@ def widget(self, *args): { "imagePath": self.page.lineEdit_image, "scale": self.page.spinBox_scale, - "stretchScale": self.page.spinBox_scale_stretch, "rotate": self.page.spinBox_rotate, "color": self.page.spinBox_color, "xPosition": self.page.spinBox_x, @@ -54,7 +53,6 @@ def frameRender(self, frameNo): def drawFrame(self, width, height): frame = BlankFrame(width, height) if self.imagePath and os.path.exists(self.imagePath): - scale = self.scale if not self.stretched else self.stretchScale image = Image.open(self.imagePath) # Modify image's appearance @@ -64,9 +62,9 @@ def drawFrame(self, width, height): image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) if self.stretched and image.size != (width, height): image = image.resize((width, height), Image.Resampling.LANCZOS) - if scale != 100: - newHeight = int((image.height / 100) * scale) - newWidth = int((image.width / 100) * scale) + if self.scale != 100: + newHeight = int((image.height / 100) * self.scale) + newWidth = int((image.width / 100) * self.scale) image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS) # Paste image at correct position @@ -106,24 +104,3 @@ def command(self, arg): def commandHelp(self): print("Load an image:\n path=/filepath/to/image.png") - - def savePreset(self): - # Maintain the illusion that the scale spinbox is one widget - scaleBox = self.page.spinBox_scale - stretchScaleBox = self.page.spinBox_scale_stretch - if self.page.checkBox_stretch.isChecked(): - scaleBox.setValue(stretchScaleBox.value()) - else: - stretchScaleBox.setValue(scaleBox.value()) - return super().savePreset() - - def update(self): - # Maintain the illusion that the scale spinbox is one widget - scaleBox = self.page.spinBox_scale - stretchScaleBox = self.page.spinBox_scale_stretch - if self.page.checkBox_stretch.isChecked(): - scaleBox.setVisible(False) - stretchScaleBox.setVisible(True) - else: - scaleBox.setVisible(True) - stretchScaleBox.setVisible(False) diff --git a/src/avp/components/image.ui b/src/avp/components/image.ui index 2dad127..4998774 100644 --- a/src/avp/components/image.ui +++ b/src/avp/components/image.ui @@ -84,10 +84,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -193,10 +193,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -219,14 +219,14 @@ Rotate - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows ° @@ -245,10 +245,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Fixed + QSizePolicy::Policy::Fixed @@ -270,14 +270,14 @@ Scale - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows % @@ -293,22 +293,6 @@ - - - - % - - - 10 - - - 400 - - - 100 - - - @@ -316,7 +300,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -338,14 +322,14 @@ Color - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - QAbstractSpinBox::UpDownArrows + QAbstractSpinBox::ButtonSymbols::UpDownArrows % @@ -371,7 +355,7 @@ - Qt::Vertical + Qt::Orientation::Vertical From 25185bea1a03474f2f3cf684b69d92ad01435179 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 20 Jan 2026 20:37:18 -0500 Subject: [PATCH 14/21] image ignores scale if stretch checkbox checked previous commit did most of it but this polishes the GUI - fixes #89 --- src/avp/components/image.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index 8ea1604..0f4e2bf 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -32,6 +32,12 @@ def widget(self, *args): relativeWidgets=["xPosition", "yPosition", "scale"], ) + def update(self): + if self.page.checkBox_stretch.isChecked(): + self.page.spinBox_scale.setEnabled(False) + else: + self.page.spinBox_scale.setEnabled(True) + def previewRender(self): return self.drawFrame(self.width, self.height) @@ -62,7 +68,7 @@ def drawFrame(self, width, height): image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) if self.stretched and image.size != (width, height): image = image.resize((width, height), Image.Resampling.LANCZOS) - if self.scale != 100: + elif self.scale != 100: newHeight = int((image.height / 100) * self.scale) newWidth = int((image.width / 100) * self.scale) image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS) From 6e5248886822c35556193f1931feb3278e403247 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Tue, 20 Jan 2026 22:05:59 -0500 Subject: [PATCH 15/21] test Title Text component, ffmpeg command --- tests/test_mainwindow_undostack.py | 34 +++++++++++++++--- tests/test_text_comp.py | 32 +++++++++++++++++ tests/test_toolkit_ffmpeg.py | 55 ++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/test_text_comp.py diff --git a/tests/test_mainwindow_undostack.py b/tests/test_mainwindow_undostack.py index 3c49339..6a441b0 100644 --- a/tests/test_mainwindow_undostack.py +++ b/tests/test_mainwindow_undostack.py @@ -1,13 +1,19 @@ +from pytest import fixture from pytestqt import qtbot from avp.gui.mainwindow import MainWindow from . import getTestDataPath -def test_undo_image_scale(qtbot): - """Undo Image component scale setting should undo multiple merged actions.""" +@fixture +def window(qtbot): qtbot.addWidget(window := MainWindow(None, None)) window.settings.setValue("outputWidth", 1920) window.settings.setValue("outputHeight", 1080) + yield window + + +def test_undo_image_scale(window, qtbot): + """Undo Image component scale setting should undo multiple merged actions.""" window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) comp = window.core.selectedComponents[0] comp.imagePath = getTestDataPath("test.jpg") @@ -21,10 +27,9 @@ def test_undo_image_scale(qtbot): assert comp.scale == 100 -def test_undo_classic_visualizer_sensitivity(qtbot): +def test_undo_classic_visualizer_sensitivity(window, qtbot): """Undo Classic Visualizer component sensitivity setting should undo multiple merged actions.""" - qtbot.addWidget(window := MainWindow(None, None)) window.core.insertComponent( 0, window.core.moduleIndexFor("Classic Visualizer"), window ) @@ -35,3 +40,24 @@ def test_undo_classic_visualizer_sensitivity(qtbot): assert comp.scale == 99 window.undoStack.undo() assert comp.scale == 20 + + +def test_undo_title_text_merged(window, qtbot): + """Undoing title text change should undo all recent changes.""" + window.core.insertComponent(0, window.core.moduleIndexFor("Title Text"), window) + comp = window.core.selectedComponents[0] + comp.page.lineEdit_title.setText("avp") + comp.page.lineEdit_title.setText("test") + window.undoStack.undo() + assert comp.title == "Text" + + +def test_undo_title_text_not_merged(window, qtbot): + """Undoing title text change should undo up to previous different action""" + window.core.insertComponent(0, window.core.moduleIndexFor("Title Text"), window) + comp = window.core.selectedComponents[0] + comp.page.lineEdit_title.setText("avp") + comp.page.spinBox_xTextAlign.setValue(0) + comp.page.lineEdit_title.setText("test") + window.undoStack.undo() + assert comp.title == "avp" diff --git a/tests/test_text_comp.py b/tests/test_text_comp.py new file mode 100644 index 0000000..3bc0be6 --- /dev/null +++ b/tests/test_text_comp.py @@ -0,0 +1,32 @@ +from avp.command import Command +from pytestqt import qtbot +from pytest import fixture +from . import audioData, MockSignal, imageDataSum + + +@fixture +def coreWithTextComp(qtbot): + """Fixture providing a Command object with Title Text component added""" + command = Command() + command.core.insertComponent(0, command.core.moduleIndexFor("Title Text"), command) + yield command.core + + +def test_comp_text_renderFrame_resize(coreWithTextComp): + """Call renderFrame of Title Text component added to Command object.""" + comp = coreWithTextComp.selectedComponents[0] + comp.parent.settings.setValue("outputWidth", 1920) + comp.parent.settings.setValue("outputHeight", 1080) + comp.parent.core.updateComponent(0) + image = comp.frameRender(0) + assert imageDataSum(image) == 2957069 + + +def test_comp_text_renderFrame(coreWithTextComp): + """Call renderFrame of Title Text component added to Command object.""" + comp = coreWithTextComp.selectedComponents[0] + comp.parent.settings.setValue("outputWidth", 1280) + comp.parent.settings.setValue("outputHeight", 720) + comp.parent.core.updateComponent(0) + image = comp.frameRender(0) + assert imageDataSum(image) == 1412293 or 1379298 diff --git a/tests/test_toolkit_ffmpeg.py b/tests/test_toolkit_ffmpeg.py index c159806..b015470 100644 --- a/tests/test_toolkit_ffmpeg.py +++ b/tests/test_toolkit_ffmpeg.py @@ -1,3 +1,6 @@ +import pytest +from avp.command import Command +from avp.toolkit.ffmpeg import createFfmpegCommand from . import audioData @@ -7,3 +10,55 @@ def test_readAudioFile_data(audioData): def test_readAudioFile_duration(audioData): assert audioData[1] == 3.95 + + +@pytest.mark.parametrize("width, height", ((1920, 1080), (1280, 720))) +def test_createFfmpegCommand(width, height): + command = Command() + command.settings.setValue("outputWidth", width) + command.settings.setValue("outputHeight", height) + ffmpegCmd = createFfmpegCommand("test.ogg", "/tmp", command.core.selectedComponents) + assert ffmpegCmd == [ + "ffmpeg", + "-thread_queue_size", + "512", + "-y", + "-f", + "rawvideo", + "-vcodec", + "rawvideo", + "-s", + "%sx%s" % (width, height), + "-pix_fmt", + "rgba", + "-r", + "30", + "-t", + "0.100", + "-an", + "-i", + "-", + "-t", + "0.100", + "-i", + "test.ogg", + "-map", + "0:v", + "-map", + "1:a", + "-vcodec", + "libx264", + "-acodec", + "aac", + "-b:v", + "2500k", + "-b:a", + "192k", + "-pix_fmt", + "yuv420p", + "-preset", + "medium", + "-f", + "mp4", + "/tmp", + ] From f02deb83d5aafbeec3f49c3e7d451601a8b67a04 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 21 Jan 2026 10:40:28 -0500 Subject: [PATCH 16/21] Image v2: replace stretched setting with resizeMode 3 resize modes are scale, cover, and stretch. Scale only applies when resizeMode is set to scale. Cover uses ImageOps.fit() to stretch while maintaining aspect ratio. Also, spinBox_scale was moved to be underneath comboBox_resizeMode. --- src/avp/components/image.py | 24 +++++++----- src/avp/components/image.ui | 63 +++++++++++++++++------------- tests/test_mainwindow_undostack.py | 34 ++++++++++------ 3 files changed, 73 insertions(+), 48 deletions(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index 0f4e2bf..3a2485e 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -1,5 +1,5 @@ -from PIL import Image, ImageDraw, ImageEnhance -from PyQt6 import QtGui, QtCore, QtWidgets +from PIL import Image, ImageOps, ImageEnhance +from PyQt6 import QtWidgets import os from ..component import Component @@ -8,11 +8,15 @@ class Component(Component): name = "Image" - version = "1.0.1" + version = "2.0.0" def widget(self, *args): super().widget(*args) self.page.pushButton_image.clicked.connect(self.pickImage) + self.page.comboBox_resizeMode.addItem("Scale") + self.page.comboBox_resizeMode.addItem("Cover") + self.page.comboBox_resizeMode.addItem("Stretch") + self.page.comboBox_resizeMode.setCurrentIndex(0) self.trackWidgets( { "imagePath": self.page.lineEdit_image, @@ -21,7 +25,7 @@ def widget(self, *args): "color": self.page.spinBox_color, "xPosition": self.page.spinBox_x, "yPosition": self.page.spinBox_y, - "stretched": self.page.checkBox_stretch, + "resizeMode": self.page.comboBox_resizeMode, "mirror": self.page.checkBox_mirror, }, presetNames={ @@ -33,10 +37,10 @@ def widget(self, *args): ) def update(self): - if self.page.checkBox_stretch.isChecked(): - self.page.spinBox_scale.setEnabled(False) - else: + if self.page.comboBox_resizeMode.currentIndex() == 0: self.page.spinBox_scale.setEnabled(True) + else: + self.page.spinBox_scale.setEnabled(False) def previewRender(self): return self.drawFrame(self.width, self.height) @@ -66,9 +70,11 @@ def drawFrame(self, width, height): image = ImageEnhance.Color(image).enhance(float(self.color / 100)) if self.mirror: image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - if self.stretched and image.size != (width, height): + if self.resizeMode == 1: # Cover + image = ImageOps.fit(image, (width, height), Image.Resampling.LANCZOS) + elif self.resizeMode == 2: # Stretch image = image.resize((width, height), Image.Resampling.LANCZOS) - elif self.scale != 100: + elif self.scale != 100: # Scale newHeight = int((image.height / 100) * self.scale) newWidth = int((image.width / 100) * self.scale) image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS) diff --git a/src/avp/components/image.ui b/src/avp/components/image.ui index 4998774..3c50d9f 100644 --- a/src/avp/components/image.ui +++ b/src/avp/components/image.ui @@ -181,15 +181,21 @@ - - - Stretch + + + + 0 + 0 + - - false + + Resize + + + @@ -214,7 +220,26 @@ - + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + Rotate @@ -242,24 +267,12 @@ + + + + - - - Qt::Orientation::Horizontal - - - QSizePolicy::Policy::Fixed - - - - 10 - 20 - - - - - - + 0 @@ -293,10 +306,6 @@ - - - - diff --git a/tests/test_mainwindow_undostack.py b/tests/test_mainwindow_undostack.py index 6a441b0..1eec1ef 100644 --- a/tests/test_mainwindow_undostack.py +++ b/tests/test_mainwindow_undostack.py @@ -6,12 +6,28 @@ @fixture def window(qtbot): - qtbot.addWidget(window := MainWindow(None, None)) + window = MainWindow(None, None) + qtbot.addWidget(window) window.settings.setValue("outputWidth", 1920) window.settings.setValue("outputHeight", 1080) yield window +def test_undo_classic_visualizer_sensitivity(window, qtbot): + """Undo Classic Visualizer component sensitivity setting + should undo multiple merged actions.""" + window.core.insertComponent( + 0, window.core.moduleIndexFor("Classic Visualizer"), window + ) + comp = window.core.selectedComponents[0] + comp.imagePath = getTestDataPath("test.jpg") + for i in range(1, 100): + comp.page.spinBox_scale.setValue(i) + assert comp.scale == 99 + window.undoStack.undo() + assert comp.scale == 20 + + def test_undo_image_scale(window, qtbot): """Undo Image component scale setting should undo multiple merged actions.""" window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) @@ -27,19 +43,13 @@ def test_undo_image_scale(window, qtbot): assert comp.scale == 100 -def test_undo_classic_visualizer_sensitivity(window, qtbot): - """Undo Classic Visualizer component sensitivity setting - should undo multiple merged actions.""" - window.core.insertComponent( - 0, window.core.moduleIndexFor("Classic Visualizer"), window - ) +def test_undo_image_resizeMode(window, qtbot): + window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) comp = window.core.selectedComponents[0] - comp.imagePath = getTestDataPath("test.jpg") - for i in range(1, 100): - comp.page.spinBox_scale.setValue(i) - assert comp.scale == 99 + comp.page.comboBox_resizeMode.setCurrentIndex(1) + assert not comp.page.spinBox_scale.isEnabled() window.undoStack.undo() - assert comp.scale == 20 + assert comp.page.spinBox_scale.isEnabled() def test_undo_title_text_merged(window, qtbot): From f8e6d6a711a048b2e2179606432cd798fb4fcfd5 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 21 Jan 2026 20:01:40 -0500 Subject: [PATCH 17/21] change transformData into staticmethod the purpose is to allow easier reuse in other components --- src/avp/components/original.py | 14 ++++++++------ tests/test_classic_visualizer.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/avp/components/original.py b/src/avp/components/original.py index d66419c..64eba4d 100644 --- a/src/avp/components/original.py +++ b/src/avp/components/original.py @@ -57,8 +57,8 @@ def previewRender(self): def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) - self.smoothConstantDown = 0.08 + 0 if not self.smooth else self.smooth / 15 - self.smoothConstantUp = 0.8 - 0 if not self.smooth else self.smooth / 15 + smoothConstantDown = 0.08 if not self.smooth else self.smooth / 15 + smoothConstantUp = 0.8 if not self.smooth else self.smooth / 15 self.lastSpectrum = None self.spectrumArray = {} @@ -69,9 +69,10 @@ def preFrameRender(self, **kwargs): i, self.completeAudioArray, self.sampleSize, - self.smoothConstantDown, - self.smoothConstantUp, + smoothConstantDown, + smoothConstantUp, self.lastSpectrum, + self.scale, ) self.spectrumArray[i] = copy(self.lastSpectrum) @@ -92,14 +93,15 @@ def frameRender(self, frameNo): self.layout, ) + @staticmethod def transformData( - self, i, completeAudioArray, sampleSize, smoothConstantDown, smoothConstantUp, lastSpectrum, + scale, ): if len(completeAudioArray) < (i + sampleSize): sampleSize = len(completeAudioArray) - i @@ -118,7 +120,7 @@ def transformData( # y[y<80] = 0 with numpy.errstate(divide="ignore"): - y = self.scale * numpy.log10(y) + y = scale * numpy.log10(y) y[numpy.isinf(y)] = 0 diff --git a/tests/test_classic_visualizer.py b/tests/test_classic_visualizer.py index 242a19a..e301263 100644 --- a/tests/test_classic_visualizer.py +++ b/tests/test_classic_visualizer.py @@ -33,12 +33,18 @@ def test_comp_classic_drawBars(coreWithClassicComp, audioData): spectrumArray = { 0: coreWithClassicComp.selectedComponents[0].transformData( - 0, audioData[0], sampleSize, 0.08, 0.8, None + 0, audioData[0], sampleSize, 0.08, 0.8, None, 20 ) } for i in range(sampleSize, len(audioData[0]), sampleSize): spectrumArray[i] = coreWithClassicComp.selectedComponents[0].transformData( - i, audioData[0], sampleSize, 0.08, 0.8, spectrumArray[i - sampleSize].copy() + 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 From 3b91c14e105fd9d7f3e1c3a47e2b61f25ea273c5 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 21 Jan 2026 20:04:42 -0500 Subject: [PATCH 18/21] add respondToAudio option to Image component this causes the image to scale up and down slightly based on the input audio file --- src/avp/components/image.py | 83 +++++++++++++++++++++++++++++++++---- src/avp/components/image.ui | 76 +++++++++++++++++++++++++-------- 2 files changed, 132 insertions(+), 27 deletions(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index 3a2485e..ef13125 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -1,9 +1,11 @@ from PIL import Image, ImageOps, ImageEnhance from PyQt6 import QtWidgets import os +from copy import copy from ..component import Component from ..toolkit.frame import BlankFrame +from .original import Component as Visualizer class Component(Component): @@ -12,6 +14,7 @@ class Component(Component): def widget(self, *args): super().widget(*args) + self.page.pushButton_image.clicked.connect(self.pickImage) self.page.comboBox_resizeMode.addItem("Scale") self.page.comboBox_resizeMode.addItem("Cover") @@ -27,6 +30,8 @@ def widget(self, *args): "yPosition": self.page.spinBox_y, "resizeMode": self.page.comboBox_resizeMode, "mirror": self.page.checkBox_mirror, + "respondToAudio": self.page.checkBox_respondToAudio, + "sensitivity": self.page.spinBox_sensitivity, }, presetNames={ "imagePath": "image", @@ -37,16 +42,18 @@ def widget(self, *args): ) def update(self): - if self.page.comboBox_resizeMode.currentIndex() == 0: - self.page.spinBox_scale.setEnabled(True) - else: - self.page.spinBox_scale.setEnabled(False) + self.page.spinBox_sensitivity.setEnabled( + self.page.checkBox_respondToAudio.isChecked() + ) + self.page.spinBox_scale.setEnabled( + self.page.comboBox_resizeMode.currentIndex() == 0 + ) def previewRender(self): - return self.drawFrame(self.width, self.height) + return self.drawFrame(self.width, self.height, None) def properties(self): - props = ["static"] + props = ["pcm" if self.respondToAudio else "static"] if not os.path.exists(self.imagePath): props.append("error") return props @@ -57,10 +64,49 @@ def error(self): if not os.path.exists(self.imagePath): return "The image selected does not exist!" + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + if not self.respondToAudio: + return + + smoothConstantDown = 0.08 + 0 + smoothConstantUp = 0.8 - 0 + self.lastSpectrum = None + self.spectrumArray = {} + + for i in range(0, len(self.completeAudioArray), self.sampleSize): + if self.canceled: + break + self.lastSpectrum = Visualizer.transformData( + i, + self.completeAudioArray, + self.sampleSize, + smoothConstantDown, + smoothConstantUp, + self.lastSpectrum, + self.sensitivity, + ) + self.spectrumArray[i] = copy(self.lastSpectrum) + + progress = int(100 * (i / len(self.completeAudioArray))) + if progress >= 100: + progress = 100 + pStr = "Analyzing audio: " + str(progress) + "%" + self.progressBarSetText.emit(pStr) + self.progressBarUpdate.emit(int(progress)) + def frameRender(self, frameNo): - return self.drawFrame(self.width, self.height) + return self.drawFrame( + self.width, + self.height, + ( + None + if not self.respondToAudio + else self.spectrumArray[frameNo * self.sampleSize] + ), + ) - def drawFrame(self, width, height): + def drawFrame(self, width, height, dynamicScale): frame = BlankFrame(width, height) if self.imagePath and os.path.exists(self.imagePath): image = Image.open(self.imagePath) @@ -79,8 +125,27 @@ def drawFrame(self, width, height): newWidth = int((image.width / 100) * self.scale) image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS) + # Respond to audio + scale = 0 + if dynamicScale is not None: + scale = dynamicScale[36 * 4] / 4 + image = ImageOps.contain( + image, + ( + image.width + int(scale / 2), + image.height + int(scale / 2), + ), + Image.Resampling.LANCZOS, + ) + # Paste image at correct position - frame.paste(image, box=(self.xPosition, self.yPosition)) + frame.paste( + image, + box=( + self.xPosition - (0 if not self.respondToAudio else int(scale / 2)), + self.yPosition - (0 if not self.respondToAudio else int(scale / 2)), + ), + ) if self.rotate != 0: frame = frame.rotate(self.rotate) diff --git a/src/avp/components/image.ui b/src/avp/components/image.ui index 3c50d9f..45f3747 100644 --- a/src/avp/components/image.ui +++ b/src/avp/components/image.ui @@ -197,16 +197,13 @@ - + Qt::Orientation::Horizontal - - QSizePolicy::Policy::Fixed - - 5 + 40 20 @@ -214,24 +211,14 @@ + + Qt::LayoutDirection::RightToLeft + Mirror - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - @@ -359,6 +346,59 @@ + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Scale image in response to input audio file + + + Qt::LayoutDirection::RightToLeft + + + Respond to Audio + + + true + + + false + + + false + + + + + + + Sensitivity + + + + + + + 1 + + + + + From ff54d3a4b1a33fc9c8b4f8bdb9b0a5b27d3fba8c Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Wed, 21 Jan 2026 20:59:55 -0500 Subject: [PATCH 19/21] cache static portion of image when animating increases rendering speed of a 1-minute video by 12 seconds (based on two manual tests anyway) --- src/avp/components/image.py | 43 ++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index ef13125..59ce8be 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -15,6 +15,9 @@ class Component(Component): def widget(self, *args): super().widget(*args) + # cache a modified image object in case we are rendering beyond frame 1 + self.existingImage = None + self.page.pushButton_image.clicked.connect(self.pickImage) self.page.comboBox_resizeMode.addItem("Scale") self.page.comboBox_resizeMode.addItem("Cover") @@ -109,21 +112,28 @@ def frameRender(self, frameNo): def drawFrame(self, width, height, dynamicScale): frame = BlankFrame(width, height) if self.imagePath and os.path.exists(self.imagePath): - image = Image.open(self.imagePath) - - # Modify image's appearance - if self.color != 100: - image = ImageEnhance.Color(image).enhance(float(self.color / 100)) - if self.mirror: - image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - if self.resizeMode == 1: # Cover - image = ImageOps.fit(image, (width, height), Image.Resampling.LANCZOS) - elif self.resizeMode == 2: # Stretch - image = image.resize((width, height), Image.Resampling.LANCZOS) - elif self.scale != 100: # Scale - newHeight = int((image.height / 100) * self.scale) - newWidth = int((image.width / 100) * self.scale) - image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS) + if self.existingImage: + image = self.existingImage + else: + image = Image.open(self.imagePath) + # Modify static image appearance + if self.color != 100: + image = ImageEnhance.Color(image).enhance(float(self.color / 100)) + if self.mirror: + image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + if self.resizeMode == 1: # Cover + image = ImageOps.fit( + image, (width, height), Image.Resampling.LANCZOS + ) + elif self.resizeMode == 2: # Stretch + image = image.resize((width, height), Image.Resampling.LANCZOS) + elif self.scale != 100: # Scale + newHeight = int((image.height / 100) * self.scale) + newWidth = int((image.width / 100) * self.scale) + image = image.resize( + (newWidth, newHeight), Image.Resampling.LANCZOS + ) + self.existingImage = image # Respond to audio scale = 0 @@ -151,6 +161,9 @@ def drawFrame(self, width, height, dynamicScale): return frame + def postFrameRender(self): + self.existingImage = None + def pickImage(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( From 4780f937e4a41d7d505463dff42126692f040aa0 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Thu, 22 Jan 2026 16:25:19 -0500 Subject: [PATCH 20/21] delete saved image when a new export begins --- src/avp/components/image.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/avp/components/image.py b/src/avp/components/image.py index 59ce8be..bada15f 100644 --- a/src/avp/components/image.py +++ b/src/avp/components/image.py @@ -72,6 +72,9 @@ def preFrameRender(self, **kwargs): if not self.respondToAudio: return + # Trigger creation of new base image + self.existingImage = None + smoothConstantDown = 0.08 + 0 smoothConstantUp = 0.8 - 0 self.lastSpectrum = None @@ -112,7 +115,7 @@ def frameRender(self, frameNo): def drawFrame(self, width, height, dynamicScale): frame = BlankFrame(width, height) if self.imagePath and os.path.exists(self.imagePath): - if self.existingImage: + if dynamicScale is not None and self.existingImage: image = self.existingImage else: image = Image.open(self.imagePath) From 06b4c71efb3ae9b83159b50959b5f286c0d8078b Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Thu, 22 Jan 2026 16:25:55 -0500 Subject: [PATCH 21/21] increase default sensitivity of image component --- src/avp/components/image.ui | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/avp/components/image.ui b/src/avp/components/image.ui index 45f3747..72593a3 100644 --- a/src/avp/components/image.ui +++ b/src/avp/components/image.ui @@ -395,6 +395,9 @@ 1 + + 20 +