aboutsummaryrefslogtreecommitdiff
path: root/src/avp/components
diff options
context:
space:
mode:
authorBrianna Rainey2026-01-22 16:29:46 -0500
committerGitHub2026-01-22 16:29:46 -0500
commita12be862e22bdec6a243a3f0b5f4f28d69084a2a (patch)
tree7f2b21f58cf54deb81bfe77d7ef45358c80454f0 /src/avp/components
parent36760579a0ae604074034c4b78cda2e3f3b001de (diff)
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)
Diffstat (limited to 'src/avp/components')
-rw-r--r--src/avp/components/image.py160
-rw-r--r--src/avp/components/image.ui154
-rw-r--r--src/avp/components/original.py16
3 files changed, 220 insertions, 110 deletions
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 @@
<item>
<spacer name="horizontalSpacer_9">
<property name="orientation">
- <enum>Qt::Horizontal</enum>
+ <enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeType">
- <enum>QSizePolicy::Fixed</enum>
+ <enum>QSizePolicy::Policy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@@ -181,26 +181,29 @@
<item>
<layout class="QHBoxLayout" name="horizontalLayout_9">
<item>
- <widget class="QCheckBox" name="checkBox_stretch">
- <property name="text">
- <string>Stretch</string>
+ <widget class="QLabel" name="label_resizeMode">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
</property>
- <property name="checked">
- <bool>false</bool>
+ <property name="text">
+ <string>Resize</string>
</property>
</widget>
</item>
<item>
- <spacer name="horizontalSpacer_10">
+ <widget class="QComboBox" name="comboBox_resizeMode"/>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer_3">
<property name="orientation">
- <enum>Qt::Horizontal</enum>
- </property>
- <property name="sizeType">
- <enum>QSizePolicy::Fixed</enum>
+ <enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
- <width>5</width>
+ <width>40</width>
<height>20</height>
</size>
</property>
@@ -208,25 +211,34 @@
</item>
<item>
<widget class="QCheckBox" name="checkBox_mirror">
+ <property name="layoutDirection">
+ <enum>Qt::LayoutDirection::RightToLeft</enum>
+ </property>
<property name="text">
<string>Mirror</string>
</property>
</widget>
</item>
<item>
- <widget class="QLabel" name="label_2">
+ <widget class="QLabel" name="label_rotate">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
<property name="text">
<string>Rotate</string>
</property>
<property name="alignment">
- <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ <set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="spinBox_rotate">
<property name="buttonSymbols">
- <enum>QAbstractSpinBox::UpDownArrows</enum>
+ <enum>QAbstractSpinBox::ButtonSymbols::UpDownArrows</enum>
</property>
<property name="suffix">
<string notr="true">°</string>
@@ -242,24 +254,12 @@
</property>
</widget>
</item>
+ </layout>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
- <spacer name="horizontalSpacer">
- <property name="orientation">
- <enum>Qt::Horizontal</enum>
- </property>
- <property name="sizeType">
- <enum>QSizePolicy::Fixed</enum>
- </property>
- <property name="sizeHint" stdset="0">
- <size>
- <width>10</width>
- <height>20</height>
- </size>
- </property>
- </spacer>
- </item>
- <item>
- <widget class="QLabel" name="label">
+ <widget class="QLabel" name="label_scale">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
@@ -270,14 +270,14 @@
<string>Scale</string>
</property>
<property name="alignment">
- <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ <set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="spinBox_scale">
<property name="buttonSymbols">
- <enum>QAbstractSpinBox::UpDownArrows</enum>
+ <enum>QAbstractSpinBox::ButtonSymbols::UpDownArrows</enum>
</property>
<property name="suffix">
<string>%</string>
@@ -294,29 +294,9 @@
</widget>
</item>
<item>
- <widget class="QSpinBox" name="spinBox_scale_stretch">
- <property name="suffix">
- <string>%</string>
- </property>
- <property name="minimum">
- <number>10</number>
- </property>
- <property name="maximum">
- <number>400</number>
- </property>
- <property name="value">
- <number>100</number>
- </property>
- </widget>
- </item>
- </layout>
- </item>
- <item>
- <layout class="QHBoxLayout" name="horizontalLayout_2">
- <item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
- <enum>Qt::Horizontal</enum>
+ <enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@@ -338,14 +318,14 @@
<string>Color</string>
</property>
<property name="alignment">
- <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ <set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="spinBox_color">
<property name="buttonSymbols">
- <enum>QAbstractSpinBox::UpDownArrows</enum>
+ <enum>QAbstractSpinBox::ButtonSymbols::UpDownArrows</enum>
</property>
<property name="suffix">
<string>%</string>
@@ -366,12 +346,68 @@
</item>
</layout>
</item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Orientation::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="checkBox_respondToAudio">
+ <property name="toolTip">
+ <string>Scale image in response to input audio file</string>
+ </property>
+ <property name="layoutDirection">
+ <enum>Qt::LayoutDirection::RightToLeft</enum>
+ </property>
+ <property name="text">
+ <string>Respond to Audio</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <property name="autoRepeat">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="label_sensitivity">
+ <property name="text">
+ <string>Sensitivity</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QSpinBox" name="spinBox_sensitivity">
+ <property name="minimum">
+ <number>1</number>
+ </property>
+ <property name="value">
+ <number>20</number>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
- <enum>Qt::Vertical</enum>
+ <enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
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: