summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBrianna Rainey2026-02-12 15:38:54 -0500
committerGitHub2026-02-12 15:38:54 -0500
commitf03a3a686c7304588dd434322c73506531e53595 (patch)
treeee41d920873e9a77c41f4a65857af019e71a4754
parent48a9105eab94e64101470402427564203e1d8970 (diff)
v2.2.4 - Quiet FFmpeg; add "invert" option to Classic Vis; fix CLI parsing for Image component (#96)
* change noisiness of terminal output ffmpeg no longer prints everything into the terminal unless we're in `--verbose` mode. percentage progress text stays on one line while not in verbose mode. * Added hint to run `avp --verbose` if `avp --log` is run with no avp_debug.log file present * Classic Visualizer: add invert option * Image component: fix path commandline option * Image component: restrict file formats in CLI to match GUI * Color component: add tooltip to color2 picker (second color of gradients) * change tests to work with pytest-xdist avp core stores its config (location of `settings.ini`) in temp directories if using multiple workers to run tests, so they don't interfere with each other. when using a single worker, the `tests/data/config` directory is still used * check alt comp names when parsing cmdline * rename `original.py` to `classic.py` * move `component.py` into subpackage * rename comp_original to comp_classic * show traceback if renderFrame() raises exception * do not try to insert non-existent components from project files * add "composite" property for components if a component returns "composite" then it will receive a frame to draw on during calls to previewRender and frameRender * more tests of projects, actions, waveform, spectrum, image, color, classic * do not change presetDir to "projects" within PresetManager
-rw-r--r--.gitignore1
-rw-r--r--pyproject.toml2
-rw-r--r--src/avp/__init__.py2
-rw-r--r--src/avp/cli.py4
-rw-r--r--src/avp/command.py50
-rw-r--r--src/avp/components/classic.py (renamed from src/avp/components/original.py)81
-rw-r--r--src/avp/components/classic.ui (renamed from src/avp/components/original.ui)9
-rw-r--r--src/avp/components/color.py4
-rw-r--r--src/avp/components/color.ui3
-rw-r--r--src/avp/components/image.py16
-rw-r--r--src/avp/components/life.py7
-rw-r--r--src/avp/components/sound.py6
-rw-r--r--src/avp/components/spectrum.py9
-rw-r--r--src/avp/components/text.py10
-rw-r--r--src/avp/components/video.py8
-rw-r--r--src/avp/components/waveform.py8
-rw-r--r--src/avp/core.py12
-rw-r--r--src/avp/gui/actions.py10
-rw-r--r--src/avp/gui/mainwindow.py46
-rw-r--r--src/avp/gui/presetmanager.py4
-rw-r--r--src/avp/gui/preview_thread.py16
-rw-r--r--src/avp/libcomponent/__init__.py4
-rw-r--r--src/avp/libcomponent/actions.py104
-rw-r--r--src/avp/libcomponent/component.py (renamed from src/avp/component.py)439
-rw-r--r--src/avp/libcomponent/exceptions.py63
-rw-r--r--src/avp/libcomponent/metaclass.py257
-rw-r--r--src/avp/toolkit/ffmpeg.py44
-rw-r--r--src/avp/toolkit/visualizer.py4
-rw-r--r--src/avp/video_thread.py43
-rw-r--r--tests/__init__.py48
-rw-r--r--tests/test_commandline_export.py3
-rw-r--r--tests/test_comp_classic.py103
-rw-r--r--tests/test_comp_color.py12
-rw-r--r--tests/test_comp_image.py17
-rw-r--r--tests/test_comp_original.py67
-rw-r--r--tests/test_comp_spectrum.py17
-rw-r--r--tests/test_comp_waveform.py26
-rw-r--r--tests/test_core_init.py8
-rw-r--r--tests/test_mainwindow_comp_actions.py (renamed from tests/test_mainwindow_undostack.py)2
-rw-r--r--tests/test_mainwindow_list_actions.py52
-rw-r--r--tests/test_mainwindow_projects.py17
-rw-r--r--tests/test_toolkit_ffmpeg.py10
-rw-r--r--uv.lock2
43 files changed, 974 insertions, 676 deletions
diff --git a/.gitignore b/.gitignore
index 5f7cabb..68c7fcb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ prof/
10.env/ 10.env/
11.vscode/ 11.vscode/
12tests/data/config/log/ 12tests/data/config/log/
13tests/data/config/presets/
13tests/data/config/settings.ini 14tests/data/config/settings.ini
14tests/data/config/autosave.avp 15tests/data/config/autosave.avp
15*.mkv 16*.mkv
diff --git a/pyproject.toml b/pyproject.toml
index 2d604f5..a382882 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "uv_build"
6name = "audio-visualizer-python" 6name = "audio-visualizer-python"
7description = "Create audio visualization videos from a GUI or commandline" 7description = "Create audio visualization videos from a GUI or commandline"
8readme = "README.md" 8readme = "README.md"
9version = "2.2.3" 9version = "2.2.4"
10requires-python = ">= 3.12" 10requires-python = ">= 3.12"
11license = "MIT" 11license = "MIT"
12classifiers=[ 12classifiers=[
diff --git a/src/avp/__init__.py b/src/avp/__init__.py
index 8783660..184afda 100644
--- a/src/avp/__init__.py
+++ b/src/avp/__init__.py
@@ -3,7 +3,7 @@ import os
3import logging 3import logging
4 4
5 5
6__version__ = "2.2.3" 6__version__ = "2.2.4"
7 7
8 8
9class Logger(logging.getLoggerClass()): 9class Logger(logging.getLoggerClass()):
diff --git a/src/avp/cli.py b/src/avp/cli.py
index 0176f76..7d58fe1 100644
--- a/src/avp/cli.py
+++ b/src/avp/cli.py
@@ -40,10 +40,8 @@ def main() -> int:
40 screen = app.primaryScreen() 40 screen = app.primaryScreen()
41 if screen is None: 41 if screen is None:
42 dpi = None 42 dpi = None
43 log.error("Could not detect DPI")
44 else: 43 else:
45 dpi = screen.physicalDotsPerInchX() 44 dpi = screen.physicalDotsPerInchX()
46 log.info("Detected screen DPI: %s", dpi)
47 45
48 # Launch program 46 # Launch program
49 if mode == "commandline": 47 if mode == "commandline":
@@ -53,6 +51,8 @@ def main() -> int:
53 mode = main.parseArgs() 51 mode = main.parseArgs()
54 log.debug("Finished creating command object") 52 log.debug("Finished creating command object")
55 53
54 log.info(f"QApplication Platform: {QApplication.platformName()}")
55 log.info(f"Detected screen DPI: {dpi}")
56 # Both branches here may occur in one execution: 56 # Both branches here may occur in one execution:
57 # Commandline parsing could change mode back to GUI 57 # Commandline parsing could change mode back to GUI
58 if mode == "GUI": 58 if mode == "GUI":
diff --git a/src/avp/command.py b/src/avp/command.py
index 870391b..b6700a5 100644
--- a/src/avp/command.py
+++ b/src/avp/command.py
@@ -14,7 +14,8 @@ import signal
14import shutil 14import shutil
15import logging 15import logging
16 16
17from . import core, __version__ 17from . import __version__
18from .core import Core
18 19
19 20
20log = logging.getLogger("AVP.Commandline") 21log = logging.getLogger("AVP.Commandline")
@@ -29,11 +30,11 @@ class Command(QtCore.QObject):
29 30
30 def __init__(self): 31 def __init__(self):
31 super().__init__() 32 super().__init__()
32 self.core = core.Core() 33 self.core = Core()
33 core.Core.mode = "commandline" 34 Core.mode = "commandline"
34 self.dataDir = self.core.dataDir 35 self.dataDir = self.core.dataDir
35 self.canceled = False 36 self.canceled = False
36 self.settings = core.Core.settings 37 self.settings = Core.settings
37 38
38 # ctrl-c stops the export thread 39 # ctrl-c stops the export thread
39 signal.signal(signal.SIGINT, self.stopVideo) 40 signal.signal(signal.SIGINT, self.stopVideo)
@@ -71,9 +72,10 @@ class Command(QtCore.QObject):
71 help="copy and shorten recent log files into ~/avp_log.txt", 72 help="copy and shorten recent log files into ~/avp_log.txt",
72 ) 73 )
73 debugCommands.add_argument( 74 debugCommands.add_argument(
74 "--verbose", "-v", 75 "--verbose",
76 "-v",
75 action="store_true", 77 action="store_true",
76 help="create bigger logfiles while program is running", 78 help="send log messages and ffmpeg output to stdout, and create more verbose log files (good to use before --log)",
77 ) 79 )
78 80
79 # project/GUI options 81 # project/GUI options
@@ -101,8 +103,8 @@ class Command(QtCore.QObject):
101 args = parser.parse_args() 103 args = parser.parse_args()
102 104
103 if args.verbose: 105 if args.verbose:
104 core.STDOUT_LOGLVL = logging.DEBUG 106 Core.stdoutLogLvl = logging.DEBUG
105 core.Core.makeLogger(deleteOldLogs=False, fileLogLvl=logging.DEBUG) 107 Core.makeLogger(deleteOldLogs=False, fileLogLvl=logging.DEBUG)
106 108
107 if args.log: 109 if args.log:
108 self.createLogFile() 110 self.createLogFile()
@@ -168,7 +170,7 @@ class Command(QtCore.QObject):
168 return "commandline" 170 return "commandline"
169 171
170 elif args.no_preview: 172 elif args.no_preview:
171 core.Core.previewEnabled = False 173 Core.previewEnabled = False
172 174
173 elif ( 175 elif (
174 args.projpath is None 176 args.projpath is None
@@ -203,20 +205,18 @@ class Command(QtCore.QObject):
203 205
204 @QtCore.pyqtSlot(str) 206 @QtCore.pyqtSlot(str)
205 def progressBarSetText(self, value): 207 def progressBarSetText(self, value):
206 if "Export " in value: 208 if "Export " in value or time.time() - self.lastProgressUpdate < 0.1:
207 # Don't duplicate completion/failure messages 209 # Don't duplicate completion/failure messages or send too many messages
208 return 210 return
209 if ( 211
210 not value.startswith("Exporting") 212 if not value.endswith("%"):
211 and time.time() - self.lastProgressUpdate >= 0.05
212 ):
213 # Show most messages very often 213 # Show most messages very often
214 print(value) 214 print(value)
215 elif time.time() - self.lastProgressUpdate >= 2.0: 215 elif log.getEffectiveLevel() > logging.INFO:
216 # Give user time to read ffmpeg's output during the export 216 # if ffmpeg isn't printing export progress for us,
217 print("##### %s" % value) 217 # then overwrite previous message with the next one
218 else: 218 # if this text is our main export progress
219 return 219 print(f"{value}\r", end="")
220 self.lastProgressUpdate = time.time() 220 self.lastProgressUpdate = time.time()
221 221
222 @QtCore.pyqtSlot() 222 @QtCore.pyqtSlot()
@@ -224,6 +224,7 @@ class Command(QtCore.QObject):
224 self.quit(0) 224 self.quit(0)
225 225
226 def quit(self, code): 226 def quit(self, code):
227 print()
227 quit(code) 228 quit(code)
228 229
229 def showMessage(self, **kwargs): 230 def showMessage(self, **kwargs):
@@ -242,12 +243,14 @@ class Command(QtCore.QObject):
242 243
243 def parseCompName(self, name): 244 def parseCompName(self, name):
244 """Deduces a proper component name out of a commandline arg""" 245 """Deduces a proper component name out of a commandline arg"""
245
246 if name.title() in self.core.compNames: 246 if name.title() in self.core.compNames:
247 return name.title() 247 return name.title()
248 for compName in self.core.compNames: 248 for compName in self.core.compNames:
249 if name.capitalize() in compName: 249 if name.capitalize() in compName:
250 return compName 250 return compName
251 for altName, moduleIndex in self.core.altCompNames:
252 if name.title() in altName:
253 return self.core.compNames[moduleIndex]
251 254
252 compFileNames = [ 255 compFileNames = [
253 os.path.splitext(os.path.basename(mod.__file__))[0] 256 os.path.splitext(os.path.basename(mod.__file__))[0]
@@ -281,16 +284,17 @@ class Command(QtCore.QObject):
281 print("Log file could not be created (too many exist).") 284 print("Log file could not be created (too many exist).")
282 return 285 return
283 try: 286 try:
284 shutil.copy(os.path.join(core.Core.logDir, "avp_debug.log"), filename) 287 shutil.copy(os.path.join(Core.logDir, "avp_debug.log"), filename)
285 with open(filename, "a") as f: 288 with open(filename, "a") as f:
286 f.write(f"{'='*60} debug log ends {'='*60}\n") 289 f.write(f"{'='*60} debug log ends {'='*60}\n")
287 except FileNotFoundError: 290 except FileNotFoundError:
291 print("No debug log was found. Run `avp --verbose` before `avp --log`.")
288 with open(filename, "w") as f: 292 with open(filename, "w") as f:
289 f.write(f"{'='*60} no debug log {'='*60}\n") 293 f.write(f"{'='*60} no debug log {'='*60}\n")
290 294
291 def concatenateLogs(logPattern): 295 def concatenateLogs(logPattern):
292 nonlocal filename 296 nonlocal filename
293 renderLogs = glob.glob(os.path.join(core.Core.logDir, logPattern)) 297 renderLogs = glob.glob(os.path.join(Core.logDir, logPattern))
294 with open(filename, "a") as fw: 298 with open(filename, "a") as fw:
295 for renderLog in renderLogs: 299 for renderLog in renderLogs:
296 with open(renderLog, "r") as fr: 300 with open(renderLog, "r") as fr:
diff --git a/src/avp/components/original.py b/src/avp/components/classic.py
index 0da78dc..72089af 100644
--- a/src/avp/components/original.py
+++ b/src/avp/components/classic.py
@@ -1,21 +1,23 @@
1import numpy 1import numpy
2from PIL import Image, ImageDraw 2from PIL import Image, ImageDraw
3from copy import copy
4 3
5from ..component import Component 4from ..libcomponent import BaseComponent
6from ..toolkit.frame import BlankFrame 5from ..toolkit.frame import BlankFrame, FloodFrame
7from ..toolkit.visualizer import createSpectrumArray 6from ..toolkit.visualizer import createSpectrumArray
8 7
9 8
10class Component(Component): 9class Component(BaseComponent):
11 name = "Classic Visualizer" 10 name = "Classic Visualizer"
12 version = "1.1.0" 11 version = "1.2.0"
13 12
14 def names(*args): 13 def names(*args):
15 return ["Original Audio Visualization"] 14 return ["Original"]
16 15
17 def properties(self): 16 def properties(self):
18 return ["pcm"] 17 props = ["pcm"]
18 if self.invert:
19 props.append("composite")
20 return props
19 21
20 def widget(self, *args): 22 def widget(self, *args):
21 self.scale = 20 23 self.scale = 20
@@ -37,6 +39,7 @@ class Component(Component):
37 "y": self.page.spinBox_y, 39 "y": self.page.spinBox_y,
38 "smooth": self.page.spinBox_sensitivity, 40 "smooth": self.page.spinBox_sensitivity,
39 "bars": self.page.spinBox_bars, 41 "bars": self.page.spinBox_bars,
42 "invert": self.page.checkBox_invert,
40 }, 43 },
41 colorWidgets={ 44 colorWidgets={
42 "visColor": self.page.pushButton_visColor, 45 "visColor": self.page.pushButton_visColor,
@@ -46,14 +49,19 @@ class Component(Component):
46 ], 49 ],
47 ) 50 )
48 51
49 def previewRender(self): 52 def previewRender(self, frame=None):
50 spectrum = numpy.fromfunction( 53 spectrum = numpy.fromfunction(
51 lambda x: float(self.scale) / 2500 * (x - 128) ** 2, 54 lambda x: float(self.scale) / 2500 * (x - 128) ** 2,
52 (255,), 55 (255,),
53 dtype="int16", 56 dtype="int16",
54 ) 57 )
55 return self.drawBars( 58 return self.drawBars(
56 self.width, self.height, spectrum, self.visColor, self.layout 59 self.width,
60 self.height,
61 spectrum,
62 self.visColor,
63 self.layout,
64 frame,
57 ) 65 )
58 66
59 def preFrameRender(self, **kwargs): 67 def preFrameRender(self, **kwargs):
@@ -71,7 +79,7 @@ class Component(Component):
71 self.progressBarSetText, 79 self.progressBarSetText,
72 ) 80 )
73 81
74 def frameRender(self, frameNo): 82 def frameRender(self, frameNo, frame=None):
75 arrayNo = frameNo * self.sampleSize 83 arrayNo = frameNo * self.sampleSize
76 return self.drawBars( 84 return self.drawBars(
77 self.width, 85 self.width,
@@ -79,9 +87,10 @@ class Component(Component):
79 self.spectrumArray[arrayNo], 87 self.spectrumArray[arrayNo],
80 self.visColor, 88 self.visColor,
81 self.layout, 89 self.layout,
90 frame,
82 ) 91 )
83 92
84 def drawBars(self, width, height, spectrum, color, layout): 93 def drawBars(self, width, height, spectrum, color, layout, frame):
85 bigYCoord = height - height / 8 94 bigYCoord = height - height / 8
86 smallYCoord = height / 1200 95 smallYCoord = height / 1200
87 bigXCoord = width / (self.bars + 1) 96 bigXCoord = width / (self.bars + 1)
@@ -94,32 +103,44 @@ class Component(Component):
94 color2 = (r, g, b, 125) 103 color2 = (r, g, b, 125)
95 104
96 for i in range(self.bars): 105 for i in range(self.bars):
97 x0 = middleXCoord + i * bigXCoord 106 # draw outline behind rectangles if not inverted
98 y0 = bigYCoord + smallXCoord 107 if frame is None:
99 y1 = bigYCoord + smallXCoord - spectrum[i * 4] * smallYCoord - middleXCoord 108 x0 = middleXCoord + i * bigXCoord
100 x1 = middleXCoord + i * bigXCoord + bigXCoord 109 y0 = bigYCoord + smallXCoord
101 draw.rectangle( 110 x1 = middleXCoord + i * bigXCoord + bigXCoord
102 ( 111 y1 = (
112 bigYCoord
113 + smallXCoord
114 - spectrum[i * 4] * smallYCoord
115 - middleXCoord
116 )
117 selection = (
103 x0, 118 x0,
104 y0 if y0 < y1 else y1, 119 y0 if y0 < y1 else y1,
105 x1 if x1 > x0 else x0, 120 x1 if x1 > x0 else x0,
106 y1 if y0 < y1 else y0, 121 y1 if y0 < y1 else y0,
107 ), 122 )
108 fill=color2, 123 draw.rectangle(
109 ) 124 selection,
125 fill=color2,
126 )
110 127
111 x0 = middleXCoord + smallXCoord + i * bigXCoord 128 x0 = middleXCoord + smallXCoord + i * bigXCoord
112 y0 = bigYCoord 129 y0 = bigYCoord
113 x1 = middleXCoord + smallXCoord + i * bigXCoord + middleXCoord 130 x1 = middleXCoord + smallXCoord + i * bigXCoord + middleXCoord
114 y1 = bigYCoord - spectrum[i * 4] * smallYCoord 131 y1 = bigYCoord - spectrum[i * 4] * smallYCoord
132 selection = (
133 x0,
134 y0 if y0 < y1 else y1,
135 x1 if x1 > x0 else x0,
136 y1 if y0 < y1 else y0,
137 )
138 # fill rectangle if not inverted
115 draw.rectangle( 139 draw.rectangle(
116 ( 140 selection,
117 x0, 141 fill=color if frame is None else (0, 0, 0, 0),
118 y0 if y0 < y1 else y1, 142 outline=color,
119 x1 if x1 > x0 else x0, 143 width=int(x1 - x0),
120 y1 if y0 < y1 else y0,
121 ),
122 fill=color,
123 ) 144 )
124 145
125 imBottom = imTop.transpose(Image.Transpose.FLIP_TOP_BOTTOM) 146 imBottom = imTop.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
@@ -146,7 +167,11 @@ class Component(Component):
146 y = self.y - int(height / 100 * 10) 167 y = self.y - int(height / 100 * 10)
147 im.paste(imBottom, (0, y), mask=imBottom) 168 im.paste(imBottom, (0, y), mask=imBottom)
148 169
149 return im 170 if frame is None:
171 return im
172 f = FloodFrame(width, height, color)
173 f.paste(frame, (0, 0), mask=im)
174 return f
150 175
151 def command(self, arg): 176 def command(self, arg):
152 if "=" in arg: 177 if "=" in arg:
diff --git a/src/avp/components/original.ui b/src/avp/components/classic.ui
index 8dbdaa2..1ae7faa 100644
--- a/src/avp/components/original.ui
+++ b/src/avp/components/classic.ui
@@ -86,7 +86,7 @@
86 <item> 86 <item>
87 <widget class="QLineEdit" name="lineEdit_visColor"> 87 <widget class="QLineEdit" name="lineEdit_visColor">
88 <property name="text"> 88 <property name="text">
89 <string></string> 89 <string/>
90 </property> 90 </property>
91 </widget> 91 </widget>
92 </item> 92 </item>
@@ -233,6 +233,13 @@
233 </widget> 233 </widget>
234 </item> 234 </item>
235 <item> 235 <item>
236 <widget class="QCheckBox" name="checkBox_invert">
237 <property name="text">
238 <string>Invert</string>
239 </property>
240 </widget>
241 </item>
242 <item>
236 <spacer name="horizontalSpacer"> 243 <spacer name="horizontalSpacer">
237 <property name="orientation"> 244 <property name="orientation">
238 <enum>Qt::Orientation::Horizontal</enum> 245 <enum>Qt::Orientation::Horizontal</enum>
diff --git a/src/avp/components/color.py b/src/avp/components/color.py
index cb0960a..826f37f 100644
--- a/src/avp/components/color.py
+++ b/src/avp/components/color.py
@@ -1,14 +1,14 @@
1from PyQt6 import QtGui 1from PyQt6 import QtGui
2import logging 2import logging
3 3
4from ..component import Component 4from ..libcomponent import BaseComponent
5from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter 5from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter
6 6
7 7
8log = logging.getLogger("AVP.Components.Color") 8log = logging.getLogger("AVP.Components.Color")
9 9
10 10
11class Component(Component): 11class Component(BaseComponent):
12 name = "Color" 12 name = "Color"
13 version = "1.0.0" 13 version = "1.0.0"
14 14
diff --git a/src/avp/components/color.ui b/src/avp/components/color.ui
index c36bdd8..788adb9 100644
--- a/src/avp/components/color.ui
+++ b/src/avp/components/color.ui
@@ -124,6 +124,9 @@
124 <height>32</height> 124 <height>32</height>
125 </size> 125 </size>
126 </property> 126 </property>
127 <property name="toolTip">
128 <string>End color of gradient. Disabled if fill is solid.</string>
129 </property>
127 <property name="text"> 130 <property name="text">
128 <string/> 131 <string/>
129 </property> 132 </property>
diff --git a/src/avp/components/image.py b/src/avp/components/image.py
index e012cec..a082092 100644
--- a/src/avp/components/image.py
+++ b/src/avp/components/image.py
@@ -1,14 +1,13 @@
1from PIL import Image, ImageOps, ImageEnhance 1from PIL import Image, ImageOps, ImageEnhance
2from PyQt6 import QtWidgets 2from PyQt6 import QtWidgets
3import os 3import os
4from copy import copy
5 4
6from ..component import Component 5from ..libcomponent import BaseComponent
7from ..toolkit.frame import BlankFrame, addShadow 6from ..toolkit.frame import BlankFrame, addShadow
8from ..toolkit.visualizer import createSpectrumArray 7from ..toolkit.visualizer import createSpectrumArray
9 8
10 9
11class Component(Component): 10class Component(BaseComponent):
12 name = "Image" 11 name = "Image"
13 version = "2.1.0" 12 version = "2.1.0"
14 13
@@ -177,17 +176,22 @@ class Component(Component):
177 self.mergeUndo = True 176 self.mergeUndo = True
178 177
179 def command(self, arg): 178 def command(self, arg):
179 def fail():
180 print("Not a supported image format")
181 quit(1)
182
180 if "=" in arg: 183 if "=" in arg:
181 key, arg = arg.split("=", 1) 184 key, arg = arg.split("=", 1)
182 if key == "path" and os.path.exists(arg): 185 if key == "path" and os.path.exists(arg):
186 if f"*{os.path.splitext(arg)[1]}" not in self.core.imageFormats:
187 fail()
183 try: 188 try:
184 Image.open(arg) 189 Image.open(arg)
185 self.page.lineEdit_image.setText(arg) 190 self.page.lineEdit_image.setText(arg)
186 self.page.checkBox_stretch.setChecked(True) 191 self.page.comboBox_resizeMode.setCurrentIndex(2)
187 return 192 return
188 except OSError as e: 193 except OSError as e:
189 print("Not a supported image format") 194 fail()
190 quit(1)
191 super().command(arg) 195 super().command(arg)
192 196
193 def commandHelp(self): 197 def commandHelp(self):
diff --git a/src/avp/components/life.py b/src/avp/components/life.py
index a062617..374b299 100644
--- a/src/avp/components/life.py
+++ b/src/avp/components/life.py
@@ -1,13 +1,12 @@
1from PyQt6 import QtCore, QtWidgets 1from PyQt6 import QtCore, QtWidgets
2from PyQt6.QtGui import QUndoCommand 2from PyQt6.QtGui import QUndoCommand
3from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter, ImageOps 3from PIL import Image, ImageDraw
4import os 4import os
5from copy import copy
6import math 5import math
7import logging 6import logging
8 7
9 8
10from ..component import Component 9from ..libcomponent import BaseComponent
11from ..toolkit.frame import BlankFrame, scale, addShadow 10from ..toolkit.frame import BlankFrame, scale, addShadow
12from ..toolkit.visualizer import createSpectrumArray 11from ..toolkit.visualizer import createSpectrumArray
13 12
@@ -15,7 +14,7 @@ from ..toolkit.visualizer import createSpectrumArray
15log = logging.getLogger("AVP.Component.Life") 14log = logging.getLogger("AVP.Component.Life")
16 15
17 16
18class Component(Component): 17class Component(BaseComponent):
19 name = "Conway's Game of Life" 18 name = "Conway's Game of Life"
20 version = "2.0.1" 19 version = "2.0.1"
21 20
diff --git a/src/avp/components/sound.py b/src/avp/components/sound.py
index 2df8e38..c212870 100644
--- a/src/avp/components/sound.py
+++ b/src/avp/components/sound.py
@@ -1,11 +1,11 @@
1from PyQt6 import QtGui, QtCore, QtWidgets 1from PyQt6 import QtWidgets
2import os 2import os
3 3
4from ..component import Component 4from ..libcomponent import BaseComponent
5from ..toolkit.frame import BlankFrame 5from ..toolkit.frame import BlankFrame
6 6
7 7
8class Component(Component): 8class Component(BaseComponent):
9 name = "Sound" 9 name = "Sound"
10 version = "1.0.0" 10 version = "1.0.0"
11 11
diff --git a/src/avp/components/spectrum.py b/src/avp/components/spectrum.py
index 062ebc7..0446865 100644
--- a/src/avp/components/spectrum.py
+++ b/src/avp/components/spectrum.py
@@ -1,14 +1,11 @@
1from PIL import Image 1from PIL import Image
2from PyQt6 import QtGui, QtCore, QtWidgets
3import os 2import os
4import math
5import subprocess 3import subprocess
6import time
7import logging 4import logging
8 5
9from ..component import Component 6from ..libcomponent import BaseComponent
10from ..toolkit.frame import BlankFrame, scale 7from ..toolkit.frame import BlankFrame, scale
11from ..toolkit import checkOutput, connectWidget 8from ..toolkit import connectWidget
12from ..toolkit.ffmpeg import ( 9from ..toolkit.ffmpeg import (
13 openPipe, 10 openPipe,
14 closePipe, 11 closePipe,
@@ -21,7 +18,7 @@ from ..toolkit.ffmpeg import (
21log = logging.getLogger("AVP.Components.Spectrum") 18log = logging.getLogger("AVP.Components.Spectrum")
22 19
23 20
24class Component(Component): 21class Component(BaseComponent):
25 name = "Spectrum" 22 name = "Spectrum"
26 version = "1.0.1" 23 version = "1.0.1"
27 24
diff --git a/src/avp/components/text.py b/src/avp/components/text.py
index bee117e..d248772 100644
--- a/src/avp/components/text.py
+++ b/src/avp/components/text.py
@@ -1,16 +1,14 @@
1from PIL import ImageEnhance, ImageFilter, ImageChops 1from PyQt6.QtGui import QFont
2from PyQt6.QtGui import QColor, QFont 2from PyQt6 import QtGui, QtCore
3from PyQt6 import QtGui, QtCore, QtWidgets
4import os
5import logging 3import logging
6 4
7from ..component import Component 5from ..libcomponent import BaseComponent
8from ..toolkit.frame import FramePainter, addShadow 6from ..toolkit.frame import FramePainter, addShadow
9 7
10log = logging.getLogger("AVP.Components.Text") 8log = logging.getLogger("AVP.Components.Text")
11 9
12 10
13class Component(Component): 11class Component(BaseComponent):
14 name = "Title Text" 12 name = "Title Text"
15 version = "1.0.1" 13 version = "1.0.1"
16 14
diff --git a/src/avp/components/video.py b/src/avp/components/video.py
index 65a05af..1e9b788 100644
--- a/src/avp/components/video.py
+++ b/src/avp/components/video.py
@@ -1,20 +1,18 @@
1from PIL import Image 1from PIL import Image
2from PyQt6 import QtGui, QtCore, QtWidgets 2from PyQt6 import QtWidgets
3import os 3import os
4import math
5import subprocess 4import subprocess
6import logging 5import logging
7 6
8from ..component import Component 7from ..libcomponent import BaseComponent
9from ..toolkit.frame import BlankFrame, scale 8from ..toolkit.frame import BlankFrame, scale
10from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo 9from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo
11from ..toolkit import checkOutput
12 10
13 11
14log = logging.getLogger("AVP.Components.Video") 12log = logging.getLogger("AVP.Components.Video")
15 13
16 14
17class Component(Component): 15class Component(BaseComponent):
18 name = "Video" 16 name = "Video"
19 version = "1.0.0" 17 version = "1.0.0"
20 18
diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py
index e10dec2..bfebc30 100644
--- a/src/avp/components/waveform.py
+++ b/src/avp/components/waveform.py
@@ -3,12 +3,10 @@ from PyQt6.QtGui import QColor
3import os 3import os
4import subprocess 4import subprocess
5import logging 5import logging
6from copy import copy
7 6
8from ..component import Component 7from ..libcomponent import BaseComponent
9from ..toolkit.visualizer import transformData, createSpectrumArray 8from ..toolkit.visualizer import createSpectrumArray
10from ..toolkit.frame import BlankFrame, scale 9from ..toolkit.frame import BlankFrame, scale
11from ..toolkit import checkOutput
12from ..toolkit.ffmpeg import ( 10from ..toolkit.ffmpeg import (
13 openPipe, 11 openPipe,
14 closePipe, 12 closePipe,
@@ -21,7 +19,7 @@ from ..toolkit.ffmpeg import (
21log = logging.getLogger("AVP.Components.Waveform") 19log = logging.getLogger("AVP.Components.Waveform")
22 20
23 21
24class Component(Component): 22class Component(BaseComponent):
25 name = "Waveform" 23 name = "Waveform"
26 version = "2.0.0" 24 version = "2.0.0"
27 25
diff --git a/src/avp/core.py b/src/avp/core.py
index 347a5dd..c8e070b 100644
--- a/src/avp/core.py
+++ b/src/avp/core.py
@@ -14,7 +14,6 @@ from . import toolkit
14 14
15appName = "Audio Visualizer Python" 15appName = "Audio Visualizer Python"
16log = logging.getLogger("AVP.Core") 16log = logging.getLogger("AVP.Core")
17STDOUT_LOGLVL = logging.WARNING
18 17
19 18
20class Core: 19class Core:
@@ -26,6 +25,8 @@ class Core:
26 This class also stores constants as class variables. 25 This class also stores constants as class variables.
27 """ 26 """
28 27
28 stdoutLogLvl = logging.WARNING
29
29 def __init__(self): 30 def __init__(self):
30 self.importComponents() 31 self.importComponents()
31 self.selectedComponents = [] 32 self.selectedComponents = []
@@ -77,7 +78,10 @@ class Core:
77 compPos = len(self.selectedComponents) 78 compPos = len(self.selectedComponents)
78 if len(self.selectedComponents) > 50: 79 if len(self.selectedComponents) > 50:
79 return -1 80 return -1
80 if type(component) is int: 81 if component is None:
82 log.warning("Tried to insert non-existent component")
83 return -1
84 elif type(component) is int:
81 # create component using module index in self.modules 85 # create component using module index in self.modules
82 moduleIndex = int(component) 86 moduleIndex = int(component)
83 log.debug("Creating new component from module #%s", str(moduleIndex)) 87 log.debug("Creating new component from module #%s", str(moduleIndex))
@@ -197,7 +201,7 @@ class Core:
197 ) 201 )
198 continue 202 continue
199 if i == -1: 203 if i == -1:
200 loader.showMessage(msg="Too many components!") 204 loader.showMessage(msg="Invalid components!")
201 break 205 break
202 206
203 try: 207 try:
@@ -554,7 +558,7 @@ class Core:
554 def makeLogger(deleteOldLogs=False, fileLogLvl=None): 558 def makeLogger(deleteOldLogs=False, fileLogLvl=None):
555 # send critical log messages to stdout 559 # send critical log messages to stdout
556 logStream = logging.StreamHandler() 560 logStream = logging.StreamHandler()
557 logStream.setLevel(STDOUT_LOGLVL) 561 logStream.setLevel(Core.stdoutLogLvl)
558 streamFormatter = logging.Formatter("<%(name)s> %(levelname)s: %(message)s") 562 streamFormatter = logging.Formatter("<%(name)s> %(levelname)s: %(message)s")
559 logStream.setFormatter(streamFormatter) 563 logStream.setFormatter(streamFormatter)
560 log = logging.getLogger("AVP") 564 log = logging.getLogger("AVP")
diff --git a/src/avp/gui/actions.py b/src/avp/gui/actions.py
index 654b2a0..6a01bdd 100644
--- a/src/avp/gui/actions.py
+++ b/src/avp/gui/actions.py
@@ -1,5 +1,5 @@
1""" 1"""
2QCommand classes for every undoable user action performed in the MainWindow 2QUndoCommand classes for every undoable user action performed in the MainWindow
3""" 3"""
4 4
5from PyQt6.QtGui import QUndoCommand 5from PyQt6.QtGui import QUndoCommand
@@ -13,9 +13,9 @@ from ..core import Core
13log = logging.getLogger("AVP.Gui.Actions") 13log = logging.getLogger("AVP.Gui.Actions")
14 14
15 15
16# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 16# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
17# COMPONENT ACTIONS 17# COMPONENT ACTIONS
18# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 18# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
19 19
20 20
21class AddComponent(QUndoCommand): 21class AddComponent(QUndoCommand):
@@ -107,9 +107,9 @@ class MoveComponent(QUndoCommand):
107 self.do(self.newRow, self.row) 107 self.do(self.newRow, self.row)
108 108
109 109
110# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 110# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
111# PRESET ACTIONS 111# PRESET ACTIONS
112# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 112# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
113 113
114 114
115class ClearPreset(QUndoCommand): 115class ClearPreset(QUndoCommand):
diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py
index 3221783..fac1e41 100644
--- a/src/avp/gui/mainwindow.py
+++ b/src/avp/gui/mainwindow.py
@@ -25,7 +25,7 @@ from . import preview_thread
25from .preview_win import PreviewWindow 25from .preview_win import PreviewWindow
26from .presetmanager import PresetManager 26from .presetmanager import PresetManager
27from .actions import * 27from .actions import *
28from ..toolkit.ffmpeg import createFfmpegCommand 28from ..toolkit.ffmpeg import createFfmpegCommand, checkFfmpegVersion
29from ..toolkit import ( 29from ..toolkit import (
30 disableWhenEncoding, 30 disableWhenEncoding,
31 disableWhenOpeningProject, 31 disableWhenOpeningProject,
@@ -330,26 +330,13 @@ class MainWindow(QtWidgets.QMainWindow):
330 ) 330 )
331 else: 331 else:
332 if not self.settings.value("ffmpegMsgShown"): 332 if not self.settings.value("ffmpegMsgShown"):
333 try: 333 ffmpegGoodVersion, ffmpegVersionNum = checkFfmpegVersion()
334 with open(os.devnull, "w") as f: 334 if not ffmpegGoodVersion:
335 ffmpegVers = checkOutput( 335 self.showMessage(
336 [self.core.FFMPEG_BIN, "-version"], stderr=f 336 msg="The version of FFmpeg ({ffmpegVersionNum}) is "
337 ) 337 "not recognized. Some features may not work as expected."
338 ffmpegVers = str(ffmpegVers).split()[2].split(".", 1)[0] 338 )
339 if ffmpegVers.startswith("n"): 339 self.settings.setValue("ffmpegMsgShown", True)
340 ffmpegVers = ffmpegVers[1:]
341 goodVersion = int(ffmpegVers) > 3
342 except Exception:
343 goodVersion = False
344 else:
345 goodVersion = True
346
347 if not goodVersion:
348 self.showMessage(
349 msg="You're using an old version of Ffmpeg. "
350 "Some features may not work as expected."
351 )
352 self.settings.setValue("ffmpegMsgShown", True)
353 340
354 # Hotkeys for projects 341 # Hotkeys for projects
355 342
@@ -734,6 +721,23 @@ class MainWindow(QtWidgets.QMainWindow):
734 self.progressLabel.setText(value) 721 self.progressLabel.setText(value)
735 else: 722 else:
736 self.progressBar_createVideo.setFormat(value) 723 self.progressBar_createVideo.setFormat(value)
724 if log.getEffectiveLevel() > logging.INFO:
725 # if ffmpeg is quiet, print progress ourselves
726 if any(
727 [
728 value.startswith("Export C"),
729 value.startswith("Analyzing"),
730 value.startswith("Loading"),
731 ]
732 ):
733 # Don't duplicate completion/failure messages or send too many messages
734 return
735 elif not value.startswith("Exporting"):
736 print(value)
737 else:
738 # overwrite previous message with next one
739 # if the text is our main export progress
740 print(f"\r{value}", end="")
737 741
738 def updateResolution(self): 742 def updateResolution(self):
739 resIndex = int(self.comboBox_resolution.currentIndex()) 743 resIndex = int(self.comboBox_resolution.currentIndex())
diff --git a/src/avp/gui/presetmanager.py b/src/avp/gui/presetmanager.py
index ca0029d..bdcff91 100644
--- a/src/avp/gui/presetmanager.py
+++ b/src/avp/gui/presetmanager.py
@@ -25,9 +25,7 @@ class PresetManager(QtWidgets.QDialog):
25 self.settings = parent.settings 25 self.settings = parent.settings
26 self.presetDir = parent.presetDir 26 self.presetDir = parent.presetDir
27 if not self.settings.value("presetDir"): 27 if not self.settings.value("presetDir"):
28 self.settings.setValue( 28 self.settings.setValue("presetDir", os.path.join(parent.dataDir, "presets"))
29 "presetDir", os.path.join(parent.dataDir, "projects")
30 )
31 29
32 self.findPresets() 30 self.findPresets()
33 31
diff --git a/src/avp/gui/preview_thread.py b/src/avp/gui/preview_thread.py
index a59652a..8507f45 100644
--- a/src/avp/gui/preview_thread.py
+++ b/src/avp/gui/preview_thread.py
@@ -61,7 +61,10 @@ class Worker(QtCore.QObject):
61 for component in reversed(components): 61 for component in reversed(components):
62 try: 62 try:
63 component.lockSize(width, height) 63 component.lockSize(width, height)
64 newFrame = component.previewRender() 64 if "composite" in component.properties():
65 newFrame = component.previewRender(frame)
66 else:
67 newFrame = component.previewRender()
65 component.unlockSize() 68 component.unlockSize()
66 frame = Image.alpha_composite(frame, newFrame) 69 frame = Image.alpha_composite(frame, newFrame)
67 70
@@ -72,11 +75,12 @@ class Worker(QtCore.QObject):
72 % ( 75 % (
73 str(component), 76 str(component),
74 str(e).capitalize(), 77 str(e).capitalize(),
75 "is None" if newFrame is None else "size was %s*%s; should be %s*%s" % ( 78 (
76 newFrame.width, 79 "is None"
77 newFrame.height, 80 if newFrame is None
78 width, 81 else "size was %s*%s; should be %s*%s"
79 height), 82 % (newFrame.width, newFrame.height, width, height)
83 ),
80 ) 84 )
81 ) 85 )
82 log.critical(errMsg) 86 log.critical(errMsg)
diff --git a/src/avp/libcomponent/__init__.py b/src/avp/libcomponent/__init__.py
new file mode 100644
index 0000000..5b04b45
--- /dev/null
+++ b/src/avp/libcomponent/__init__.py
@@ -0,0 +1,4 @@
1from .component import Component as BaseComponent
2from .exceptions import ComponentError
3
4__all__ = [BaseComponent, ComponentError]
diff --git a/src/avp/libcomponent/actions.py b/src/avp/libcomponent/actions.py
new file mode 100644
index 0000000..f534685
--- /dev/null
+++ b/src/avp/libcomponent/actions.py
@@ -0,0 +1,104 @@
1"""
2QUndoCommand class for generic undoable user actions performed to a BaseComponent
3
4See `../life.py` for an example of a component that uses a custom QUndoCommand
5"""
6
7from PyQt6.QtGui import QUndoCommand
8from copy import copy
9import logging
10
11log = logging.getLogger("AVP.ComponentHandler")
12
13
14class ComponentUpdate(QUndoCommand):
15 """Command object for making a component action undoable"""
16
17 def __init__(self, parent, oldWidgetVals, modifiedVals):
18 super().__init__("change %s component #%s" % (parent.name, parent.compPos))
19 self.undone = False
20 self.res = (int(parent.width), int(parent.height))
21 self.parent = parent
22 self.oldWidgetVals = {
23 attr: (
24 copy(val)
25 if attr not in self.parent._relativeWidgets
26 else self.parent.floatValForAttr(attr, val, axis=self.res)
27 )
28 for attr, val in oldWidgetVals.items()
29 if attr in modifiedVals
30 }
31 self.modifiedVals = {
32 attr: (
33 val
34 if attr not in self.parent._relativeWidgets
35 else self.parent.floatValForAttr(attr, val, axis=self.res)
36 )
37 for attr, val in modifiedVals.items()
38 }
39
40 # Because relative widgets change themselves every update based on
41 # their previous value, we must store ALL their values in case of undo
42 self.relativeWidgetValsAfterUndo = {
43 attr: copy(getattr(self.parent, attr))
44 for attr in self.parent._relativeWidgets
45 }
46
47 # Determine if this update is mergeable
48 self.id_ = -1
49 if self.parent.mergeUndo:
50 if len(self.modifiedVals) == 1:
51 attr, val = self.modifiedVals.popitem()
52 self.id_ = sum([ord(letter) for letter in attr[-14:]])
53 self.modifiedVals[attr] = val
54 return
55 log.warning(
56 "%s component settings changed at once. (%s)",
57 len(self.modifiedVals),
58 repr(self.modifiedVals),
59 )
60
61 def id(self):
62 """If 2 consecutive updates have same id, Qt will call mergeWith()"""
63 return self.id_
64
65 def mergeWith(self, other):
66 self.modifiedVals.update(other.modifiedVals)
67 return True
68
69 def setWidgetValues(self, attrDict):
70 """
71 Mask the component's usual method to handle our
72 relative widgets in case the resolution has changed.
73 """
74 newAttrDict = {
75 attr: (
76 val
77 if attr not in self.parent._relativeWidgets
78 else self.parent.pixelValForAttr(attr, val)
79 )
80 for attr, val in attrDict.items()
81 }
82 self.parent.setWidgetValues(newAttrDict)
83
84 def redo(self):
85 if self.undone:
86 log.info("Redoing component update")
87 self.parent.oldAttrs = self.relativeWidgetValsAfterUndo
88 self.setWidgetValues(self.modifiedVals)
89 self.parent.update(auto=True)
90 self.parent.oldAttrs = None
91 if not self.undone:
92 self.relativeWidgetValsAfterRedo = {
93 attr: copy(getattr(self.parent, attr))
94 for attr in self.parent._relativeWidgets
95 }
96 self.parent._sendUpdateSignal()
97
98 def undo(self):
99 log.info("Undoing component update")
100 self.undone = True
101 self.parent.oldAttrs = self.relativeWidgetValsAfterRedo
102 self.setWidgetValues(self.oldWidgetVals)
103 self.parent.update(auto=True)
104 self.parent.oldAttrs = None
diff --git a/src/avp/component.py b/src/avp/libcomponent/component.py
index 5906ab1..1f81e07 100644
--- a/src/avp/component.py
+++ b/src/avp/libcomponent/component.py
@@ -4,274 +4,26 @@ on making a valid component.
4""" 4"""
5 5
6from PyQt6 import uic, QtCore, QtWidgets 6from PyQt6 import uic, QtCore, QtWidgets
7from PyQt6.QtGui import QColor, QUndoCommand 7from PyQt6.QtGui import QColor
8import os 8import os
9import sys
10import math 9import math
11import time
12import logging 10import logging
13from copy import copy 11from copy import copy
14 12
15from .toolkit.frame import BlankFrame 13from .metaclass import ComponentMetaclass
16from .toolkit import ( 14from .actions import ComponentUpdate
15from .exceptions import ComponentError
16from ..toolkit.frame import BlankFrame
17
18from ..toolkit import (
17 getWidgetValue, 19 getWidgetValue,
18 setWidgetValue, 20 setWidgetValue,
19 connectWidget,
20 rgbFromString, 21 rgbFromString,
21 randomColor, 22 randomColor,
22 blockSignals, 23 blockSignals,
23) 24)
24 25
25 26log = logging.getLogger("AVP.BaseComponent")
26log = logging.getLogger("AVP.ComponentHandler")
27
28
29class ComponentMetaclass(type(QtCore.QObject)):
30 """
31 Checks the validity of each Component class and mutates some attrs.
32 E.g., takes only major version from version string & decorates methods
33 """
34
35 def initializationWrapper(func):
36 def initializationWrapper(self, *args, **kwargs):
37 try:
38 return func(self, *args, **kwargs)
39 except Exception:
40 try:
41 raise ComponentError(self, "initialization process")
42 except ComponentError:
43 return
44
45 return initializationWrapper
46
47 def renderWrapper(func):
48 def renderWrapper(self, *args, **kwargs):
49 try:
50 log.verbose(
51 "### %s #%s renders a preview frame ###",
52 self.__class__.name,
53 str(self.compPos),
54 )
55 return func(self, *args, **kwargs)
56 except Exception as e:
57 try:
58 if e.__class__.__name__.startswith("Component"):
59 raise
60 else:
61 raise ComponentError(self, "renderer")
62 except ComponentError:
63 return BlankFrame()
64
65 return renderWrapper
66
67 def commandWrapper(func):
68 """Intercepts the command() method to check for global args"""
69
70 def commandWrapper(self, arg):
71 if arg.startswith("preset="):
72 _, preset = arg.split("=", 1)
73 path = os.path.join(self.core.getPresetDir(self), preset)
74 if not os.path.exists(path):
75 print('Couldn\'t locate preset "%s"' % preset)
76 quit(1)
77 else:
78 print('Opening "%s" preset on layer %s' % (preset, self.compPos))
79 self.core.openPreset(path, self.compPos, preset)
80 # Don't call the component's command() method
81 return
82 else:
83 return func(self, arg)
84
85 return commandWrapper
86
87 def propertiesWrapper(func):
88 """Intercepts the usual properties if the properties are locked."""
89
90 def propertiesWrapper(self):
91 if self._lockedProperties is not None:
92 return self._lockedProperties
93 else:
94 try:
95 return func(self)
96 except Exception:
97 try:
98 raise ComponentError(self, "properties")
99 except ComponentError:
100 return []
101
102 return propertiesWrapper
103
104 def errorWrapper(func):
105 """Intercepts the usual error message if it is locked."""
106
107 def errorWrapper(self):
108 if self._lockedError is not None:
109 return self._lockedError
110 else:
111 return func(self)
112
113 return errorWrapper
114
115 def loadPresetWrapper(func):
116 """Wraps loadPreset to handle the self.openingPreset boolean"""
117
118 class openingPreset:
119 def __init__(self, comp):
120 self.comp = comp
121
122 def __enter__(self):
123 self.comp.openingPreset = True
124
125 def __exit__(self, *args):
126 self.comp.openingPreset = False
127
128 def presetWrapper(self, *args):
129 with openingPreset(self):
130 try:
131 return func(self, *args)
132 except Exception:
133 try:
134 raise ComponentError(self, "preset loader")
135 except ComponentError:
136 return
137
138 return presetWrapper
139
140 def updateWrapper(func):
141 """
142 Calls _preUpdate before every subclass update().
143 Afterwards, for non-user updates, calls _autoUpdate().
144 For undoable updates triggered by the user, calls _userUpdate()
145 """
146
147 class wrap:
148 def __init__(self, comp, auto):
149 self.comp = comp
150 self.auto = auto
151
152 def __enter__(self):
153 self.comp._preUpdate()
154
155 def __exit__(self, *args):
156 if (
157 self.auto
158 or self.comp.openingPreset
159 or not hasattr(self.comp.parent, "undoStack")
160 ):
161 log.verbose("Automatic update")
162 self.comp._autoUpdate()
163 else:
164 log.verbose("User update")
165 self.comp._userUpdate()
166
167 def updateWrapper(self, **kwargs):
168 auto = kwargs["auto"] if "auto" in kwargs else False
169 with wrap(self, auto):
170 try:
171 return func(self)
172 except Exception:
173 try:
174 raise ComponentError(self, "update method")
175 except ComponentError:
176 return
177
178 return updateWrapper
179
180 def widgetWrapper(func):
181 """Connects all widgets to update method after the subclass's method"""
182
183 class wrap:
184 def __init__(self, comp):
185 self.comp = comp
186
187 def __enter__(self):
188 pass
189
190 def __exit__(self, *args):
191 for widgetList in self.comp._allWidgets.values():
192 for widget in widgetList:
193 log.verbose("Connecting %s", str(widget.__class__.__name__))
194 connectWidget(widget, self.comp.update)
195
196 def widgetWrapper(self, *args, **kwargs):
197 auto = kwargs["auto"] if "auto" in kwargs else False
198 with wrap(self):
199 try:
200 return func(self, *args, **kwargs)
201 except Exception:
202 try:
203 raise ComponentError(self, "widget creation")
204 except ComponentError:
205 return
206
207 return widgetWrapper
208
209 def __new__(cls, name, parents, attrs):
210 if "ui" not in attrs:
211 # Use module name as ui filename by default
212 attrs["ui"] = (
213 "%s.ui" % os.path.splitext(attrs["__module__"].split(".")[-1])[0]
214 )
215
216 decorate = (
217 "names", # Class methods
218 "error",
219 "audio",
220 "properties", # Properties
221 "preFrameRender",
222 "previewRender",
223 "loadPreset",
224 "command",
225 "update",
226 "widget",
227 )
228
229 # Auto-decorate methods
230 for key in decorate:
231 if key not in attrs:
232 continue
233 if key in ("names"):
234 attrs[key] = classmethod(attrs[key])
235 elif key in ("audio"):
236 attrs[key] = property(attrs[key])
237 elif key == "command":
238 attrs[key] = cls.commandWrapper(attrs[key])
239 elif key == "previewRender":
240 attrs[key] = cls.renderWrapper(attrs[key])
241 elif key == "preFrameRender":
242 attrs[key] = cls.initializationWrapper(attrs[key])
243 elif key == "properties":
244 attrs[key] = cls.propertiesWrapper(attrs[key])
245 elif key == "error":
246 attrs[key] = cls.errorWrapper(attrs[key])
247 elif key == "loadPreset":
248 attrs[key] = cls.loadPresetWrapper(attrs[key])
249 elif key == "update":
250 attrs[key] = cls.updateWrapper(attrs[key])
251 elif key == "widget" and parents[0] != QtCore.QObject:
252 attrs[key] = cls.widgetWrapper(attrs[key])
253
254 # Turn version string into a number
255 try:
256 if "version" not in attrs:
257 log.error(
258 "No version attribute in %s. Defaulting to 1",
259 attrs["name"],
260 )
261 attrs["version"] = 1
262 else:
263 attrs["version"] = int(attrs["version"].split(".")[0])
264 except ValueError:
265 log.critical(
266 "%s component has an invalid version string:\n%s",
267 attrs["name"],
268 str(attrs["version"]),
269 )
270 except KeyError:
271 log.critical("%s component has no version string.", attrs["name"])
272 else:
273 return super().__new__(cls, name, parents, attrs)
274 quit(1)
275 27
276 28
277class Component(QtCore.QObject, metaclass=ComponentMetaclass): 29class Component(QtCore.QObject, metaclass=ComponentMetaclass):
@@ -340,9 +92,9 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
340 pprint.pformat(preset), 92 pprint.pformat(preset),
341 ) 93 )
342 94
343 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 95 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
344 # Render Methods 96 # Render Methods
345 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 97 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
346 98
347 def previewRender(self): 99 def previewRender(self):
348 image = BlankFrame(self.width, self.height) 100 image = BlankFrame(self.width, self.height)
@@ -371,15 +123,18 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
371 def postFrameRender(self): 123 def postFrameRender(self):
372 pass 124 pass
373 125
374 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 126 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
375 # Properties 127 # Properties
376 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 128 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
377 129
378 def properties(self): 130 def properties(self):
379 """ 131 """
380 Return a list of properties to signify if your component is 132 Return a list of properties with certain meanings:
381 non-animated ('static'), returns sound ('audio'), or has 133 `static`: non-animated
382 encountered an error in configuration ('error'). 134 `audio`: has extra sound to add
135 `error`: bad configuration
136 `pcm`: request raw audio data
137 `composite`: request frame to draw on
383 """ 138 """
384 return [] 139 return []
385 140
@@ -403,9 +158,9 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
403 https://ffmpeg.org/ffmpeg-filters.html 158 https://ffmpeg.org/ffmpeg-filters.html
404 """ 159 """
405 160
406 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 161 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
407 # Idle Methods 162 # Idle Methods
408 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 163 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
409 164
410 def widget(self, parent): 165 def widget(self, parent):
411 """ 166 """
@@ -510,9 +265,9 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
510 self.commandHelp() 265 self.commandHelp()
511 quit(0) 266 quit(0)
512 267
513 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 268 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
514 # "Private" Methods 269 # "Private" Methods
515 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 270 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
516 def _preUpdate(self): 271 def _preUpdate(self):
517 """Happens before subclass update()""" 272 """Happens before subclass update()"""
518 for attr in self._relativeWidgets: 273 for attr in self._relativeWidgets:
@@ -826,153 +581,3 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
826 maxRes = int(self.core.resolutions[0].split("x")[0]) 581 maxRes = int(self.core.resolutions[0].split("x")[0])
827 newMaximumValue = self.width * (self._relativeMaximums[attr] / maxRes) 582 newMaximumValue = self.width * (self._relativeMaximums[attr] / maxRes)
828 self._trackedWidgets[attr].setMaximum(int(newMaximumValue)) 583 self._trackedWidgets[attr].setMaximum(int(newMaximumValue))
829
830
831class ComponentError(RuntimeError):
832 """Gives the MainWindow a traceback to display, and cancels the export."""
833
834 prevErrors = []
835 lastTime = time.time()
836
837 def __init__(self, caller, name, msg=None):
838 if msg is None and sys.exc_info()[0] is not None:
839 msg = str(sys.exc_info()[1])
840 else:
841 msg = "Unknown error."
842 log.error("ComponentError by %s's %s: %s" % (caller.name, name, msg))
843
844 # Don't create multiple windows for quickly repeated messages
845 if len(ComponentError.prevErrors) > 1:
846 ComponentError.prevErrors.pop()
847 ComponentError.prevErrors.insert(0, name)
848 curTime = time.time()
849 if (
850 name in ComponentError.prevErrors[1:]
851 and curTime - ComponentError.lastTime < 1.0
852 ):
853 return
854 ComponentError.lastTime = time.time()
855
856 from .toolkit import formatTraceback
857
858 if sys.exc_info()[0] is not None:
859 string = "%s component (#%s): %s encountered %s %s: %s" % (
860 caller.__class__.name,
861 str(caller.compPos),
862 name,
863 (
864 "an"
865 if any(
866 [
867 sys.exc_info()[0].__name__.startswith(vowel)
868 for vowel in ("A", "I", "U", "O", "E")
869 ]
870 )
871 else "a"
872 ),
873 sys.exc_info()[0].__name__,
874 str(sys.exc_info()[1]),
875 )
876 detail = formatTraceback(sys.exc_info()[2])
877 else:
878 string = name
879 detail = "Attributes:\n%s" % (
880 "\n".join([m for m in dir(caller) if not m.startswith("_")])
881 )
882
883 super().__init__(string)
884 caller.lockError(string)
885 caller._error.emit(string, detail)
886
887
888class ComponentUpdate(QUndoCommand):
889 """Command object for making a component action undoable"""
890
891 def __init__(self, parent, oldWidgetVals, modifiedVals):
892 super().__init__("change %s component #%s" % (parent.name, parent.compPos))
893 self.undone = False
894 self.res = (int(parent.width), int(parent.height))
895 self.parent = parent
896 self.oldWidgetVals = {
897 attr: (
898 copy(val)
899 if attr not in self.parent._relativeWidgets
900 else self.parent.floatValForAttr(attr, val, axis=self.res)
901 )
902 for attr, val in oldWidgetVals.items()
903 if attr in modifiedVals
904 }
905 self.modifiedVals = {
906 attr: (
907 val
908 if attr not in self.parent._relativeWidgets
909 else self.parent.floatValForAttr(attr, val, axis=self.res)
910 )
911 for attr, val in modifiedVals.items()
912 }
913
914 # Because relative widgets change themselves every update based on
915 # their previous value, we must store ALL their values in case of undo
916 self.relativeWidgetValsAfterUndo = {
917 attr: copy(getattr(self.parent, attr))
918 for attr in self.parent._relativeWidgets
919 }
920
921 # Determine if this update is mergeable
922 self.id_ = -1
923 if self.parent.mergeUndo:
924 if len(self.modifiedVals) == 1:
925 attr, val = self.modifiedVals.popitem()
926 self.id_ = sum([ord(letter) for letter in attr[-14:]])
927 self.modifiedVals[attr] = val
928 return
929 log.warning(
930 "%s component settings changed at once. (%s)",
931 len(self.modifiedVals),
932 repr(self.modifiedVals),
933 )
934
935 def id(self):
936 """If 2 consecutive updates have same id, Qt will call mergeWith()"""
937 return self.id_
938
939 def mergeWith(self, other):
940 self.modifiedVals.update(other.modifiedVals)
941 return True
942
943 def setWidgetValues(self, attrDict):
944 """
945 Mask the component's usual method to handle our
946 relative widgets in case the resolution has changed.
947 """
948 newAttrDict = {
949 attr: (
950 val
951 if attr not in self.parent._relativeWidgets
952 else self.parent.pixelValForAttr(attr, val)
953 )
954 for attr, val in attrDict.items()
955 }
956 self.parent.setWidgetValues(newAttrDict)
957
958 def redo(self):
959 if self.undone:
960 log.info("Redoing component update")
961 self.parent.oldAttrs = self.relativeWidgetValsAfterUndo
962 self.setWidgetValues(self.modifiedVals)
963 self.parent.update(auto=True)
964 self.parent.oldAttrs = None
965 if not self.undone:
966 self.relativeWidgetValsAfterRedo = {
967 attr: copy(getattr(self.parent, attr))
968 for attr in self.parent._relativeWidgets
969 }
970 self.parent._sendUpdateSignal()
971
972 def undo(self):
973 log.info("Undoing component update")
974 self.undone = True
975 self.parent.oldAttrs = self.relativeWidgetValsAfterRedo
976 self.setWidgetValues(self.oldWidgetVals)
977 self.parent.update(auto=True)
978 self.parent.oldAttrs = None
diff --git a/src/avp/libcomponent/exceptions.py b/src/avp/libcomponent/exceptions.py
new file mode 100644
index 0000000..5498414
--- /dev/null
+++ b/src/avp/libcomponent/exceptions.py
@@ -0,0 +1,63 @@
1import time
2import sys
3import logging
4
5from ..toolkit import formatTraceback
6
7
8log = logging.getLogger("AVP.ComponentHandler")
9
10
11class ComponentError(RuntimeError):
12 """Gives the MainWindow a traceback to display, and cancels the export."""
13
14 prevErrors = []
15 lastTime = time.time()
16
17 def __init__(self, caller, name, msg=None):
18 if msg is None and sys.exc_info()[0] is not None:
19 msg = str(sys.exc_info()[1])
20 else:
21 msg = "Unknown error."
22 log.error("ComponentError by %s's %s: %s" % (caller.name, name, msg))
23
24 # Don't create multiple windows for quickly repeated messages
25 if len(ComponentError.prevErrors) > 1:
26 ComponentError.prevErrors.pop()
27 ComponentError.prevErrors.insert(0, name)
28 curTime = time.time()
29 if (
30 name in ComponentError.prevErrors[1:]
31 and curTime - ComponentError.lastTime < 1.0
32 ):
33 return
34 ComponentError.lastTime = time.time()
35
36 if sys.exc_info()[0] is not None:
37 string = "%s component (#%s): %s encountered %s %s: %s" % (
38 caller.__class__.name,
39 str(caller.compPos),
40 name,
41 (
42 "an"
43 if any(
44 [
45 sys.exc_info()[0].__name__.startswith(vowel)
46 for vowel in ("A", "I", "U", "O", "E")
47 ]
48 )
49 else "a"
50 ),
51 sys.exc_info()[0].__name__,
52 str(sys.exc_info()[1]),
53 )
54 detail = formatTraceback(sys.exc_info()[2])
55 else:
56 string = name
57 detail = "Attributes:\n%s" % (
58 "\n".join([m for m in dir(caller) if not m.startswith("_")])
59 )
60
61 super().__init__(string)
62 caller.lockError(string)
63 caller._error.emit(string, detail)
diff --git a/src/avp/libcomponent/metaclass.py b/src/avp/libcomponent/metaclass.py
new file mode 100644
index 0000000..e8ad949
--- /dev/null
+++ b/src/avp/libcomponent/metaclass.py
@@ -0,0 +1,257 @@
1import os
2import logging
3from PyQt6 import QtCore
4
5from .exceptions import ComponentError
6from ..toolkit import connectWidget
7from ..toolkit.frame import BlankFrame
8
9log = logging.getLogger("AVP.ComponentHandler")
10
11
12class ComponentMetaclass(type(QtCore.QObject)):
13 """
14 Checks the validity of each Component class and mutates some attrs.
15 E.g., takes only major version from version string & decorates methods
16 """
17
18 def initializationWrapper(func):
19 def initializationWrapper(self, *args, **kwargs):
20 try:
21 return func(self, *args, **kwargs)
22 except Exception:
23 try:
24 raise ComponentError(self, "initialization process")
25 except ComponentError:
26 return
27
28 return initializationWrapper
29
30 def renderWrapper(func):
31 def renderWrapper(self, *args, **kwargs):
32 try:
33 log.verbose(
34 "### %s #%s renders a preview frame ###",
35 self.__class__.name,
36 str(self.compPos),
37 )
38 return func(self, *args, **kwargs)
39 except Exception as e:
40 try:
41 if e.__class__.__name__.startswith("Component"):
42 raise
43 else:
44 raise ComponentError(self, "renderer")
45 except ComponentError:
46 return BlankFrame()
47
48 return renderWrapper
49
50 def commandWrapper(func):
51 """Intercepts the command() method to check for global args"""
52
53 def commandWrapper(self, arg):
54 if arg.startswith("preset="):
55 _, preset = arg.split("=", 1)
56 path = os.path.join(self.core.getPresetDir(self), preset)
57 if not os.path.exists(path):
58 print('Couldn\'t locate preset "%s"' % preset)
59 quit(1)
60 else:
61 print('Opening "%s" preset on layer %s' % (preset, self.compPos))
62 self.core.openPreset(path, self.compPos, preset)
63 # Don't call the component's command() method
64 return
65 else:
66 return func(self, arg)
67
68 return commandWrapper
69
70 def propertiesWrapper(func):
71 """Intercepts the usual properties if the properties are locked."""
72
73 def propertiesWrapper(self):
74 if self._lockedProperties is not None:
75 return self._lockedProperties
76 else:
77 try:
78 return func(self)
79 except Exception:
80 try:
81 raise ComponentError(self, "properties")
82 except ComponentError:
83 return []
84
85 return propertiesWrapper
86
87 def errorWrapper(func):
88 """Intercepts the usual error message if it is locked."""
89
90 def errorWrapper(self):
91 if self._lockedError is not None:
92 return self._lockedError
93 else:
94 return func(self)
95
96 return errorWrapper
97
98 def loadPresetWrapper(func):
99 """Wraps loadPreset to handle the self.openingPreset boolean"""
100
101 class openingPreset:
102 def __init__(self, comp):
103 self.comp = comp
104
105 def __enter__(self):
106 self.comp.openingPreset = True
107
108 def __exit__(self, *args):
109 self.comp.openingPreset = False
110
111 def presetWrapper(self, *args):
112 with openingPreset(self):
113 try:
114 return func(self, *args)
115 except Exception:
116 try:
117 raise ComponentError(self, "preset loader")
118 except ComponentError:
119 return
120
121 return presetWrapper
122
123 def updateWrapper(func):
124 """
125 Calls _preUpdate before every subclass update().
126 Afterwards, for non-user updates, calls _autoUpdate().
127 For undoable updates triggered by the user, calls _userUpdate()
128 """
129
130 class wrap:
131 def __init__(self, comp, auto):
132 self.comp = comp
133 self.auto = auto
134
135 def __enter__(self):
136 self.comp._preUpdate()
137
138 def __exit__(self, *args):
139 if (
140 self.auto
141 or self.comp.openingPreset
142 or not hasattr(self.comp.parent, "undoStack")
143 ):
144 log.verbose("Automatic update")
145 self.comp._autoUpdate()
146 else:
147 log.verbose("User update")
148 self.comp._userUpdate()
149
150 def updateWrapper(self, **kwargs):
151 auto = kwargs["auto"] if "auto" in kwargs else False
152 with wrap(self, auto):
153 try:
154 return func(self)
155 except Exception:
156 try:
157 raise ComponentError(self, "update method")
158 except ComponentError:
159 return
160
161 return updateWrapper
162
163 def widgetWrapper(func):
164 """Connects all widgets to update method after the subclass's method"""
165
166 class wrap:
167 def __init__(self, comp):
168 self.comp = comp
169
170 def __enter__(self):
171 pass
172
173 def __exit__(self, *args):
174 for widgetList in self.comp._allWidgets.values():
175 for widget in widgetList:
176 log.verbose("Connecting %s", str(widget.__class__.__name__))
177 connectWidget(widget, self.comp.update)
178
179 def widgetWrapper(self, *args, **kwargs):
180 auto = kwargs["auto"] if "auto" in kwargs else False
181 with wrap(self):
182 try:
183 return func(self, *args, **kwargs)
184 except Exception:
185 try:
186 raise ComponentError(self, "widget creation")
187 except ComponentError:
188 return
189
190 return widgetWrapper
191
192 def __new__(cls, name, parents, attrs):
193 if "ui" not in attrs:
194 # Use module name as ui filename by default
195 attrs["ui"] = (
196 "%s.ui" % os.path.splitext(attrs["__module__"].split(".")[-1])[0]
197 )
198
199 decorate = (
200 "names", # Class methods
201 "error",
202 "audio",
203 "properties", # Properties
204 "preFrameRender",
205 "previewRender",
206 "loadPreset",
207 "command",
208 "update",
209 "widget",
210 )
211
212 # Auto-decorate methods
213 for key in decorate:
214 if key not in attrs:
215 continue
216 if key in ("names"):
217 attrs[key] = classmethod(attrs[key])
218 elif key in ("audio"):
219 attrs[key] = property(attrs[key])
220 elif key == "command":
221 attrs[key] = cls.commandWrapper(attrs[key])
222 elif key == "previewRender":
223 attrs[key] = cls.renderWrapper(attrs[key])
224 elif key == "preFrameRender":
225 attrs[key] = cls.initializationWrapper(attrs[key])
226 elif key == "properties":
227 attrs[key] = cls.propertiesWrapper(attrs[key])
228 elif key == "error":
229 attrs[key] = cls.errorWrapper(attrs[key])
230 elif key == "loadPreset":
231 attrs[key] = cls.loadPresetWrapper(attrs[key])
232 elif key == "update":
233 attrs[key] = cls.updateWrapper(attrs[key])
234 elif key == "widget" and parents[0] != QtCore.QObject:
235 attrs[key] = cls.widgetWrapper(attrs[key])
236
237 # Turn version string into a number
238 try:
239 if "version" not in attrs:
240 log.error(
241 "No version attribute in %s. Defaulting to 1",
242 attrs["name"],
243 )
244 attrs["version"] = 1
245 else:
246 attrs["version"] = int(attrs["version"].split(".")[0])
247 except ValueError:
248 log.critical(
249 "%s component has an invalid version string:\n%s",
250 attrs["name"],
251 str(attrs["version"]),
252 )
253 except KeyError:
254 log.critical("%s component has no version string.", attrs["name"])
255 else:
256 return super().__new__(cls, name, parents, attrs)
257 quit(1)
diff --git a/src/avp/toolkit/ffmpeg.py b/src/avp/toolkit/ffmpeg.py
index 5aedff3..93aa725 100644
--- a/src/avp/toolkit/ffmpeg.py
+++ b/src/avp/toolkit/ffmpeg.py
@@ -11,7 +11,7 @@ import signal
11from queue import PriorityQueue 11from queue import PriorityQueue
12import logging 12import logging
13 13
14from .. import core 14from ..core import Core
15from .common import checkOutput, pipeWrapper 15from .common import checkOutput, pipeWrapper
16 16
17 17
@@ -19,7 +19,7 @@ log = logging.getLogger("AVP.Toolkit.Ffmpeg")
19 19
20 20
21class FfmpegVideo: 21class FfmpegVideo:
22 """Opens a pipe to ffmpeg and stores a buffer of raw video frames.""" 22 """Opens an input pipe to ffmpeg and stores a buffer of raw video frames."""
23 23
24 # error from the thread used to fill the buffer 24 # error from the thread used to fill the buffer
25 threadError = None 25 threadError = None
@@ -53,7 +53,7 @@ class FfmpegVideo:
53 kwargs["filter_"] = None 53 kwargs["filter_"] = None
54 54
55 self.command = [ 55 self.command = [
56 core.Core.FFMPEG_BIN, 56 Core.FFMPEG_BIN,
57 "-thread_queue_size", 57 "-thread_queue_size",
58 "512", 58 "512",
59 "-r", 59 "-r",
@@ -98,11 +98,11 @@ class FfmpegVideo:
98 self.frameBuffer.task_done() 98 self.frameBuffer.task_done()
99 99
100 def fillBuffer(self): 100 def fillBuffer(self):
101 from ..component import ComponentError 101 from ..libcomponent import ComponentError
102 102
103 if core.Core.logEnabled: 103 if Core.logEnabled:
104 logFilename = os.path.join( 104 logFilename = os.path.join(
105 core.Core.logDir, "render_%s.log" % str(self.component.compPos) 105 Core.logDir, "render_%s.log" % str(self.component.compPos)
106 ) 106 )
107 log.debug("Creating ffmpeg process (log at %s)", logFilename) 107 log.debug("Creating ffmpeg process (log at %s)", logFilename)
108 with open(logFilename, "w") as logf: 108 with open(logFilename, "w") as logf:
@@ -176,7 +176,7 @@ def findFfmpeg():
176 176
177 if getattr(sys, "frozen", False): 177 if getattr(sys, "frozen", False):
178 # The application is frozen 178 # The application is frozen
179 bin = os.path.join(core.Core.wd, bin) 179 bin = os.path.join(Core.wd, bin)
180 180
181 with open(os.devnull, "w") as f: 181 with open(os.devnull, "w") as f:
182 try: 182 try:
@@ -187,7 +187,9 @@ def findFfmpeg():
187 return bin 187 return bin
188 188
189 189
190def createFfmpegCommand(inputFile, outputFile, components, duration=-1): 190def createFfmpegCommand(
191 inputFile, outputFile, components, duration=-1, logLevel="info"
192):
191 """ 193 """
192 Constructs the major ffmpeg command used to export the video 194 Constructs the major ffmpeg command used to export the video
193 """ 195 """
@@ -195,7 +197,6 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
195 duration = getAudioDuration(inputFile) 197 duration = getAudioDuration(inputFile)
196 safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters 198 safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters
197 duration = "{0:.3f}".format(duration + 0.1) # used by input sources 199 duration = "{0:.3f}".format(duration + 0.1) # used by input sources
198 Core = core.Core
199 200
200 # Test if user has libfdk_aac 201 # Test if user has libfdk_aac
201 encoders = checkOutput("%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True) 202 encoders = checkOutput("%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True)
@@ -243,6 +244,8 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
243 244
244 ffmpegCommand = [ 245 ffmpegCommand = [
245 Core.FFMPEG_BIN, 246 Core.FFMPEG_BIN,
247 "-loglevel",
248 logLevel,
246 "-thread_queue_size", 249 "-thread_queue_size",
247 "512", 250 "512",
248 "-y", # overwrite the output file if it already exists. 251 "-y", # overwrite the output file if it already exists.
@@ -415,7 +418,7 @@ def createAudioFilterCommand(extraAudio, duration):
415def testAudioStream(filename): 418def testAudioStream(filename):
416 """Test if an audio stream definitely exists""" 419 """Test if an audio stream definitely exists"""
417 audioTestCommand = [ 420 audioTestCommand = [
418 core.Core.FFMPEG_BIN, 421 Core.FFMPEG_BIN,
419 "-i", 422 "-i",
420 filename, 423 filename,
421 "-vn", 424 "-vn",
@@ -433,7 +436,7 @@ def testAudioStream(filename):
433 436
434def getAudioDuration(filename): 437def getAudioDuration(filename):
435 """Try to get duration of audio file as float, or False if not possible""" 438 """Try to get duration of audio file as float, or False if not possible"""
436 command = [core.Core.FFMPEG_BIN, "-i", filename] 439 command = [Core.FFMPEG_BIN, "-i", filename]
437 440
438 try: 441 try:
439 fileInfo = checkOutput(command, stderr=subprocess.STDOUT) 442 fileInfo = checkOutput(command, stderr=subprocess.STDOUT)
@@ -473,7 +476,7 @@ def readAudioFile(filename, videoWorker):
473 return 476 return
474 477
475 command = [ 478 command = [
476 core.Core.FFMPEG_BIN, 479 Core.FFMPEG_BIN,
477 "-i", 480 "-i",
478 filename, 481 filename,
479 "-f", 482 "-f",
@@ -498,7 +501,7 @@ def readAudioFile(filename, videoWorker):
498 progress = 0 501 progress = 0
499 lastPercent = None 502 lastPercent = None
500 while True: 503 while True:
501 if core.Core.canceled: 504 if Core.canceled:
502 return 505 return
503 # read 2 seconds of audio 506 # read 2 seconds of audio
504 progress += 4 507 progress += 4
@@ -543,3 +546,18 @@ def exampleSound(style="white", extra="apulsator=offset_l=0.35:offset_r=0.67"):
543 src = "0.1*sin(2*PI*(360-2.5/2)*t) | 0.1*sin(2*PI*(360+2.5/2)*t)" 546 src = "0.1*sin(2*PI*(360-2.5/2)*t) | 0.1*sin(2*PI*(360+2.5/2)*t)"
544 547
545 return "aevalsrc='%s', %s%s" % (src, extra, ", " if extra else "") 548 return "aevalsrc='%s', %s%s" % (src, extra, ", " if extra else "")
549
550
551def checkFfmpegVersion():
552 try:
553 with open(os.devnull, "w") as f:
554 ffmpegVers = checkOutput([Core.FFMPEG_BIN, "-version"], stderr=f)
555 ffmpegVers = str(ffmpegVers).split()[2].split(".", 1)[0]
556 if ffmpegVers.startswith("n"):
557 ffmpegVers = ffmpegVers[1:]
558 versionNum = int(ffmpegVers)
559 goodVersion = versionNum > 3
560 except Exception:
561 versionNum = -1
562 goodVersion = False
563 return goodVersion, versionNum
diff --git a/src/avp/toolkit/visualizer.py b/src/avp/toolkit/visualizer.py
index c55a3f3..6477559 100644
--- a/src/avp/toolkit/visualizer.py
+++ b/src/avp/toolkit/visualizer.py
@@ -14,6 +14,7 @@ def createSpectrumArray(
14 progressBarUpdate, 14 progressBarUpdate,
15 progressBarSetText, 15 progressBarSetText,
16): 16):
17 lastProgress = 0
17 lastSpectrum = None 18 lastSpectrum = None
18 spectrumArray = {} 19 spectrumArray = {}
19 for i in range(0, len(completeAudioArray), sampleSize): 20 for i in range(0, len(completeAudioArray), sampleSize):
@@ -33,9 +34,12 @@ def createSpectrumArray(
33 progress = int(100 * (i / len(completeAudioArray))) 34 progress = int(100 * (i / len(completeAudioArray)))
34 if progress >= 100: 35 if progress >= 100:
35 progress = 100 36 progress = 100
37 if progress == lastProgress:
38 continue
36 progressText = f"Analyzing audio: {str(progress)}%" 39 progressText = f"Analyzing audio: {str(progress)}%"
37 progressBarSetText.emit(progressText) 40 progressBarSetText.emit(progressText)
38 progressBarUpdate.emit(int(progress)) 41 progressBarUpdate.emit(int(progress))
42 lastProgress = progress
39 return spectrumArray 43 return spectrumArray
40 44
41 45
diff --git a/src/avp/video_thread.py b/src/avp/video_thread.py
index 967d2fe..ecd8c4c 100644
--- a/src/avp/video_thread.py
+++ b/src/avp/video_thread.py
@@ -12,16 +12,16 @@ from PyQt6 import QtCore, QtGui
12from PyQt6.QtCore import pyqtSignal, pyqtSlot 12from PyQt6.QtCore import pyqtSignal, pyqtSlot
13from PIL import Image 13from PIL import Image
14from PIL.ImageQt import ImageQt 14from PIL.ImageQt import ImageQt
15
15import numpy 16import numpy
16import subprocess as sp 17import subprocess as sp
17import sys 18import sys
18import os 19import os
19import time
20import signal 20import signal
21import logging 21import logging
22 22
23from .component import ComponentError 23from .libcomponent import ComponentError
24from .toolkit.frame import Checkerboard 24from .toolkit import formatTraceback
25from .toolkit.ffmpeg import ( 25from .toolkit.ffmpeg import (
26 openPipe, 26 openPipe,
27 readAudioFile, 27 readAudioFile,
@@ -61,7 +61,11 @@ class Worker(QtCore.QObject):
61 def createFfmpegCommand(self, duration): 61 def createFfmpegCommand(self, duration):
62 try: 62 try:
63 ffmpegCommand = createFfmpegCommand( 63 ffmpegCommand = createFfmpegCommand(
64 self.inputFile, self.outputFile, self.components, duration 64 self.inputFile,
65 self.outputFile,
66 self.components,
67 duration,
68 "info" if log.getEffectiveLevel() < logging.WARNING else "error",
65 ) 69 )
66 except sp.CalledProcessError as e: 70 except sp.CalledProcessError as e:
67 # FIXME video_thread should own this error signal, not components 71 # FIXME video_thread should own this error signal, not components
@@ -111,6 +115,7 @@ class Worker(QtCore.QObject):
111 Also prerenders "static" components like text and merges them if possible 115 Also prerenders "static" components like text and merges them if possible
112 """ 116 """
113 self.staticComponents = {} 117 self.staticComponents = {}
118 self.compositeComponents = set()
114 119
115 # Call preFrameRender on each component 120 # Call preFrameRender on each component
116 canceledByComponent = False 121 canceledByComponent = False
@@ -160,6 +165,8 @@ class Worker(QtCore.QObject):
160 if "static" in compProps: 165 if "static" in compProps:
161 log.info("Saving static frame from #%s %s", compNo, comp) 166 log.info("Saving static frame from #%s %s", compNo, comp)
162 self.staticComponents[compNo] = comp.frameRender(0).copy() 167 self.staticComponents[compNo] = comp.frameRender(0).copy()
168 elif compNo > 0 and "composite" in compProps:
169 self.compositeComponents.add(compNo)
163 170
164 # Check if any errors occured 171 # Check if any errors occured
165 log.debug("Checking if a component wishes to cancel the export...") 172 log.debug("Checking if a component wishes to cancel the export...")
@@ -208,9 +215,11 @@ class Worker(QtCore.QObject):
208 self.closePipe() 215 self.closePipe()
209 self.cancelExport() 216 self.cancelExport()
210 self.error = True 217 self.error = True
211 msg = "A call to renderFrame in the video thread failed critically." 218 msg = f"{comp.name} renderFrame({int(audioI / self.sampleSize)}) raised an exception."
212 log.critical(msg) 219 tb = formatTraceback()
213 comp._error.emit(msg, str(e)) 220 details = f"{e.__class__.__name__}: {str(e)}\n\n{tb}"
221 log.critical(f"{msg}\n{details}")
222 comp._error.emit(msg, details)
214 223
215 bgI = int(audioI / self.sampleSize) 224 bgI = int(audioI / self.sampleSize)
216 frame = None 225 frame = None
@@ -230,6 +239,9 @@ class Worker(QtCore.QObject):
230 frame, self.staticComponents[layerNo] 239 frame, self.staticComponents[layerNo]
231 ) 240 )
232 241
242 elif layerNo in self.compositeComponents:
243 # component that uses previous frame to draw
244 frame = Image.alpha_composite(frame, comp.frameRender(bgI, frame))
233 else: 245 else:
234 # animated component 246 # animated component
235 if frame is None: # bottom-most layer 247 if frame is None: # bottom-most layer
@@ -309,9 +321,9 @@ class Worker(QtCore.QObject):
309 log.critical("Out_Pipe to FFmpeg couldn't be created!", exc_info=True) 321 log.critical("Out_Pipe to FFmpeg couldn't be created!", exc_info=True)
310 raise 322 raise
311 323
312 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 324 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
313 # START CREATING THE VIDEO 325 # START CREATING THE VIDEO
314 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 326 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
315 progressBarValue = 0 327 progressBarValue = 0
316 self.progressBarUpdate.emit(progressBarValue) 328 self.progressBarUpdate.emit(progressBarValue)
317 # Begin piping into ffmpeg! 329 # Begin piping into ffmpeg!
@@ -335,16 +347,13 @@ class Worker(QtCore.QObject):
335 completion = (audioI / self.audioArrayLen) * 100 347 completion = (audioI / self.audioArrayLen) * 100
336 if progressBarValue + 1 <= completion: 348 if progressBarValue + 1 <= completion:
337 progressBarValue = numpy.floor(completion).astype(int) 349 progressBarValue = numpy.floor(completion).astype(int)
350 msg = "Exporting video: %s%%" % str(int(progressBarValue))
338 self.progressBarUpdate.emit(progressBarValue) 351 self.progressBarUpdate.emit(progressBarValue)
339 self.progressBarSetText.emit( 352 self.progressBarSetText.emit(msg)
340 "Exporting video: %s%%" % str(int(progressBarValue))
341 )
342 353
343 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 354 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
344 # Finished creating the video! 355 # Finished creating the video!
345 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 356 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
346
347 numpy.seterr(all="print")
348 357
349 self.closePipe() 358 self.closePipe()
350 359
@@ -363,7 +372,7 @@ class Worker(QtCore.QObject):
363 if self.error: 372 if self.error:
364 self.failExport() 373 self.failExport()
365 else: 374 else:
366 print("Export Complete") 375 print("\nExport Complete")
367 self.progressBarUpdate.emit(100) 376 self.progressBarUpdate.emit(100)
368 self.progressBarSetText.emit("Export Complete") 377 self.progressBarSetText.emit("Export Complete")
369 378
diff --git a/tests/__init__.py b/tests/__init__.py
index bb35f72..b08a6bd 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,4 +1,5 @@
1import os 1import os
2import tempfile
2import numpy 3import numpy
3 4
4from avp.core import Core 5from avp.core import Core
@@ -8,10 +9,19 @@ from avp.toolkit.ffmpeg import readAudioFile
8from pytest import fixture 9from pytest import fixture
9 10
10 11
12PYTEST_XDIST_WORKER_COUNT = os.environ.get("PYTEST_XDIST_WORKER_COUNT", 0)
13
14
15@fixture
16def settings():
17 """Doesn't instantiate core: just calls a static method to store `settings.ini`"""
18 initCore()
19 yield None
20
21
11@fixture 22@fixture
12def audioData(): 23def audioData():
13 """Fixture that gives a tuple of (completeAudioArray, duration)""" 24 """Fixture that gives a tuple of (completeAudioArray, duration)"""
14 # Core.storeSettings() needed to store ffmpeg bin location
15 initCore() 25 initCore()
16 soundFile = getTestDataPath("inputfiles/test.ogg") 26 soundFile = getTestDataPath("inputfiles/test.ogg")
17 yield readAudioFile(soundFile, MockVideoWorker()) 27 yield readAudioFile(soundFile, MockVideoWorker())
@@ -28,6 +38,8 @@ def command(qtbot):
28@fixture 38@fixture
29def window(qtbot): 39def window(qtbot):
30 initCore() 40 initCore()
41 # patch out any modal dialog that might happen
42 MainWindow.showMessage = lambda self, msg, **kwargs: print(msg)
31 window = MainWindow(None, None) 43 window = MainWindow(None, None)
32 window.clear() 44 window.clear()
33 qtbot.addWidget(window) 45 qtbot.addWidget(window)
@@ -43,13 +55,41 @@ def getTestDataPath(filename=""):
43 55
44 56
45def initCore(): 57def initCore():
46 testDataDir = getTestDataPath("config") 58 """
59 Initializes the Core by creating `settings.ini`
60 Returns the temp directory path where settings.ini was created
61 or None if multiple pytest workers are not enabled.
62 """
63 try:
64 numWorkers = int(PYTEST_XDIST_WORKER_COUNT)
65 except ValueError:
66 numWorkers = 0
67 if numWorkers > 0:
68 # use temporary directories for multiple workers
69 # so they don't interfere with each other
70 configDir = tempfile.mkdtemp(prefix="avp-config-")
71 else:
72 # use test data path so we can easily see it after
73 # a failed test, and help us understand the config
74 configDir = getTestDataPath("config")
47 unwanted = ["autosave.avp", "settings.ini"] 75 unwanted = ["autosave.avp", "settings.ini"]
48 for file in unwanted: 76 for file in unwanted:
49 filename = os.path.join(testDataDir, "autosave.avp") 77 filename = os.path.join(configDir, "autosave.avp")
50 if os.path.exists(filename): 78 if os.path.exists(filename):
51 os.remove(filename) 79 os.remove(filename)
52 Core.storeSettings(testDataDir) 80 Core.storeSettings(configDir)
81 return configDir if numWorkers > 0 else None
82
83
84def preFrameRender(audioData, comp):
85 """Prepares a component for calls to frameRender()"""
86 comp.preFrameRender(
87 audioFile=getTestDataPath("inputfiles/test.ogg"),
88 completeAudioArray=audioData[0],
89 sampleSize=1470,
90 progressBarSetText=MockSignal(),
91 progressBarUpdate=MockSignal(),
92 )
53 93
54 94
55class MockSignal: 95class MockSignal:
diff --git a/tests/test_commandline_export.py b/tests/test_commandline_export.py
index 6d7f068..6eb533d 100644
--- a/tests/test_commandline_export.py
+++ b/tests/test_commandline_export.py
@@ -8,13 +8,14 @@ from pytestqt import qtbot
8def test_commandline_classic_export(qtbot, command): 8def test_commandline_classic_export(qtbot, command):
9 """Run Qt event loop and create a video in the system /tmp or /temp""" 9 """Run Qt event loop and create a video in the system /tmp or /temp"""
10 soundFile = getTestDataPath("inputfiles/test.ogg") 10 soundFile = getTestDataPath("inputfiles/test.ogg")
11 outputDir = tempfile.mkdtemp(prefix="avp-test-") 11 outputDir = tempfile.mkdtemp(prefix="avp-export-")
12 outputFilename = os.path.join(outputDir, "output.mp4") 12 outputFilename = os.path.join(outputDir, "output.mp4")
13 sys.argv = [ 13 sys.argv = [
14 "", 14 "",
15 "-c", 15 "-c",
16 "0", 16 "0",
17 "classic", 17 "classic",
18 "color=255,255,255",
18 "-i", 19 "-i",
19 soundFile, 20 soundFile,
20 "-o", 21 "-o",
diff --git a/tests/test_comp_classic.py b/tests/test_comp_classic.py
new file mode 100644
index 0000000..a942d89
--- /dev/null
+++ b/tests/test_comp_classic.py
@@ -0,0 +1,103 @@
1from avp.toolkit.visualizer import transformData
2from pytestqt import qtbot
3from pytest import fixture, mark
4from . import audioData, command, imageDataSum, preFrameRender
5
6
7sampleSize = 1470 # 44100 / 30 = 1470
8
9
10def createSpectrumArray(audioData):
11 """Creates enough `spectrumArray` for one call to Component.drawBars()"""
12 spectrumArray = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)}
13 for i in range(sampleSize, len(audioData[0]), sampleSize):
14 spectrumArray[i] = transformData(
15 i,
16 audioData[0],
17 sampleSize,
18 0.08,
19 0.8,
20 spectrumArray[i - sampleSize].copy(),
21 20,
22 )
23 return spectrumArray
24
25
26@fixture
27def coreWithClassicComp(qtbot, command):
28 """Fixture providing a Command object with Classic Visualizer component added"""
29 command.core.insertComponent(
30 0, command.core.moduleIndexFor("Classic Visualizer"), command
31 )
32 yield command.core
33
34
35def test_comp_classic_added(coreWithClassicComp):
36 """Add Classic Visualizer to core"""
37 assert len(coreWithClassicComp.selectedComponents) == 1
38
39
40def test_comp_classic_removed(coreWithClassicComp):
41 """Remove Classic Visualizer from core"""
42 coreWithClassicComp.removeComponent(0)
43 assert len(coreWithClassicComp.selectedComponents) == 0
44
45
46@mark.parametrize("layout", (0, 1, 2, 3))
47def test_comp_classic_drawBars(coreWithClassicComp, audioData, layout):
48 """Call drawBars after creating audio spectrum data manually."""
49 spectrumArray = createSpectrumArray(audioData)
50 comp = coreWithClassicComp.selectedComponents[0]
51 image = comp.drawBars(
52 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), layout, None
53 )
54 imageSize = 37872316
55 assert imageDataSum(image) == imageSize if layout < 2 else imageSize / 2
56
57
58def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData):
59 """Call drawBars after creating audio spectrum data using preFrameRender."""
60 comp = coreWithClassicComp.selectedComponents[0]
61 preFrameRender(audioData, comp)
62 image = comp.drawBars(
63 1920,
64 1080,
65 coreWithClassicComp.selectedComponents[0].spectrumArray[sampleSize * 4],
66 (0, 0, 0),
67 0,
68 None,
69 )
70 assert imageDataSum(image) == 37872316
71
72
73def test_comp_classic_command_layout(coreWithClassicComp):
74 comp = coreWithClassicComp.selectedComponents[0]
75 comp.command("layout=top")
76 assert comp.layout == 3
77
78
79def test_comp_classic_command_color(coreWithClassicComp):
80 comp = coreWithClassicComp.selectedComponents[0]
81 comp.command("color=111,111,111")
82 assert comp.visColor == (111, 111, 111)
83
84
85def test_comp_classic_command_preset(coreWithClassicComp):
86 comp = coreWithClassicComp.selectedComponents[0]
87 saveValueStore = comp.savePreset()
88 saveValueStore["preset"] = "testPreset"
89 coreWithClassicComp.createPresetFile(
90 comp.name, comp.version, "testPreset", saveValueStore
91 )
92 comp.command("preset=testPreset")
93 assert comp.currentPreset == "testPreset"
94
95
96def test_comp_classic_loadPreset(coreWithClassicComp):
97 comp = coreWithClassicComp.selectedComponents[0]
98 comp.scale = 99
99 saveValueStore = comp.savePreset()
100 saveValueStore["preset"] = "testPreset"
101 comp.scale = 20
102 comp.loadPreset(saveValueStore, "testPreset")
103 assert comp.scale == 99
diff --git a/tests/test_comp_color.py b/tests/test_comp_color.py
index 48b07ff..2aa1f2c 100644
--- a/tests/test_comp_color.py
+++ b/tests/test_comp_color.py
@@ -14,8 +14,18 @@ def coreWithColorComp(qtbot, command):
14 14
15 15
16def test_comp_color_set_color(coreWithColorComp): 16def test_comp_color_set_color(coreWithColorComp):
17 "Set imagePath of Image component" 17 """Set imagePath of Image component"""
18 comp = coreWithColorComp.selectedComponents[0] 18 comp = coreWithColorComp.selectedComponents[0]
19 comp.page.lineEdit_color1.setText("111,111,111") 19 comp.page.lineEdit_color1.setText("111,111,111")
20 image = comp.previewRender() 20 image = comp.previewRender()
21 assert imageDataSum(image) == 1219276800 21 assert imageDataSum(image) == 1219276800
22
23
24def test_comp_color_gradient(coreWithColorComp):
25 """Test changing fill type to a gradient"""
26 comp = coreWithColorComp.selectedComponents[0]
27 comp.page.comboBox_fill.setCurrentIndex(1)
28 comp.page.lineEdit_color1.setText("0,0,0")
29 comp.page.lineEdit_color2.setText("255,255,255")
30 image = comp.previewRender()
31 assert imageDataSum(image) == 1849285965
diff --git a/tests/test_comp_image.py b/tests/test_comp_image.py
index c580d5a..b221df4 100644
--- a/tests/test_comp_image.py
+++ b/tests/test_comp_image.py
@@ -1,3 +1,4 @@
1import os
1from avp.command import Command 2from avp.command import Command
2from pytestqt import qtbot 3from pytestqt import qtbot
3from pytest import fixture 4from pytest import fixture
@@ -5,6 +6,7 @@ from . import audioData, command, MockSignal, imageDataSum, getTestDataPath
5 6
6 7
7sampleSize = 1470 # 44100 / 30 = 1470 8sampleSize = 1470 # 44100 / 30 = 1470
9testFile = "inputfiles/test.jpg"
8 10
9 11
10@fixture 12@fixture
@@ -19,7 +21,7 @@ def coreWithImageComp(qtbot, command):
19def test_comp_image_set_path(coreWithImageComp): 21def test_comp_image_set_path(coreWithImageComp):
20 "Set imagePath of Image component" 22 "Set imagePath of Image component"
21 comp = coreWithImageComp.selectedComponents[0] 23 comp = coreWithImageComp.selectedComponents[0]
22 comp.imagePath = getTestDataPath("inputfiles/test.jpg") 24 comp.imagePath = getTestDataPath(testFile)
23 image = comp.previewRender() 25 image = comp.previewRender()
24 assert imageDataSum(image) == 463711601 26 assert imageDataSum(image) == 463711601
25 27
@@ -27,7 +29,7 @@ def test_comp_image_set_path(coreWithImageComp):
27def test_comp_image_scale_50_1080p(coreWithImageComp): 29def test_comp_image_scale_50_1080p(coreWithImageComp):
28 """Image component stretches image to 50% at 1080p""" 30 """Image component stretches image to 50% at 1080p"""
29 comp = coreWithImageComp.selectedComponents[0] 31 comp = coreWithImageComp.selectedComponents[0]
30 comp.imagePath = getTestDataPath("inputfiles/test.jpg") 32 comp.imagePath = getTestDataPath(testFile)
31 image = comp.previewRender() 33 image = comp.previewRender()
32 sum = imageDataSum(image) 34 sum = imageDataSum(image)
33 comp.page.spinBox_scale.setValue(50) 35 comp.page.spinBox_scale.setValue(50)
@@ -37,7 +39,7 @@ def test_comp_image_scale_50_1080p(coreWithImageComp):
37def test_comp_image_scale_50_720p(coreWithImageComp): 39def test_comp_image_scale_50_720p(coreWithImageComp):
38 """Image component stretches image to 50% at 720p""" 40 """Image component stretches image to 50% at 720p"""
39 comp = coreWithImageComp.selectedComponents[0] 41 comp = coreWithImageComp.selectedComponents[0]
40 comp.imagePath = getTestDataPath("inputfiles/test.jpg") 42 comp.imagePath = getTestDataPath(testFile)
41 comp.page.spinBox_scale.setValue(50) 43 comp.page.spinBox_scale.setValue(50)
42 image = comp.previewRender() 44 image = comp.previewRender()
43 sum = imageDataSum(image) 45 sum = imageDataSum(image)
@@ -47,3 +49,12 @@ def test_comp_image_scale_50_720p(coreWithImageComp):
47 assert image.width == 1920 49 assert image.width == 1920
48 assert newImage.width == 1280 50 assert newImage.width == 1280
49 assert imageDataSum(comp.previewRender()) == sum 51 assert imageDataSum(comp.previewRender()) == sum
52
53
54def test_comp_image_command_path(coreWithImageComp):
55 """Image component accepts commandline argument:
56 `path=test.jpg`"""
57 imgPath = os.path.realpath(getTestDataPath(testFile))
58 comp = coreWithImageComp.selectedComponents[0]
59 comp.command(f"path={imgPath}")
60 assert comp.imagePath == imgPath
diff --git a/tests/test_comp_original.py b/tests/test_comp_original.py
deleted file mode 100644
index 8cd00a4..0000000
--- a/tests/test_comp_original.py
+++ /dev/null
@@ -1,67 +0,0 @@
1from avp.command import Command
2from avp.toolkit.visualizer import transformData
3from pytestqt import qtbot
4from pytest import fixture
5from . import audioData, command, MockSignal, imageDataSum
6
7
8sampleSize = 1470 # 44100 / 30 = 1470
9
10
11@fixture
12def coreWithClassicComp(qtbot, command):
13 """Fixture providing a Command object with Classic Visualizer component added"""
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 = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)}
35 for i in range(sampleSize, len(audioData[0]), sampleSize):
36 spectrumArray[i] = transformData(
37 i,
38 audioData[0],
39 sampleSize,
40 0.08,
41 0.8,
42 spectrumArray[i - sampleSize].copy(),
43 20,
44 )
45 image = coreWithClassicComp.selectedComponents[0].drawBars(
46 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), 0
47 )
48 assert imageDataSum(image) == 37872316
49
50
51def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData):
52 """Call drawBars after creating audio spectrum data using preFrameRender."""
53 comp = coreWithClassicComp.selectedComponents[0]
54 comp.preFrameRender(
55 completeAudioArray=audioData[0],
56 sampleSize=sampleSize,
57 progressBarSetText=MockSignal(),
58 progressBarUpdate=MockSignal(),
59 )
60 image = comp.drawBars(
61 1920,
62 1080,
63 coreWithClassicComp.selectedComponents[0].spectrumArray[sampleSize * 4],
64 (0, 0, 0),
65 0,
66 )
67 assert imageDataSum(image) == 37872316
diff --git a/tests/test_comp_spectrum.py b/tests/test_comp_spectrum.py
index 870185c..5dd4e2d 100644
--- a/tests/test_comp_spectrum.py
+++ b/tests/test_comp_spectrum.py
@@ -1,7 +1,12 @@
1from avp.command import Command 1from avp.command import Command
2from pytestqt import qtbot 2from pytestqt import qtbot
3from pytest import fixture 3from pytest import fixture
4from . import imageDataSum, command 4from . import (
5 imageDataSum,
6 command,
7 preFrameRender,
8 audioData,
9)
5 10
6 11
7@fixture 12@fixture
@@ -13,7 +18,15 @@ def coreWithSpectrumComp(qtbot, command):
13 yield command.core 18 yield command.core
14 19
15 20
16def test_comp_waveform_previewRender(coreWithSpectrumComp): 21def test_comp_spectrum_previewRender(coreWithSpectrumComp):
17 comp = coreWithSpectrumComp.selectedComponents[0] 22 comp = coreWithSpectrumComp.selectedComponents[0]
18 image = comp.previewRender() 23 image = comp.previewRender()
19 assert imageDataSum(image) == 71992628 24 assert imageDataSum(image) == 71992628
25
26
27def test_comp_spectrum_renderFrame(coreWithSpectrumComp, audioData):
28 comp = coreWithSpectrumComp.selectedComponents[0]
29 preFrameRender(audioData, comp)
30 image = comp.frameRender(0)
31 comp.postFrameRender()
32 assert imageDataSum(image) == 117
diff --git a/tests/test_comp_waveform.py b/tests/test_comp_waveform.py
index eb5800d..d295dbe 100644
--- a/tests/test_comp_waveform.py
+++ b/tests/test_comp_waveform.py
@@ -1,11 +1,14 @@
1from pytestqt import qtbot 1from pytestqt import qtbot
2from pytest import fixture 2from pytest import fixture
3from . import command 3from avp.toolkit.ffmpeg import checkFfmpegVersion
4from . import command, imageDataSum, audioData, preFrameRender
4 5
5 6
6@fixture 7@fixture
7def coreWithWaveformComp(qtbot, command): 8def coreWithWaveformComp(qtbot, command):
8 """Fixture providing a Command object with Waveform component added""" 9 """Fixture providing a Command object with Waveform component added"""
10 command.settings.setValue("outputWidth", 1920)
11 command.settings.setValue("outputHeight", 1080)
9 command.core.insertComponent(0, command.core.moduleIndexFor("Waveform"), command) 12 command.core.insertComponent(0, command.core.moduleIndexFor("Waveform"), command)
10 yield command.core 13 yield command.core
11 14
@@ -14,3 +17,24 @@ def test_comp_waveform_setColor(coreWithWaveformComp):
14 comp = coreWithWaveformComp.selectedComponents[0] 17 comp = coreWithWaveformComp.selectedComponents[0]
15 comp.page.lineEdit_color.setText("255,255,255") 18 comp.page.lineEdit_color.setText("255,255,255")
16 assert comp.color == (255, 255, 255) 19 assert comp.color == (255, 255, 255)
20
21
22def test_comp_waveform_previewRender(coreWithWaveformComp):
23 comp = coreWithWaveformComp.selectedComponents[0]
24 comp.page.lineEdit_color.setText("255,255,255")
25 _, version = checkFfmpegVersion()
26 if version > 6:
27 # FFmpeg 8 has different colors from 6
28 # TODO check version 7
29 assert imageDataSum(comp.previewRender()) == 36114120
30 else:
31 assert imageDataSum(comp.previewRender()) == 37210620
32
33
34def test_comp_waveform_renderFrame(coreWithWaveformComp, audioData):
35 comp = coreWithWaveformComp.selectedComponents[0]
36 comp.page.lineEdit_color.setText("255,255,255")
37 preFrameRender(audioData, comp)
38 image = comp.frameRender(0)
39 comp.postFrameRender()
40 assert imageDataSum(image) == 8331360
diff --git a/tests/test_core_init.py b/tests/test_core_init.py
index e1f2dbb..30477ef 100644
--- a/tests/test_core_init.py
+++ b/tests/test_core_init.py
@@ -1,10 +1,9 @@
1import os 1import os
2from avp.core import Core 2from avp.core import Core
3from . import getTestDataPath, initCore 3from . import getTestDataPath, settings
4 4
5 5
6def test_component_names(): 6def test_component_names(settings):
7 initCore()
8 core = Core() 7 core = Core()
9 assert core.compNames == [ 8 assert core.compNames == [
10 "Classic Visualizer", 9 "Classic Visualizer",
@@ -19,8 +18,7 @@ def test_component_names():
19 ] 18 ]
20 19
21 20
22def test_moduleindex(): 21def test_moduleindex(settings):
23 initCore()
24 core = Core() 22 core = Core()
25 assert core.moduleIndexFor("Classic Visualizer") == 0 23 assert core.moduleIndexFor("Classic Visualizer") == 0
26 24
diff --git a/tests/test_mainwindow_undostack.py b/tests/test_mainwindow_comp_actions.py
index ceaf87e..5d3cc7a 100644
--- a/tests/test_mainwindow_undostack.py
+++ b/tests/test_mainwindow_comp_actions.py
@@ -1,3 +1,5 @@
1"""Tests of MainWindow undoing certain ComponentActions (changes to component settings)"""
2
1from pytest import fixture 3from pytest import fixture
2from pytestqt import qtbot 4from pytestqt import qtbot
3from avp.gui.mainwindow import MainWindow 5from avp.gui.mainwindow import MainWindow
diff --git a/tests/test_mainwindow_list_actions.py b/tests/test_mainwindow_list_actions.py
new file mode 100644
index 0000000..5f8bde4
--- /dev/null
+++ b/tests/test_mainwindow_list_actions.py
@@ -0,0 +1,52 @@
1"""Tests of `actions.py` - MainWindow component list manipulation via undoable actions"""
2
3from PyQt6 import QtCore
4import os
5from pytest import fixture
6from pytestqt import qtbot
7from . import getTestDataPath, window
8
9
10def test_mainwindow_addComponent(qtbot, window):
11 window.compMenu.actions()[0].trigger()
12 assert len(window.core.selectedComponents) == 1
13
14
15def test_mainwindow_removeComponent(qtbot, window):
16 window.compMenu.actions()[0].trigger() # add component
17 window.pushButton_removeComponent.click() # remove it
18 assert len(window.core.selectedComponents) == 0
19
20
21def test_mainwindow_moveComponent(qtbot, window):
22 # add first two components from menu
23 window.compMenu.actions()[0].trigger()
24 window.compMenu.actions()[1].trigger()
25 comp0 = window.core.selectedComponents[0].ui
26 window.pushButton_listMoveDown.click()
27 # check if 0 is now 1
28 assert window.core.selectedComponents[1].ui == comp0
29
30
31def test_mainwindow_addComponent_undo(qtbot, window):
32 window.compMenu.actions()[0].trigger()
33 window.undoStack.undo()
34 assert len(window.core.selectedComponents) == 0
35
36
37def test_mainwindow_removeComponent_undo(qtbot, window):
38 window.compMenu.actions()[0].trigger() # add component
39 window.pushButton_removeComponent.click() # remove it
40 window.undoStack.undo()
41 assert len(window.core.selectedComponents) == 1
42
43
44def test_mainwindow_moveComponent_undo(qtbot, window):
45 # add first two components from menu
46 window.compMenu.actions()[0].trigger()
47 window.compMenu.actions()[1].trigger()
48 comp0 = window.core.selectedComponents[0].ui
49 window.pushButton_listMoveDown.click()
50 window.undoStack.undo()
51 # check if 0 is still 0 after undo
52 assert window.core.selectedComponents[1].ui != comp0
diff --git a/tests/test_mainwindow_projects.py b/tests/test_mainwindow_projects.py
index 6b49799..a6df476 100644
--- a/tests/test_mainwindow_projects.py
+++ b/tests/test_mainwindow_projects.py
@@ -2,7 +2,15 @@ from PyQt6 import QtCore
2import os 2import os
3from pytest import fixture 3from pytest import fixture
4from pytestqt import qtbot 4from pytestqt import qtbot
5from . import getTestDataPath, window 5from avp.gui.mainwindow import MainWindow
6from . import getTestDataPath, window, settings
7
8
9def test_mainwindow_init_with_project(qtbot, settings):
10 window = MainWindow(getTestDataPath("config/projects/testproject.avp"), None)
11 qtbot.addWidget(window)
12 assert window.core.selectedComponents[0].name == "Classic Visualizer"
13 assert window.core.selectedComponents[1].name == "Color"
6 14
7 15
8def test_mainwindow_clear(qtbot, window): 16def test_mainwindow_clear(qtbot, window):
@@ -11,11 +19,8 @@ def test_mainwindow_clear(qtbot, window):
11 19
12 20
13def test_mainwindow_presetDir_in_tests(qtbot, window): 21def test_mainwindow_presetDir_in_tests(qtbot, window):
14 # FIXME presetDir gets set to projectDir for some reason 22 """`presetDir` is the filepath on which "Import Preset" file picker opens"""
15 assert ( 23 assert os.path.basename(window.core.settings.value("presetDir")) == "presets"
16 os.path.basename(os.path.dirname(window.core.settings.value("presetDir")))
17 == "config"
18 )
19 24
20 25
21def test_mainwindow_openProject(qtbot, window): 26def test_mainwindow_openProject(qtbot, window):
diff --git a/tests/test_toolkit_ffmpeg.py b/tests/test_toolkit_ffmpeg.py
index 363eba1..cc56495 100644
--- a/tests/test_toolkit_ffmpeg.py
+++ b/tests/test_toolkit_ffmpeg.py
@@ -1,8 +1,6 @@
1import pytest 1import pytest
2from avp.core import Core
3from avp.command import Command
4from avp.toolkit.ffmpeg import createFfmpegCommand 2from avp.toolkit.ffmpeg import createFfmpegCommand
5from . import audioData, getTestDataPath, initCore 3from . import audioData, getTestDataPath, command
6 4
7 5
8def test_readAudioFile_data(audioData): 6def test_readAudioFile_data(audioData):
@@ -14,14 +12,14 @@ def test_readAudioFile_duration(audioData):
14 12
15 13
16@pytest.mark.parametrize("width, height", ((1920, 1080), (1280, 720))) 14@pytest.mark.parametrize("width, height", ((1920, 1080), (1280, 720)))
17def test_createFfmpegCommand(width, height): 15def test_createFfmpegCommand(command, width, height):
18 initCore()
19 command = Command()
20 command.settings.setValue("outputWidth", width) 16 command.settings.setValue("outputWidth", width)
21 command.settings.setValue("outputHeight", height) 17 command.settings.setValue("outputHeight", height)
22 ffmpegCmd = createFfmpegCommand("test.ogg", "/tmp", command.core.selectedComponents) 18 ffmpegCmd = createFfmpegCommand("test.ogg", "/tmp", command.core.selectedComponents)
23 assert ffmpegCmd == [ 19 assert ffmpegCmd == [
24 "ffmpeg", 20 "ffmpeg",
21 "-loglevel",
22 "info",
25 "-thread_queue_size", 23 "-thread_queue_size",
26 "512", 24 "512",
27 "-y", 25 "-y",
diff --git a/uv.lock b/uv.lock
index e403a68..461e725 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4,7 +4,7 @@ requires-python = ">=3.12"
4 4
5[[package]] 5[[package]]
6name = "audio-visualizer-python" 6name = "audio-visualizer-python"
7version = "2.2.3" 7version = "2.2.4"
8source = { editable = "." } 8source = { editable = "." }
9dependencies = [ 9dependencies = [
10 { name = "numpy" }, 10 { name = "numpy" },