From f03a3a686c7304588dd434322c73506531e53595 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Thu, 12 Feb 2026 15:38:54 -0500 Subject: 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--- .gitignore | 1 + pyproject.toml | 2 +- src/avp/__init__.py | 2 +- src/avp/cli.py | 4 +- src/avp/command.py | 50 +- src/avp/component.py | 978 ---------------------------------- src/avp/components/classic.py | 210 ++++++++ src/avp/components/classic.ui | 274 ++++++++++ src/avp/components/color.py | 4 +- src/avp/components/color.ui | 3 + src/avp/components/image.py | 16 +- src/avp/components/life.py | 7 +- src/avp/components/original.py | 185 ------- src/avp/components/original.ui | 267 ---------- src/avp/components/sound.py | 6 +- src/avp/components/spectrum.py | 9 +- src/avp/components/text.py | 10 +- src/avp/components/video.py | 8 +- src/avp/components/waveform.py | 8 +- src/avp/core.py | 12 +- src/avp/gui/actions.py | 10 +- src/avp/gui/mainwindow.py | 46 +- src/avp/gui/presetmanager.py | 4 +- src/avp/gui/preview_thread.py | 16 +- src/avp/libcomponent/__init__.py | 4 + src/avp/libcomponent/actions.py | 104 ++++ src/avp/libcomponent/component.py | 583 ++++++++++++++++++++ src/avp/libcomponent/exceptions.py | 63 +++ src/avp/libcomponent/metaclass.py | 257 +++++++++ src/avp/toolkit/ffmpeg.py | 44 +- src/avp/toolkit/visualizer.py | 4 + src/avp/video_thread.py | 43 +- tests/__init__.py | 48 +- tests/test_commandline_export.py | 3 +- tests/test_comp_classic.py | 103 ++++ tests/test_comp_color.py | 12 +- tests/test_comp_image.py | 17 +- tests/test_comp_original.py | 67 --- tests/test_comp_spectrum.py | 17 +- tests/test_comp_waveform.py | 26 +- tests/test_core_init.py | 8 +- tests/test_mainwindow_comp_actions.py | 66 +++ tests/test_mainwindow_list_actions.py | 52 ++ tests/test_mainwindow_projects.py | 17 +- tests/test_mainwindow_undostack.py | 64 --- tests/test_toolkit_ffmpeg.py | 10 +- uv.lock | 2 +- 47 files changed, 2022 insertions(+), 1724 deletions(-) delete mode 100644 src/avp/component.py create mode 100644 src/avp/components/classic.py create mode 100644 src/avp/components/classic.ui delete mode 100644 src/avp/components/original.py delete mode 100644 src/avp/components/original.ui create mode 100644 src/avp/libcomponent/__init__.py create mode 100644 src/avp/libcomponent/actions.py create mode 100644 src/avp/libcomponent/component.py create mode 100644 src/avp/libcomponent/exceptions.py create mode 100644 src/avp/libcomponent/metaclass.py create mode 100644 tests/test_comp_classic.py delete mode 100644 tests/test_comp_original.py create mode 100644 tests/test_mainwindow_comp_actions.py create mode 100644 tests/test_mainwindow_list_actions.py delete mode 100644 tests/test_mainwindow_undostack.py diff --git a/.gitignore b/.gitignore index 5f7cabb..68c7fcb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ prof/ .env/ .vscode/ tests/data/config/log/ +tests/data/config/presets/ tests/data/config/settings.ini tests/data/config/autosave.avp *.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" name = "audio-visualizer-python" description = "Create audio visualization videos from a GUI or commandline" readme = "README.md" -version = "2.2.3" +version = "2.2.4" requires-python = ">= 3.12" license = "MIT" 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 import logging -__version__ = "2.2.3" +__version__ = "2.2.4" 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: screen = app.primaryScreen() if screen is None: dpi = None - log.error("Could not detect DPI") else: dpi = screen.physicalDotsPerInchX() - log.info("Detected screen DPI: %s", dpi) # Launch program if mode == "commandline": @@ -53,6 +51,8 @@ def main() -> int: mode = main.parseArgs() log.debug("Finished creating command object") + log.info(f"QApplication Platform: {QApplication.platformName()}") + log.info(f"Detected screen DPI: {dpi}") # Both branches here may occur in one execution: # Commandline parsing could change mode back to GUI 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 import shutil import logging -from . import core, __version__ +from . import __version__ +from .core import Core log = logging.getLogger("AVP.Commandline") @@ -29,11 +30,11 @@ class Command(QtCore.QObject): def __init__(self): super().__init__() - self.core = core.Core() - core.Core.mode = "commandline" + self.core = Core() + Core.mode = "commandline" self.dataDir = self.core.dataDir self.canceled = False - self.settings = core.Core.settings + self.settings = Core.settings # ctrl-c stops the export thread signal.signal(signal.SIGINT, self.stopVideo) @@ -71,9 +72,10 @@ class Command(QtCore.QObject): help="copy and shorten recent log files into ~/avp_log.txt", ) debugCommands.add_argument( - "--verbose", "-v", + "--verbose", + "-v", action="store_true", - help="create bigger logfiles while program is running", + help="send log messages and ffmpeg output to stdout, and create more verbose log files (good to use before --log)", ) # project/GUI options @@ -101,8 +103,8 @@ class Command(QtCore.QObject): args = parser.parse_args() if args.verbose: - core.STDOUT_LOGLVL = logging.DEBUG - core.Core.makeLogger(deleteOldLogs=False, fileLogLvl=logging.DEBUG) + Core.stdoutLogLvl = logging.DEBUG + Core.makeLogger(deleteOldLogs=False, fileLogLvl=logging.DEBUG) if args.log: self.createLogFile() @@ -168,7 +170,7 @@ class Command(QtCore.QObject): return "commandline" elif args.no_preview: - core.Core.previewEnabled = False + Core.previewEnabled = False elif ( args.projpath is None @@ -203,20 +205,18 @@ class Command(QtCore.QObject): @QtCore.pyqtSlot(str) def progressBarSetText(self, value): - if "Export " in value: - # Don't duplicate completion/failure messages + if "Export " in value or time.time() - self.lastProgressUpdate < 0.1: + # Don't duplicate completion/failure messages or send too many messages return - if ( - not value.startswith("Exporting") - and time.time() - self.lastProgressUpdate >= 0.05 - ): + + if not value.endswith("%"): # Show most messages very often print(value) - elif time.time() - self.lastProgressUpdate >= 2.0: - # Give user time to read ffmpeg's output during the export - print("##### %s" % value) - else: - return + elif log.getEffectiveLevel() > logging.INFO: + # if ffmpeg isn't printing export progress for us, + # then overwrite previous message with the next one + # if this text is our main export progress + print(f"{value}\r", end="") self.lastProgressUpdate = time.time() @QtCore.pyqtSlot() @@ -224,6 +224,7 @@ class Command(QtCore.QObject): self.quit(0) def quit(self, code): + print() quit(code) def showMessage(self, **kwargs): @@ -242,12 +243,14 @@ class Command(QtCore.QObject): def parseCompName(self, name): """Deduces a proper component name out of a commandline arg""" - if name.title() in self.core.compNames: return name.title() for compName in self.core.compNames: if name.capitalize() in compName: return compName + for altName, moduleIndex in self.core.altCompNames: + if name.title() in altName: + return self.core.compNames[moduleIndex] compFileNames = [ os.path.splitext(os.path.basename(mod.__file__))[0] @@ -281,16 +284,17 @@ class Command(QtCore.QObject): print("Log file could not be created (too many exist).") return try: - shutil.copy(os.path.join(core.Core.logDir, "avp_debug.log"), filename) + shutil.copy(os.path.join(Core.logDir, "avp_debug.log"), filename) with open(filename, "a") as f: f.write(f"{'='*60} debug log ends {'='*60}\n") except FileNotFoundError: + print("No debug log was found. Run `avp --verbose` before `avp --log`.") with open(filename, "w") as f: f.write(f"{'='*60} no debug log {'='*60}\n") def concatenateLogs(logPattern): nonlocal filename - renderLogs = glob.glob(os.path.join(core.Core.logDir, logPattern)) + renderLogs = glob.glob(os.path.join(Core.logDir, logPattern)) with open(filename, "a") as fw: for renderLog in renderLogs: with open(renderLog, "r") as fr: diff --git a/src/avp/component.py b/src/avp/component.py deleted file mode 100644 index 5906ab1..0000000 --- a/src/avp/component.py +++ /dev/null @@ -1,978 +0,0 @@ -""" -Base classes for components to import. Read comments for some documentation -on making a valid component. -""" - -from PyQt6 import uic, QtCore, QtWidgets -from PyQt6.QtGui import QColor, QUndoCommand -import os -import sys -import math -import time -import logging -from copy import copy - -from .toolkit.frame import BlankFrame -from .toolkit import ( - getWidgetValue, - setWidgetValue, - connectWidget, - rgbFromString, - randomColor, - blockSignals, -) - - -log = logging.getLogger("AVP.ComponentHandler") - - -class ComponentMetaclass(type(QtCore.QObject)): - """ - Checks the validity of each Component class and mutates some attrs. - E.g., takes only major version from version string & decorates methods - """ - - def initializationWrapper(func): - def initializationWrapper(self, *args, **kwargs): - try: - return func(self, *args, **kwargs) - except Exception: - try: - raise ComponentError(self, "initialization process") - except ComponentError: - return - - return initializationWrapper - - def renderWrapper(func): - def renderWrapper(self, *args, **kwargs): - try: - log.verbose( - "### %s #%s renders a preview frame ###", - self.__class__.name, - str(self.compPos), - ) - return func(self, *args, **kwargs) - except Exception as e: - try: - if e.__class__.__name__.startswith("Component"): - raise - else: - raise ComponentError(self, "renderer") - except ComponentError: - return BlankFrame() - - return renderWrapper - - def commandWrapper(func): - """Intercepts the command() method to check for global args""" - - def commandWrapper(self, arg): - if arg.startswith("preset="): - _, preset = arg.split("=", 1) - path = os.path.join(self.core.getPresetDir(self), preset) - if not os.path.exists(path): - print('Couldn\'t locate preset "%s"' % preset) - quit(1) - else: - print('Opening "%s" preset on layer %s' % (preset, self.compPos)) - self.core.openPreset(path, self.compPos, preset) - # Don't call the component's command() method - return - else: - return func(self, arg) - - return commandWrapper - - def propertiesWrapper(func): - """Intercepts the usual properties if the properties are locked.""" - - def propertiesWrapper(self): - if self._lockedProperties is not None: - return self._lockedProperties - else: - try: - return func(self) - except Exception: - try: - raise ComponentError(self, "properties") - except ComponentError: - return [] - - return propertiesWrapper - - def errorWrapper(func): - """Intercepts the usual error message if it is locked.""" - - def errorWrapper(self): - if self._lockedError is not None: - return self._lockedError - else: - return func(self) - - return errorWrapper - - def loadPresetWrapper(func): - """Wraps loadPreset to handle the self.openingPreset boolean""" - - class openingPreset: - def __init__(self, comp): - self.comp = comp - - def __enter__(self): - self.comp.openingPreset = True - - def __exit__(self, *args): - self.comp.openingPreset = False - - def presetWrapper(self, *args): - with openingPreset(self): - try: - return func(self, *args) - except Exception: - try: - raise ComponentError(self, "preset loader") - except ComponentError: - return - - return presetWrapper - - def updateWrapper(func): - """ - Calls _preUpdate before every subclass update(). - Afterwards, for non-user updates, calls _autoUpdate(). - For undoable updates triggered by the user, calls _userUpdate() - """ - - class wrap: - def __init__(self, comp, auto): - self.comp = comp - self.auto = auto - - def __enter__(self): - self.comp._preUpdate() - - def __exit__(self, *args): - if ( - self.auto - or self.comp.openingPreset - or not hasattr(self.comp.parent, "undoStack") - ): - log.verbose("Automatic update") - self.comp._autoUpdate() - else: - log.verbose("User update") - self.comp._userUpdate() - - def updateWrapper(self, **kwargs): - auto = kwargs["auto"] if "auto" in kwargs else False - with wrap(self, auto): - try: - return func(self) - except Exception: - try: - raise ComponentError(self, "update method") - except ComponentError: - return - - return updateWrapper - - def widgetWrapper(func): - """Connects all widgets to update method after the subclass's method""" - - class wrap: - def __init__(self, comp): - self.comp = comp - - def __enter__(self): - pass - - def __exit__(self, *args): - for widgetList in self.comp._allWidgets.values(): - for widget in widgetList: - log.verbose("Connecting %s", str(widget.__class__.__name__)) - connectWidget(widget, self.comp.update) - - def widgetWrapper(self, *args, **kwargs): - auto = kwargs["auto"] if "auto" in kwargs else False - with wrap(self): - try: - return func(self, *args, **kwargs) - except Exception: - try: - raise ComponentError(self, "widget creation") - except ComponentError: - return - - return widgetWrapper - - def __new__(cls, name, parents, attrs): - if "ui" not in attrs: - # Use module name as ui filename by default - attrs["ui"] = ( - "%s.ui" % os.path.splitext(attrs["__module__"].split(".")[-1])[0] - ) - - decorate = ( - "names", # Class methods - "error", - "audio", - "properties", # Properties - "preFrameRender", - "previewRender", - "loadPreset", - "command", - "update", - "widget", - ) - - # Auto-decorate methods - for key in decorate: - if key not in attrs: - continue - if key in ("names"): - attrs[key] = classmethod(attrs[key]) - elif key in ("audio"): - attrs[key] = property(attrs[key]) - elif key == "command": - attrs[key] = cls.commandWrapper(attrs[key]) - elif key == "previewRender": - attrs[key] = cls.renderWrapper(attrs[key]) - elif key == "preFrameRender": - attrs[key] = cls.initializationWrapper(attrs[key]) - elif key == "properties": - attrs[key] = cls.propertiesWrapper(attrs[key]) - elif key == "error": - attrs[key] = cls.errorWrapper(attrs[key]) - elif key == "loadPreset": - attrs[key] = cls.loadPresetWrapper(attrs[key]) - elif key == "update": - attrs[key] = cls.updateWrapper(attrs[key]) - elif key == "widget" and parents[0] != QtCore.QObject: - attrs[key] = cls.widgetWrapper(attrs[key]) - - # Turn version string into a number - try: - if "version" not in attrs: - log.error( - "No version attribute in %s. Defaulting to 1", - attrs["name"], - ) - attrs["version"] = 1 - else: - attrs["version"] = int(attrs["version"].split(".")[0]) - except ValueError: - log.critical( - "%s component has an invalid version string:\n%s", - attrs["name"], - str(attrs["version"]), - ) - except KeyError: - log.critical("%s component has no version string.", attrs["name"]) - else: - return super().__new__(cls, name, parents, attrs) - quit(1) - - -class Component(QtCore.QObject, metaclass=ComponentMetaclass): - """ - The base class for components to inherit. - """ - - name = "Component" - # ui = 'name_Of_Non_Default_Ui_File' - - version = "1.0.0" - # The major version (before the first dot) is used to determine - # preset compatibility; the rest is ignored so it can be non-numeric. - - modified = QtCore.pyqtSignal(int, dict) - _error = QtCore.pyqtSignal(str, str) - - def __init__(self, moduleIndex, compPos, core): - super().__init__() - self.moduleIndex = moduleIndex - self.compPos = compPos - self.core = core - - # STATUS VARIABLES - self.currentPreset = None - self._allWidgets = {} - self._trackedWidgets = {} - self._presetNames = {} - self._commandArgs = {} - self._colorWidgets = {} - self._colorFuncs = {} - self._relativeWidgets = {} - # Pixel values stored as floats - self._relativeValues = {} - # Maximum values of spinBoxes at 1080p (Core.resolutions[0]) - self._relativeMaximums = {} - - # LOCKING VARIABLES - self.openingPreset = False - self.mergeUndo = True - self._lockedProperties = None - self._lockedError = None - self._lockedSize = None - # If set to a dict, values are used as basis to update relative widgets - self.oldAttrs = None - # Stop lengthy processes in response to this variable - self.canceled = False - - def __str__(self): - return self.__class__.name - - def __repr__(self): - import pprint - - try: - preset = self.savePreset() - except Exception as e: - preset = "%s occurred while saving preset" % str(e) - - return "Component(module %s, pos %s) (%s)\n" "Name: %s v%s\nPreset: %s" % ( - self.moduleIndex, - self.compPos, - object.__repr__(self), - self.__class__.name, - str(self.__class__.version), - pprint.pformat(preset), - ) - - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - # Render Methods - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - - def previewRender(self): - image = BlankFrame(self.width, self.height) - return image - - def preFrameRender(self, **kwargs): - """ - Must call super() when subclassing - Triggered only before a video is exported (video_thread.py) - self.audioFile = filepath to the main input audio file - self.completeAudioArray = a list of audio samples - self.sampleSize = number of audio samples per video frame - self.progressBarUpdate = signal to set progress bar number - self.progressBarSetText = signal to set progress bar text - Use the latter two signals to update the MainWindow if needed - for a long initialization procedure (i.e., for a visualizer) - """ - for key, value in kwargs.items(): - setattr(self, key, value) - - def frameRender(self, frameNo): - audioArrayIndex = frameNo * self.sampleSize - image = BlankFrame(self.width, self.height) - return image - - def postFrameRender(self): - pass - - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - # Properties - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - - def properties(self): - """ - Return a list of properties to signify if your component is - non-animated ('static'), returns sound ('audio'), or has - encountered an error in configuration ('error'). - """ - return [] - - def error(self): - """ - Return a string containing an error message, or None for a default. - Or tuple of two strings for a message with details. - Alternatively use lockError(msgString) within properties() - to skip this method entirely. - """ - return - - def audio(self): - """ - Return audio to mix into master as a tuple with two elements: - The first element can be: - - A string (path to audio file), - - Or an object that returns audio data through a pipe - The second element must be a dictionary of ffmpeg filters/options - to apply to the input stream. See the filter docs for ideas: - https://ffmpeg.org/ffmpeg-filters.html - """ - - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - # Idle Methods - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - - def widget(self, parent): - """ - Call super().widget(*args) to create the component widget - which also auto-connects any common widgets (e.g., checkBoxes) - to self.update(). Then in a subclass connect special actions - (e.g., pushButtons to select a file) and initialize - """ - self.parent = parent - self.settings = parent.settings - log.verbose( - "Creating UI for %s #%s's widget", - self.__class__.name, - self.compPos, - ) - self.page = self.loadUi(self.__class__.ui) - - # Find all normal widgets which will be connected after subclass method - self._allWidgets = { - "lineEdit": self.page.findChildren(QtWidgets.QLineEdit), - "checkBox": self.page.findChildren(QtWidgets.QCheckBox), - "spinBox": self.page.findChildren(QtWidgets.QSpinBox), - "comboBox": self.page.findChildren(QtWidgets.QComboBox), - } - self._allWidgets["spinBox"].extend( - self.page.findChildren(QtWidgets.QDoubleSpinBox) - ) - - def update(self): - """ - Starting point for a component update. A subclass should override - this method, and the base class will then magically insert a call - to either _autoUpdate() or _userUpdate() at the end. - """ - - def loadPreset(self, presetDict, presetName=None): - """ - Subclasses should take (presetDict, *args) as args. - Must use super().loadPreset(presetDict, *args) first, - then update self.page widgets using the preset dict. - """ - self.currentPreset = ( - presetName if presetName is not None else presetDict["preset"] - ) - for attr, widget in self._trackedWidgets.items(): - key = attr if attr not in self._presetNames else self._presetNames[attr] - try: - val = presetDict[key] - except KeyError as e: - log.info( - "%s missing value %s. Outdated preset?", - self.currentPreset, - str(e), - ) - val = getattr(self, key) - - if attr in self._colorWidgets: - widget.setText("%s,%s,%s" % val) - btnStyle = ( - "QPushButton { background-color : %s; outline: none; }" - % QColor(*val).name() - ) - self._colorWidgets[attr].setStyleSheet(btnStyle) - elif attr in self._relativeWidgets: - self._relativeValues[attr] = val - pixelVal = self.pixelValForAttr(attr, val) - setWidgetValue(widget, pixelVal) - else: - setWidgetValue(widget, val) - - def savePreset(self): - saveValueStore = {} - for attr, widget in self._trackedWidgets.items(): - presetAttrName = ( - attr if attr not in self._presetNames else self._presetNames[attr] - ) - if attr in self._relativeWidgets: - try: - val = self._relativeValues[attr] - except AttributeError: - val = self.floatValForAttr(attr) - else: - val = getattr(self, attr) - - saveValueStore[presetAttrName] = val - return saveValueStore - - def commandHelp(self): - """Help text as string for this component's commandline arguments""" - - def command(self, arg=""): - """ - Configure a component using an arg from the commandline. This is - never called if global args like 'preset=' are found in the arg. - So simply check for any non-global args in your component and - call super().command() at the end to get a Help message. - """ - print( - self.__class__.name, - "Usage:\n" "Open a preset for this component:\n" ' "preset=Preset Name"', - ) - self.commandHelp() - quit(0) - - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - # "Private" Methods - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - def _preUpdate(self): - """Happens before subclass update()""" - for attr in self._relativeWidgets: - self.updateRelativeWidget(attr) - - def _userUpdate(self): - """Happens after subclass update() for an undoable update by user.""" - oldWidgetVals = { - attr: copy(getattr(self, attr)) for attr in self._trackedWidgets - } - newWidgetVals = { - attr: ( - getWidgetValue(widget) - if attr not in self._colorWidgets - else rgbFromString(widget.text()) - ) - for attr, widget in self._trackedWidgets.items() - } - modifiedWidgets = { - attr: val - for attr, val in newWidgetVals.items() - if val != oldWidgetVals[attr] - } - if modifiedWidgets: - action = ComponentUpdate(self, oldWidgetVals, modifiedWidgets) - self.parent.undoStack.push(action) - - def _autoUpdate(self): - """Happens after subclass update() for an internal component update.""" - newWidgetVals = { - attr: getWidgetValue(widget) - for attr, widget in self._trackedWidgets.items() - } - self.setAttrs(newWidgetVals) - self._sendUpdateSignal() - - def setAttrs(self, attrDict): - """ - Sets attrs (linked to trackedWidgets) in this component to - the values in the attrDict. Mutates certain widget values if needed - """ - for attr, val in attrDict.items(): - if attr in self._colorWidgets: - # Color Widgets must have a tuple & have a button to update - if type(val) is tuple: - rgbTuple = val - else: - rgbTuple = rgbFromString(val) - btnStyle = ( - "QPushButton { background-color : %s; outline: none; }" - % QColor(*rgbTuple).name() - ) - self._colorWidgets[attr].setStyleSheet(btnStyle) - setattr(self, attr, rgbTuple) - - else: - # Normal tracked widget - setattr(self, attr, val) - log.verbose("Setting %s self.%s to %s" % (self.__class__.name, attr, val)) - - def setWidgetValues(self, attrDict): - """ - Sets widgets defined by keys in trackedWidgets in this preset to - the values in the attrDict. - """ - affectedWidgets = [self._trackedWidgets[attr] for attr in attrDict] - with blockSignals(affectedWidgets): - for attr, val in attrDict.items(): - widget = self._trackedWidgets[attr] - if attr in self._colorWidgets: - val = "%s,%s,%s" % val - setWidgetValue(widget, val) - - def _sendUpdateSignal(self): - if not self.core.openingProject: - self.parent.drawPreview() - saveValueStore = self.savePreset() - saveValueStore["preset"] = self.currentPreset - self.modified.emit(self.compPos, saveValueStore) - - def trackWidgets(self, trackDict, **kwargs): - """ - Name widgets to track in update(), savePreset(), loadPreset(), and - command(). Requires a dict of attr names as keys, widgets as values - - Optional args: - 'presetNames': preset variable names to replace attr names - 'commandArgs': arg keywords that differ from attr names - 'colorWidgets': identify attr as RGB tuple & update button CSS - 'relativeWidgets': change value proportionally to resolution - - NOTE: Any kwarg key set to None will selectively disable tracking. - """ - self._trackedWidgets = trackDict - for kwarg in kwargs: - try: - if kwarg in ( - "presetNames", - "commandArgs", - "colorWidgets", - "relativeWidgets", - ): - setattr(self, "_{}".format(kwarg), kwargs[kwarg]) - else: - raise ComponentError(self, "Nonsensical keywords to trackWidgets.") - except ComponentError: - continue - - if kwarg == "colorWidgets": - - def makeColorFunc(attr): - def pickColor_(): - self.mergeUndo = False - self.pickColor( - self._trackedWidgets[attr], - self._colorWidgets[attr], - ) - self.mergeUndo = True - - return pickColor_ - - self._colorFuncs = {attr: makeColorFunc(attr) for attr in kwargs[kwarg]} - for attr, func in self._colorFuncs.items(): - colorText = self._trackedWidgets[attr].text() - if colorText == "": - rndColor = randomColor() - self._trackedWidgets[attr].setText(str(rndColor)[1:-1]) - self._colorWidgets[attr].clicked.connect(func) - self._colorWidgets[attr].setStyleSheet( - "QPushButton {" - "background-color : %s; outline: none; }" - % QColor( - *rgbFromString(colorText) if colorText else rndColor - ).name() - ) - - if kwarg == "relativeWidgets": - # store maximum values of spinBoxes to be scaled appropriately - for attr in kwargs[kwarg]: - self._relativeMaximums[attr] = self._trackedWidgets[attr].maximum() - self.updateRelativeWidgetMaximum(attr) - setattr(self, attr, getWidgetValue(self._trackedWidgets[attr])) - - self._preUpdate() - self._autoUpdate() - - def pickColor(self, textWidget, button): - """Use color picker to get color input from the user.""" - dialog = QtWidgets.QColorDialog() - # TODO alpha channel is not actually shown in most color picker widgets? - dialog.setOption( - QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel, True - ) - color = dialog.getColor() - if color.isValid(): - RGBstring = "%s,%s,%s" % ( - str(color.red()), - str(color.green()), - str(color.blue()), - ) - btnStyle = ( - "QPushButton{background-color: %s; outline: none;}" % color.name() - ) - textWidget.setText(RGBstring) - button.setStyleSheet(btnStyle) - - def lockProperties(self, propList): - self._lockedProperties = propList - - def lockError(self, msg): - self._lockedError = msg - - def lockSize(self, w, h): - self._lockedSize = (w, h) - - def unlockProperties(self): - self._lockedProperties = None - - def unlockError(self): - self._lockedError = None - - def unlockSize(self): - self._lockedSize = None - - def loadUi(self, filename): - """Load a Qt Designer ui file to use for this component's widget""" - return uic.loadUi(os.path.join(self.core.componentsPath, filename)) - - @property - def width(self): - if self._lockedSize is None: - return int(self.settings.value("outputWidth")) - else: - return self._lockedSize[0] - - @property - def height(self): - if self._lockedSize is None: - return int(self.settings.value("outputHeight")) - else: - return self._lockedSize[1] - - def cancel(self): - """Stop any lengthy process in response to this variable.""" - self.canceled = True - - def reset(self): - self.canceled = False - self.unlockProperties() - self.unlockError() - - def relativeWidgetAxis(func): - def relativeWidgetAxis(self, attr, *args, **kwargs): - hasVerticalWords = ( - lambda attr: "height" in attr.lower() - or "ypos" in attr.lower() - or attr == "y" - ) - if "axis" not in kwargs: - axis = self.width - if hasVerticalWords(attr): - axis = self.height - kwargs["axis"] = axis - if "axis" in kwargs and type(kwargs["axis"]) is tuple: - axis = kwargs["axis"][0] - if hasVerticalWords(attr): - axis = kwargs["axis"][1] - kwargs["axis"] = axis - return func(self, attr, *args, **kwargs) - - return relativeWidgetAxis - - @relativeWidgetAxis - def pixelValForAttr(self, attr, val=None, **kwargs): - if val is None: - val = self._relativeValues[attr] - if val > 50.0: - log.warning( - "%s #%s attempted to set %s to dangerously high number %s", - self.__class__.name, - self.compPos, - attr, - val, - ) - val = 50.0 - result = math.ceil(kwargs["axis"] * val) - log.verbose( - "Converting %s: f%s to px%s using axis %s", - attr, - val, - result, - kwargs["axis"], - ) - return result - - @relativeWidgetAxis - def floatValForAttr(self, attr, val=None, **kwargs): - if val is None: - val = self._trackedWidgets[attr].value() - return val / kwargs["axis"] - - def setRelativeWidget(self, attr, floatVal): - """Set a relative widget using a float""" - pixelVal = self.pixelValForAttr(attr, floatVal) - with blockSignals(self._trackedWidgets[attr]): - self._trackedWidgets[attr].setValue(pixelVal) - self.update(auto=True) - - def getOldAttr(self, attr): - """ - Returns previous state of this attr. Used to determine whether - a relative widget must be updated. Required because undoing/redoing - can make determining the 'previous' value tricky. - """ - if self.oldAttrs is not None: - return self.oldAttrs[attr] - else: - try: - return getattr(self, attr) - except AttributeError: - log.error("Using visible values instead of oldAttrs") - return self._trackedWidgets[attr].value() - - def updateRelativeWidget(self, attr): - """Called by _preUpdate() for each relativeWidget before each update""" - oldUserValue = self.getOldAttr(attr) - newUserValue = self._trackedWidgets[attr].value() - newRelativeVal = self.floatValForAttr(attr, newUserValue) - - if attr in self._relativeValues: - oldRelativeVal = self._relativeValues[attr] - if oldUserValue == newUserValue and oldRelativeVal != newRelativeVal: - # Float changed without pixel value changing, which - # means the pixel value needs to be updated - # TODO QDoubleSpinBox doesn't work with relativeWidgets because of this - log.debug( - "Updating %s #%s's relative widget: %s", - self.__class__.name, - self.compPos, - attr, - ) - with blockSignals(self._trackedWidgets[attr]): - self.updateRelativeWidgetMaximum(attr) - pixelVal = self.pixelValForAttr(attr, oldRelativeVal) - self._trackedWidgets[attr].setValue(pixelVal) - - if attr not in self._relativeValues or oldUserValue != newUserValue: - self._relativeValues[attr] = newRelativeVal - - def updateRelativeWidgetMaximum(self, attr): - maxRes = int(self.core.resolutions[0].split("x")[0]) - newMaximumValue = self.width * (self._relativeMaximums[attr] / maxRes) - self._trackedWidgets[attr].setMaximum(int(newMaximumValue)) - - -class ComponentError(RuntimeError): - """Gives the MainWindow a traceback to display, and cancels the export.""" - - prevErrors = [] - lastTime = time.time() - - def __init__(self, caller, name, msg=None): - if msg is None and sys.exc_info()[0] is not None: - msg = str(sys.exc_info()[1]) - else: - msg = "Unknown error." - log.error("ComponentError by %s's %s: %s" % (caller.name, name, msg)) - - # Don't create multiple windows for quickly repeated messages - if len(ComponentError.prevErrors) > 1: - ComponentError.prevErrors.pop() - ComponentError.prevErrors.insert(0, name) - curTime = time.time() - if ( - name in ComponentError.prevErrors[1:] - and curTime - ComponentError.lastTime < 1.0 - ): - return - ComponentError.lastTime = time.time() - - from .toolkit import formatTraceback - - if sys.exc_info()[0] is not None: - string = "%s component (#%s): %s encountered %s %s: %s" % ( - caller.__class__.name, - str(caller.compPos), - name, - ( - "an" - if any( - [ - sys.exc_info()[0].__name__.startswith(vowel) - for vowel in ("A", "I", "U", "O", "E") - ] - ) - else "a" - ), - sys.exc_info()[0].__name__, - str(sys.exc_info()[1]), - ) - detail = formatTraceback(sys.exc_info()[2]) - else: - string = name - detail = "Attributes:\n%s" % ( - "\n".join([m for m in dir(caller) if not m.startswith("_")]) - ) - - super().__init__(string) - caller.lockError(string) - caller._error.emit(string, detail) - - -class ComponentUpdate(QUndoCommand): - """Command object for making a component action undoable""" - - def __init__(self, parent, oldWidgetVals, modifiedVals): - super().__init__("change %s component #%s" % (parent.name, parent.compPos)) - self.undone = False - self.res = (int(parent.width), int(parent.height)) - self.parent = parent - self.oldWidgetVals = { - attr: ( - copy(val) - if attr not in self.parent._relativeWidgets - else self.parent.floatValForAttr(attr, val, axis=self.res) - ) - for attr, val in oldWidgetVals.items() - if attr in modifiedVals - } - self.modifiedVals = { - attr: ( - val - if attr not in self.parent._relativeWidgets - else self.parent.floatValForAttr(attr, val, axis=self.res) - ) - for attr, val in modifiedVals.items() - } - - # Because relative widgets change themselves every update based on - # their previous value, we must store ALL their values in case of undo - self.relativeWidgetValsAfterUndo = { - attr: copy(getattr(self.parent, attr)) - for attr in self.parent._relativeWidgets - } - - # Determine if this update is mergeable - self.id_ = -1 - if self.parent.mergeUndo: - if len(self.modifiedVals) == 1: - attr, val = self.modifiedVals.popitem() - self.id_ = sum([ord(letter) for letter in attr[-14:]]) - self.modifiedVals[attr] = val - return - log.warning( - "%s component settings changed at once. (%s)", - len(self.modifiedVals), - repr(self.modifiedVals), - ) - - def id(self): - """If 2 consecutive updates have same id, Qt will call mergeWith()""" - return self.id_ - - def mergeWith(self, other): - self.modifiedVals.update(other.modifiedVals) - return True - - def setWidgetValues(self, attrDict): - """ - Mask the component's usual method to handle our - relative widgets in case the resolution has changed. - """ - newAttrDict = { - attr: ( - val - if attr not in self.parent._relativeWidgets - else self.parent.pixelValForAttr(attr, val) - ) - for attr, val in attrDict.items() - } - self.parent.setWidgetValues(newAttrDict) - - def redo(self): - if self.undone: - log.info("Redoing component update") - self.parent.oldAttrs = self.relativeWidgetValsAfterUndo - self.setWidgetValues(self.modifiedVals) - self.parent.update(auto=True) - self.parent.oldAttrs = None - if not self.undone: - self.relativeWidgetValsAfterRedo = { - attr: copy(getattr(self.parent, attr)) - for attr in self.parent._relativeWidgets - } - self.parent._sendUpdateSignal() - - def undo(self): - log.info("Undoing component update") - self.undone = True - self.parent.oldAttrs = self.relativeWidgetValsAfterRedo - self.setWidgetValues(self.oldWidgetVals) - self.parent.update(auto=True) - self.parent.oldAttrs = None diff --git a/src/avp/components/classic.py b/src/avp/components/classic.py new file mode 100644 index 0000000..72089af --- /dev/null +++ b/src/avp/components/classic.py @@ -0,0 +1,210 @@ +import numpy +from PIL import Image, ImageDraw + +from ..libcomponent import BaseComponent +from ..toolkit.frame import BlankFrame, FloodFrame +from ..toolkit.visualizer import createSpectrumArray + + +class Component(BaseComponent): + name = "Classic Visualizer" + version = "1.2.0" + + def names(*args): + return ["Original"] + + def properties(self): + props = ["pcm"] + if self.invert: + props.append("composite") + return props + + def widget(self, *args): + self.scale = 20 + self.bars = 63 + self.y = 0 + super().widget(*args) + + self.page.comboBox_visLayout.addItem("Classic") + self.page.comboBox_visLayout.addItem("Split") + self.page.comboBox_visLayout.addItem("Bottom") + self.page.comboBox_visLayout.addItem("Top") + self.page.comboBox_visLayout.setCurrentIndex(0) + + self.trackWidgets( + { + "visColor": self.page.lineEdit_visColor, + "layout": self.page.comboBox_visLayout, + "scale": self.page.spinBox_scale, + "y": self.page.spinBox_y, + "smooth": self.page.spinBox_sensitivity, + "bars": self.page.spinBox_bars, + "invert": self.page.checkBox_invert, + }, + colorWidgets={ + "visColor": self.page.pushButton_visColor, + }, + relativeWidgets=[ + "y", + ], + ) + + def previewRender(self, frame=None): + spectrum = numpy.fromfunction( + lambda x: float(self.scale) / 2500 * (x - 128) ** 2, + (255,), + dtype="int16", + ) + return self.drawBars( + self.width, + self.height, + spectrum, + self.visColor, + self.layout, + frame, + ) + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + smoothConstantDown = 0.08 if not self.smooth else self.smooth / 15 + smoothConstantUp = 0.8 if not self.smooth else self.smooth / 15 + self.spectrumArray = createSpectrumArray( + self, + self.completeAudioArray, + self.sampleSize, + smoothConstantDown, + smoothConstantUp, + self.scale, + self.progressBarUpdate, + self.progressBarSetText, + ) + + def frameRender(self, frameNo, frame=None): + arrayNo = frameNo * self.sampleSize + return self.drawBars( + self.width, + self.height, + self.spectrumArray[arrayNo], + self.visColor, + self.layout, + frame, + ) + + def drawBars(self, width, height, spectrum, color, layout, frame): + bigYCoord = height - height / 8 + smallYCoord = height / 1200 + bigXCoord = width / (self.bars + 1) + middleXCoord = bigXCoord / 2 + smallXCoord = bigXCoord / 4 + + imTop = BlankFrame(width, height) + draw = ImageDraw.Draw(imTop) + r, g, b = color + color2 = (r, g, b, 125) + + for i in range(self.bars): + # draw outline behind rectangles if not inverted + if frame is None: + x0 = middleXCoord + i * bigXCoord + y0 = bigYCoord + smallXCoord + x1 = middleXCoord + i * bigXCoord + bigXCoord + y1 = ( + bigYCoord + + smallXCoord + - spectrum[i * 4] * smallYCoord + - middleXCoord + ) + selection = ( + x0, + y0 if y0 < y1 else y1, + x1 if x1 > x0 else x0, + y1 if y0 < y1 else y0, + ) + draw.rectangle( + selection, + fill=color2, + ) + + x0 = middleXCoord + smallXCoord + i * bigXCoord + y0 = bigYCoord + x1 = middleXCoord + smallXCoord + i * bigXCoord + middleXCoord + y1 = bigYCoord - spectrum[i * 4] * smallYCoord + selection = ( + x0, + y0 if y0 < y1 else y1, + x1 if x1 > x0 else x0, + y1 if y0 < y1 else y0, + ) + # fill rectangle if not inverted + draw.rectangle( + selection, + fill=color if frame is None else (0, 0, 0, 0), + outline=color, + width=int(x1 - x0), + ) + + imBottom = imTop.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + + im = BlankFrame(width, height) + + if layout == 0: # Classic + y = self.y - int(height / 100 * 43) + im.paste(imTop, (0, y), mask=imTop) + y = self.y + int(height / 100 * 43) + im.paste(imBottom, (0, y), mask=imBottom) + + if layout == 1: # Split + y = self.y + int(height / 100 * 10) + im.paste(imTop, (0, y), mask=imTop) + y = self.y - int(height / 100 * 10) + im.paste(imBottom, (0, y), mask=imBottom) + + if layout == 2: # Bottom + y = self.y + int(height / 100 * 10) + im.paste(imTop, (0, y), mask=imTop) + + if layout == 3: # Top + y = self.y - int(height / 100 * 10) + im.paste(imBottom, (0, y), mask=imBottom) + + if frame is None: + return im + f = FloodFrame(width, height, color) + f.paste(frame, (0, 0), mask=im) + return f + + def command(self, arg): + if "=" in arg: + key, arg = arg.split("=", 1) + try: + if key == "color": + self.page.lineEdit_visColor.setText(arg) + return + elif key == "layout": + if arg == "classic": + self.page.comboBox_visLayout.setCurrentIndex(0) + elif arg == "split": + self.page.comboBox_visLayout.setCurrentIndex(1) + elif arg == "bottom": + self.page.comboBox_visLayout.setCurrentIndex(2) + elif arg == "top": + self.page.comboBox_visLayout.setCurrentIndex(3) + return + elif key == "scale": + arg = int(arg) + self.page.spinBox_scale.setValue(arg) + return + elif key == "y": + arg = int(arg) + self.page.spinBox_y.setValue(arg) + return + except ValueError: + print("You must enter a number.") + quit(1) + super().command(arg) + + def commandHelp(self): + print("Give a layout name:\n layout=[classic/split/bottom/top]") + print("Specify a color:\n color=255,255,255") + print("Visualizer scale (20 is default):\n scale=number") + print("Y position:\n y=number") diff --git a/src/avp/components/classic.ui b/src/avp/components/classic.ui new file mode 100644 index 0000000..1ae7faa --- /dev/null +++ b/src/avp/components/classic.ui @@ -0,0 +1,274 @@ + + + Form + + + + 0 + 0 + 586 + 178 + + + + + 180 + 0 + + + + Form + + + + + + 4 + + + + + + 0 + 0 + + + + Layout + + + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + Color + + + + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 5 + 20 + + + + + + + + Y + + + + + + + QAbstractSpinBox::ButtonSymbols::UpDownArrows + + + -5000 + + + 5000 + + + 10 + + + 0 + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + 4 + + + + + Scale + + + + + + + QAbstractSpinBox::ButtonSymbols::PlusMinus + + + 1 + + + 20 + + + + + + + Sensitivity + + + + + + + 5 + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Expanding + + + + 40 + 20 + + + + + + + + + + QLayout::SizeConstraint::SetDefaultConstraint + + + 4 + + + + + Bars + + + + + + + 63 + + + 64 + + + 63 + + + + + + + Invert + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + 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 @@ from PyQt6 import QtGui import logging -from ..component import Component +from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter log = logging.getLogger("AVP.Components.Color") -class Component(Component): +class Component(BaseComponent): name = "Color" version = "1.0.0" 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 @@ 32 + + End color of gradient. Disabled if fill is solid. + 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 @@ from PIL import Image, ImageOps, ImageEnhance from PyQt6 import QtWidgets import os -from copy import copy -from ..component import Component +from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, addShadow from ..toolkit.visualizer import createSpectrumArray -class Component(Component): +class Component(BaseComponent): name = "Image" version = "2.1.0" @@ -177,17 +176,22 @@ class Component(Component): self.mergeUndo = True def command(self, arg): + def fail(): + print("Not a supported image format") + quit(1) + if "=" in arg: key, arg = arg.split("=", 1) if key == "path" and os.path.exists(arg): + if f"*{os.path.splitext(arg)[1]}" not in self.core.imageFormats: + fail() try: Image.open(arg) self.page.lineEdit_image.setText(arg) - self.page.checkBox_stretch.setChecked(True) + self.page.comboBox_resizeMode.setCurrentIndex(2) return except OSError as e: - print("Not a supported image format") - quit(1) + fail() super().command(arg) 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 @@ from PyQt6 import QtCore, QtWidgets from PyQt6.QtGui import QUndoCommand -from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter, ImageOps +from PIL import Image, ImageDraw import os -from copy import copy import math import logging -from ..component import Component +from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, scale, addShadow from ..toolkit.visualizer import createSpectrumArray @@ -15,7 +14,7 @@ from ..toolkit.visualizer import createSpectrumArray log = logging.getLogger("AVP.Component.Life") -class Component(Component): +class Component(BaseComponent): name = "Conway's Game of Life" version = "2.0.1" diff --git a/src/avp/components/original.py b/src/avp/components/original.py deleted file mode 100644 index 0da78dc..0000000 --- a/src/avp/components/original.py +++ /dev/null @@ -1,185 +0,0 @@ -import numpy -from PIL import Image, ImageDraw -from copy import copy - -from ..component import Component -from ..toolkit.frame import BlankFrame -from ..toolkit.visualizer import createSpectrumArray - - -class Component(Component): - name = "Classic Visualizer" - version = "1.1.0" - - def names(*args): - return ["Original Audio Visualization"] - - def properties(self): - return ["pcm"] - - def widget(self, *args): - self.scale = 20 - self.bars = 63 - self.y = 0 - super().widget(*args) - - self.page.comboBox_visLayout.addItem("Classic") - self.page.comboBox_visLayout.addItem("Split") - self.page.comboBox_visLayout.addItem("Bottom") - self.page.comboBox_visLayout.addItem("Top") - self.page.comboBox_visLayout.setCurrentIndex(0) - - self.trackWidgets( - { - "visColor": self.page.lineEdit_visColor, - "layout": self.page.comboBox_visLayout, - "scale": self.page.spinBox_scale, - "y": self.page.spinBox_y, - "smooth": self.page.spinBox_sensitivity, - "bars": self.page.spinBox_bars, - }, - colorWidgets={ - "visColor": self.page.pushButton_visColor, - }, - relativeWidgets=[ - "y", - ], - ) - - def previewRender(self): - spectrum = numpy.fromfunction( - lambda x: float(self.scale) / 2500 * (x - 128) ** 2, - (255,), - dtype="int16", - ) - return self.drawBars( - self.width, self.height, spectrum, self.visColor, self.layout - ) - - def preFrameRender(self, **kwargs): - super().preFrameRender(**kwargs) - smoothConstantDown = 0.08 if not self.smooth else self.smooth / 15 - smoothConstantUp = 0.8 if not self.smooth else self.smooth / 15 - self.spectrumArray = createSpectrumArray( - self, - self.completeAudioArray, - self.sampleSize, - smoothConstantDown, - smoothConstantUp, - self.scale, - self.progressBarUpdate, - self.progressBarSetText, - ) - - def frameRender(self, frameNo): - arrayNo = frameNo * self.sampleSize - return self.drawBars( - self.width, - self.height, - self.spectrumArray[arrayNo], - self.visColor, - self.layout, - ) - - def drawBars(self, width, height, spectrum, color, layout): - bigYCoord = height - height / 8 - smallYCoord = height / 1200 - bigXCoord = width / (self.bars + 1) - middleXCoord = bigXCoord / 2 - smallXCoord = bigXCoord / 4 - - imTop = BlankFrame(width, height) - draw = ImageDraw.Draw(imTop) - r, g, b = color - color2 = (r, g, b, 125) - - for i in range(self.bars): - x0 = middleXCoord + i * bigXCoord - y0 = bigYCoord + smallXCoord - y1 = bigYCoord + smallXCoord - spectrum[i * 4] * smallYCoord - middleXCoord - x1 = middleXCoord + i * bigXCoord + bigXCoord - draw.rectangle( - ( - x0, - y0 if y0 < y1 else y1, - x1 if x1 > x0 else x0, - y1 if y0 < y1 else y0, - ), - fill=color2, - ) - - x0 = middleXCoord + smallXCoord + i * bigXCoord - y0 = bigYCoord - x1 = middleXCoord + smallXCoord + i * bigXCoord + middleXCoord - y1 = bigYCoord - spectrum[i * 4] * smallYCoord - draw.rectangle( - ( - x0, - y0 if y0 < y1 else y1, - x1 if x1 > x0 else x0, - y1 if y0 < y1 else y0, - ), - fill=color, - ) - - imBottom = imTop.transpose(Image.Transpose.FLIP_TOP_BOTTOM) - - im = BlankFrame(width, height) - - if layout == 0: # Classic - y = self.y - int(height / 100 * 43) - im.paste(imTop, (0, y), mask=imTop) - y = self.y + int(height / 100 * 43) - im.paste(imBottom, (0, y), mask=imBottom) - - if layout == 1: # Split - y = self.y + int(height / 100 * 10) - im.paste(imTop, (0, y), mask=imTop) - y = self.y - int(height / 100 * 10) - im.paste(imBottom, (0, y), mask=imBottom) - - if layout == 2: # Bottom - y = self.y + int(height / 100 * 10) - im.paste(imTop, (0, y), mask=imTop) - - if layout == 3: # Top - y = self.y - int(height / 100 * 10) - im.paste(imBottom, (0, y), mask=imBottom) - - return im - - def command(self, arg): - if "=" in arg: - key, arg = arg.split("=", 1) - try: - if key == "color": - self.page.lineEdit_visColor.setText(arg) - return - elif key == "layout": - if arg == "classic": - self.page.comboBox_visLayout.setCurrentIndex(0) - elif arg == "split": - self.page.comboBox_visLayout.setCurrentIndex(1) - elif arg == "bottom": - self.page.comboBox_visLayout.setCurrentIndex(2) - elif arg == "top": - self.page.comboBox_visLayout.setCurrentIndex(3) - return - elif key == "scale": - arg = int(arg) - self.page.spinBox_scale.setValue(arg) - return - elif key == "y": - arg = int(arg) - self.page.spinBox_y.setValue(arg) - return - except ValueError: - print("You must enter a number.") - quit(1) - super().command(arg) - - def commandHelp(self): - print("Give a layout name:\n layout=[classic/split/bottom/top]") - print("Specify a color:\n color=255,255,255") - print("Visualizer scale (20 is default):\n scale=number") - print("Y position:\n y=number") diff --git a/src/avp/components/original.ui b/src/avp/components/original.ui deleted file mode 100644 index 8dbdaa2..0000000 --- a/src/avp/components/original.ui +++ /dev/null @@ -1,267 +0,0 @@ - - - Form - - - - 0 - 0 - 586 - 178 - - - - - 180 - 0 - - - - Form - - - - - - 4 - - - - - - 0 - 0 - - - - Layout - - - - - - - - - - Qt::Orientation::Horizontal - - - QSizePolicy::Policy::Fixed - - - - 5 - 20 - - - - - - - - Color - - - - - - - - 32 - 32 - - - - - - - - 32 - 32 - - - - - - - - - - - - - - - Qt::Orientation::Horizontal - - - QSizePolicy::Policy::Fixed - - - - 5 - 20 - - - - - - - - Y - - - - - - - QAbstractSpinBox::ButtonSymbols::UpDownArrows - - - -5000 - - - 5000 - - - 10 - - - 0 - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - - 4 - - - - - Scale - - - - - - - QAbstractSpinBox::ButtonSymbols::PlusMinus - - - 1 - - - 20 - - - - - - - Sensitivity - - - - - - - 5 - - - - - - - Qt::Orientation::Horizontal - - - QSizePolicy::Policy::Expanding - - - - 40 - 20 - - - - - - - - - - QLayout::SizeConstraint::SetDefaultConstraint - - - 4 - - - - - Bars - - - - - - - 63 - - - 64 - - - 63 - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - 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 @@ -from PyQt6 import QtGui, QtCore, QtWidgets +from PyQt6 import QtWidgets import os -from ..component import Component +from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame -class Component(Component): +class Component(BaseComponent): name = "Sound" version = "1.0.0" 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 @@ from PIL import Image -from PyQt6 import QtGui, QtCore, QtWidgets import os -import math import subprocess -import time import logging -from ..component import Component +from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, scale -from ..toolkit import checkOutput, connectWidget +from ..toolkit import connectWidget from ..toolkit.ffmpeg import ( openPipe, closePipe, @@ -21,7 +18,7 @@ from ..toolkit.ffmpeg import ( log = logging.getLogger("AVP.Components.Spectrum") -class Component(Component): +class Component(BaseComponent): name = "Spectrum" version = "1.0.1" 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 @@ -from PIL import ImageEnhance, ImageFilter, ImageChops -from PyQt6.QtGui import QColor, QFont -from PyQt6 import QtGui, QtCore, QtWidgets -import os +from PyQt6.QtGui import QFont +from PyQt6 import QtGui, QtCore import logging -from ..component import Component +from ..libcomponent import BaseComponent from ..toolkit.frame import FramePainter, addShadow log = logging.getLogger("AVP.Components.Text") -class Component(Component): +class Component(BaseComponent): name = "Title Text" version = "1.0.1" 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 @@ from PIL import Image -from PyQt6 import QtGui, QtCore, QtWidgets +from PyQt6 import QtWidgets import os -import math import subprocess import logging -from ..component import Component +from ..libcomponent import BaseComponent from ..toolkit.frame import BlankFrame, scale from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo -from ..toolkit import checkOutput log = logging.getLogger("AVP.Components.Video") -class Component(Component): +class Component(BaseComponent): name = "Video" version = "1.0.0" 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 import os import subprocess import logging -from copy import copy -from ..component import Component -from ..toolkit.visualizer import transformData, createSpectrumArray +from ..libcomponent import BaseComponent +from ..toolkit.visualizer import createSpectrumArray from ..toolkit.frame import BlankFrame, scale -from ..toolkit import checkOutput from ..toolkit.ffmpeg import ( openPipe, closePipe, @@ -21,7 +19,7 @@ from ..toolkit.ffmpeg import ( log = logging.getLogger("AVP.Components.Waveform") -class Component(Component): +class Component(BaseComponent): name = "Waveform" version = "2.0.0" 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 appName = "Audio Visualizer Python" log = logging.getLogger("AVP.Core") -STDOUT_LOGLVL = logging.WARNING class Core: @@ -26,6 +25,8 @@ class Core: This class also stores constants as class variables. """ + stdoutLogLvl = logging.WARNING + def __init__(self): self.importComponents() self.selectedComponents = [] @@ -77,7 +78,10 @@ class Core: compPos = len(self.selectedComponents) if len(self.selectedComponents) > 50: return -1 - if type(component) is int: + if component is None: + log.warning("Tried to insert non-existent component") + return -1 + elif type(component) is int: # create component using module index in self.modules moduleIndex = int(component) log.debug("Creating new component from module #%s", str(moduleIndex)) @@ -197,7 +201,7 @@ class Core: ) continue if i == -1: - loader.showMessage(msg="Too many components!") + loader.showMessage(msg="Invalid components!") break try: @@ -554,7 +558,7 @@ class Core: def makeLogger(deleteOldLogs=False, fileLogLvl=None): # send critical log messages to stdout logStream = logging.StreamHandler() - logStream.setLevel(STDOUT_LOGLVL) + logStream.setLevel(Core.stdoutLogLvl) streamFormatter = logging.Formatter("<%(name)s> %(levelname)s: %(message)s") logStream.setFormatter(streamFormatter) 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 @@ """ -QCommand classes for every undoable user action performed in the MainWindow +QUndoCommand classes for every undoable user action performed in the MainWindow """ from PyQt6.QtGui import QUndoCommand @@ -13,9 +13,9 @@ from ..core import Core log = logging.getLogger("AVP.Gui.Actions") -# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # COMPONENT ACTIONS -# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ class AddComponent(QUndoCommand): @@ -107,9 +107,9 @@ class MoveComponent(QUndoCommand): self.do(self.newRow, self.row) -# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # PRESET ACTIONS -# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ 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 from .preview_win import PreviewWindow from .presetmanager import PresetManager from .actions import * -from ..toolkit.ffmpeg import createFfmpegCommand +from ..toolkit.ffmpeg import createFfmpegCommand, checkFfmpegVersion from ..toolkit import ( disableWhenEncoding, disableWhenOpeningProject, @@ -330,26 +330,13 @@ class MainWindow(QtWidgets.QMainWindow): ) else: if not self.settings.value("ffmpegMsgShown"): - try: - with open(os.devnull, "w") as f: - ffmpegVers = checkOutput( - [self.core.FFMPEG_BIN, "-version"], stderr=f - ) - ffmpegVers = str(ffmpegVers).split()[2].split(".", 1)[0] - if ffmpegVers.startswith("n"): - ffmpegVers = ffmpegVers[1:] - goodVersion = int(ffmpegVers) > 3 - except Exception: - goodVersion = False - else: - goodVersion = True - - if not goodVersion: - self.showMessage( - msg="You're using an old version of Ffmpeg. " - "Some features may not work as expected." - ) - self.settings.setValue("ffmpegMsgShown", True) + ffmpegGoodVersion, ffmpegVersionNum = checkFfmpegVersion() + if not ffmpegGoodVersion: + self.showMessage( + msg="The version of FFmpeg ({ffmpegVersionNum}) is " + "not recognized. Some features may not work as expected." + ) + self.settings.setValue("ffmpegMsgShown", True) # Hotkeys for projects @@ -734,6 +721,23 @@ class MainWindow(QtWidgets.QMainWindow): self.progressLabel.setText(value) else: self.progressBar_createVideo.setFormat(value) + if log.getEffectiveLevel() > logging.INFO: + # if ffmpeg is quiet, print progress ourselves + if any( + [ + value.startswith("Export C"), + value.startswith("Analyzing"), + value.startswith("Loading"), + ] + ): + # Don't duplicate completion/failure messages or send too many messages + return + elif not value.startswith("Exporting"): + print(value) + else: + # overwrite previous message with next one + # if the text is our main export progress + print(f"\r{value}", end="") def updateResolution(self): 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): self.settings = parent.settings self.presetDir = parent.presetDir if not self.settings.value("presetDir"): - self.settings.setValue( - "presetDir", os.path.join(parent.dataDir, "projects") - ) + self.settings.setValue("presetDir", os.path.join(parent.dataDir, "presets")) self.findPresets() 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): for component in reversed(components): try: component.lockSize(width, height) - newFrame = component.previewRender() + if "composite" in component.properties(): + newFrame = component.previewRender(frame) + else: + newFrame = component.previewRender() component.unlockSize() frame = Image.alpha_composite(frame, newFrame) @@ -72,11 +75,12 @@ class Worker(QtCore.QObject): % ( str(component), str(e).capitalize(), - "is None" if newFrame is None else "size was %s*%s; should be %s*%s" % ( - newFrame.width, - newFrame.height, - width, - height), + ( + "is None" + if newFrame is None + else "size was %s*%s; should be %s*%s" + % (newFrame.width, newFrame.height, width, height) + ), ) ) 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 @@ +from .component import Component as BaseComponent +from .exceptions import ComponentError + +__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 @@ +""" +QUndoCommand class for generic undoable user actions performed to a BaseComponent + +See `../life.py` for an example of a component that uses a custom QUndoCommand +""" + +from PyQt6.QtGui import QUndoCommand +from copy import copy +import logging + +log = logging.getLogger("AVP.ComponentHandler") + + +class ComponentUpdate(QUndoCommand): + """Command object for making a component action undoable""" + + def __init__(self, parent, oldWidgetVals, modifiedVals): + super().__init__("change %s component #%s" % (parent.name, parent.compPos)) + self.undone = False + self.res = (int(parent.width), int(parent.height)) + self.parent = parent + self.oldWidgetVals = { + attr: ( + copy(val) + if attr not in self.parent._relativeWidgets + else self.parent.floatValForAttr(attr, val, axis=self.res) + ) + for attr, val in oldWidgetVals.items() + if attr in modifiedVals + } + self.modifiedVals = { + attr: ( + val + if attr not in self.parent._relativeWidgets + else self.parent.floatValForAttr(attr, val, axis=self.res) + ) + for attr, val in modifiedVals.items() + } + + # Because relative widgets change themselves every update based on + # their previous value, we must store ALL their values in case of undo + self.relativeWidgetValsAfterUndo = { + attr: copy(getattr(self.parent, attr)) + for attr in self.parent._relativeWidgets + } + + # Determine if this update is mergeable + self.id_ = -1 + if self.parent.mergeUndo: + if len(self.modifiedVals) == 1: + attr, val = self.modifiedVals.popitem() + self.id_ = sum([ord(letter) for letter in attr[-14:]]) + self.modifiedVals[attr] = val + return + log.warning( + "%s component settings changed at once. (%s)", + len(self.modifiedVals), + repr(self.modifiedVals), + ) + + def id(self): + """If 2 consecutive updates have same id, Qt will call mergeWith()""" + return self.id_ + + def mergeWith(self, other): + self.modifiedVals.update(other.modifiedVals) + return True + + def setWidgetValues(self, attrDict): + """ + Mask the component's usual method to handle our + relative widgets in case the resolution has changed. + """ + newAttrDict = { + attr: ( + val + if attr not in self.parent._relativeWidgets + else self.parent.pixelValForAttr(attr, val) + ) + for attr, val in attrDict.items() + } + self.parent.setWidgetValues(newAttrDict) + + def redo(self): + if self.undone: + log.info("Redoing component update") + self.parent.oldAttrs = self.relativeWidgetValsAfterUndo + self.setWidgetValues(self.modifiedVals) + self.parent.update(auto=True) + self.parent.oldAttrs = None + if not self.undone: + self.relativeWidgetValsAfterRedo = { + attr: copy(getattr(self.parent, attr)) + for attr in self.parent._relativeWidgets + } + self.parent._sendUpdateSignal() + + def undo(self): + log.info("Undoing component update") + self.undone = True + self.parent.oldAttrs = self.relativeWidgetValsAfterRedo + self.setWidgetValues(self.oldWidgetVals) + self.parent.update(auto=True) + self.parent.oldAttrs = None diff --git a/src/avp/libcomponent/component.py b/src/avp/libcomponent/component.py new file mode 100644 index 0000000..1f81e07 --- /dev/null +++ b/src/avp/libcomponent/component.py @@ -0,0 +1,583 @@ +""" +Base classes for components to import. Read comments for some documentation +on making a valid component. +""" + +from PyQt6 import uic, QtCore, QtWidgets +from PyQt6.QtGui import QColor +import os +import math +import logging +from copy import copy + +from .metaclass import ComponentMetaclass +from .actions import ComponentUpdate +from .exceptions import ComponentError +from ..toolkit.frame import BlankFrame + +from ..toolkit import ( + getWidgetValue, + setWidgetValue, + rgbFromString, + randomColor, + blockSignals, +) + +log = logging.getLogger("AVP.BaseComponent") + + +class Component(QtCore.QObject, metaclass=ComponentMetaclass): + """ + The base class for components to inherit. + """ + + name = "Component" + # ui = 'name_Of_Non_Default_Ui_File' + + version = "1.0.0" + # The major version (before the first dot) is used to determine + # preset compatibility; the rest is ignored so it can be non-numeric. + + modified = QtCore.pyqtSignal(int, dict) + _error = QtCore.pyqtSignal(str, str) + + def __init__(self, moduleIndex, compPos, core): + super().__init__() + self.moduleIndex = moduleIndex + self.compPos = compPos + self.core = core + + # STATUS VARIABLES + self.currentPreset = None + self._allWidgets = {} + self._trackedWidgets = {} + self._presetNames = {} + self._commandArgs = {} + self._colorWidgets = {} + self._colorFuncs = {} + self._relativeWidgets = {} + # Pixel values stored as floats + self._relativeValues = {} + # Maximum values of spinBoxes at 1080p (Core.resolutions[0]) + self._relativeMaximums = {} + + # LOCKING VARIABLES + self.openingPreset = False + self.mergeUndo = True + self._lockedProperties = None + self._lockedError = None + self._lockedSize = None + # If set to a dict, values are used as basis to update relative widgets + self.oldAttrs = None + # Stop lengthy processes in response to this variable + self.canceled = False + + def __str__(self): + return self.__class__.name + + def __repr__(self): + import pprint + + try: + preset = self.savePreset() + except Exception as e: + preset = "%s occurred while saving preset" % str(e) + + return "Component(module %s, pos %s) (%s)\n" "Name: %s v%s\nPreset: %s" % ( + self.moduleIndex, + self.compPos, + object.__repr__(self), + self.__class__.name, + str(self.__class__.version), + pprint.pformat(preset), + ) + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Render Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def previewRender(self): + image = BlankFrame(self.width, self.height) + return image + + def preFrameRender(self, **kwargs): + """ + Must call super() when subclassing + Triggered only before a video is exported (video_thread.py) + self.audioFile = filepath to the main input audio file + self.completeAudioArray = a list of audio samples + self.sampleSize = number of audio samples per video frame + self.progressBarUpdate = signal to set progress bar number + self.progressBarSetText = signal to set progress bar text + Use the latter two signals to update the MainWindow if needed + for a long initialization procedure (i.e., for a visualizer) + """ + for key, value in kwargs.items(): + setattr(self, key, value) + + def frameRender(self, frameNo): + audioArrayIndex = frameNo * self.sampleSize + image = BlankFrame(self.width, self.height) + return image + + def postFrameRender(self): + pass + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Properties + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def properties(self): + """ + Return a list of properties with certain meanings: + `static`: non-animated + `audio`: has extra sound to add + `error`: bad configuration + `pcm`: request raw audio data + `composite`: request frame to draw on + """ + return [] + + def error(self): + """ + Return a string containing an error message, or None for a default. + Or tuple of two strings for a message with details. + Alternatively use lockError(msgString) within properties() + to skip this method entirely. + """ + return + + def audio(self): + """ + Return audio to mix into master as a tuple with two elements: + The first element can be: + - A string (path to audio file), + - Or an object that returns audio data through a pipe + The second element must be a dictionary of ffmpeg filters/options + to apply to the input stream. See the filter docs for ideas: + https://ffmpeg.org/ffmpeg-filters.html + """ + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Idle Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def widget(self, parent): + """ + Call super().widget(*args) to create the component widget + which also auto-connects any common widgets (e.g., checkBoxes) + to self.update(). Then in a subclass connect special actions + (e.g., pushButtons to select a file) and initialize + """ + self.parent = parent + self.settings = parent.settings + log.verbose( + "Creating UI for %s #%s's widget", + self.__class__.name, + self.compPos, + ) + self.page = self.loadUi(self.__class__.ui) + + # Find all normal widgets which will be connected after subclass method + self._allWidgets = { + "lineEdit": self.page.findChildren(QtWidgets.QLineEdit), + "checkBox": self.page.findChildren(QtWidgets.QCheckBox), + "spinBox": self.page.findChildren(QtWidgets.QSpinBox), + "comboBox": self.page.findChildren(QtWidgets.QComboBox), + } + self._allWidgets["spinBox"].extend( + self.page.findChildren(QtWidgets.QDoubleSpinBox) + ) + + def update(self): + """ + Starting point for a component update. A subclass should override + this method, and the base class will then magically insert a call + to either _autoUpdate() or _userUpdate() at the end. + """ + + def loadPreset(self, presetDict, presetName=None): + """ + Subclasses should take (presetDict, *args) as args. + Must use super().loadPreset(presetDict, *args) first, + then update self.page widgets using the preset dict. + """ + self.currentPreset = ( + presetName if presetName is not None else presetDict["preset"] + ) + for attr, widget in self._trackedWidgets.items(): + key = attr if attr not in self._presetNames else self._presetNames[attr] + try: + val = presetDict[key] + except KeyError as e: + log.info( + "%s missing value %s. Outdated preset?", + self.currentPreset, + str(e), + ) + val = getattr(self, key) + + if attr in self._colorWidgets: + widget.setText("%s,%s,%s" % val) + btnStyle = ( + "QPushButton { background-color : %s; outline: none; }" + % QColor(*val).name() + ) + self._colorWidgets[attr].setStyleSheet(btnStyle) + elif attr in self._relativeWidgets: + self._relativeValues[attr] = val + pixelVal = self.pixelValForAttr(attr, val) + setWidgetValue(widget, pixelVal) + else: + setWidgetValue(widget, val) + + def savePreset(self): + saveValueStore = {} + for attr, widget in self._trackedWidgets.items(): + presetAttrName = ( + attr if attr not in self._presetNames else self._presetNames[attr] + ) + if attr in self._relativeWidgets: + try: + val = self._relativeValues[attr] + except AttributeError: + val = self.floatValForAttr(attr) + else: + val = getattr(self, attr) + + saveValueStore[presetAttrName] = val + return saveValueStore + + def commandHelp(self): + """Help text as string for this component's commandline arguments""" + + def command(self, arg=""): + """ + Configure a component using an arg from the commandline. This is + never called if global args like 'preset=' are found in the arg. + So simply check for any non-global args in your component and + call super().command() at the end to get a Help message. + """ + print( + self.__class__.name, + "Usage:\n" "Open a preset for this component:\n" ' "preset=Preset Name"', + ) + self.commandHelp() + quit(0) + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # "Private" Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ + def _preUpdate(self): + """Happens before subclass update()""" + for attr in self._relativeWidgets: + self.updateRelativeWidget(attr) + + def _userUpdate(self): + """Happens after subclass update() for an undoable update by user.""" + oldWidgetVals = { + attr: copy(getattr(self, attr)) for attr in self._trackedWidgets + } + newWidgetVals = { + attr: ( + getWidgetValue(widget) + if attr not in self._colorWidgets + else rgbFromString(widget.text()) + ) + for attr, widget in self._trackedWidgets.items() + } + modifiedWidgets = { + attr: val + for attr, val in newWidgetVals.items() + if val != oldWidgetVals[attr] + } + if modifiedWidgets: + action = ComponentUpdate(self, oldWidgetVals, modifiedWidgets) + self.parent.undoStack.push(action) + + def _autoUpdate(self): + """Happens after subclass update() for an internal component update.""" + newWidgetVals = { + attr: getWidgetValue(widget) + for attr, widget in self._trackedWidgets.items() + } + self.setAttrs(newWidgetVals) + self._sendUpdateSignal() + + def setAttrs(self, attrDict): + """ + Sets attrs (linked to trackedWidgets) in this component to + the values in the attrDict. Mutates certain widget values if needed + """ + for attr, val in attrDict.items(): + if attr in self._colorWidgets: + # Color Widgets must have a tuple & have a button to update + if type(val) is tuple: + rgbTuple = val + else: + rgbTuple = rgbFromString(val) + btnStyle = ( + "QPushButton { background-color : %s; outline: none; }" + % QColor(*rgbTuple).name() + ) + self._colorWidgets[attr].setStyleSheet(btnStyle) + setattr(self, attr, rgbTuple) + + else: + # Normal tracked widget + setattr(self, attr, val) + log.verbose("Setting %s self.%s to %s" % (self.__class__.name, attr, val)) + + def setWidgetValues(self, attrDict): + """ + Sets widgets defined by keys in trackedWidgets in this preset to + the values in the attrDict. + """ + affectedWidgets = [self._trackedWidgets[attr] for attr in attrDict] + with blockSignals(affectedWidgets): + for attr, val in attrDict.items(): + widget = self._trackedWidgets[attr] + if attr in self._colorWidgets: + val = "%s,%s,%s" % val + setWidgetValue(widget, val) + + def _sendUpdateSignal(self): + if not self.core.openingProject: + self.parent.drawPreview() + saveValueStore = self.savePreset() + saveValueStore["preset"] = self.currentPreset + self.modified.emit(self.compPos, saveValueStore) + + def trackWidgets(self, trackDict, **kwargs): + """ + Name widgets to track in update(), savePreset(), loadPreset(), and + command(). Requires a dict of attr names as keys, widgets as values + + Optional args: + 'presetNames': preset variable names to replace attr names + 'commandArgs': arg keywords that differ from attr names + 'colorWidgets': identify attr as RGB tuple & update button CSS + 'relativeWidgets': change value proportionally to resolution + + NOTE: Any kwarg key set to None will selectively disable tracking. + """ + self._trackedWidgets = trackDict + for kwarg in kwargs: + try: + if kwarg in ( + "presetNames", + "commandArgs", + "colorWidgets", + "relativeWidgets", + ): + setattr(self, "_{}".format(kwarg), kwargs[kwarg]) + else: + raise ComponentError(self, "Nonsensical keywords to trackWidgets.") + except ComponentError: + continue + + if kwarg == "colorWidgets": + + def makeColorFunc(attr): + def pickColor_(): + self.mergeUndo = False + self.pickColor( + self._trackedWidgets[attr], + self._colorWidgets[attr], + ) + self.mergeUndo = True + + return pickColor_ + + self._colorFuncs = {attr: makeColorFunc(attr) for attr in kwargs[kwarg]} + for attr, func in self._colorFuncs.items(): + colorText = self._trackedWidgets[attr].text() + if colorText == "": + rndColor = randomColor() + self._trackedWidgets[attr].setText(str(rndColor)[1:-1]) + self._colorWidgets[attr].clicked.connect(func) + self._colorWidgets[attr].setStyleSheet( + "QPushButton {" + "background-color : %s; outline: none; }" + % QColor( + *rgbFromString(colorText) if colorText else rndColor + ).name() + ) + + if kwarg == "relativeWidgets": + # store maximum values of spinBoxes to be scaled appropriately + for attr in kwargs[kwarg]: + self._relativeMaximums[attr] = self._trackedWidgets[attr].maximum() + self.updateRelativeWidgetMaximum(attr) + setattr(self, attr, getWidgetValue(self._trackedWidgets[attr])) + + self._preUpdate() + self._autoUpdate() + + def pickColor(self, textWidget, button): + """Use color picker to get color input from the user.""" + dialog = QtWidgets.QColorDialog() + # TODO alpha channel is not actually shown in most color picker widgets? + dialog.setOption( + QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel, True + ) + color = dialog.getColor() + if color.isValid(): + RGBstring = "%s,%s,%s" % ( + str(color.red()), + str(color.green()), + str(color.blue()), + ) + btnStyle = ( + "QPushButton{background-color: %s; outline: none;}" % color.name() + ) + textWidget.setText(RGBstring) + button.setStyleSheet(btnStyle) + + def lockProperties(self, propList): + self._lockedProperties = propList + + def lockError(self, msg): + self._lockedError = msg + + def lockSize(self, w, h): + self._lockedSize = (w, h) + + def unlockProperties(self): + self._lockedProperties = None + + def unlockError(self): + self._lockedError = None + + def unlockSize(self): + self._lockedSize = None + + def loadUi(self, filename): + """Load a Qt Designer ui file to use for this component's widget""" + return uic.loadUi(os.path.join(self.core.componentsPath, filename)) + + @property + def width(self): + if self._lockedSize is None: + return int(self.settings.value("outputWidth")) + else: + return self._lockedSize[0] + + @property + def height(self): + if self._lockedSize is None: + return int(self.settings.value("outputHeight")) + else: + return self._lockedSize[1] + + def cancel(self): + """Stop any lengthy process in response to this variable.""" + self.canceled = True + + def reset(self): + self.canceled = False + self.unlockProperties() + self.unlockError() + + def relativeWidgetAxis(func): + def relativeWidgetAxis(self, attr, *args, **kwargs): + hasVerticalWords = ( + lambda attr: "height" in attr.lower() + or "ypos" in attr.lower() + or attr == "y" + ) + if "axis" not in kwargs: + axis = self.width + if hasVerticalWords(attr): + axis = self.height + kwargs["axis"] = axis + if "axis" in kwargs and type(kwargs["axis"]) is tuple: + axis = kwargs["axis"][0] + if hasVerticalWords(attr): + axis = kwargs["axis"][1] + kwargs["axis"] = axis + return func(self, attr, *args, **kwargs) + + return relativeWidgetAxis + + @relativeWidgetAxis + def pixelValForAttr(self, attr, val=None, **kwargs): + if val is None: + val = self._relativeValues[attr] + if val > 50.0: + log.warning( + "%s #%s attempted to set %s to dangerously high number %s", + self.__class__.name, + self.compPos, + attr, + val, + ) + val = 50.0 + result = math.ceil(kwargs["axis"] * val) + log.verbose( + "Converting %s: f%s to px%s using axis %s", + attr, + val, + result, + kwargs["axis"], + ) + return result + + @relativeWidgetAxis + def floatValForAttr(self, attr, val=None, **kwargs): + if val is None: + val = self._trackedWidgets[attr].value() + return val / kwargs["axis"] + + def setRelativeWidget(self, attr, floatVal): + """Set a relative widget using a float""" + pixelVal = self.pixelValForAttr(attr, floatVal) + with blockSignals(self._trackedWidgets[attr]): + self._trackedWidgets[attr].setValue(pixelVal) + self.update(auto=True) + + def getOldAttr(self, attr): + """ + Returns previous state of this attr. Used to determine whether + a relative widget must be updated. Required because undoing/redoing + can make determining the 'previous' value tricky. + """ + if self.oldAttrs is not None: + return self.oldAttrs[attr] + else: + try: + return getattr(self, attr) + except AttributeError: + log.error("Using visible values instead of oldAttrs") + return self._trackedWidgets[attr].value() + + def updateRelativeWidget(self, attr): + """Called by _preUpdate() for each relativeWidget before each update""" + oldUserValue = self.getOldAttr(attr) + newUserValue = self._trackedWidgets[attr].value() + newRelativeVal = self.floatValForAttr(attr, newUserValue) + + if attr in self._relativeValues: + oldRelativeVal = self._relativeValues[attr] + if oldUserValue == newUserValue and oldRelativeVal != newRelativeVal: + # Float changed without pixel value changing, which + # means the pixel value needs to be updated + # TODO QDoubleSpinBox doesn't work with relativeWidgets because of this + log.debug( + "Updating %s #%s's relative widget: %s", + self.__class__.name, + self.compPos, + attr, + ) + with blockSignals(self._trackedWidgets[attr]): + self.updateRelativeWidgetMaximum(attr) + pixelVal = self.pixelValForAttr(attr, oldRelativeVal) + self._trackedWidgets[attr].setValue(pixelVal) + + if attr not in self._relativeValues or oldUserValue != newUserValue: + self._relativeValues[attr] = newRelativeVal + + def updateRelativeWidgetMaximum(self, attr): + maxRes = int(self.core.resolutions[0].split("x")[0]) + newMaximumValue = self.width * (self._relativeMaximums[attr] / maxRes) + self._trackedWidgets[attr].setMaximum(int(newMaximumValue)) 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 @@ +import time +import sys +import logging + +from ..toolkit import formatTraceback + + +log = logging.getLogger("AVP.ComponentHandler") + + +class ComponentError(RuntimeError): + """Gives the MainWindow a traceback to display, and cancels the export.""" + + prevErrors = [] + lastTime = time.time() + + def __init__(self, caller, name, msg=None): + if msg is None and sys.exc_info()[0] is not None: + msg = str(sys.exc_info()[1]) + else: + msg = "Unknown error." + log.error("ComponentError by %s's %s: %s" % (caller.name, name, msg)) + + # Don't create multiple windows for quickly repeated messages + if len(ComponentError.prevErrors) > 1: + ComponentError.prevErrors.pop() + ComponentError.prevErrors.insert(0, name) + curTime = time.time() + if ( + name in ComponentError.prevErrors[1:] + and curTime - ComponentError.lastTime < 1.0 + ): + return + ComponentError.lastTime = time.time() + + if sys.exc_info()[0] is not None: + string = "%s component (#%s): %s encountered %s %s: %s" % ( + caller.__class__.name, + str(caller.compPos), + name, + ( + "an" + if any( + [ + sys.exc_info()[0].__name__.startswith(vowel) + for vowel in ("A", "I", "U", "O", "E") + ] + ) + else "a" + ), + sys.exc_info()[0].__name__, + str(sys.exc_info()[1]), + ) + detail = formatTraceback(sys.exc_info()[2]) + else: + string = name + detail = "Attributes:\n%s" % ( + "\n".join([m for m in dir(caller) if not m.startswith("_")]) + ) + + super().__init__(string) + caller.lockError(string) + 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 @@ +import os +import logging +from PyQt6 import QtCore + +from .exceptions import ComponentError +from ..toolkit import connectWidget +from ..toolkit.frame import BlankFrame + +log = logging.getLogger("AVP.ComponentHandler") + + +class ComponentMetaclass(type(QtCore.QObject)): + """ + Checks the validity of each Component class and mutates some attrs. + E.g., takes only major version from version string & decorates methods + """ + + def initializationWrapper(func): + def initializationWrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except Exception: + try: + raise ComponentError(self, "initialization process") + except ComponentError: + return + + return initializationWrapper + + def renderWrapper(func): + def renderWrapper(self, *args, **kwargs): + try: + log.verbose( + "### %s #%s renders a preview frame ###", + self.__class__.name, + str(self.compPos), + ) + return func(self, *args, **kwargs) + except Exception as e: + try: + if e.__class__.__name__.startswith("Component"): + raise + else: + raise ComponentError(self, "renderer") + except ComponentError: + return BlankFrame() + + return renderWrapper + + def commandWrapper(func): + """Intercepts the command() method to check for global args""" + + def commandWrapper(self, arg): + if arg.startswith("preset="): + _, preset = arg.split("=", 1) + path = os.path.join(self.core.getPresetDir(self), preset) + if not os.path.exists(path): + print('Couldn\'t locate preset "%s"' % preset) + quit(1) + else: + print('Opening "%s" preset on layer %s' % (preset, self.compPos)) + self.core.openPreset(path, self.compPos, preset) + # Don't call the component's command() method + return + else: + return func(self, arg) + + return commandWrapper + + def propertiesWrapper(func): + """Intercepts the usual properties if the properties are locked.""" + + def propertiesWrapper(self): + if self._lockedProperties is not None: + return self._lockedProperties + else: + try: + return func(self) + except Exception: + try: + raise ComponentError(self, "properties") + except ComponentError: + return [] + + return propertiesWrapper + + def errorWrapper(func): + """Intercepts the usual error message if it is locked.""" + + def errorWrapper(self): + if self._lockedError is not None: + return self._lockedError + else: + return func(self) + + return errorWrapper + + def loadPresetWrapper(func): + """Wraps loadPreset to handle the self.openingPreset boolean""" + + class openingPreset: + def __init__(self, comp): + self.comp = comp + + def __enter__(self): + self.comp.openingPreset = True + + def __exit__(self, *args): + self.comp.openingPreset = False + + def presetWrapper(self, *args): + with openingPreset(self): + try: + return func(self, *args) + except Exception: + try: + raise ComponentError(self, "preset loader") + except ComponentError: + return + + return presetWrapper + + def updateWrapper(func): + """ + Calls _preUpdate before every subclass update(). + Afterwards, for non-user updates, calls _autoUpdate(). + For undoable updates triggered by the user, calls _userUpdate() + """ + + class wrap: + def __init__(self, comp, auto): + self.comp = comp + self.auto = auto + + def __enter__(self): + self.comp._preUpdate() + + def __exit__(self, *args): + if ( + self.auto + or self.comp.openingPreset + or not hasattr(self.comp.parent, "undoStack") + ): + log.verbose("Automatic update") + self.comp._autoUpdate() + else: + log.verbose("User update") + self.comp._userUpdate() + + def updateWrapper(self, **kwargs): + auto = kwargs["auto"] if "auto" in kwargs else False + with wrap(self, auto): + try: + return func(self) + except Exception: + try: + raise ComponentError(self, "update method") + except ComponentError: + return + + return updateWrapper + + def widgetWrapper(func): + """Connects all widgets to update method after the subclass's method""" + + class wrap: + def __init__(self, comp): + self.comp = comp + + def __enter__(self): + pass + + def __exit__(self, *args): + for widgetList in self.comp._allWidgets.values(): + for widget in widgetList: + log.verbose("Connecting %s", str(widget.__class__.__name__)) + connectWidget(widget, self.comp.update) + + def widgetWrapper(self, *args, **kwargs): + auto = kwargs["auto"] if "auto" in kwargs else False + with wrap(self): + try: + return func(self, *args, **kwargs) + except Exception: + try: + raise ComponentError(self, "widget creation") + except ComponentError: + return + + return widgetWrapper + + def __new__(cls, name, parents, attrs): + if "ui" not in attrs: + # Use module name as ui filename by default + attrs["ui"] = ( + "%s.ui" % os.path.splitext(attrs["__module__"].split(".")[-1])[0] + ) + + decorate = ( + "names", # Class methods + "error", + "audio", + "properties", # Properties + "preFrameRender", + "previewRender", + "loadPreset", + "command", + "update", + "widget", + ) + + # Auto-decorate methods + for key in decorate: + if key not in attrs: + continue + if key in ("names"): + attrs[key] = classmethod(attrs[key]) + elif key in ("audio"): + attrs[key] = property(attrs[key]) + elif key == "command": + attrs[key] = cls.commandWrapper(attrs[key]) + elif key == "previewRender": + attrs[key] = cls.renderWrapper(attrs[key]) + elif key == "preFrameRender": + attrs[key] = cls.initializationWrapper(attrs[key]) + elif key == "properties": + attrs[key] = cls.propertiesWrapper(attrs[key]) + elif key == "error": + attrs[key] = cls.errorWrapper(attrs[key]) + elif key == "loadPreset": + attrs[key] = cls.loadPresetWrapper(attrs[key]) + elif key == "update": + attrs[key] = cls.updateWrapper(attrs[key]) + elif key == "widget" and parents[0] != QtCore.QObject: + attrs[key] = cls.widgetWrapper(attrs[key]) + + # Turn version string into a number + try: + if "version" not in attrs: + log.error( + "No version attribute in %s. Defaulting to 1", + attrs["name"], + ) + attrs["version"] = 1 + else: + attrs["version"] = int(attrs["version"].split(".")[0]) + except ValueError: + log.critical( + "%s component has an invalid version string:\n%s", + attrs["name"], + str(attrs["version"]), + ) + except KeyError: + log.critical("%s component has no version string.", attrs["name"]) + else: + return super().__new__(cls, name, parents, attrs) + 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 from queue import PriorityQueue import logging -from .. import core +from ..core import Core from .common import checkOutput, pipeWrapper @@ -19,7 +19,7 @@ log = logging.getLogger("AVP.Toolkit.Ffmpeg") class FfmpegVideo: - """Opens a pipe to ffmpeg and stores a buffer of raw video frames.""" + """Opens an input pipe to ffmpeg and stores a buffer of raw video frames.""" # error from the thread used to fill the buffer threadError = None @@ -53,7 +53,7 @@ class FfmpegVideo: kwargs["filter_"] = None self.command = [ - core.Core.FFMPEG_BIN, + Core.FFMPEG_BIN, "-thread_queue_size", "512", "-r", @@ -98,11 +98,11 @@ class FfmpegVideo: self.frameBuffer.task_done() def fillBuffer(self): - from ..component import ComponentError + from ..libcomponent import ComponentError - if core.Core.logEnabled: + if Core.logEnabled: logFilename = os.path.join( - core.Core.logDir, "render_%s.log" % str(self.component.compPos) + Core.logDir, "render_%s.log" % str(self.component.compPos) ) log.debug("Creating ffmpeg process (log at %s)", logFilename) with open(logFilename, "w") as logf: @@ -176,7 +176,7 @@ def findFfmpeg(): if getattr(sys, "frozen", False): # The application is frozen - bin = os.path.join(core.Core.wd, bin) + bin = os.path.join(Core.wd, bin) with open(os.devnull, "w") as f: try: @@ -187,7 +187,9 @@ def findFfmpeg(): return bin -def createFfmpegCommand(inputFile, outputFile, components, duration=-1): +def createFfmpegCommand( + inputFile, outputFile, components, duration=-1, logLevel="info" +): """ Constructs the major ffmpeg command used to export the video """ @@ -195,7 +197,6 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1): duration = getAudioDuration(inputFile) safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters duration = "{0:.3f}".format(duration + 0.1) # used by input sources - Core = core.Core # Test if user has libfdk_aac encoders = checkOutput("%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True) @@ -243,6 +244,8 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1): ffmpegCommand = [ Core.FFMPEG_BIN, + "-loglevel", + logLevel, "-thread_queue_size", "512", "-y", # overwrite the output file if it already exists. @@ -415,7 +418,7 @@ def createAudioFilterCommand(extraAudio, duration): def testAudioStream(filename): """Test if an audio stream definitely exists""" audioTestCommand = [ - core.Core.FFMPEG_BIN, + Core.FFMPEG_BIN, "-i", filename, "-vn", @@ -433,7 +436,7 @@ def testAudioStream(filename): def getAudioDuration(filename): """Try to get duration of audio file as float, or False if not possible""" - command = [core.Core.FFMPEG_BIN, "-i", filename] + command = [Core.FFMPEG_BIN, "-i", filename] try: fileInfo = checkOutput(command, stderr=subprocess.STDOUT) @@ -473,7 +476,7 @@ def readAudioFile(filename, videoWorker): return command = [ - core.Core.FFMPEG_BIN, + Core.FFMPEG_BIN, "-i", filename, "-f", @@ -498,7 +501,7 @@ def readAudioFile(filename, videoWorker): progress = 0 lastPercent = None while True: - if core.Core.canceled: + if Core.canceled: return # read 2 seconds of audio progress += 4 @@ -543,3 +546,18 @@ def exampleSound(style="white", extra="apulsator=offset_l=0.35:offset_r=0.67"): src = "0.1*sin(2*PI*(360-2.5/2)*t) | 0.1*sin(2*PI*(360+2.5/2)*t)" return "aevalsrc='%s', %s%s" % (src, extra, ", " if extra else "") + + +def checkFfmpegVersion(): + try: + with open(os.devnull, "w") as f: + ffmpegVers = checkOutput([Core.FFMPEG_BIN, "-version"], stderr=f) + ffmpegVers = str(ffmpegVers).split()[2].split(".", 1)[0] + if ffmpegVers.startswith("n"): + ffmpegVers = ffmpegVers[1:] + versionNum = int(ffmpegVers) + goodVersion = versionNum > 3 + except Exception: + versionNum = -1 + goodVersion = False + 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( progressBarUpdate, progressBarSetText, ): + lastProgress = 0 lastSpectrum = None spectrumArray = {} for i in range(0, len(completeAudioArray), sampleSize): @@ -33,9 +34,12 @@ def createSpectrumArray( progress = int(100 * (i / len(completeAudioArray))) if progress >= 100: progress = 100 + if progress == lastProgress: + continue progressText = f"Analyzing audio: {str(progress)}%" progressBarSetText.emit(progressText) progressBarUpdate.emit(int(progress)) + lastProgress = progress return spectrumArray 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 from PyQt6.QtCore import pyqtSignal, pyqtSlot from PIL import Image from PIL.ImageQt import ImageQt + import numpy import subprocess as sp import sys import os -import time import signal import logging -from .component import ComponentError -from .toolkit.frame import Checkerboard +from .libcomponent import ComponentError +from .toolkit import formatTraceback from .toolkit.ffmpeg import ( openPipe, readAudioFile, @@ -61,7 +61,11 @@ class Worker(QtCore.QObject): def createFfmpegCommand(self, duration): try: ffmpegCommand = createFfmpegCommand( - self.inputFile, self.outputFile, self.components, duration + self.inputFile, + self.outputFile, + self.components, + duration, + "info" if log.getEffectiveLevel() < logging.WARNING else "error", ) except sp.CalledProcessError as e: # FIXME video_thread should own this error signal, not components @@ -111,6 +115,7 @@ class Worker(QtCore.QObject): Also prerenders "static" components like text and merges them if possible """ self.staticComponents = {} + self.compositeComponents = set() # Call preFrameRender on each component canceledByComponent = False @@ -160,6 +165,8 @@ class Worker(QtCore.QObject): if "static" in compProps: log.info("Saving static frame from #%s %s", compNo, comp) self.staticComponents[compNo] = comp.frameRender(0).copy() + elif compNo > 0 and "composite" in compProps: + self.compositeComponents.add(compNo) # Check if any errors occured log.debug("Checking if a component wishes to cancel the export...") @@ -208,9 +215,11 @@ class Worker(QtCore.QObject): self.closePipe() self.cancelExport() self.error = True - msg = "A call to renderFrame in the video thread failed critically." - log.critical(msg) - comp._error.emit(msg, str(e)) + msg = f"{comp.name} renderFrame({int(audioI / self.sampleSize)}) raised an exception." + tb = formatTraceback() + details = f"{e.__class__.__name__}: {str(e)}\n\n{tb}" + log.critical(f"{msg}\n{details}") + comp._error.emit(msg, details) bgI = int(audioI / self.sampleSize) frame = None @@ -230,6 +239,9 @@ class Worker(QtCore.QObject): frame, self.staticComponents[layerNo] ) + elif layerNo in self.compositeComponents: + # component that uses previous frame to draw + frame = Image.alpha_composite(frame, comp.frameRender(bgI, frame)) else: # animated component if frame is None: # bottom-most layer @@ -309,9 +321,9 @@ class Worker(QtCore.QObject): log.critical("Out_Pipe to FFmpeg couldn't be created!", exc_info=True) raise - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # START CREATING THE VIDEO - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ progressBarValue = 0 self.progressBarUpdate.emit(progressBarValue) # Begin piping into ffmpeg! @@ -335,16 +347,13 @@ class Worker(QtCore.QObject): completion = (audioI / self.audioArrayLen) * 100 if progressBarValue + 1 <= completion: progressBarValue = numpy.floor(completion).astype(int) + msg = "Exporting video: %s%%" % str(int(progressBarValue)) self.progressBarUpdate.emit(progressBarValue) - self.progressBarSetText.emit( - "Exporting video: %s%%" % str(int(progressBarValue)) - ) + self.progressBarSetText.emit(msg) - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Finished creating the video! - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - - numpy.seterr(all="print") + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ self.closePipe() @@ -363,7 +372,7 @@ class Worker(QtCore.QObject): if self.error: self.failExport() else: - print("Export Complete") + print("\nExport Complete") self.progressBarUpdate.emit(100) self.progressBarSetText.emit("Export Complete") 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 @@ import os +import tempfile import numpy from avp.core import Core @@ -8,10 +9,19 @@ from avp.toolkit.ffmpeg import readAudioFile from pytest import fixture +PYTEST_XDIST_WORKER_COUNT = os.environ.get("PYTEST_XDIST_WORKER_COUNT", 0) + + +@fixture +def settings(): + """Doesn't instantiate core: just calls a static method to store `settings.ini`""" + initCore() + yield None + + @fixture def audioData(): """Fixture that gives a tuple of (completeAudioArray, duration)""" - # Core.storeSettings() needed to store ffmpeg bin location initCore() soundFile = getTestDataPath("inputfiles/test.ogg") yield readAudioFile(soundFile, MockVideoWorker()) @@ -28,6 +38,8 @@ def command(qtbot): @fixture def window(qtbot): initCore() + # patch out any modal dialog that might happen + MainWindow.showMessage = lambda self, msg, **kwargs: print(msg) window = MainWindow(None, None) window.clear() qtbot.addWidget(window) @@ -43,13 +55,41 @@ def getTestDataPath(filename=""): def initCore(): - testDataDir = getTestDataPath("config") + """ + Initializes the Core by creating `settings.ini` + Returns the temp directory path where settings.ini was created + or None if multiple pytest workers are not enabled. + """ + try: + numWorkers = int(PYTEST_XDIST_WORKER_COUNT) + except ValueError: + numWorkers = 0 + if numWorkers > 0: + # use temporary directories for multiple workers + # so they don't interfere with each other + configDir = tempfile.mkdtemp(prefix="avp-config-") + else: + # use test data path so we can easily see it after + # a failed test, and help us understand the config + configDir = getTestDataPath("config") unwanted = ["autosave.avp", "settings.ini"] for file in unwanted: - filename = os.path.join(testDataDir, "autosave.avp") + filename = os.path.join(configDir, "autosave.avp") if os.path.exists(filename): os.remove(filename) - Core.storeSettings(testDataDir) + Core.storeSettings(configDir) + return configDir if numWorkers > 0 else None + + +def preFrameRender(audioData, comp): + """Prepares a component for calls to frameRender()""" + comp.preFrameRender( + audioFile=getTestDataPath("inputfiles/test.ogg"), + completeAudioArray=audioData[0], + sampleSize=1470, + progressBarSetText=MockSignal(), + progressBarUpdate=MockSignal(), + ) 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 def test_commandline_classic_export(qtbot, command): """Run Qt event loop and create a video in the system /tmp or /temp""" soundFile = getTestDataPath("inputfiles/test.ogg") - outputDir = tempfile.mkdtemp(prefix="avp-test-") + outputDir = tempfile.mkdtemp(prefix="avp-export-") outputFilename = os.path.join(outputDir, "output.mp4") sys.argv = [ "", "-c", "0", "classic", + "color=255,255,255", "-i", soundFile, "-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 @@ +from avp.toolkit.visualizer import transformData +from pytestqt import qtbot +from pytest import fixture, mark +from . import audioData, command, imageDataSum, preFrameRender + + +sampleSize = 1470 # 44100 / 30 = 1470 + + +def createSpectrumArray(audioData): + """Creates enough `spectrumArray` for one call to Component.drawBars()""" + spectrumArray = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)} + for i in range(sampleSize, len(audioData[0]), sampleSize): + spectrumArray[i] = transformData( + i, + audioData[0], + sampleSize, + 0.08, + 0.8, + spectrumArray[i - sampleSize].copy(), + 20, + ) + return spectrumArray + + +@fixture +def coreWithClassicComp(qtbot, command): + """Fixture providing a Command object with Classic Visualizer component added""" + command.core.insertComponent( + 0, command.core.moduleIndexFor("Classic Visualizer"), command + ) + yield command.core + + +def test_comp_classic_added(coreWithClassicComp): + """Add Classic Visualizer to core""" + assert len(coreWithClassicComp.selectedComponents) == 1 + + +def test_comp_classic_removed(coreWithClassicComp): + """Remove Classic Visualizer from core""" + coreWithClassicComp.removeComponent(0) + assert len(coreWithClassicComp.selectedComponents) == 0 + + +@mark.parametrize("layout", (0, 1, 2, 3)) +def test_comp_classic_drawBars(coreWithClassicComp, audioData, layout): + """Call drawBars after creating audio spectrum data manually.""" + spectrumArray = createSpectrumArray(audioData) + comp = coreWithClassicComp.selectedComponents[0] + image = comp.drawBars( + 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), layout, None + ) + imageSize = 37872316 + assert imageDataSum(image) == imageSize if layout < 2 else imageSize / 2 + + +def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData): + """Call drawBars after creating audio spectrum data using preFrameRender.""" + comp = coreWithClassicComp.selectedComponents[0] + preFrameRender(audioData, comp) + image = comp.drawBars( + 1920, + 1080, + coreWithClassicComp.selectedComponents[0].spectrumArray[sampleSize * 4], + (0, 0, 0), + 0, + None, + ) + assert imageDataSum(image) == 37872316 + + +def test_comp_classic_command_layout(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + comp.command("layout=top") + assert comp.layout == 3 + + +def test_comp_classic_command_color(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + comp.command("color=111,111,111") + assert comp.visColor == (111, 111, 111) + + +def test_comp_classic_command_preset(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + saveValueStore = comp.savePreset() + saveValueStore["preset"] = "testPreset" + coreWithClassicComp.createPresetFile( + comp.name, comp.version, "testPreset", saveValueStore + ) + comp.command("preset=testPreset") + assert comp.currentPreset == "testPreset" + + +def test_comp_classic_loadPreset(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + comp.scale = 99 + saveValueStore = comp.savePreset() + saveValueStore["preset"] = "testPreset" + comp.scale = 20 + comp.loadPreset(saveValueStore, "testPreset") + 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): def test_comp_color_set_color(coreWithColorComp): - "Set imagePath of Image component" + """Set imagePath of Image component""" comp = coreWithColorComp.selectedComponents[0] comp.page.lineEdit_color1.setText("111,111,111") image = comp.previewRender() assert imageDataSum(image) == 1219276800 + + +def test_comp_color_gradient(coreWithColorComp): + """Test changing fill type to a gradient""" + comp = coreWithColorComp.selectedComponents[0] + comp.page.comboBox_fill.setCurrentIndex(1) + comp.page.lineEdit_color1.setText("0,0,0") + comp.page.lineEdit_color2.setText("255,255,255") + image = comp.previewRender() + 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 @@ +import os from avp.command import Command from pytestqt import qtbot from pytest import fixture @@ -5,6 +6,7 @@ from . import audioData, command, MockSignal, imageDataSum, getTestDataPath sampleSize = 1470 # 44100 / 30 = 1470 +testFile = "inputfiles/test.jpg" @fixture @@ -19,7 +21,7 @@ def coreWithImageComp(qtbot, command): def test_comp_image_set_path(coreWithImageComp): "Set imagePath of Image component" comp = coreWithImageComp.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") + comp.imagePath = getTestDataPath(testFile) image = comp.previewRender() assert imageDataSum(image) == 463711601 @@ -27,7 +29,7 @@ def test_comp_image_set_path(coreWithImageComp): def test_comp_image_scale_50_1080p(coreWithImageComp): """Image component stretches image to 50% at 1080p""" comp = coreWithImageComp.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") + comp.imagePath = getTestDataPath(testFile) image = comp.previewRender() sum = imageDataSum(image) comp.page.spinBox_scale.setValue(50) @@ -37,7 +39,7 @@ def test_comp_image_scale_50_1080p(coreWithImageComp): def test_comp_image_scale_50_720p(coreWithImageComp): """Image component stretches image to 50% at 720p""" comp = coreWithImageComp.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") + comp.imagePath = getTestDataPath(testFile) comp.page.spinBox_scale.setValue(50) image = comp.previewRender() sum = imageDataSum(image) @@ -47,3 +49,12 @@ def test_comp_image_scale_50_720p(coreWithImageComp): assert image.width == 1920 assert newImage.width == 1280 assert imageDataSum(comp.previewRender()) == sum + + +def test_comp_image_command_path(coreWithImageComp): + """Image component accepts commandline argument: + `path=test.jpg`""" + imgPath = os.path.realpath(getTestDataPath(testFile)) + comp = coreWithImageComp.selectedComponents[0] + comp.command(f"path={imgPath}") + 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 @@ -from avp.command import Command -from avp.toolkit.visualizer import transformData -from pytestqt import qtbot -from pytest import fixture -from . import audioData, command, MockSignal, imageDataSum - - -sampleSize = 1470 # 44100 / 30 = 1470 - - -@fixture -def coreWithClassicComp(qtbot, command): - """Fixture providing a Command object with Classic Visualizer component added""" - command.core.insertComponent( - 0, command.core.moduleIndexFor("Classic Visualizer"), command - ) - yield command.core - - -def test_comp_classic_added(coreWithClassicComp): - """Add Classic Visualizer to core""" - assert len(coreWithClassicComp.selectedComponents) == 1 - - -def test_comp_classic_removed(coreWithClassicComp): - """Remove Classic Visualizer from core""" - coreWithClassicComp.removeComponent(0) - assert len(coreWithClassicComp.selectedComponents) == 0 - - -def test_comp_classic_drawBars(coreWithClassicComp, audioData): - """Call drawBars after creating audio spectrum data manually.""" - - spectrumArray = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)} - for i in range(sampleSize, len(audioData[0]), sampleSize): - spectrumArray[i] = transformData( - i, - audioData[0], - sampleSize, - 0.08, - 0.8, - spectrumArray[i - sampleSize].copy(), - 20, - ) - image = coreWithClassicComp.selectedComponents[0].drawBars( - 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), 0 - ) - assert imageDataSum(image) == 37872316 - - -def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData): - """Call drawBars after creating audio spectrum data using preFrameRender.""" - comp = coreWithClassicComp.selectedComponents[0] - comp.preFrameRender( - completeAudioArray=audioData[0], - sampleSize=sampleSize, - progressBarSetText=MockSignal(), - progressBarUpdate=MockSignal(), - ) - image = comp.drawBars( - 1920, - 1080, - coreWithClassicComp.selectedComponents[0].spectrumArray[sampleSize * 4], - (0, 0, 0), - 0, - ) - 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 @@ from avp.command import Command from pytestqt import qtbot from pytest import fixture -from . import imageDataSum, command +from . import ( + imageDataSum, + command, + preFrameRender, + audioData, +) @fixture @@ -13,7 +18,15 @@ def coreWithSpectrumComp(qtbot, command): yield command.core -def test_comp_waveform_previewRender(coreWithSpectrumComp): +def test_comp_spectrum_previewRender(coreWithSpectrumComp): comp = coreWithSpectrumComp.selectedComponents[0] image = comp.previewRender() assert imageDataSum(image) == 71992628 + + +def test_comp_spectrum_renderFrame(coreWithSpectrumComp, audioData): + comp = coreWithSpectrumComp.selectedComponents[0] + preFrameRender(audioData, comp) + image = comp.frameRender(0) + comp.postFrameRender() + 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 @@ from pytestqt import qtbot from pytest import fixture -from . import command +from avp.toolkit.ffmpeg import checkFfmpegVersion +from . import command, imageDataSum, audioData, preFrameRender @fixture def coreWithWaveformComp(qtbot, command): """Fixture providing a Command object with Waveform component added""" + command.settings.setValue("outputWidth", 1920) + command.settings.setValue("outputHeight", 1080) command.core.insertComponent(0, command.core.moduleIndexFor("Waveform"), command) yield command.core @@ -14,3 +17,24 @@ def test_comp_waveform_setColor(coreWithWaveformComp): comp = coreWithWaveformComp.selectedComponents[0] comp.page.lineEdit_color.setText("255,255,255") assert comp.color == (255, 255, 255) + + +def test_comp_waveform_previewRender(coreWithWaveformComp): + comp = coreWithWaveformComp.selectedComponents[0] + comp.page.lineEdit_color.setText("255,255,255") + _, version = checkFfmpegVersion() + if version > 6: + # FFmpeg 8 has different colors from 6 + # TODO check version 7 + assert imageDataSum(comp.previewRender()) == 36114120 + else: + assert imageDataSum(comp.previewRender()) == 37210620 + + +def test_comp_waveform_renderFrame(coreWithWaveformComp, audioData): + comp = coreWithWaveformComp.selectedComponents[0] + comp.page.lineEdit_color.setText("255,255,255") + preFrameRender(audioData, comp) + image = comp.frameRender(0) + comp.postFrameRender() + 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 @@ import os from avp.core import Core -from . import getTestDataPath, initCore +from . import getTestDataPath, settings -def test_component_names(): - initCore() +def test_component_names(settings): core = Core() assert core.compNames == [ "Classic Visualizer", @@ -19,8 +18,7 @@ def test_component_names(): ] -def test_moduleindex(): - initCore() +def test_moduleindex(settings): core = Core() assert core.moduleIndexFor("Classic Visualizer") == 0 diff --git a/tests/test_mainwindow_comp_actions.py b/tests/test_mainwindow_comp_actions.py new file mode 100644 index 0000000..5d3cc7a --- /dev/null +++ b/tests/test_mainwindow_comp_actions.py @@ -0,0 +1,66 @@ +"""Tests of MainWindow undoing certain ComponentActions (changes to component settings)""" + +from pytest import fixture +from pytestqt import qtbot +from avp.gui.mainwindow import MainWindow +from . import getTestDataPath, window + + +def test_undo_classic_visualizer_sensitivity(window, qtbot): + """Undo Classic Visualizer component sensitivity setting + should undo multiple merged actions.""" + window.core.insertComponent( + 0, window.core.moduleIndexFor("Classic Visualizer"), window + ) + comp = window.core.selectedComponents[0] + comp.imagePath = getTestDataPath("inputfiles/test.jpg") + for i in range(1, 100): + comp.page.spinBox_scale.setValue(i) + assert comp.scale == 99 + window.undoStack.undo() + assert comp.scale == 20 + + +def test_undo_image_scale(window, qtbot): + """Undo Image component scale setting should undo multiple merged actions.""" + window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) + comp = window.core.selectedComponents[0] + comp.imagePath = getTestDataPath("inputfiles/test.jpg") + comp.page.spinBox_scale.setValue(100) + for i in range(10, 401): + comp.page.spinBox_scale.setValue(i) + assert comp.scale == 400 + window.undoStack.undo() + assert comp.scale == 10 + window.undoStack.undo() + assert comp.scale == 100 + + +def test_undo_image_resizeMode(window, qtbot): + window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) + comp = window.core.selectedComponents[0] + comp.page.comboBox_resizeMode.setCurrentIndex(1) + assert not comp.page.spinBox_scale.isEnabled() + window.undoStack.undo() + assert comp.page.spinBox_scale.isEnabled() + + +def test_undo_title_text_merged(window, qtbot): + """Undoing title text change should undo all recent changes.""" + window.core.insertComponent(0, window.core.moduleIndexFor("Title Text"), window) + comp = window.core.selectedComponents[0] + comp.page.lineEdit_title.setText("avp") + comp.page.lineEdit_title.setText("test") + window.undoStack.undo() + assert comp.title == "Text" + + +def test_undo_title_text_not_merged(window, qtbot): + """Undoing title text change should undo up to previous different action""" + window.core.insertComponent(0, window.core.moduleIndexFor("Title Text"), window) + comp = window.core.selectedComponents[0] + comp.page.lineEdit_title.setText("avp") + comp.page.spinBox_xTextAlign.setValue(0) + comp.page.lineEdit_title.setText("test") + window.undoStack.undo() + assert comp.title == "avp" 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 @@ +"""Tests of `actions.py` - MainWindow component list manipulation via undoable actions""" + +from PyQt6 import QtCore +import os +from pytest import fixture +from pytestqt import qtbot +from . import getTestDataPath, window + + +def test_mainwindow_addComponent(qtbot, window): + window.compMenu.actions()[0].trigger() + assert len(window.core.selectedComponents) == 1 + + +def test_mainwindow_removeComponent(qtbot, window): + window.compMenu.actions()[0].trigger() # add component + window.pushButton_removeComponent.click() # remove it + assert len(window.core.selectedComponents) == 0 + + +def test_mainwindow_moveComponent(qtbot, window): + # add first two components from menu + window.compMenu.actions()[0].trigger() + window.compMenu.actions()[1].trigger() + comp0 = window.core.selectedComponents[0].ui + window.pushButton_listMoveDown.click() + # check if 0 is now 1 + assert window.core.selectedComponents[1].ui == comp0 + + +def test_mainwindow_addComponent_undo(qtbot, window): + window.compMenu.actions()[0].trigger() + window.undoStack.undo() + assert len(window.core.selectedComponents) == 0 + + +def test_mainwindow_removeComponent_undo(qtbot, window): + window.compMenu.actions()[0].trigger() # add component + window.pushButton_removeComponent.click() # remove it + window.undoStack.undo() + assert len(window.core.selectedComponents) == 1 + + +def test_mainwindow_moveComponent_undo(qtbot, window): + # add first two components from menu + window.compMenu.actions()[0].trigger() + window.compMenu.actions()[1].trigger() + comp0 = window.core.selectedComponents[0].ui + window.pushButton_listMoveDown.click() + window.undoStack.undo() + # check if 0 is still 0 after undo + 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 import os from pytest import fixture from pytestqt import qtbot -from . import getTestDataPath, window +from avp.gui.mainwindow import MainWindow +from . import getTestDataPath, window, settings + + +def test_mainwindow_init_with_project(qtbot, settings): + window = MainWindow(getTestDataPath("config/projects/testproject.avp"), None) + qtbot.addWidget(window) + assert window.core.selectedComponents[0].name == "Classic Visualizer" + assert window.core.selectedComponents[1].name == "Color" def test_mainwindow_clear(qtbot, window): @@ -11,11 +19,8 @@ def test_mainwindow_clear(qtbot, window): def test_mainwindow_presetDir_in_tests(qtbot, window): - # FIXME presetDir gets set to projectDir for some reason - assert ( - os.path.basename(os.path.dirname(window.core.settings.value("presetDir"))) - == "config" - ) + """`presetDir` is the filepath on which "Import Preset" file picker opens""" + assert os.path.basename(window.core.settings.value("presetDir")) == "presets" def test_mainwindow_openProject(qtbot, window): diff --git a/tests/test_mainwindow_undostack.py b/tests/test_mainwindow_undostack.py deleted file mode 100644 index ceaf87e..0000000 --- a/tests/test_mainwindow_undostack.py +++ /dev/null @@ -1,64 +0,0 @@ -from pytest import fixture -from pytestqt import qtbot -from avp.gui.mainwindow import MainWindow -from . import getTestDataPath, window - - -def test_undo_classic_visualizer_sensitivity(window, qtbot): - """Undo Classic Visualizer component sensitivity setting - should undo multiple merged actions.""" - window.core.insertComponent( - 0, window.core.moduleIndexFor("Classic Visualizer"), window - ) - comp = window.core.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") - for i in range(1, 100): - comp.page.spinBox_scale.setValue(i) - assert comp.scale == 99 - window.undoStack.undo() - assert comp.scale == 20 - - -def test_undo_image_scale(window, qtbot): - """Undo Image component scale setting should undo multiple merged actions.""" - window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) - comp = window.core.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") - comp.page.spinBox_scale.setValue(100) - for i in range(10, 401): - comp.page.spinBox_scale.setValue(i) - assert comp.scale == 400 - window.undoStack.undo() - assert comp.scale == 10 - window.undoStack.undo() - assert comp.scale == 100 - - -def test_undo_image_resizeMode(window, qtbot): - window.core.insertComponent(0, window.core.moduleIndexFor("Image"), window) - comp = window.core.selectedComponents[0] - comp.page.comboBox_resizeMode.setCurrentIndex(1) - assert not comp.page.spinBox_scale.isEnabled() - window.undoStack.undo() - assert comp.page.spinBox_scale.isEnabled() - - -def test_undo_title_text_merged(window, qtbot): - """Undoing title text change should undo all recent changes.""" - window.core.insertComponent(0, window.core.moduleIndexFor("Title Text"), window) - comp = window.core.selectedComponents[0] - comp.page.lineEdit_title.setText("avp") - comp.page.lineEdit_title.setText("test") - window.undoStack.undo() - assert comp.title == "Text" - - -def test_undo_title_text_not_merged(window, qtbot): - """Undoing title text change should undo up to previous different action""" - window.core.insertComponent(0, window.core.moduleIndexFor("Title Text"), window) - comp = window.core.selectedComponents[0] - comp.page.lineEdit_title.setText("avp") - comp.page.spinBox_xTextAlign.setValue(0) - comp.page.lineEdit_title.setText("test") - window.undoStack.undo() - assert comp.title == "avp" 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 @@ import pytest -from avp.core import Core -from avp.command import Command from avp.toolkit.ffmpeg import createFfmpegCommand -from . import audioData, getTestDataPath, initCore +from . import audioData, getTestDataPath, command def test_readAudioFile_data(audioData): @@ -14,14 +12,14 @@ def test_readAudioFile_duration(audioData): @pytest.mark.parametrize("width, height", ((1920, 1080), (1280, 720))) -def test_createFfmpegCommand(width, height): - initCore() - command = Command() +def test_createFfmpegCommand(command, width, height): command.settings.setValue("outputWidth", width) command.settings.setValue("outputHeight", height) ffmpegCmd = createFfmpegCommand("test.ogg", "/tmp", command.core.selectedComponents) assert ffmpegCmd == [ "ffmpeg", + "-loglevel", + "info", "-thread_queue_size", "512", "-y", diff --git a/uv.lock b/uv.lock index e403a68..461e725 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "audio-visualizer-python" -version = "2.2.3" +version = "2.2.4" source = { editable = "." } dependencies = [ { name = "numpy" }, -- cgit v1.2.3