summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBrianna Rainey2026-01-22 16:29:46 -0500
committerGitHub2026-01-22 16:29:46 -0500
commita12be862e22bdec6a243a3f0b5f4f28d69084a2a (patch)
tree7f2b21f58cf54deb81bfe77d7ef45358c80454f0
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)
-rw-r--r--src/avp/component.py11
-rw-r--r--src/avp/components/image.py160
-rw-r--r--src/avp/components/image.ui154
-rw-r--r--src/avp/components/original.py16
-rw-r--r--src/avp/core.py2
-rw-r--r--src/avp/video_thread.py16
-rw-r--r--tests/__init__.py50
-rw-r--r--tests/test_classic_visualizer.py71
-rw-r--r--tests/test_commandline_parser.py21
-rw-r--r--tests/test_image_comp.py50
-rw-r--r--tests/test_mainwindow_undostack.py73
-rw-r--r--tests/test_text_comp.py32
-rw-r--r--tests/test_toolkit_common.py13
-rw-r--r--tests/test_toolkit_ffmpeg.py64
-rw-r--r--tests/test_toolkit_frame.py14
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 @@
1from PIL import Image, ImageDraw, ImageEnhance 1from PIL import Image, ImageOps, ImageEnhance
2from PyQt6 import QtGui, QtCore, QtWidgets 2from PyQt6 import QtWidgets
3import os 3import os
4from copy import copy
4 5
5from ..component import Component 6from ..component import Component
6from ..toolkit.frame import BlankFrame 7from ..toolkit.frame import BlankFrame
8from .original import Component as Visualizer
7 9
8 10
9class Component(Component): 11class 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 @@
1import pytest
2import os 1import os
3import sys 2import numpy
3
4# core always has to be imported first
5import avp.core
6from avp.toolkit.ffmpeg import readAudioFile
7from pytest import fixture
8
9
10@fixture
11def audioData():
12 """Fixture that gives a tuple of (completeAudioArray, duration)"""
13 soundFile = getTestDataPath("test.ogg")
14 yield readAudioFile(soundFile, MockVideoWorker())
4 15
5 16
6def getTestDataPath(filename): 17def 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
11def run(logFile): 23class 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( 30class 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__ 37def 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 @@
1from avp.command import Command
2from pytestqt import qtbot
3from pytest import fixture
4from . import audioData, MockSignal, imageDataSum
5
6
7sampleSize = 1470 # 44100 / 30 = 1470
8
9
10@fixture
11def 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
20def test_comp_classic_added(coreWithClassicComp):
21 """Add Classic Visualizer to core"""
22 assert len(coreWithClassicComp.selectedComponents) == 1
23
24
25def test_comp_classic_removed(coreWithClassicComp):
26 """Remove Classic Visualizer from core"""
27 coreWithClassicComp.removeComponent(0)
28 assert len(coreWithClassicComp.selectedComponents) == 0
29
30
31def 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
55def 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 @@
1import sys 1import sys
2import pytest 2import pytest
3from avp.command import Command 3from avp.command import Command
4from pytestqt import qtbot
4 5
5 6
6def test_commandline_help(): 7def 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
13def test_commandline_help_if_bad_args(): 14def 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
20def test_commandline_launches_gui_if_verbose(): 21def 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
27def test_commandline_launches_gui_if_verbose_with_project(): 28def 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
34def test_commandline_tries_to_export(): 35def 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
49def test_commandline_parses_classic_by_alias(qtbot):
50 command = Command()
51 assert command.parseCompName("original") == "Classic Visualizer"
52
53
54def 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 @@
1from avp.command import Command
2from pytestqt import qtbot
3from pytest import fixture
4from . import audioData, MockSignal, imageDataSum, getTestDataPath
5
6
7sampleSize = 1470 # 44100 / 30 = 1470
8
9
10@fixture
11def 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
20def 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
28def 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
38def 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 @@
1from pytest import fixture
2from pytestqt import qtbot
3from avp.gui.mainwindow import MainWindow
4from . import getTestDataPath
5
6
7@fixture
8def 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
16def 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
31def 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
46def 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
55def 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
65def 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 @@
1from avp.command import Command
2from pytestqt import qtbot
3from pytest import fixture
4from . import audioData, MockSignal, imageDataSum
5
6
7@fixture
8def 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
15def 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
25def 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 @@
1from pytestqt import qtbot
2from avp.command import Command
3from avp.toolkit import blockSignals
4
5
6def 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 @@
1import pytest
2from avp.command import Command
3from avp.toolkit.ffmpeg import createFfmpegCommand
4from . import audioData
5
6
7def test_readAudioFile_data(audioData):
8 assert len(audioData[0]) == 218453
9
10
11def test_readAudioFile_duration(audioData):
12 assert audioData[1] == 3.95
13
14
15@pytest.mark.parametrize("width, height", ((1920, 1080), (1280, 720)))
16def 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 @@
1import numpy
2from avp.toolkit.frame import BlankFrame, FloodFrame
3
4
5def test_blank_frame():
6 """BlankFrame creates a frame of all zeros"""
7 assert numpy.asarray(BlankFrame(1920, 1080), dtype="int32").sum() == 0
8
9
10def 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 )