diff options
| author | Brianna Rainey | 2026-02-12 15:38:54 -0500 |
|---|---|---|
| committer | GitHub | 2026-02-12 15:38:54 -0500 |
| commit | f03a3a686c7304588dd434322c73506531e53595 (patch) | |
| tree | ee41d920873e9a77c41f4a65857af019e71a4754 | |
| parent | 48a9105eab94e64101470402427564203e1d8970 (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
43 files changed, 974 insertions, 676 deletions
| @@ -10,6 +10,7 @@ prof/ | |||
| 10 | .env/ | 10 | .env/ |
| 11 | .vscode/ | 11 | .vscode/ |
| 12 | tests/data/config/log/ | 12 | tests/data/config/log/ |
| 13 | tests/data/config/presets/ | ||
| 13 | tests/data/config/settings.ini | 14 | tests/data/config/settings.ini |
| 14 | tests/data/config/autosave.avp | 15 | tests/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" | |||
| 6 | name = "audio-visualizer-python" | 6 | name = "audio-visualizer-python" |
| 7 | description = "Create audio visualization videos from a GUI or commandline" | 7 | description = "Create audio visualization videos from a GUI or commandline" |
| 8 | readme = "README.md" | 8 | readme = "README.md" |
| 9 | version = "2.2.3" | 9 | version = "2.2.4" |
| 10 | requires-python = ">= 3.12" | 10 | requires-python = ">= 3.12" |
| 11 | license = "MIT" | 11 | license = "MIT" |
| 12 | classifiers=[ | 12 | classifiers=[ |
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 | |||
| 3 | import logging | 3 | import logging |
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | __version__ = "2.2.3" | 6 | __version__ = "2.2.4" |
| 7 | 7 | ||
| 8 | 8 | ||
| 9 | class Logger(logging.getLoggerClass()): | 9 | class 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 | |||
| 14 | import shutil | 14 | import shutil |
| 15 | import logging | 15 | import logging |
| 16 | 16 | ||
| 17 | from . import core, __version__ | 17 | from . import __version__ |
| 18 | from .core import Core | ||
| 18 | 19 | ||
| 19 | 20 | ||
| 20 | log = logging.getLogger("AVP.Commandline") | 21 | log = 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 @@ | |||
| 1 | import numpy | 1 | import numpy |
| 2 | from PIL import Image, ImageDraw | 2 | from PIL import Image, ImageDraw |
| 3 | from copy import copy | ||
| 4 | 3 | ||
| 5 | from ..component import Component | 4 | from ..libcomponent import BaseComponent |
| 6 | from ..toolkit.frame import BlankFrame | 5 | from ..toolkit.frame import BlankFrame, FloodFrame |
| 7 | from ..toolkit.visualizer import createSpectrumArray | 6 | from ..toolkit.visualizer import createSpectrumArray |
| 8 | 7 | ||
| 9 | 8 | ||
| 10 | class Component(Component): | 9 | class 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 @@ | |||
| 1 | from PyQt6 import QtGui | 1 | from PyQt6 import QtGui |
| 2 | import logging | 2 | import logging |
| 3 | 3 | ||
| 4 | from ..component import Component | 4 | from ..libcomponent import BaseComponent |
| 5 | from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter | 5 | from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter |
| 6 | 6 | ||
| 7 | 7 | ||
| 8 | log = logging.getLogger("AVP.Components.Color") | 8 | log = logging.getLogger("AVP.Components.Color") |
| 9 | 9 | ||
| 10 | 10 | ||
| 11 | class Component(Component): | 11 | class 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 @@ | |||
| 1 | from PIL import Image, ImageOps, ImageEnhance | 1 | from PIL import Image, ImageOps, ImageEnhance |
| 2 | from PyQt6 import QtWidgets | 2 | from PyQt6 import QtWidgets |
| 3 | import os | 3 | import os |
| 4 | from copy import copy | ||
| 5 | 4 | ||
| 6 | from ..component import Component | 5 | from ..libcomponent import BaseComponent |
| 7 | from ..toolkit.frame import BlankFrame, addShadow | 6 | from ..toolkit.frame import BlankFrame, addShadow |
| 8 | from ..toolkit.visualizer import createSpectrumArray | 7 | from ..toolkit.visualizer import createSpectrumArray |
| 9 | 8 | ||
| 10 | 9 | ||
| 11 | class Component(Component): | 10 | class 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 @@ | |||
| 1 | from PyQt6 import QtCore, QtWidgets | 1 | from PyQt6 import QtCore, QtWidgets |
| 2 | from PyQt6.QtGui import QUndoCommand | 2 | from PyQt6.QtGui import QUndoCommand |
| 3 | from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter, ImageOps | 3 | from PIL import Image, ImageDraw |
| 4 | import os | 4 | import os |
| 5 | from copy import copy | ||
| 6 | import math | 5 | import math |
| 7 | import logging | 6 | import logging |
| 8 | 7 | ||
| 9 | 8 | ||
| 10 | from ..component import Component | 9 | from ..libcomponent import BaseComponent |
| 11 | from ..toolkit.frame import BlankFrame, scale, addShadow | 10 | from ..toolkit.frame import BlankFrame, scale, addShadow |
| 12 | from ..toolkit.visualizer import createSpectrumArray | 11 | from ..toolkit.visualizer import createSpectrumArray |
| 13 | 12 | ||
| @@ -15,7 +14,7 @@ from ..toolkit.visualizer import createSpectrumArray | |||
| 15 | log = logging.getLogger("AVP.Component.Life") | 14 | log = logging.getLogger("AVP.Component.Life") |
| 16 | 15 | ||
| 17 | 16 | ||
| 18 | class Component(Component): | 17 | class 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 @@ | |||
| 1 | from PyQt6 import QtGui, QtCore, QtWidgets | 1 | from PyQt6 import QtWidgets |
| 2 | import os | 2 | import os |
| 3 | 3 | ||
| 4 | from ..component import Component | 4 | from ..libcomponent import BaseComponent |
| 5 | from ..toolkit.frame import BlankFrame | 5 | from ..toolkit.frame import BlankFrame |
| 6 | 6 | ||
| 7 | 7 | ||
| 8 | class Component(Component): | 8 | class 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 @@ | |||
| 1 | from PIL import Image | 1 | from PIL import Image |
| 2 | from PyQt6 import QtGui, QtCore, QtWidgets | ||
| 3 | import os | 2 | import os |
| 4 | import math | ||
| 5 | import subprocess | 3 | import subprocess |
| 6 | import time | ||
| 7 | import logging | 4 | import logging |
| 8 | 5 | ||
| 9 | from ..component import Component | 6 | from ..libcomponent import BaseComponent |
| 10 | from ..toolkit.frame import BlankFrame, scale | 7 | from ..toolkit.frame import BlankFrame, scale |
| 11 | from ..toolkit import checkOutput, connectWidget | 8 | from ..toolkit import connectWidget |
| 12 | from ..toolkit.ffmpeg import ( | 9 | from ..toolkit.ffmpeg import ( |
| 13 | openPipe, | 10 | openPipe, |
| 14 | closePipe, | 11 | closePipe, |
| @@ -21,7 +18,7 @@ from ..toolkit.ffmpeg import ( | |||
| 21 | log = logging.getLogger("AVP.Components.Spectrum") | 18 | log = logging.getLogger("AVP.Components.Spectrum") |
| 22 | 19 | ||
| 23 | 20 | ||
| 24 | class Component(Component): | 21 | class 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 @@ | |||
| 1 | from PIL import ImageEnhance, ImageFilter, ImageChops | 1 | from PyQt6.QtGui import QFont |
| 2 | from PyQt6.QtGui import QColor, QFont | 2 | from PyQt6 import QtGui, QtCore |
| 3 | from PyQt6 import QtGui, QtCore, QtWidgets | ||
| 4 | import os | ||
| 5 | import logging | 3 | import logging |
| 6 | 4 | ||
| 7 | from ..component import Component | 5 | from ..libcomponent import BaseComponent |
| 8 | from ..toolkit.frame import FramePainter, addShadow | 6 | from ..toolkit.frame import FramePainter, addShadow |
| 9 | 7 | ||
| 10 | log = logging.getLogger("AVP.Components.Text") | 8 | log = logging.getLogger("AVP.Components.Text") |
| 11 | 9 | ||
| 12 | 10 | ||
| 13 | class Component(Component): | 11 | class 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 @@ | |||
| 1 | from PIL import Image | 1 | from PIL import Image |
| 2 | from PyQt6 import QtGui, QtCore, QtWidgets | 2 | from PyQt6 import QtWidgets |
| 3 | import os | 3 | import os |
| 4 | import math | ||
| 5 | import subprocess | 4 | import subprocess |
| 6 | import logging | 5 | import logging |
| 7 | 6 | ||
| 8 | from ..component import Component | 7 | from ..libcomponent import BaseComponent |
| 9 | from ..toolkit.frame import BlankFrame, scale | 8 | from ..toolkit.frame import BlankFrame, scale |
| 10 | from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo | 9 | from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo |
| 11 | from ..toolkit import checkOutput | ||
| 12 | 10 | ||
| 13 | 11 | ||
| 14 | log = logging.getLogger("AVP.Components.Video") | 12 | log = logging.getLogger("AVP.Components.Video") |
| 15 | 13 | ||
| 16 | 14 | ||
| 17 | class Component(Component): | 15 | class 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 | |||
| 3 | import os | 3 | import os |
| 4 | import subprocess | 4 | import subprocess |
| 5 | import logging | 5 | import logging |
| 6 | from copy import copy | ||
| 7 | 6 | ||
| 8 | from ..component import Component | 7 | from ..libcomponent import BaseComponent |
| 9 | from ..toolkit.visualizer import transformData, createSpectrumArray | 8 | from ..toolkit.visualizer import createSpectrumArray |
| 10 | from ..toolkit.frame import BlankFrame, scale | 9 | from ..toolkit.frame import BlankFrame, scale |
| 11 | from ..toolkit import checkOutput | ||
| 12 | from ..toolkit.ffmpeg import ( | 10 | from ..toolkit.ffmpeg import ( |
| 13 | openPipe, | 11 | openPipe, |
| 14 | closePipe, | 12 | closePipe, |
| @@ -21,7 +19,7 @@ from ..toolkit.ffmpeg import ( | |||
| 21 | log = logging.getLogger("AVP.Components.Waveform") | 19 | log = logging.getLogger("AVP.Components.Waveform") |
| 22 | 20 | ||
| 23 | 21 | ||
| 24 | class Component(Component): | 22 | class 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 | ||
| 15 | appName = "Audio Visualizer Python" | 15 | appName = "Audio Visualizer Python" |
| 16 | log = logging.getLogger("AVP.Core") | 16 | log = logging.getLogger("AVP.Core") |
| 17 | STDOUT_LOGLVL = logging.WARNING | ||
| 18 | 17 | ||
| 19 | 18 | ||
| 20 | class Core: | 19 | class 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 | """ |
| 2 | QCommand classes for every undoable user action performed in the MainWindow | 2 | QUndoCommand classes for every undoable user action performed in the MainWindow |
| 3 | """ | 3 | """ |
| 4 | 4 | ||
| 5 | from PyQt6.QtGui import QUndoCommand | 5 | from PyQt6.QtGui import QUndoCommand |
| @@ -13,9 +13,9 @@ from ..core import Core | |||
| 13 | log = logging.getLogger("AVP.Gui.Actions") | 13 | log = logging.getLogger("AVP.Gui.Actions") |
| 14 | 14 | ||
| 15 | 15 | ||
| 16 | # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ | 16 | # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ |
| 17 | # COMPONENT ACTIONS | 17 | # COMPONENT ACTIONS |
| 18 | # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ | 18 | # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ |
| 19 | 19 | ||
| 20 | 20 | ||
| 21 | class AddComponent(QUndoCommand): | 21 | class 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 | ||
| 115 | class ClearPreset(QUndoCommand): | 115 | class 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 | |||
| 25 | from .preview_win import PreviewWindow | 25 | from .preview_win import PreviewWindow |
| 26 | from .presetmanager import PresetManager | 26 | from .presetmanager import PresetManager |
| 27 | from .actions import * | 27 | from .actions import * |
| 28 | from ..toolkit.ffmpeg import createFfmpegCommand | 28 | from ..toolkit.ffmpeg import createFfmpegCommand, checkFfmpegVersion |
| 29 | from ..toolkit import ( | 29 | from ..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 @@ | |||
| 1 | from .component import Component as BaseComponent | ||
| 2 | from .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 | """ | ||
| 2 | QUndoCommand class for generic undoable user actions performed to a BaseComponent | ||
| 3 | |||
| 4 | See `../life.py` for an example of a component that uses a custom QUndoCommand | ||
| 5 | """ | ||
| 6 | |||
| 7 | from PyQt6.QtGui import QUndoCommand | ||
| 8 | from copy import copy | ||
| 9 | import logging | ||
| 10 | |||
| 11 | log = logging.getLogger("AVP.ComponentHandler") | ||
| 12 | |||
| 13 | |||
| 14 | class 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 | ||
| 6 | from PyQt6 import uic, QtCore, QtWidgets | 6 | from PyQt6 import uic, QtCore, QtWidgets |
| 7 | from PyQt6.QtGui import QColor, QUndoCommand | 7 | from PyQt6.QtGui import QColor |
| 8 | import os | 8 | import os |
| 9 | import sys | ||
| 10 | import math | 9 | import math |
| 11 | import time | ||
| 12 | import logging | 10 | import logging |
| 13 | from copy import copy | 11 | from copy import copy |
| 14 | 12 | ||
| 15 | from .toolkit.frame import BlankFrame | 13 | from .metaclass import ComponentMetaclass |
| 16 | from .toolkit import ( | 14 | from .actions import ComponentUpdate |
| 15 | from .exceptions import ComponentError | ||
| 16 | from ..toolkit.frame import BlankFrame | ||
| 17 | |||
| 18 | from ..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 | 26 | log = logging.getLogger("AVP.BaseComponent") | |
| 26 | log = logging.getLogger("AVP.ComponentHandler") | ||
| 27 | |||
| 28 | |||
| 29 | class 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 | ||
| 277 | class Component(QtCore.QObject, metaclass=ComponentMetaclass): | 29 | class 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 | |||
| 831 | class 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 | |||
| 888 | class 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 @@ | |||
| 1 | import time | ||
| 2 | import sys | ||
| 3 | import logging | ||
| 4 | |||
| 5 | from ..toolkit import formatTraceback | ||
| 6 | |||
| 7 | |||
| 8 | log = logging.getLogger("AVP.ComponentHandler") | ||
| 9 | |||
| 10 | |||
| 11 | class 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 @@ | |||
| 1 | import os | ||
| 2 | import logging | ||
| 3 | from PyQt6 import QtCore | ||
| 4 | |||
| 5 | from .exceptions import ComponentError | ||
| 6 | from ..toolkit import connectWidget | ||
| 7 | from ..toolkit.frame import BlankFrame | ||
| 8 | |||
| 9 | log = logging.getLogger("AVP.ComponentHandler") | ||
| 10 | |||
| 11 | |||
| 12 | class 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 | |||
| 11 | from queue import PriorityQueue | 11 | from queue import PriorityQueue |
| 12 | import logging | 12 | import logging |
| 13 | 13 | ||
| 14 | from .. import core | 14 | from ..core import Core |
| 15 | from .common import checkOutput, pipeWrapper | 15 | from .common import checkOutput, pipeWrapper |
| 16 | 16 | ||
| 17 | 17 | ||
| @@ -19,7 +19,7 @@ log = logging.getLogger("AVP.Toolkit.Ffmpeg") | |||
| 19 | 19 | ||
| 20 | 20 | ||
| 21 | class FfmpegVideo: | 21 | class 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 | ||
| 190 | def createFfmpegCommand(inputFile, outputFile, components, duration=-1): | 190 | def 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): | |||
| 415 | def testAudioStream(filename): | 418 | def 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 | ||
| 434 | def getAudioDuration(filename): | 437 | def 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 | |||
| 551 | def 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 | |||
| 12 | from PyQt6.QtCore import pyqtSignal, pyqtSlot | 12 | from PyQt6.QtCore import pyqtSignal, pyqtSlot |
| 13 | from PIL import Image | 13 | from PIL import Image |
| 14 | from PIL.ImageQt import ImageQt | 14 | from PIL.ImageQt import ImageQt |
| 15 | |||
| 15 | import numpy | 16 | import numpy |
| 16 | import subprocess as sp | 17 | import subprocess as sp |
| 17 | import sys | 18 | import sys |
| 18 | import os | 19 | import os |
| 19 | import time | ||
| 20 | import signal | 20 | import signal |
| 21 | import logging | 21 | import logging |
| 22 | 22 | ||
| 23 | from .component import ComponentError | 23 | from .libcomponent import ComponentError |
| 24 | from .toolkit.frame import Checkerboard | 24 | from .toolkit import formatTraceback |
| 25 | from .toolkit.ffmpeg import ( | 25 | from .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 @@ | |||
| 1 | import os | 1 | import os |
| 2 | import tempfile | ||
| 2 | import numpy | 3 | import numpy |
| 3 | 4 | ||
| 4 | from avp.core import Core | 5 | from avp.core import Core |
| @@ -8,10 +9,19 @@ from avp.toolkit.ffmpeg import readAudioFile | |||
| 8 | from pytest import fixture | 9 | from pytest import fixture |
| 9 | 10 | ||
| 10 | 11 | ||
| 12 | PYTEST_XDIST_WORKER_COUNT = os.environ.get("PYTEST_XDIST_WORKER_COUNT", 0) | ||
| 13 | |||
| 14 | |||
| 15 | @fixture | ||
| 16 | def 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 |
| 12 | def audioData(): | 23 | def 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 |
| 29 | def window(qtbot): | 39 | def 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 | ||
| 45 | def initCore(): | 57 | def 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 | |||
| 84 | def 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 | ||
| 55 | class MockSignal: | 95 | class 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 | |||
| 8 | def test_commandline_classic_export(qtbot, command): | 8 | def 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 @@ | |||
| 1 | from avp.toolkit.visualizer import transformData | ||
| 2 | from pytestqt import qtbot | ||
| 3 | from pytest import fixture, mark | ||
| 4 | from . import audioData, command, imageDataSum, preFrameRender | ||
| 5 | |||
| 6 | |||
| 7 | sampleSize = 1470 # 44100 / 30 = 1470 | ||
| 8 | |||
| 9 | |||
| 10 | def 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 | ||
| 27 | def 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 | |||
| 35 | def test_comp_classic_added(coreWithClassicComp): | ||
| 36 | """Add Classic Visualizer to core""" | ||
| 37 | assert len(coreWithClassicComp.selectedComponents) == 1 | ||
| 38 | |||
| 39 | |||
| 40 | def 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)) | ||
| 47 | def 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 | |||
| 58 | def 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 | |||
| 73 | def test_comp_classic_command_layout(coreWithClassicComp): | ||
| 74 | comp = coreWithClassicComp.selectedComponents[0] | ||
| 75 | comp.command("layout=top") | ||
| 76 | assert comp.layout == 3 | ||
| 77 | |||
| 78 | |||
| 79 | def 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 | |||
| 85 | def 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 | |||
| 96 | def 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 | ||
| 16 | def test_comp_color_set_color(coreWithColorComp): | 16 | def 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 | |||
| 24 | def 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 @@ | |||
| 1 | import os | ||
| 1 | from avp.command import Command | 2 | from avp.command import Command |
| 2 | from pytestqt import qtbot | 3 | from pytestqt import qtbot |
| 3 | from pytest import fixture | 4 | from pytest import fixture |
| @@ -5,6 +6,7 @@ from . import audioData, command, MockSignal, imageDataSum, getTestDataPath | |||
| 5 | 6 | ||
| 6 | 7 | ||
| 7 | sampleSize = 1470 # 44100 / 30 = 1470 | 8 | sampleSize = 1470 # 44100 / 30 = 1470 |
| 9 | testFile = "inputfiles/test.jpg" | ||
| 8 | 10 | ||
| 9 | 11 | ||
| 10 | @fixture | 12 | @fixture |
| @@ -19,7 +21,7 @@ def coreWithImageComp(qtbot, command): | |||
| 19 | def test_comp_image_set_path(coreWithImageComp): | 21 | def 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): | |||
| 27 | def test_comp_image_scale_50_1080p(coreWithImageComp): | 29 | def 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): | |||
| 37 | def test_comp_image_scale_50_720p(coreWithImageComp): | 39 | def 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 | |||
| 54 | def 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 @@ | |||
| 1 | from avp.command import Command | ||
| 2 | from avp.toolkit.visualizer import transformData | ||
| 3 | from pytestqt import qtbot | ||
| 4 | from pytest import fixture | ||
| 5 | from . import audioData, command, MockSignal, imageDataSum | ||
| 6 | |||
| 7 | |||
| 8 | sampleSize = 1470 # 44100 / 30 = 1470 | ||
| 9 | |||
| 10 | |||
| 11 | @fixture | ||
| 12 | def 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 | |||
| 20 | def test_comp_classic_added(coreWithClassicComp): | ||
| 21 | """Add Classic Visualizer to core""" | ||
| 22 | assert len(coreWithClassicComp.selectedComponents) == 1 | ||
| 23 | |||
| 24 | |||
| 25 | def test_comp_classic_removed(coreWithClassicComp): | ||
| 26 | """Remove Classic Visualizer from core""" | ||
| 27 | coreWithClassicComp.removeComponent(0) | ||
| 28 | assert len(coreWithClassicComp.selectedComponents) == 0 | ||
| 29 | |||
| 30 | |||
| 31 | def test_comp_classic_drawBars(coreWithClassicComp, audioData): | ||
| 32 | """Call drawBars after creating audio spectrum data manually.""" | ||
| 33 | |||
| 34 | spectrumArray = {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 | |||
| 51 | def 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 @@ | |||
| 1 | from avp.command import Command | 1 | from avp.command import Command |
| 2 | from pytestqt import qtbot | 2 | from pytestqt import qtbot |
| 3 | from pytest import fixture | 3 | from pytest import fixture |
| 4 | from . import imageDataSum, command | 4 | from . 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 | ||
| 16 | def test_comp_waveform_previewRender(coreWithSpectrumComp): | 21 | def 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 | |||
| 27 | def 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 @@ | |||
| 1 | from pytestqt import qtbot | 1 | from pytestqt import qtbot |
| 2 | from pytest import fixture | 2 | from pytest import fixture |
| 3 | from . import command | 3 | from avp.toolkit.ffmpeg import checkFfmpegVersion |
| 4 | from . import command, imageDataSum, audioData, preFrameRender | ||
| 4 | 5 | ||
| 5 | 6 | ||
| 6 | @fixture | 7 | @fixture |
| 7 | def coreWithWaveformComp(qtbot, command): | 8 | def 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 | |||
| 22 | def 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 | |||
| 34 | def 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 @@ | |||
| 1 | import os | 1 | import os |
| 2 | from avp.core import Core | 2 | from avp.core import Core |
| 3 | from . import getTestDataPath, initCore | 3 | from . import getTestDataPath, settings |
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | def test_component_names(): | 6 | def 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 | ||
| 22 | def test_moduleindex(): | 21 | def 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 | |||
| 1 | from pytest import fixture | 3 | from pytest import fixture |
| 2 | from pytestqt import qtbot | 4 | from pytestqt import qtbot |
| 3 | from avp.gui.mainwindow import MainWindow | 5 | from 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 | |||
| 3 | from PyQt6 import QtCore | ||
| 4 | import os | ||
| 5 | from pytest import fixture | ||
| 6 | from pytestqt import qtbot | ||
| 7 | from . import getTestDataPath, window | ||
| 8 | |||
| 9 | |||
| 10 | def test_mainwindow_addComponent(qtbot, window): | ||
| 11 | window.compMenu.actions()[0].trigger() | ||
| 12 | assert len(window.core.selectedComponents) == 1 | ||
| 13 | |||
| 14 | |||
| 15 | def 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 | |||
| 21 | def 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 | |||
| 31 | def 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 | |||
| 37 | def 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 | |||
| 44 | def 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 | |||
| 2 | import os | 2 | import os |
| 3 | from pytest import fixture | 3 | from pytest import fixture |
| 4 | from pytestqt import qtbot | 4 | from pytestqt import qtbot |
| 5 | from . import getTestDataPath, window | 5 | from avp.gui.mainwindow import MainWindow |
| 6 | from . import getTestDataPath, window, settings | ||
| 7 | |||
| 8 | |||
| 9 | def 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 | ||
| 8 | def test_mainwindow_clear(qtbot, window): | 16 | def test_mainwindow_clear(qtbot, window): |
| @@ -11,11 +19,8 @@ def test_mainwindow_clear(qtbot, window): | |||
| 11 | 19 | ||
| 12 | 20 | ||
| 13 | def test_mainwindow_presetDir_in_tests(qtbot, window): | 21 | def 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 | ||
| 21 | def test_mainwindow_openProject(qtbot, window): | 26 | def 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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from avp.core import Core | ||
| 3 | from avp.command import Command | ||
| 4 | from avp.toolkit.ffmpeg import createFfmpegCommand | 2 | from avp.toolkit.ffmpeg import createFfmpegCommand |
| 5 | from . import audioData, getTestDataPath, initCore | 3 | from . import audioData, getTestDataPath, command |
| 6 | 4 | ||
| 7 | 5 | ||
| 8 | def test_readAudioFile_data(audioData): | 6 | def 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))) |
| 17 | def test_createFfmpegCommand(width, height): | 15 | def 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", |
| @@ -4,7 +4,7 @@ requires-python = ">=3.12" | |||
| 4 | 4 | ||
| 5 | [[package]] | 5 | [[package]] |
| 6 | name = "audio-visualizer-python" | 6 | name = "audio-visualizer-python" |
| 7 | version = "2.2.3" | 7 | version = "2.2.4" |
| 8 | source = { editable = "." } | 8 | source = { editable = "." } |
| 9 | dependencies = [ | 9 | dependencies = [ |
| 10 | { name = "numpy" }, | 10 | { name = "numpy" }, |
