From a12be862e22bdec6a243a3f0b5f4f28d69084a2a Mon Sep 17 00:00:00 2001
From: Brianna Rainey
Date: Thu, 22 Jan 2026 16:29:46 -0500
Subject: fix #89 with Image component v2.0 + 23 tests (#90)
* 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
* add tests for drawBars, readAudioFile, BlankFrame
* replace numpy.seterr with numpy.errstate
* fix incorrect comment
* add MockVideoWorker and imageDataSum
* test further into visualization (less likely to be a false positive)
* test FloodFrame function
* add failing test for Image component
one step towards fixing #89
* test component name CLI parsing
* prevent log warning when 1 setting changed
* correct tests to use widgets when needed
* test undo and blockSignals
* remove stretch_scale (use scale only)
* image ignores scale if stretch checkbox checked
fixes #89
* test Title Text component, ffmpeg command
* 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.
* change transformData into staticmethod
the purpose is to allow easier reuse in other components
* add respondToAudio option to Image component
this causes the image to scale up and down slightly based on the input audio file
* 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/component.py | 11 +--
src/avp/components/image.py | 160 +++++++++++++++++++++++++++++------------
src/avp/components/image.ui | 154 ++++++++++++++++++++++++---------------
src/avp/components/original.py | 16 +++--
src/avp/core.py | 2 +-
src/avp/video_thread.py | 16 ++---
6 files changed, 234 insertions(+), 125 deletions(-)
(limited to 'src/avp')
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 @@ class ComponentUpdate(QUndoCommand):
# 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),
diff --git a/src/avp/components/image.py b/src/avp/components/image.py
index 2393611..bada15f 100644
--- a/src/avp/components/image.py
+++ b/src/avp/components/image.py
@@ -1,29 +1,40 @@
-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 copy import copy
from ..component import Component
from ..toolkit.frame import BlankFrame
+from .original import Component as Visualizer
class Component(Component):
name = "Image"
- version = "1.0.1"
+ version = "2.0.0"
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")
+ self.page.comboBox_resizeMode.addItem("Stretch")
+ self.page.comboBox_resizeMode.setCurrentIndex(0)
self.trackWidgets(
{
"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,
"yPosition": self.page.spinBox_y,
- "stretched": self.page.checkBox_stretch,
+ "resizeMode": self.page.comboBox_resizeMode,
"mirror": self.page.checkBox_mirror,
+ "respondToAudio": self.page.checkBox_respondToAudio,
+ "sensitivity": self.page.spinBox_sensitivity,
},
presetNames={
"imagePath": "image",
@@ -33,11 +44,19 @@ class Component(Component):
relativeWidgets=["xPosition", "yPosition", "scale"],
)
+ def update(self):
+ 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
@@ -48,34 +67,106 @@ class Component(Component):
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
+
+ # Trigger creation of new base image
+ self.existingImage = None
+
+ smoothConstantDown = 0.08 + 0
+ smoothConstantUp = 0.8 - 0
+ self.lastSpectrum = None
+ self.spectrumArray = {}
+
+ for i in range(0, len(self.completeAudioArray), self.sampleSize):
+ if self.canceled:
+ break
+ self.lastSpectrum = Visualizer.transformData(
+ i,
+ self.completeAudioArray,
+ self.sampleSize,
+ smoothConstantDown,
+ smoothConstantUp,
+ self.lastSpectrum,
+ self.sensitivity,
+ )
+ self.spectrumArray[i] = copy(self.lastSpectrum)
+
+ progress = int(100 * (i / len(self.completeAudioArray)))
+ if progress >= 100:
+ progress = 100
+ pStr = "Analyzing audio: " + str(progress) + "%"
+ self.progressBarSetText.emit(pStr)
+ self.progressBarUpdate.emit(int(progress))
+
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):
- scale = self.scale if not self.stretched else self.stretchScale
- 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.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)
- image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS)
+ if dynamicScale is not None and 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
+ 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)
return frame
+ def postFrameRender(self):
+ self.existingImage = None
+
def pickImage(self):
imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
filename, _ = QtWidgets.QFileDialog.getOpenFileName(
@@ -106,24 +197,3 @@ class Component(Component):
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..72593a3 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
@@ -181,26 +181,29 @@
-
-
-
-
- Stretch
+
+
+
+ 0
+ 0
+
-
- false
+
+ Resize
-
-
+
+
+ -
+
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
+ Qt::Orientation::Horizontal
- 5
+ 40
20
@@ -208,25 +211,34 @@
-
+
+ Qt::LayoutDirection::RightToLeft
+
Mirror
-
-
+
+
+
+ 0
+ 0
+
+
Rotate
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter
-
- QAbstractSpinBox::UpDownArrows
+ QAbstractSpinBox::ButtonSymbols::UpDownArrows
°
@@ -242,24 +254,12 @@
+
+
+ -
+
-
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 10
- 20
-
-
-
-
- -
-
+
0
@@ -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,30 +293,10 @@
- -
-
-
- %
-
-
- 10
-
-
- 400
-
-
- 100
-
-
-
-
-
- -
-
-
- Qt::Horizontal
+ Qt::Orientation::Horizontal
@@ -338,14 +318,14 @@
Color
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter
-
- QAbstractSpinBox::UpDownArrows
+ QAbstractSpinBox::ButtonSymbols::UpDownArrows
%
@@ -366,12 +346,68 @@
+ -
+
+
-
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Scale image in response to input audio file
+
+
+ Qt::LayoutDirection::RightToLeft
+
+
+ Respond to Audio
+
+
+ true
+
+
+ false
+
+
+ false
+
+
+
+ -
+
+
+ Sensitivity
+
+
+
+ -
+
+
+ 1
+
+
+ 20
+
+
+
+
+
-
- Qt::Vertical
+ Qt::Orientation::Vertical
diff --git a/src/avp/components/original.py b/src/avp/components/original.py
index 1e7ef86..64eba4d 100644
--- a/src/avp/components/original.py
+++ b/src/avp/components/original.py
@@ -57,8 +57,8 @@ class Component(Component):
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 @@ class Component(Component):
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 @@ class Component(Component):
self.layout,
)
+ @staticmethod
def transformData(
- self,
i,
completeAudioArray,
sampleSize,
smoothConstantDown,
smoothConstantUp,
lastSpectrum,
+ scale,
):
if len(completeAudioArray) < (i + sampleSize):
sampleSize = len(completeAudioArray) - i
@@ -117,7 +119,9 @@ class Component(Component):
# filter the noise away
# y[y<80] = 0
- y = self.scale * numpy.log10(y)
+ with numpy.errstate(divide="ignore"):
+ y = scale * numpy.log10(y)
+
y[numpy.isinf(y)] = 0
if lastSpectrum is not None:
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 @@ class Core:
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)
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 @@ class Worker(QtCore.QObject):
@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"))
--
cgit v1.2.3