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--- 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 +- 30 files changed, 1669 insertions(+), 1562 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 (limited to 'src/avp') 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") -- cgit v1.2.3