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 | |
| 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)
| -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 | ||||
| -rw-r--r-- | tests/__init__.py | 50 | ||||
| -rw-r--r-- | tests/test_classic_visualizer.py | 71 | ||||
| -rw-r--r-- | tests/test_commandline_parser.py | 21 | ||||
| -rw-r--r-- | tests/test_image_comp.py | 50 | ||||
| -rw-r--r-- | tests/test_mainwindow_undostack.py | 73 | ||||
| -rw-r--r-- | tests/test_text_comp.py | 32 | ||||
| -rw-r--r-- | tests/test_toolkit_common.py | 13 | ||||
| -rw-r--r-- | tests/test_toolkit_ffmpeg.py | 64 | ||||
| -rw-r--r-- | tests/test_toolkit_frame.py | 14 |
15 files changed, 598 insertions, 149 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): | |||
| 910 | 910 | ||
| 911 | # Determine if this update is mergeable | 911 | # Determine if this update is mergeable |
| 912 | self.id_ = -1 | 912 | self.id_ = -1 |
| 913 | if len(self.modifiedVals) == 1 and self.parent.mergeUndo: | 913 | if self.parent.mergeUndo: |
| 914 | attr, val = self.modifiedVals.popitem() | 914 | if len(self.modifiedVals) == 1: |
| 915 | self.id_ = sum([ord(letter) for letter in attr[-14:]]) | 915 | attr, val = self.modifiedVals.popitem() |
| 916 | self.modifiedVals[attr] = val | 916 | self.id_ = sum([ord(letter) for letter in attr[-14:]]) |
| 917 | else: | 917 | self.modifiedVals[attr] = val |
| 918 | return | ||
| 918 | log.warning( | 919 | log.warning( |
| 919 | "%s component settings changed at once. (%s)", | 920 | "%s component settings changed at once. (%s)", |
| 920 | len(self.modifiedVals), | 921 | 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 @@ | |||
| 1 | from PIL import Image, ImageDraw, ImageEnhance | 1 | from PIL import Image, ImageOps, ImageEnhance |
| 2 | from PyQt6 import QtGui, QtCore, QtWidgets | 2 | from PyQt6 import QtWidgets |
| 3 | import os | 3 | import os |
| 4 | from copy import copy | ||
| 4 | 5 | ||
| 5 | from ..component import Component | 6 | from ..component import Component |
| 6 | from ..toolkit.frame import BlankFrame | 7 | from ..toolkit.frame import BlankFrame |
| 8 | from .original import Component as Visualizer | ||
| 7 | 9 | ||
| 8 | 10 | ||
| 9 | class Component(Component): | 11 | class Component(Component): |
| 10 | name = "Image" | 12 | name = "Image" |
| 11 | version = "1.0.1" | 13 | version = "2.0.0" |
| 12 | 14 | ||
| 13 | def widget(self, *args): | 15 | def widget(self, *args): |
| 14 | super().widget(*args) | 16 | super().widget(*args) |
| 17 | |||
| 18 | # cache a modified image object in case we are rendering beyond frame 1 | ||
| 19 | self.existingImage = None | ||
| 20 | |||
| 15 | self.page.pushButton_image.clicked.connect(self.pickImage) | 21 | self.page.pushButton_image.clicked.connect(self.pickImage) |
| 22 | self.page.comboBox_resizeMode.addItem("Scale") | ||
| 23 | self.page.comboBox_resizeMode.addItem("Cover") | ||
| 24 | self.page.comboBox_resizeMode.addItem("Stretch") | ||
| 25 | self.page.comboBox_resizeMode.setCurrentIndex(0) | ||
| 16 | self.trackWidgets( | 26 | self.trackWidgets( |
| 17 | { | 27 | { |
| 18 | "imagePath": self.page.lineEdit_image, | 28 | "imagePath": self.page.lineEdit_image, |
| 19 | "scale": self.page.spinBox_scale, | 29 | "scale": self.page.spinBox_scale, |
| 20 | "stretchScale": self.page.spinBox_scale_stretch, | ||
| 21 | "rotate": self.page.spinBox_rotate, | 30 | "rotate": self.page.spinBox_rotate, |
| 22 | "color": self.page.spinBox_color, | 31 | "color": self.page.spinBox_color, |
| 23 | "xPosition": self.page.spinBox_x, | 32 | "xPosition": self.page.spinBox_x, |
| 24 | "yPosition": self.page.spinBox_y, | 33 | "yPosition": self.page.spinBox_y, |
| 25 | "stretched": self.page.checkBox_stretch, | 34 | "resizeMode": self.page.comboBox_resizeMode, |
| 26 | "mirror": self.page.checkBox_mirror, | 35 | "mirror": self.page.checkBox_mirror, |
| 36 | "respondToAudio": self.page.checkBox_respondToAudio, | ||
| 37 | "sensitivity": self.page.spinBox_sensitivity, | ||
| 27 | }, | 38 | }, |
| 28 | presetNames={ | 39 | presetNames={ |
| 29 | "imagePath": "image", | 40 | "imagePath": "image", |
| @@ -33,11 +44,19 @@ class Component(Component): | |||
| 33 | relativeWidgets=["xPosition", "yPosition", "scale"], | 44 | relativeWidgets=["xPosition", "yPosition", "scale"], |
| 34 | ) | 45 | ) |
| 35 | 46 | ||
| 47 | def update(self): | ||
| 48 | self.page.spinBox_sensitivity.setEnabled( | ||
| 49 | self.page.checkBox_respondToAudio.isChecked() | ||
| 50 | ) | ||
| 51 | self.page.spinBox_scale.setEnabled( | ||
| 52 | self.page.comboBox_resizeMode.currentIndex() == 0 | ||
| 53 | ) | ||
| 54 | |||
| 36 | def previewRender(self): | 55 | def previewRender(self): |
| 37 | return self.drawFrame(self.width, self.height) | 56 | return self.drawFrame(self.width, self.height, None) |
| 38 | 57 | ||
| 39 | def properties(self): | 58 | def properties(self): |
| 40 | props = ["static"] | 59 | props = ["pcm" if self.respondToAudio else "static"] |
| 41 | if not os.path.exists(self.imagePath): | 60 | if not os.path.exists(self.imagePath): |
| 42 | props.append("error") | 61 | props.append("error") |
| 43 | return props | 62 | return props |
| @@ -48,34 +67,106 @@ class Component(Component): | |||
| 48 | if not os.path.exists(self.imagePath): | 67 | if not os.path.exists(self.imagePath): |
| 49 | return "The image selected does not exist!" | 68 | return "The image selected does not exist!" |
| 50 | 69 | ||
| 70 | def preFrameRender(self, **kwargs): | ||
| 71 | super().preFrameRender(**kwargs) | ||
| 72 | if not self.respondToAudio: | ||
| 73 | return | ||
| 74 | |||
| 75 | # Trigger creation of new base image | ||
| 76 | self.existingImage = None | ||
| 77 | |||
| 78 | smoothConstantDown = 0.08 + 0 | ||
| 79 | smoothConstantUp = 0.8 - 0 | ||
| 80 | self.lastSpectrum = None | ||
| 81 | self.spectrumArray = {} | ||
| 82 | |||
| 83 | for i in range(0, len(self.completeAudioArray), self.sampleSize): | ||
| 84 | if self.canceled: | ||
| 85 | break | ||
| 86 | self.lastSpectrum = Visualizer.transformData( | ||
| 87 | i, | ||
| 88 | self.completeAudioArray, | ||
| 89 | self.sampleSize, | ||
| 90 | smoothConstantDown, | ||
| 91 | smoothConstantUp, | ||
| 92 | self.lastSpectrum, | ||
| 93 | self.sensitivity, | ||
| 94 | ) | ||
| 95 | self.spectrumArray[i] = copy(self.lastSpectrum) | ||
| 96 | |||
| 97 | progress = int(100 * (i / len(self.completeAudioArray))) | ||
| 98 | if progress >= 100: | ||
| 99 | progress = 100 | ||
| 100 | pStr = "Analyzing audio: " + str(progress) + "%" | ||
| 101 | self.progressBarSetText.emit(pStr) | ||
| 102 | self.progressBarUpdate.emit(int(progress)) | ||
| 103 | |||
| 51 | def frameRender(self, frameNo): | 104 | def frameRender(self, frameNo): |
| 52 | return self.drawFrame(self.width, self.height) | 105 | return self.drawFrame( |
| 106 | self.width, | ||
| 107 | self.height, | ||
| 108 | ( | ||
| 109 | None | ||
| 110 | if not self.respondToAudio | ||
| 111 | else self.spectrumArray[frameNo * self.sampleSize] | ||
| 112 | ), | ||
| 113 | ) | ||
| 53 | 114 | ||
| 54 | def drawFrame(self, width, height): | 115 | def drawFrame(self, width, height, dynamicScale): |
| 55 | frame = BlankFrame(width, height) | 116 | frame = BlankFrame(width, height) |
| 56 | if self.imagePath and os.path.exists(self.imagePath): | 117 | if self.imagePath and os.path.exists(self.imagePath): |
| 57 | scale = self.scale if not self.stretched else self.stretchScale | 118 | if dynamicScale is not None and self.existingImage: |
| 58 | image = Image.open(self.imagePath) | 119 | image = self.existingImage |
| 59 | 120 | else: | |
| 60 | # Modify image's appearance | 121 | image = Image.open(self.imagePath) |
| 61 | if self.color != 100: | 122 | # Modify static image appearance |
| 62 | image = ImageEnhance.Color(image).enhance(float(self.color / 100)) | 123 | if self.color != 100: |
| 63 | if self.mirror: | 124 | image = ImageEnhance.Color(image).enhance(float(self.color / 100)) |
| 64 | image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) | 125 | if self.mirror: |
| 65 | if self.stretched and image.size != (width, height): | 126 | image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) |
| 66 | image = image.resize((width, height), Image.Resampling.LANCZOS) | 127 | if self.resizeMode == 1: # Cover |
| 67 | if scale != 100: | 128 | image = ImageOps.fit( |
| 68 | newHeight = int((image.height / 100) * scale) | 129 | image, (width, height), Image.Resampling.LANCZOS |
| 69 | newWidth = int((image.width / 100) * scale) | 130 | ) |
| 70 | image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS) | 131 | elif self.resizeMode == 2: # Stretch |
| 132 | image = image.resize((width, height), Image.Resampling.LANCZOS) | ||
| 133 | elif self.scale != 100: # Scale | ||
| 134 | newHeight = int((image.height / 100) * self.scale) | ||
| 135 | newWidth = int((image.width / 100) * self.scale) | ||
| 136 | image = image.resize( | ||
| 137 | (newWidth, newHeight), Image.Resampling.LANCZOS | ||
| 138 | ) | ||
| 139 | self.existingImage = image | ||
| 140 | |||
| 141 | # Respond to audio | ||
| 142 | scale = 0 | ||
| 143 | if dynamicScale is not None: | ||
| 144 | scale = dynamicScale[36 * 4] / 4 | ||
| 145 | image = ImageOps.contain( | ||
| 146 | image, | ||
| 147 | ( | ||
| 148 | image.width + int(scale / 2), | ||
| 149 | image.height + int(scale / 2), | ||
| 150 | ), | ||
| 151 | Image.Resampling.LANCZOS, | ||
| 152 | ) | ||
| 71 | 153 | ||
| 72 | # Paste image at correct position | 154 | # Paste image at correct position |
| 73 | frame.paste(image, box=(self.xPosition, self.yPosition)) | 155 | frame.paste( |
| 156 | image, | ||
| 157 | box=( | ||
| 158 | self.xPosition - (0 if not self.respondToAudio else int(scale / 2)), | ||
| 159 | self.yPosition - (0 if not self.respondToAudio else int(scale / 2)), | ||
| 160 | ), | ||
| 161 | ) | ||
| 74 | if self.rotate != 0: | 162 | if self.rotate != 0: |
| 75 | frame = frame.rotate(self.rotate) | 163 | frame = frame.rotate(self.rotate) |
| 76 | 164 | ||
| 77 | return frame | 165 | return frame |
| 78 | 166 | ||
| 167 | def postFrameRender(self): | ||
| 168 | self.existingImage = None | ||
| 169 | |||
| 79 | def pickImage(self): | 170 | def pickImage(self): |
| 80 | imgDir = self.settings.value("componentDir", os.path.expanduser("~")) | 171 | imgDir = self.settings.value("componentDir", os.path.expanduser("~")) |
| 81 | filename, _ = QtWidgets.QFileDialog.getOpenFileName( | 172 | filename, _ = QtWidgets.QFileDialog.getOpenFileName( |
| @@ -106,24 +197,3 @@ class Component(Component): | |||
| 106 | 197 | ||
| 107 | def commandHelp(self): | 198 | def commandHelp(self): |
| 108 | print("Load an image:\n path=/filepath/to/image.png") | 199 | print("Load an image:\n path=/filepath/to/image.png") |
| 109 | |||
| 110 | def savePreset(self): | ||
| 111 | # Maintain the illusion that the scale spinbox is one widget | ||
| 112 | scaleBox = self.page.spinBox_scale | ||
| 113 | stretchScaleBox = self.page.spinBox_scale_stretch | ||
| 114 | if self.page.checkBox_stretch.isChecked(): | ||
| 115 | scaleBox.setValue(stretchScaleBox.value()) | ||
| 116 | else: | ||
| 117 | stretchScaleBox.setValue(scaleBox.value()) | ||
| 118 | return super().savePreset() | ||
| 119 | |||
| 120 | def update(self): | ||
| 121 | # Maintain the illusion that the scale spinbox is one widget | ||
| 122 | scaleBox = self.page.spinBox_scale | ||
| 123 | stretchScaleBox = self.page.spinBox_scale_stretch | ||
| 124 | if self.page.checkBox_stretch.isChecked(): | ||
| 125 | scaleBox.setVisible(False) | ||
| 126 | stretchScaleBox.setVisible(True) | ||
| 127 | else: | ||
| 128 | scaleBox.setVisible(True) | ||
| 129 | 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 @@ | |||
| 84 | <item> | 84 | <item> |
| 85 | <spacer name="horizontalSpacer_9"> | 85 | <spacer name="horizontalSpacer_9"> |
| 86 | <property name="orientation"> | 86 | <property name="orientation"> |
| 87 | <enum>Qt::Horizontal</enum> | 87 | <enum>Qt::Orientation::Horizontal</enum> |
| 88 | </property> | 88 | </property> |
| 89 | <property name="sizeType"> | 89 | <property name="sizeType"> |
| 90 | <enum>QSizePolicy::Fixed</enum> | 90 | <enum>QSizePolicy::Policy::Fixed</enum> |
| 91 | </property> | 91 | </property> |
| 92 | <property name="sizeHint" stdset="0"> | 92 | <property name="sizeHint" stdset="0"> |
| 93 | <size> | 93 | <size> |
| @@ -181,26 +181,29 @@ | |||
| 181 | <item> | 181 | <item> |
| 182 | <layout class="QHBoxLayout" name="horizontalLayout_9"> | 182 | <layout class="QHBoxLayout" name="horizontalLayout_9"> |
| 183 | <item> | 183 | <item> |
| 184 | <widget class="QCheckBox" name="checkBox_stretch"> | 184 | <widget class="QLabel" name="label_resizeMode"> |
| 185 | <property name="text"> | 185 | <property name="sizePolicy"> |
| 186 | <string>Stretch</string> | 186 | <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> |
| 187 | <horstretch>0</horstretch> | ||
| 188 | <verstretch>0</verstretch> | ||
| 189 | </sizepolicy> | ||
| 187 | </property> | 190 | </property> |
| 188 | <property name="checked"> | 191 | <property name="text"> |
| 189 | <bool>false</bool> | 192 | <string>Resize</string> |
| 190 | </property> | 193 | </property> |
| 191 | </widget> | 194 | </widget> |
| 192 | </item> | 195 | </item> |
| 193 | <item> | 196 | <item> |
| 194 | <spacer name="horizontalSpacer_10"> | 197 | <widget class="QComboBox" name="comboBox_resizeMode"/> |
| 198 | </item> | ||
| 199 | <item> | ||
| 200 | <spacer name="horizontalSpacer_3"> | ||
| 195 | <property name="orientation"> | 201 | <property name="orientation"> |
| 196 | <enum>Qt::Horizontal</enum> | 202 | <enum>Qt::Orientation::Horizontal</enum> |
| 197 | </property> | ||
| 198 | <property name="sizeType"> | ||
| 199 | <enum>QSizePolicy::Fixed</enum> | ||
| 200 | </property> | 203 | </property> |
| 201 | <property name="sizeHint" stdset="0"> | 204 | <property name="sizeHint" stdset="0"> |
| 202 | <size> | 205 | <size> |
| 203 | <width>5</width> | 206 | <width>40</width> |
| 204 | <height>20</height> | 207 | <height>20</height> |
| 205 | </size> | 208 | </size> |
| 206 | </property> | 209 | </property> |
| @@ -208,25 +211,34 @@ | |||
| 208 | </item> | 211 | </item> |
| 209 | <item> | 212 | <item> |
| 210 | <widget class="QCheckBox" name="checkBox_mirror"> | 213 | <widget class="QCheckBox" name="checkBox_mirror"> |
| 214 | <property name="layoutDirection"> | ||
| 215 | <enum>Qt::LayoutDirection::RightToLeft</enum> | ||
| 216 | </property> | ||
| 211 | <property name="text"> | 217 | <property name="text"> |
| 212 | <string>Mirror</string> | 218 | <string>Mirror</string> |
| 213 | </property> | 219 | </property> |
| 214 | </widget> | 220 | </widget> |
| 215 | </item> | 221 | </item> |
| 216 | <item> | 222 | <item> |
| 217 | <widget class="QLabel" name="label_2"> | 223 | <widget class="QLabel" name="label_rotate"> |
| 224 | <property name="sizePolicy"> | ||
| 225 | <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> | ||
| 226 | <horstretch>0</horstretch> | ||
| 227 | <verstretch>0</verstretch> | ||
| 228 | </sizepolicy> | ||
| 229 | </property> | ||
| 218 | <property name="text"> | 230 | <property name="text"> |
| 219 | <string>Rotate</string> | 231 | <string>Rotate</string> |
| 220 | </property> | 232 | </property> |
| 221 | <property name="alignment"> | 233 | <property name="alignment"> |
| 222 | <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> | 234 | <set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set> |
| 223 | </property> | 235 | </property> |
| 224 | </widget> | 236 | </widget> |
| 225 | </item> | 237 | </item> |
| 226 | <item> | 238 | <item> |
| 227 | <widget class="QSpinBox" name="spinBox_rotate"> | 239 | <widget class="QSpinBox" name="spinBox_rotate"> |
| 228 | <property name="buttonSymbols"> | 240 | <property name="buttonSymbols"> |
| 229 | <enum>QAbstractSpinBox::UpDownArrows</enum> | 241 | <enum>QAbstractSpinBox::ButtonSymbols::UpDownArrows</enum> |
| 230 | </property> | 242 | </property> |
| 231 | <property name="suffix"> | 243 | <property name="suffix"> |
| 232 | <string notr="true">°</string> | 244 | <string notr="true">°</string> |
| @@ -242,24 +254,12 @@ | |||
| 242 | </property> | 254 | </property> |
| 243 | </widget> | 255 | </widget> |
| 244 | </item> | 256 | </item> |
| 257 | </layout> | ||
| 258 | </item> | ||
| 259 | <item> | ||
| 260 | <layout class="QHBoxLayout" name="horizontalLayout_2"> | ||
| 245 | <item> | 261 | <item> |
| 246 | <spacer name="horizontalSpacer"> | 262 | <widget class="QLabel" name="label_scale"> |
| 247 | <property name="orientation"> | ||
| 248 | <enum>Qt::Horizontal</enum> | ||
| 249 | </property> | ||
| 250 | <property name="sizeType"> | ||
| 251 | <enum>QSizePolicy::Fixed</enum> | ||
| 252 | </property> | ||
| 253 | <property name="sizeHint" stdset="0"> | ||
| 254 | <size> | ||
| 255 | <width>10</width> | ||
| 256 | <height>20</height> | ||
| 257 | </size> | ||
| 258 | </property> | ||
| 259 | </spacer> | ||
| 260 | </item> | ||
| 261 | <item> | ||
| 262 | <widget class="QLabel" name="label"> | ||
| 263 | <property name="sizePolicy"> | 263 | <property name="sizePolicy"> |
| 264 | <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> | 264 | <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> |
| 265 | <horstretch>0</horstretch> | 265 | <horstretch>0</horstretch> |
| @@ -270,14 +270,14 @@ | |||
| 270 | <string>Scale</string> | 270 | <string>Scale</string> |
| 271 | </property> | 271 | </property> |
| 272 | <property name="alignment"> | 272 | <property name="alignment"> |
| 273 | <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> | 273 | <set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set> |
| 274 | </property> | 274 | </property> |
| 275 | </widget> | 275 | </widget> |
| 276 | </item> | 276 | </item> |
| 277 | <item> | 277 | <item> |
| 278 | <widget class="QSpinBox" name="spinBox_scale"> | 278 | <widget class="QSpinBox" name="spinBox_scale"> |
| 279 | <property name="buttonSymbols"> | 279 | <property name="buttonSymbols"> |
| 280 | <enum>QAbstractSpinBox::UpDownArrows</enum> | 280 | <enum>QAbstractSpinBox::ButtonSymbols::UpDownArrows</enum> |
| 281 | </property> | 281 | </property> |
| 282 | <property name="suffix"> | 282 | <property name="suffix"> |
| 283 | <string>%</string> | 283 | <string>%</string> |
| @@ -294,29 +294,9 @@ | |||
| 294 | </widget> | 294 | </widget> |
| 295 | </item> | 295 | </item> |
| 296 | <item> | 296 | <item> |
| 297 | <widget class="QSpinBox" name="spinBox_scale_stretch"> | ||
| 298 | <property name="suffix"> | ||
| 299 | <string>%</string> | ||
| 300 | </property> | ||
| 301 | <property name="minimum"> | ||
| 302 | <number>10</number> | ||
| 303 | </property> | ||
| 304 | <property name="maximum"> | ||
| 305 | <number>400</number> | ||
| 306 | </property> | ||
| 307 | <property name="value"> | ||
| 308 | <number>100</number> | ||
| 309 | </property> | ||
| 310 | </widget> | ||
| 311 | </item> | ||
| 312 | </layout> | ||
| 313 | </item> | ||
| 314 | <item> | ||
| 315 | <layout class="QHBoxLayout" name="horizontalLayout_2"> | ||
| 316 | <item> | ||
| 317 | <spacer name="horizontalSpacer_2"> | 297 | <spacer name="horizontalSpacer_2"> |
| 318 | <property name="orientation"> | 298 | <property name="orientation"> |
| 319 | <enum>Qt::Horizontal</enum> | 299 | <enum>Qt::Orientation::Horizontal</enum> |
| 320 | </property> | 300 | </property> |
| 321 | <property name="sizeHint" stdset="0"> | 301 | <property name="sizeHint" stdset="0"> |
| 322 | <size> | 302 | <size> |
| @@ -338,14 +318,14 @@ | |||
| 338 | <string>Color</string> | 318 | <string>Color</string> |
| 339 | </property> | 319 | </property> |
| 340 | <property name="alignment"> | 320 | <property name="alignment"> |
| 341 | <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> | 321 | <set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set> |
| 342 | </property> | 322 | </property> |
| 343 | </widget> | 323 | </widget> |
| 344 | </item> | 324 | </item> |
| 345 | <item> | 325 | <item> |
| 346 | <widget class="QSpinBox" name="spinBox_color"> | 326 | <widget class="QSpinBox" name="spinBox_color"> |
| 347 | <property name="buttonSymbols"> | 327 | <property name="buttonSymbols"> |
| 348 | <enum>QAbstractSpinBox::UpDownArrows</enum> | 328 | <enum>QAbstractSpinBox::ButtonSymbols::UpDownArrows</enum> |
| 349 | </property> | 329 | </property> |
| 350 | <property name="suffix"> | 330 | <property name="suffix"> |
| 351 | <string>%</string> | 331 | <string>%</string> |
| @@ -366,12 +346,68 @@ | |||
| 366 | </item> | 346 | </item> |
| 367 | </layout> | 347 | </layout> |
| 368 | </item> | 348 | </item> |
| 349 | <item> | ||
| 350 | <layout class="QHBoxLayout" name="horizontalLayout"> | ||
| 351 | <item> | ||
| 352 | <spacer name="horizontalSpacer"> | ||
| 353 | <property name="orientation"> | ||
| 354 | <enum>Qt::Orientation::Horizontal</enum> | ||
| 355 | </property> | ||
| 356 | <property name="sizeHint" stdset="0"> | ||
| 357 | <size> | ||
| 358 | <width>40</width> | ||
| 359 | <height>20</height> | ||
| 360 | </size> | ||
| 361 | </property> | ||
| 362 | </spacer> | ||
| 363 | </item> | ||
| 364 | <item> | ||
| 365 | <widget class="QCheckBox" name="checkBox_respondToAudio"> | ||
| 366 | <property name="toolTip"> | ||
| 367 | <string>Scale image in response to input audio file</string> | ||
| 368 | </property> | ||
| 369 | <property name="layoutDirection"> | ||
| 370 | <enum>Qt::LayoutDirection::RightToLeft</enum> | ||
| 371 | </property> | ||
| 372 | <property name="text"> | ||
| 373 | <string>Respond to Audio</string> | ||
| 374 | </property> | ||
| 375 | <property name="checkable"> | ||
| 376 | <bool>true</bool> | ||
| 377 | </property> | ||
| 378 | <property name="checked"> | ||
| 379 | <bool>false</bool> | ||
| 380 | </property> | ||
| 381 | <property name="autoRepeat"> | ||
| 382 | <bool>false</bool> | ||
| 383 | </property> | ||
| 384 | </widget> | ||
| 385 | </item> | ||
| 386 | <item> | ||
| 387 | <widget class="QLabel" name="label_sensitivity"> | ||
| 388 | <property name="text"> | ||
| 389 | <string>Sensitivity</string> | ||
| 390 | </property> | ||
| 391 | </widget> | ||
| 392 | </item> | ||
| 393 | <item> | ||
| 394 | <widget class="QSpinBox" name="spinBox_sensitivity"> | ||
| 395 | <property name="minimum"> | ||
| 396 | <number>1</number> | ||
| 397 | </property> | ||
| 398 | <property name="value"> | ||
| 399 | <number>20</number> | ||
| 400 | </property> | ||
| 401 | </widget> | ||
| 402 | </item> | ||
| 403 | </layout> | ||
| 404 | </item> | ||
| 369 | </layout> | 405 | </layout> |
| 370 | </item> | 406 | </item> |
| 371 | <item> | 407 | <item> |
| 372 | <spacer name="verticalSpacer"> | 408 | <spacer name="verticalSpacer"> |
| 373 | <property name="orientation"> | 409 | <property name="orientation"> |
| 374 | <enum>Qt::Vertical</enum> | 410 | <enum>Qt::Orientation::Vertical</enum> |
| 375 | </property> | 411 | </property> |
| 376 | <property name="sizeHint" stdset="0"> | 412 | <property name="sizeHint" stdset="0"> |
| 377 | <size> | 413 | <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): | |||
| 57 | 57 | ||
| 58 | def preFrameRender(self, **kwargs): | 58 | def preFrameRender(self, **kwargs): |
| 59 | super().preFrameRender(**kwargs) | 59 | super().preFrameRender(**kwargs) |
| 60 | self.smoothConstantDown = 0.08 + 0 if not self.smooth else self.smooth / 15 | 60 | smoothConstantDown = 0.08 if not self.smooth else self.smooth / 15 |
| 61 | self.smoothConstantUp = 0.8 - 0 if not self.smooth else self.smooth / 15 | 61 | smoothConstantUp = 0.8 if not self.smooth else self.smooth / 15 |
| 62 | self.lastSpectrum = None | 62 | self.lastSpectrum = None |
| 63 | self.spectrumArray = {} | 63 | self.spectrumArray = {} |
| 64 | 64 | ||
| @@ -69,9 +69,10 @@ class Component(Component): | |||
| 69 | i, | 69 | i, |
| 70 | self.completeAudioArray, | 70 | self.completeAudioArray, |
| 71 | self.sampleSize, | 71 | self.sampleSize, |
| 72 | self.smoothConstantDown, | 72 | smoothConstantDown, |
| 73 | self.smoothConstantUp, | 73 | smoothConstantUp, |
| 74 | self.lastSpectrum, | 74 | self.lastSpectrum, |
| 75 | self.scale, | ||
| 75 | ) | 76 | ) |
| 76 | self.spectrumArray[i] = copy(self.lastSpectrum) | 77 | self.spectrumArray[i] = copy(self.lastSpectrum) |
| 77 | 78 | ||
| @@ -92,14 +93,15 @@ class Component(Component): | |||
| 92 | self.layout, | 93 | self.layout, |
| 93 | ) | 94 | ) |
| 94 | 95 | ||
| 96 | @staticmethod | ||
| 95 | def transformData( | 97 | def transformData( |
| 96 | self, | ||
| 97 | i, | 98 | i, |
| 98 | completeAudioArray, | 99 | completeAudioArray, |
| 99 | sampleSize, | 100 | sampleSize, |
| 100 | smoothConstantDown, | 101 | smoothConstantDown, |
| 101 | smoothConstantUp, | 102 | smoothConstantUp, |
| 102 | lastSpectrum, | 103 | lastSpectrum, |
| 104 | scale, | ||
| 103 | ): | 105 | ): |
| 104 | if len(completeAudioArray) < (i + sampleSize): | 106 | if len(completeAudioArray) < (i + sampleSize): |
| 105 | sampleSize = len(completeAudioArray) - i | 107 | sampleSize = len(completeAudioArray) - i |
| @@ -117,7 +119,9 @@ class Component(Component): | |||
| 117 | # filter the noise away | 119 | # filter the noise away |
| 118 | # y[y<80] = 0 | 120 | # y[y<80] = 0 |
| 119 | 121 | ||
| 120 | y = self.scale * numpy.log10(y) | 122 | with numpy.errstate(divide="ignore"): |
| 123 | y = scale * numpy.log10(y) | ||
| 124 | |||
| 121 | y[numpy.isinf(y)] = 0 | 125 | y[numpy.isinf(y)] = 0 |
| 122 | 126 | ||
| 123 | if lastSpectrum is not None: | 127 | 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: | |||
| 71 | def insertComponent(self, compPos, component, loader): | 71 | def insertComponent(self, compPos, component, loader): |
| 72 | """ | 72 | """ |
| 73 | Creates a new component using these args: | 73 | Creates a new component using these args: |
| 74 | (compPos, component obj or moduleIndex, MWindow/Command/Core obj) | 74 | (compPos, component obj or moduleIndex, MWindow/Command obj) |
| 75 | """ | 75 | """ |
| 76 | if compPos < 0 or compPos > len(self.selectedComponents): | 76 | if compPos < 0 or compPos > len(self.selectedComponents): |
| 77 | compPos = len(self.selectedComponents) | 77 | 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): | |||
| 253 | @pyqtSlot() | 253 | @pyqtSlot() |
| 254 | def createVideo(self): | 254 | def createVideo(self): |
| 255 | """ | 255 | """ |
| 256 | 1. Numpy is set to ignore division errors during this method | 256 | 1. Determine length of final video |
| 257 | 2. Determine length of final video | 257 | 2. Call preFrameRender on each component |
| 258 | 3. Call preFrameRender on each component | 258 | 3. Create the main FFmpeg command |
| 259 | 4. Create the main FFmpeg command | 259 | 4. Open the out_pipe to FFmpeg process |
| 260 | 5. Open the out_pipe to FFmpeg process | 260 | 5. Iterate over the audio data array and call frameRender on the components to get frames |
| 261 | 6. Iterate over the audio data array and call frameRender on the components to get frames | 261 | 6. Close the out_pipe |
| 262 | 7. Close the out_pipe | 262 | 7. Call postFrameRender on each component |
| 263 | 8. Call postFrameRender on each component | ||
| 264 | """ | 263 | """ |
| 265 | log.debug("Video worker received signal to createVideo") | 264 | log.debug("Video worker received signal to createVideo") |
| 266 | log.debug("Video thread id: {}".format(int(QtCore.QThread.currentThreadId()))) | 265 | log.debug("Video thread id: {}".format(int(QtCore.QThread.currentThreadId()))) |
| 267 | numpy.seterr(divide="ignore") | ||
| 268 | self.encoding.emit(True) | 266 | self.encoding.emit(True) |
| 269 | self.extraAudio = [] | 267 | self.extraAudio = [] |
| 270 | self.width = int(self.settings.value("outputWidth")) | 268 | self.width = int(self.settings.value("outputWidth")) |
diff --git a/tests/__init__.py b/tests/__init__.py index d0073ef..b615681 100644 --- a/tests/__init__.py +++ b/tests/__init__.py | |||
| @@ -1,27 +1,39 @@ | |||
| 1 | import pytest | ||
| 2 | import os | 1 | import os |
| 3 | import sys | 2 | import numpy |
| 3 | |||
| 4 | # core always has to be imported first | ||
| 5 | import avp.core | ||
| 6 | from avp.toolkit.ffmpeg import readAudioFile | ||
| 7 | from pytest import fixture | ||
| 8 | |||
| 9 | |||
| 10 | @fixture | ||
| 11 | def audioData(): | ||
| 12 | """Fixture that gives a tuple of (completeAudioArray, duration)""" | ||
| 13 | soundFile = getTestDataPath("test.ogg") | ||
| 14 | yield readAudioFile(soundFile, MockVideoWorker()) | ||
| 4 | 15 | ||
| 5 | 16 | ||
| 6 | def getTestDataPath(filename): | 17 | def getTestDataPath(filename): |
| 18 | """Get path to a file in the ./data directory""" | ||
| 7 | tests_dir = os.path.dirname(os.path.abspath(__file__)) | 19 | tests_dir = os.path.dirname(os.path.abspath(__file__)) |
| 8 | return os.path.join(tests_dir, "data", filename) | 20 | return os.path.join(tests_dir, "data", filename) |
| 9 | 21 | ||
| 10 | 22 | ||
| 11 | def run(logFile): | 23 | class MockSignal: |
| 12 | """Run Pytest, which then imports and runs all tests in this module.""" | 24 | """Pretends to be a pyqtSignal""" |
| 13 | os.environ["PYTEST_QT_API"] = "PyQt6" | 25 | |
| 14 | with open(logFile, "w") as f: | 26 | def emit(self, *args): |
| 15 | # temporarily redirect stdout to a text file so we capture pytest's output | 27 | pass |
| 16 | sys.stdout = f | 28 | |
| 17 | try: | 29 | |
| 18 | val = pytest.main( | 30 | class MockVideoWorker: |
| 19 | [ | 31 | """Pretends to be a video thread worker""" |
| 20 | os.path.dirname(__file__), | 32 | |
| 21 | "-s", # disable pytest's internal capturing of stdout etc. | 33 | progressBarSetText = MockSignal() |
| 22 | ] | 34 | progressBarUpdate = MockSignal() |
| 23 | ) | 35 | |
| 24 | finally: | 36 | |
| 25 | sys.stdout = sys.__stdout__ | 37 | def imageDataSum(image): |
| 26 | 38 | """Get sum of raw data of a Pillow Image object""" | |
| 27 | return val | 39 | return numpy.asarray(image, dtype="int32").sum(dtype="int32") |
diff --git a/tests/test_classic_visualizer.py b/tests/test_classic_visualizer.py new file mode 100644 index 0000000..e301263 --- /dev/null +++ b/tests/test_classic_visualizer.py | |||
| @@ -0,0 +1,71 @@ | |||
| 1 | from avp.command import Command | ||
| 2 | from pytestqt import qtbot | ||
| 3 | from pytest import fixture | ||
| 4 | from . import audioData, MockSignal, imageDataSum | ||
| 5 | |||
| 6 | |||
| 7 | sampleSize = 1470 # 44100 / 30 = 1470 | ||
| 8 | |||
| 9 | |||
| 10 | @fixture | ||
| 11 | def coreWithClassicComp(qtbot): | ||
| 12 | """Fixture providing a Command object with Classic Visualizer component added""" | ||
| 13 | command = Command() | ||
| 14 | command.core.insertComponent( | ||
| 15 | 0, command.core.moduleIndexFor("Classic Visualizer"), command | ||
| 16 | ) | ||
| 17 | yield command.core | ||
| 18 | |||
| 19 | |||
| 20 | def test_comp_classic_added(coreWithClassicComp): | ||
| 21 | """Add Classic Visualizer to core""" | ||
| 22 | assert len(coreWithClassicComp.selectedComponents) == 1 | ||
| 23 | |||
| 24 | |||
| 25 | def test_comp_classic_removed(coreWithClassicComp): | ||
| 26 | """Remove Classic Visualizer from core""" | ||
| 27 | coreWithClassicComp.removeComponent(0) | ||
| 28 | assert len(coreWithClassicComp.selectedComponents) == 0 | ||
| 29 | |||
| 30 | |||
| 31 | def test_comp_classic_drawBars(coreWithClassicComp, audioData): | ||
| 32 | """Call drawBars after creating audio spectrum data manually.""" | ||
| 33 | |||
| 34 | spectrumArray = { | ||
| 35 | 0: coreWithClassicComp.selectedComponents[0].transformData( | ||
| 36 | 0, audioData[0], sampleSize, 0.08, 0.8, None, 20 | ||
| 37 | ) | ||
| 38 | } | ||
| 39 | for i in range(sampleSize, len(audioData[0]), sampleSize): | ||
| 40 | spectrumArray[i] = coreWithClassicComp.selectedComponents[0].transformData( | ||
| 41 | i, | ||
| 42 | audioData[0], | ||
| 43 | sampleSize, | ||
| 44 | 0.08, | ||
| 45 | 0.8, | ||
| 46 | spectrumArray[i - sampleSize].copy(), | ||
| 47 | 20, | ||
| 48 | ) | ||
| 49 | image = coreWithClassicComp.selectedComponents[0].drawBars( | ||
| 50 | 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), 0 | ||
| 51 | ) | ||
| 52 | assert imageDataSum(image) == 37872316 | ||
| 53 | |||
| 54 | |||
| 55 | def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData): | ||
| 56 | """Call drawBars after creating audio spectrum data using preFrameRender.""" | ||
| 57 | comp = coreWithClassicComp.selectedComponents[0] | ||
| 58 | comp.preFrameRender( | ||
| 59 | completeAudioArray=audioData[0], | ||
| 60 | sampleSize=sampleSize, | ||
| 61 | progressBarSetText=MockSignal(), | ||
| 62 | progressBarUpdate=MockSignal(), | ||
| 63 | ) | ||
| 64 | image = comp.drawBars( | ||
| 65 | 1920, | ||
| 66 | 1080, | ||
| 67 | coreWithClassicComp.selectedComponents[0].spectrumArray[sampleSize * 4], | ||
| 68 | (0, 0, 0), | ||
| 69 | 0, | ||
| 70 | ) | ||
| 71 | assert imageDataSum(image) == 37872316 | ||
diff --git a/tests/test_commandline_parser.py b/tests/test_commandline_parser.py index 8b07b8c..d092072 100644 --- a/tests/test_commandline_parser.py +++ b/tests/test_commandline_parser.py | |||
| @@ -1,37 +1,38 @@ | |||
| 1 | import sys | 1 | import sys |
| 2 | import pytest | 2 | import pytest |
| 3 | from avp.command import Command | 3 | from avp.command import Command |
| 4 | from pytestqt import qtbot | ||
| 4 | 5 | ||
| 5 | 6 | ||
| 6 | def test_commandline_help(): | 7 | def test_commandline_help(qtbot): |
| 7 | command = Command() | 8 | command = Command() |
| 8 | sys.argv = ["", "--help"] | 9 | sys.argv = ["", "--help"] |
| 9 | with pytest.raises(SystemExit): | 10 | with pytest.raises(SystemExit): |
| 10 | command.parseArgs() | 11 | command.parseArgs() |
| 11 | 12 | ||
| 12 | 13 | ||
| 13 | def test_commandline_help_if_bad_args(): | 14 | def test_commandline_help_if_bad_args(qtbot): |
| 14 | command = Command() | 15 | command = Command() |
| 15 | sys.argv = ["", "--junk"] | 16 | sys.argv = ["", "--junk"] |
| 16 | with pytest.raises(SystemExit): | 17 | with pytest.raises(SystemExit): |
| 17 | command.parseArgs() | 18 | command.parseArgs() |
| 18 | 19 | ||
| 19 | 20 | ||
| 20 | def test_commandline_launches_gui_if_verbose(): | 21 | def test_commandline_launches_gui_if_verbose(qtbot): |
| 21 | command = Command() | 22 | command = Command() |
| 22 | sys.argv = ["", "--verbose"] | 23 | sys.argv = ["", "--verbose"] |
| 23 | mode = command.parseArgs() | 24 | mode = command.parseArgs() |
| 24 | assert mode == "GUI" | 25 | assert mode == "GUI" |
| 25 | 26 | ||
| 26 | 27 | ||
| 27 | def test_commandline_launches_gui_if_verbose_with_project(): | 28 | def test_commandline_launches_gui_if_verbose_with_project(qtbot): |
| 28 | command = Command() | 29 | command = Command() |
| 29 | sys.argv = ["", "test", "--verbose"] | 30 | sys.argv = ["", "test", "--verbose"] |
| 30 | mode = command.parseArgs() | 31 | mode = command.parseArgs() |
| 31 | assert mode == "GUI" | 32 | assert mode == "GUI" |
| 32 | 33 | ||
| 33 | 34 | ||
| 34 | def test_commandline_tries_to_export(): | 35 | def test_commandline_tries_to_export(qtbot): |
| 35 | command = Command() | 36 | command = Command() |
| 36 | didCallFunction = False | 37 | didCallFunction = False |
| 37 | 38 | ||
| @@ -43,3 +44,13 @@ def test_commandline_tries_to_export(): | |||
| 43 | command.createAudioVisualization = captureFunction | 44 | command.createAudioVisualization = captureFunction |
| 44 | command.parseArgs() | 45 | command.parseArgs() |
| 45 | assert didCallFunction | 46 | assert didCallFunction |
| 47 | |||
| 48 | |||
| 49 | def test_commandline_parses_classic_by_alias(qtbot): | ||
| 50 | command = Command() | ||
| 51 | assert command.parseCompName("original") == "Classic Visualizer" | ||
| 52 | |||
| 53 | |||
| 54 | def test_commandline_parses_conway_by_name(qtbot): | ||
| 55 | command = Command() | ||
| 56 | assert command.parseCompName("conway") == "Conway's Game of Life" | ||
diff --git a/tests/test_image_comp.py b/tests/test_image_comp.py new file mode 100644 index 0000000..a4f05e1 --- /dev/null +++ b/tests/test_image_comp.py | |||
| @@ -0,0 +1,50 @@ | |||
| 1 | from avp.command import Command | ||
| 2 | from pytestqt import qtbot | ||
| 3 | from pytest import fixture | ||
| 4 | from . import audioData, MockSignal, imageDataSum, getTestDataPath | ||
| 5 | |||
| 6 | |||
| 7 | sampleSize = 1470 # 44100 / 30 = 1470 | ||
| 8 | |||
| 9 | |||
| 10 | @fixture | ||
| 11 | def coreWithImageComp(qtbot): | ||
| 12 | """Fixture providing a Command object with Image component added""" | ||
| 13 | command = Command() | ||
| 14 | command.settings.setValue("outputHeight", 1080) | ||
| 15 | command.settings.setValue("outputWidth", 1920) | ||
| 16 | command.core.insertComponent(0, command.core.moduleIndexFor("Image"), command) | ||
| 17 | yield command.core | ||
| 18 | |||
| 19 | |||
| 20 | def test_comp_image_set_path(coreWithImageComp): | ||
| 21 | "Set imagePath of Image component" | ||
| 22 | comp = coreWithImageComp.selectedComponents[0] | ||
| 23 | comp.imagePath = getTestDataPath("test.jpg") | ||
| 24 | image = comp.previewRender() | ||
| 25 | assert imageDataSum(image) == 463711601 | ||
| 26 | |||
| 27 | |||
| 28 | def test_comp_image_scale_50_1080p(coreWithImageComp): | ||
| 29 | """Image component stretches image to 50% at 1080p""" | ||
| 30 | comp = coreWithImageComp.selectedComponents[0] | ||
| 31 | comp.imagePath = getTestDataPath("test.jpg") | ||
| 32 | image = comp.previewRender() | ||
| 33 | sum = imageDataSum(image) | ||
| 34 | comp.page.spinBox_scale.setValue(50) | ||
| 35 | assert imageDataSum(comp.previewRender()) - sum / 4 < 2000 | ||
| 36 | |||
| 37 | |||
| 38 | def test_comp_image_scale_50_720p(coreWithImageComp): | ||
| 39 | """Image component stretches image to 50% at 720p""" | ||
| 40 | comp = coreWithImageComp.selectedComponents[0] | ||
| 41 | comp.imagePath = getTestDataPath("test.jpg") | ||
| 42 | comp.page.spinBox_scale.setValue(50) | ||
| 43 | image = comp.previewRender() | ||
| 44 | sum = imageDataSum(image) | ||
| 45 | comp.parent.settings.setValue("outputHeight", 720) | ||
| 46 | comp.parent.settings.setValue("outputWidth", 1280) | ||
| 47 | newImage = comp.previewRender() | ||
| 48 | assert image.width == 1920 | ||
| 49 | assert newImage.width == 1280 | ||
| 50 | assert imageDataSum(comp.previewRender()) == sum | ||
diff --git a/tests/test_mainwindow_undostack.py b/tests/test_mainwindow_undostack.py new file mode 100644 index 0000000..1eec1ef --- /dev/null +++ b/tests/test_mainwindow_undostack.py | |||
| @@ -0,0 +1,73 @@ | |||
| 1 | from pytest import fixture | ||
| 2 | from pytestqt import qtbot | ||
| 3 | from avp.gui.mainwindow import MainWindow | ||
| 4 | from . import getTestDataPath | ||
| 5 | |||
| 6 | |||
| 7 | @fixture | ||
| 8 | def window(qtbot): | ||
| 9 | window = MainWindow(None, None) | ||
| 10 | qtbot.addWidget(window) | ||
| 11 | window.settings.setValue("outputWidth", 1920) | ||
| 12 | window.settings.setValue("outputHeight", 1080) | ||
| 13 | yield window | ||
| 14 | |||
| 15 | |||
| 16 | def test_undo_classic_visualizer_sensitivity(window, qtbot): | ||
| 17 | """Undo Classic Visualizer component sensitivity setting | ||
| 18 | should undo multiple merged actions.""" | ||
| 19 | window.core.insertComponent( | ||
| 20 | 0, window.core.moduleIndexFor("Classic Visualizer"), window | ||
| 21 | ) | ||
| 22 | comp = window.core.selectedComponents[0] | ||
| 23 | comp.imagePath = getTestDataPath("test.jpg") | ||
| 24 | for i in range(1, 100): | ||
| 25 | comp.page.spinBox_scale.setValue(i) | ||
| 26 | assert comp.scale == 99 | ||
| 27 | window.undoStack.undo() | ||
| 28 | assert comp.scale == 20 | ||
| 29 | |||
| 30 | |||
| 31 | def test_undo_image_scale(window, qtbot): | ||
| 32 | """Undo Image component scale setting should undo multiple merged actions.""" | ||
| 33 | window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) | ||
| 34 | comp = window.core.selectedComponents[0] | ||
| 35 | comp.imagePath = getTestDataPath("test.jpg") | ||
| 36 | comp.page.spinBox_scale.setValue(100) | ||
| 37 | for i in range(10, 401): | ||
| 38 | comp.page.spinBox_scale.setValue(i) | ||
| 39 | assert comp.scale == 400 | ||
| 40 | window.undoStack.undo() | ||
| 41 | assert comp.scale == 10 | ||
| 42 | window.undoStack.undo() | ||
| 43 | assert comp.scale == 100 | ||
| 44 | |||
| 45 | |||
| 46 | def test_undo_image_resizeMode(window, qtbot): | ||
| 47 | window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) | ||
| 48 | comp = window.core.selectedComponents[0] | ||
| 49 | comp.page.comboBox_resizeMode.setCurrentIndex(1) | ||
| 50 | assert not comp.page.spinBox_scale.isEnabled() | ||
| 51 | window.undoStack.undo() | ||
| 52 | assert comp.page.spinBox_scale.isEnabled() | ||
| 53 | |||
| 54 | |||
| 55 | def test_undo_title_text_merged(window, qtbot): | ||
| 56 | """Undoing title text change should undo all recent changes.""" | ||
| 57 | window.core.insertComponent(0, window.core.moduleIndexFor("Title Text"), window) | ||
| 58 | comp = window.core.selectedComponents[0] | ||
| 59 | comp.page.lineEdit_title.setText("avp") | ||
| 60 | comp.page.lineEdit_title.setText("test") | ||
| 61 | window.undoStack.undo() | ||
| 62 | assert comp.title == "Text" | ||
| 63 | |||
| 64 | |||
| 65 | def test_undo_title_text_not_merged(window, qtbot): | ||
| 66 | """Undoing title text change should undo up to previous different action""" | ||
| 67 | window.core.insertComponent(0, window.core.moduleIndexFor("Title Text"), window) | ||
| 68 | comp = window.core.selectedComponents[0] | ||
| 69 | comp.page.lineEdit_title.setText("avp") | ||
| 70 | comp.page.spinBox_xTextAlign.setValue(0) | ||
| 71 | comp.page.lineEdit_title.setText("test") | ||
| 72 | window.undoStack.undo() | ||
| 73 | 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 @@ | |||
| 1 | from avp.command import Command | ||
| 2 | from pytestqt import qtbot | ||
| 3 | from pytest import fixture | ||
| 4 | from . import audioData, MockSignal, imageDataSum | ||
| 5 | |||
| 6 | |||
| 7 | @fixture | ||
| 8 | def coreWithTextComp(qtbot): | ||
| 9 | """Fixture providing a Command object with Title Text component added""" | ||
| 10 | command = Command() | ||
| 11 | command.core.insertComponent(0, command.core.moduleIndexFor("Title Text"), command) | ||
| 12 | yield command.core | ||
| 13 | |||
| 14 | |||
| 15 | def test_comp_text_renderFrame_resize(coreWithTextComp): | ||
| 16 | """Call renderFrame of Title Text component added to Command object.""" | ||
| 17 | comp = coreWithTextComp.selectedComponents[0] | ||
| 18 | comp.parent.settings.setValue("outputWidth", 1920) | ||
| 19 | comp.parent.settings.setValue("outputHeight", 1080) | ||
| 20 | comp.parent.core.updateComponent(0) | ||
| 21 | image = comp.frameRender(0) | ||
| 22 | assert imageDataSum(image) == 2957069 | ||
| 23 | |||
| 24 | |||
| 25 | def test_comp_text_renderFrame(coreWithTextComp): | ||
| 26 | """Call renderFrame of Title Text component added to Command object.""" | ||
| 27 | comp = coreWithTextComp.selectedComponents[0] | ||
| 28 | comp.parent.settings.setValue("outputWidth", 1280) | ||
| 29 | comp.parent.settings.setValue("outputHeight", 720) | ||
| 30 | comp.parent.core.updateComponent(0) | ||
| 31 | image = comp.frameRender(0) | ||
| 32 | assert imageDataSum(image) == 1412293 or 1379298 | ||
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 @@ | |||
| 1 | from pytestqt import qtbot | ||
| 2 | from avp.command import Command | ||
| 3 | from avp.toolkit import blockSignals | ||
| 4 | |||
| 5 | |||
| 6 | def test_blockSignals(qtbot): | ||
| 7 | command = Command() | ||
| 8 | command.core.insertComponent(0, 0, command) | ||
| 9 | comp = command.core.selectedComponents[0] | ||
| 10 | assert comp.page.spinBox_scale.signalsBlocked() == False | ||
| 11 | with blockSignals(comp.page.spinBox_scale): | ||
| 12 | assert comp.page.spinBox_scale.signalsBlocked() == True | ||
| 13 | assert comp.page.spinBox_scale.signalsBlocked() == False | ||
diff --git a/tests/test_toolkit_ffmpeg.py b/tests/test_toolkit_ffmpeg.py new file mode 100644 index 0000000..b015470 --- /dev/null +++ b/tests/test_toolkit_ffmpeg.py | |||
| @@ -0,0 +1,64 @@ | |||
| 1 | import pytest | ||
| 2 | from avp.command import Command | ||
| 3 | from avp.toolkit.ffmpeg import createFfmpegCommand | ||
| 4 | from . import audioData | ||
| 5 | |||
| 6 | |||
| 7 | def test_readAudioFile_data(audioData): | ||
| 8 | assert len(audioData[0]) == 218453 | ||
| 9 | |||
| 10 | |||
| 11 | def test_readAudioFile_duration(audioData): | ||
| 12 | assert audioData[1] == 3.95 | ||
| 13 | |||
| 14 | |||
| 15 | @pytest.mark.parametrize("width, height", ((1920, 1080), (1280, 720))) | ||
| 16 | def test_createFfmpegCommand(width, height): | ||
| 17 | command = Command() | ||
| 18 | command.settings.setValue("outputWidth", width) | ||
| 19 | command.settings.setValue("outputHeight", height) | ||
| 20 | ffmpegCmd = createFfmpegCommand("test.ogg", "/tmp", command.core.selectedComponents) | ||
| 21 | assert ffmpegCmd == [ | ||
| 22 | "ffmpeg", | ||
| 23 | "-thread_queue_size", | ||
| 24 | "512", | ||
| 25 | "-y", | ||
| 26 | "-f", | ||
| 27 | "rawvideo", | ||
| 28 | "-vcodec", | ||
| 29 | "rawvideo", | ||
| 30 | "-s", | ||
| 31 | "%sx%s" % (width, height), | ||
| 32 | "-pix_fmt", | ||
| 33 | "rgba", | ||
| 34 | "-r", | ||
| 35 | "30", | ||
| 36 | "-t", | ||
| 37 | "0.100", | ||
| 38 | "-an", | ||
| 39 | "-i", | ||
| 40 | "-", | ||
| 41 | "-t", | ||
| 42 | "0.100", | ||
| 43 | "-i", | ||
| 44 | "test.ogg", | ||
| 45 | "-map", | ||
| 46 | "0:v", | ||
| 47 | "-map", | ||
| 48 | "1:a", | ||
| 49 | "-vcodec", | ||
| 50 | "libx264", | ||
| 51 | "-acodec", | ||
| 52 | "aac", | ||
| 53 | "-b:v", | ||
| 54 | "2500k", | ||
| 55 | "-b:a", | ||
| 56 | "192k", | ||
| 57 | "-pix_fmt", | ||
| 58 | "yuv420p", | ||
| 59 | "-preset", | ||
| 60 | "medium", | ||
| 61 | "-f", | ||
| 62 | "mp4", | ||
| 63 | "/tmp", | ||
| 64 | ] | ||
diff --git a/tests/test_toolkit_frame.py b/tests/test_toolkit_frame.py new file mode 100644 index 0000000..9486227 --- /dev/null +++ b/tests/test_toolkit_frame.py | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | import numpy | ||
| 2 | from avp.toolkit.frame import BlankFrame, FloodFrame | ||
| 3 | |||
| 4 | |||
| 5 | def test_blank_frame(): | ||
| 6 | """BlankFrame creates a frame of all zeros""" | ||
| 7 | assert numpy.asarray(BlankFrame(1920, 1080), dtype="int32").sum() == 0 | ||
| 8 | |||
| 9 | |||
| 10 | def test_flood_frame(): | ||
| 11 | """FloodFrame given (1, 1, 1, 1) creates a frame of sum 1920 * 1080 * 4""" | ||
| 12 | assert numpy.asarray(FloodFrame(1920, 1080, (1, 1, 1, 1)), dtype="int32").sum() == ( | ||
| 13 | 1920 * 1080 * 4 | ||
| 14 | ) | ||
