diff options
| author | Brianna Rainey | 2026-01-22 16:29:46 -0500 |
|---|---|---|
| committer | GitHub | 2026-01-22 16:29:46 -0500 |
| commit | a12be862e22bdec6a243a3f0b5f4f28d69084a2a (patch) | |
| tree | 7f2b21f58cf54deb81bfe77d7ef45358c80454f0 /src | |
| parent | 36760579a0ae604074034c4b78cda2e3f3b001de (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')
| -rw-r--r-- | src/avp/component.py | 11 | ||||
| -rw-r--r-- | src/avp/components/image.py | 160 | ||||
| -rw-r--r-- | src/avp/components/image.ui | 154 | ||||
| -rw-r--r-- | src/avp/components/original.py | 16 | ||||
| -rw-r--r-- | src/avp/core.py | 2 | ||||
| -rw-r--r-- | src/avp/video_thread.py | 16 |
6 files changed, 234 insertions, 125 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 @@ 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 @@ <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: 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")) |
