diff --git a/.gitignore b/.gitignore index 5f7cabb..68c7fcb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ prof/ .env/ .vscode/ tests/data/config/log/ +tests/data/config/presets/ tests/data/config/settings.ini tests/data/config/autosave.avp *.mkv diff --git a/pyproject.toml b/pyproject.toml index 2d604f5..a382882 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "uv_build" name = "audio-visualizer-python" description = "Create audio visualization videos from a GUI or commandline" readme = "README.md" -version = "2.2.3" +version = "2.2.4" requires-python = ">= 3.12" license = "MIT" classifiers=[ diff --git a/src/avp/__init__.py b/src/avp/__init__.py index 8783660..184afda 100644 --- a/src/avp/__init__.py +++ b/src/avp/__init__.py @@ -3,7 +3,7 @@ import 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 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 @@ def parseArgs(self): 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 @@ def parseArgs(self): 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 @@ def parseArgs(self): return "commandline" elif args.no_preview: - core.Core.previewEnabled = False + Core.previewEnabled = False elif ( args.projpath is None @@ -203,20 +205,18 @@ def stopVideo(self, *args): @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 @@ def videoCreated(self): self.quit(0) def quit(self, code): + print() quit(code) def showMessage(self, **kwargs): @@ -242,12 +243,14 @@ def drawPreview(self, *args): 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 @@ def getFilename(): 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/components/original.py b/src/avp/components/classic.py similarity index 74% rename from src/avp/components/original.py rename to src/avp/components/classic.py index 0da78dc..72089af 100644 --- a/src/avp/components/original.py +++ b/src/avp/components/classic.py @@ -1,21 +1,23 @@ import numpy from PIL import Image, ImageDraw -from copy import copy -from ..component import Component -from ..toolkit.frame import BlankFrame +from ..libcomponent import BaseComponent +from ..toolkit.frame import BlankFrame, FloodFrame from ..toolkit.visualizer import createSpectrumArray -class Component(Component): +class Component(BaseComponent): name = "Classic Visualizer" - version = "1.1.0" + version = "1.2.0" def names(*args): - return ["Original Audio Visualization"] + return ["Original"] def properties(self): - return ["pcm"] + props = ["pcm"] + if self.invert: + props.append("composite") + return props def widget(self, *args): self.scale = 20 @@ -37,6 +39,7 @@ def widget(self, *args): "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, @@ -46,14 +49,19 @@ def widget(self, *args): ], ) - def previewRender(self): + 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 + self.width, + self.height, + spectrum, + self.visColor, + self.layout, + frame, ) def preFrameRender(self, **kwargs): @@ -71,7 +79,7 @@ def preFrameRender(self, **kwargs): self.progressBarSetText, ) - def frameRender(self, frameNo): + def frameRender(self, frameNo, frame=None): arrayNo = frameNo * self.sampleSize return self.drawBars( self.width, @@ -79,9 +87,10 @@ def frameRender(self, frameNo): self.spectrumArray[arrayNo], self.visColor, self.layout, + frame, ) - def drawBars(self, width, height, spectrum, color, layout): + def drawBars(self, width, height, spectrum, color, layout, frame): bigYCoord = height - height / 8 smallYCoord = height / 1200 bigXCoord = width / (self.bars + 1) @@ -94,32 +103,44 @@ def drawBars(self, width, height, spectrum, color, layout): 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( - ( + # 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, - ), - fill=color2, - ) + ) + 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( - ( - x0, - y0 if y0 < y1 else y1, - x1 if x1 > x0 else x0, - y1 if y0 < y1 else y0, - ), - fill=color, + 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) @@ -146,7 +167,11 @@ def drawBars(self, width, height, spectrum, color, layout): y = self.y - int(height / 100 * 10) im.paste(imBottom, (0, y), mask=imBottom) - return im + 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: diff --git a/src/avp/components/original.ui b/src/avp/components/classic.ui similarity index 97% rename from src/avp/components/original.ui rename to src/avp/components/classic.ui index 8dbdaa2..1ae7faa 100644 --- a/src/avp/components/original.ui +++ b/src/avp/components/classic.ui @@ -86,7 +86,7 @@ - + @@ -232,6 +232,13 @@ + + + + Invert + + + 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 @@ def pickImage(self): 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 @@ 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/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 @@ 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 @@ 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 @@ 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 @@ 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 @@ def insertComponent(self, compPos, component, loader): 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 @@ def openProject(self, loader, filepath): ) continue if i == -1: - loader.showMessage(msg="Too many components!") + loader.showMessage(msg="Invalid components!") break try: @@ -554,7 +558,7 @@ def loadDefaultSettings(cls): 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 @@ log = logging.getLogger("AVP.Gui.Actions") -# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # COMPONENT ACTIONS -# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ class AddComponent(QUndoCommand): @@ -107,9 +107,9 @@ def undo(self): 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 .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 @@ def changedField(): ) 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 @@ def progressBarSetText(self, value): 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 @@ def __init__(self, parent): 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 @@ def process(self): 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 @@ def process(self): % ( 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/component.py b/src/avp/libcomponent/component.py similarity index 57% rename from src/avp/component.py rename to src/avp/libcomponent/component.py index 5906ab1..1f81e07 100644 --- a/src/avp/component.py +++ b/src/avp/libcomponent/component.py @@ -4,274 +4,26 @@ """ from PyQt6 import uic, QtCore, QtWidgets -from PyQt6.QtGui import QColor, QUndoCommand +from PyQt6.QtGui import QColor import os -import sys import math -import time import logging from copy import copy -from .toolkit.frame import BlankFrame -from .toolkit import ( +from .metaclass import ComponentMetaclass +from .actions import ComponentUpdate +from .exceptions import ComponentError +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) +log = logging.getLogger("AVP.BaseComponent") class Component(QtCore.QObject, metaclass=ComponentMetaclass): @@ -340,9 +92,9 @@ def __repr__(self): pprint.pformat(preset), ) - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Render Methods - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def previewRender(self): image = BlankFrame(self.width, self.height) @@ -371,15 +123,18 @@ def frameRender(self, frameNo): 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 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 [] @@ -403,9 +158,9 @@ def audio(self): https://ffmpeg.org/ffmpeg-filters.html """ - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Idle Methods - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def widget(self, parent): """ @@ -510,9 +265,9 @@ def command(self, arg=""): self.commandHelp() quit(0) - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ # "Private" Methods - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~ def _preUpdate(self): """Happens before subclass update()""" for attr in self._relativeWidgets: @@ -826,153 +581,3 @@ 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/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 @@ from queue import PriorityQueue import logging -from .. import core +from ..core import Core from .common import checkOutput, pipeWrapper @@ -19,7 +19,7 @@ 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 @@ def __init__(self, **kwargs): kwargs["filter_"] = None self.command = [ - core.Core.FFMPEG_BIN, + Core.FFMPEG_BIN, "-thread_queue_size", "512", "-r", @@ -98,11 +98,11 @@ def frame(self, num): 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 error(): 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.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 @@ def __init__(self, parent, inputFile, outputFile, components): 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 @@ def preFrameRender(self): 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 @@ def preFrameRender(self): 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 @@ def err(): 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 @@ def err(): 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 @@ def createVideo(self): 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 @@ def createVideo(self): 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 @@ def createVideo(self): if self.error: self.failExport() else: - print("Export Complete") + print("\nExport Complete") self.progressBarUpdate.emit(100) self.progressBarSetText.emit("Export Complete") diff --git a/tests/__init__.py b/tests/__init__.py index bb35f72..b08a6bd 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,5 @@ import os +import tempfile import numpy from avp.core import Core @@ -8,10 +9,19 @@ from pytest import fixture +PYTEST_XDIST_WORKER_COUNT = os.environ.get("PYTEST_XDIST_WORKER_COUNT", 0) + + +@fixture +def settings(): + """Doesn't instantiate core: just calls a static method to store `settings.ini`""" + initCore() + yield None + + @fixture def audioData(): """Fixture that gives a tuple of (completeAudioArray, duration)""" - # Core.storeSettings() needed to store ffmpeg bin location initCore() soundFile = getTestDataPath("inputfiles/test.ogg") yield readAudioFile(soundFile, MockVideoWorker()) @@ -28,6 +38,8 @@ def command(qtbot): @fixture def window(qtbot): initCore() + # patch out any modal dialog that might happen + MainWindow.showMessage = lambda self, msg, **kwargs: print(msg) window = MainWindow(None, None) window.clear() qtbot.addWidget(window) @@ -43,13 +55,41 @@ def getTestDataPath(filename=""): def initCore(): - testDataDir = getTestDataPath("config") + """ + Initializes the Core by creating `settings.ini` + Returns the temp directory path where settings.ini was created + or None if multiple pytest workers are not enabled. + """ + try: + numWorkers = int(PYTEST_XDIST_WORKER_COUNT) + except ValueError: + numWorkers = 0 + if numWorkers > 0: + # use temporary directories for multiple workers + # so they don't interfere with each other + configDir = tempfile.mkdtemp(prefix="avp-config-") + else: + # use test data path so we can easily see it after + # a failed test, and help us understand the config + configDir = getTestDataPath("config") unwanted = ["autosave.avp", "settings.ini"] for file in unwanted: - filename = os.path.join(testDataDir, "autosave.avp") + filename = os.path.join(configDir, "autosave.avp") if os.path.exists(filename): os.remove(filename) - Core.storeSettings(testDataDir) + Core.storeSettings(configDir) + return configDir if numWorkers > 0 else None + + +def preFrameRender(audioData, comp): + """Prepares a component for calls to frameRender()""" + comp.preFrameRender( + audioFile=getTestDataPath("inputfiles/test.ogg"), + completeAudioArray=audioData[0], + sampleSize=1470, + progressBarSetText=MockSignal(), + progressBarUpdate=MockSignal(), + ) class MockSignal: diff --git a/tests/test_commandline_export.py b/tests/test_commandline_export.py index 6d7f068..6eb533d 100644 --- a/tests/test_commandline_export.py +++ b/tests/test_commandline_export.py @@ -8,13 +8,14 @@ def test_commandline_classic_export(qtbot, command): """Run Qt event loop and create a video in the system /tmp or /temp""" soundFile = getTestDataPath("inputfiles/test.ogg") - outputDir = tempfile.mkdtemp(prefix="avp-test-") + outputDir = tempfile.mkdtemp(prefix="avp-export-") outputFilename = os.path.join(outputDir, "output.mp4") sys.argv = [ "", "-c", "0", "classic", + "color=255,255,255", "-i", soundFile, "-o", diff --git a/tests/test_comp_classic.py b/tests/test_comp_classic.py new file mode 100644 index 0000000..a942d89 --- /dev/null +++ b/tests/test_comp_classic.py @@ -0,0 +1,103 @@ +from avp.toolkit.visualizer import transformData +from pytestqt import qtbot +from pytest import fixture, mark +from . import audioData, command, imageDataSum, preFrameRender + + +sampleSize = 1470 # 44100 / 30 = 1470 + + +def createSpectrumArray(audioData): + """Creates enough `spectrumArray` for one call to Component.drawBars()""" + spectrumArray = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)} + for i in range(sampleSize, len(audioData[0]), sampleSize): + spectrumArray[i] = transformData( + i, + audioData[0], + sampleSize, + 0.08, + 0.8, + spectrumArray[i - sampleSize].copy(), + 20, + ) + return spectrumArray + + +@fixture +def coreWithClassicComp(qtbot, command): + """Fixture providing a Command object with Classic Visualizer component added""" + command.core.insertComponent( + 0, command.core.moduleIndexFor("Classic Visualizer"), command + ) + yield command.core + + +def test_comp_classic_added(coreWithClassicComp): + """Add Classic Visualizer to core""" + assert len(coreWithClassicComp.selectedComponents) == 1 + + +def test_comp_classic_removed(coreWithClassicComp): + """Remove Classic Visualizer from core""" + coreWithClassicComp.removeComponent(0) + assert len(coreWithClassicComp.selectedComponents) == 0 + + +@mark.parametrize("layout", (0, 1, 2, 3)) +def test_comp_classic_drawBars(coreWithClassicComp, audioData, layout): + """Call drawBars after creating audio spectrum data manually.""" + spectrumArray = createSpectrumArray(audioData) + comp = coreWithClassicComp.selectedComponents[0] + image = comp.drawBars( + 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), layout, None + ) + imageSize = 37872316 + assert imageDataSum(image) == imageSize if layout < 2 else imageSize / 2 + + +def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData): + """Call drawBars after creating audio spectrum data using preFrameRender.""" + comp = coreWithClassicComp.selectedComponents[0] + preFrameRender(audioData, comp) + image = comp.drawBars( + 1920, + 1080, + coreWithClassicComp.selectedComponents[0].spectrumArray[sampleSize * 4], + (0, 0, 0), + 0, + None, + ) + assert imageDataSum(image) == 37872316 + + +def test_comp_classic_command_layout(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + comp.command("layout=top") + assert comp.layout == 3 + + +def test_comp_classic_command_color(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + comp.command("color=111,111,111") + assert comp.visColor == (111, 111, 111) + + +def test_comp_classic_command_preset(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + saveValueStore = comp.savePreset() + saveValueStore["preset"] = "testPreset" + coreWithClassicComp.createPresetFile( + comp.name, comp.version, "testPreset", saveValueStore + ) + comp.command("preset=testPreset") + assert comp.currentPreset == "testPreset" + + +def test_comp_classic_loadPreset(coreWithClassicComp): + comp = coreWithClassicComp.selectedComponents[0] + comp.scale = 99 + saveValueStore = comp.savePreset() + saveValueStore["preset"] = "testPreset" + comp.scale = 20 + comp.loadPreset(saveValueStore, "testPreset") + assert comp.scale == 99 diff --git a/tests/test_comp_color.py b/tests/test_comp_color.py index 48b07ff..2aa1f2c 100644 --- a/tests/test_comp_color.py +++ b/tests/test_comp_color.py @@ -14,8 +14,18 @@ def coreWithColorComp(qtbot, command): def test_comp_color_set_color(coreWithColorComp): - "Set imagePath of Image component" + """Set imagePath of Image component""" comp = coreWithColorComp.selectedComponents[0] comp.page.lineEdit_color1.setText("111,111,111") image = comp.previewRender() assert imageDataSum(image) == 1219276800 + + +def test_comp_color_gradient(coreWithColorComp): + """Test changing fill type to a gradient""" + comp = coreWithColorComp.selectedComponents[0] + comp.page.comboBox_fill.setCurrentIndex(1) + comp.page.lineEdit_color1.setText("0,0,0") + comp.page.lineEdit_color2.setText("255,255,255") + image = comp.previewRender() + assert imageDataSum(image) == 1849285965 diff --git a/tests/test_comp_image.py b/tests/test_comp_image.py index c580d5a..b221df4 100644 --- a/tests/test_comp_image.py +++ b/tests/test_comp_image.py @@ -1,3 +1,4 @@ +import os from avp.command import Command from pytestqt import qtbot from pytest import fixture @@ -5,6 +6,7 @@ sampleSize = 1470 # 44100 / 30 = 1470 +testFile = "inputfiles/test.jpg" @fixture @@ -19,7 +21,7 @@ def coreWithImageComp(qtbot, command): def test_comp_image_set_path(coreWithImageComp): "Set imagePath of Image component" comp = coreWithImageComp.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") + comp.imagePath = getTestDataPath(testFile) image = comp.previewRender() assert imageDataSum(image) == 463711601 @@ -27,7 +29,7 @@ def test_comp_image_set_path(coreWithImageComp): def test_comp_image_scale_50_1080p(coreWithImageComp): """Image component stretches image to 50% at 1080p""" comp = coreWithImageComp.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") + comp.imagePath = getTestDataPath(testFile) image = comp.previewRender() sum = imageDataSum(image) comp.page.spinBox_scale.setValue(50) @@ -37,7 +39,7 @@ def test_comp_image_scale_50_1080p(coreWithImageComp): def test_comp_image_scale_50_720p(coreWithImageComp): """Image component stretches image to 50% at 720p""" comp = coreWithImageComp.selectedComponents[0] - comp.imagePath = getTestDataPath("inputfiles/test.jpg") + comp.imagePath = getTestDataPath(testFile) comp.page.spinBox_scale.setValue(50) image = comp.previewRender() sum = imageDataSum(image) @@ -47,3 +49,12 @@ def test_comp_image_scale_50_720p(coreWithImageComp): assert image.width == 1920 assert newImage.width == 1280 assert imageDataSum(comp.previewRender()) == sum + + +def test_comp_image_command_path(coreWithImageComp): + """Image component accepts commandline argument: + `path=test.jpg`""" + imgPath = os.path.realpath(getTestDataPath(testFile)) + comp = coreWithImageComp.selectedComponents[0] + comp.command(f"path={imgPath}") + assert comp.imagePath == imgPath diff --git a/tests/test_comp_original.py b/tests/test_comp_original.py deleted file mode 100644 index 8cd00a4..0000000 --- a/tests/test_comp_original.py +++ /dev/null @@ -1,67 +0,0 @@ -from avp.command import Command -from avp.toolkit.visualizer import transformData -from pytestqt import qtbot -from pytest import fixture -from . import audioData, command, MockSignal, imageDataSum - - -sampleSize = 1470 # 44100 / 30 = 1470 - - -@fixture -def coreWithClassicComp(qtbot, command): - """Fixture providing a Command object with Classic Visualizer component added""" - command.core.insertComponent( - 0, command.core.moduleIndexFor("Classic Visualizer"), command - ) - yield command.core - - -def test_comp_classic_added(coreWithClassicComp): - """Add Classic Visualizer to core""" - assert len(coreWithClassicComp.selectedComponents) == 1 - - -def test_comp_classic_removed(coreWithClassicComp): - """Remove Classic Visualizer from core""" - coreWithClassicComp.removeComponent(0) - assert len(coreWithClassicComp.selectedComponents) == 0 - - -def test_comp_classic_drawBars(coreWithClassicComp, audioData): - """Call drawBars after creating audio spectrum data manually.""" - - spectrumArray = {0: transformData(0, audioData[0], sampleSize, 0.08, 0.8, None, 20)} - for i in range(sampleSize, len(audioData[0]), sampleSize): - spectrumArray[i] = transformData( - i, - audioData[0], - sampleSize, - 0.08, - 0.8, - spectrumArray[i - sampleSize].copy(), - 20, - ) - image = coreWithClassicComp.selectedComponents[0].drawBars( - 1920, 1080, spectrumArray[sampleSize * 4], (0, 0, 0), 0 - ) - assert imageDataSum(image) == 37872316 - - -def test_comp_classic_drawBars_using_preFrameRender(coreWithClassicComp, audioData): - """Call drawBars after creating audio spectrum data using preFrameRender.""" - comp = coreWithClassicComp.selectedComponents[0] - comp.preFrameRender( - completeAudioArray=audioData[0], - sampleSize=sampleSize, - progressBarSetText=MockSignal(), - progressBarUpdate=MockSignal(), - ) - image = comp.drawBars( - 1920, - 1080, - coreWithClassicComp.selectedComponents[0].spectrumArray[sampleSize * 4], - (0, 0, 0), - 0, - ) - assert imageDataSum(image) == 37872316 diff --git a/tests/test_comp_spectrum.py b/tests/test_comp_spectrum.py index 870185c..5dd4e2d 100644 --- a/tests/test_comp_spectrum.py +++ b/tests/test_comp_spectrum.py @@ -1,7 +1,12 @@ from avp.command import Command from pytestqt import qtbot from pytest import fixture -from . import imageDataSum, command +from . import ( + imageDataSum, + command, + preFrameRender, + audioData, +) @fixture @@ -13,7 +18,15 @@ def coreWithSpectrumComp(qtbot, command): yield command.core -def test_comp_waveform_previewRender(coreWithSpectrumComp): +def test_comp_spectrum_previewRender(coreWithSpectrumComp): comp = coreWithSpectrumComp.selectedComponents[0] image = comp.previewRender() assert imageDataSum(image) == 71992628 + + +def test_comp_spectrum_renderFrame(coreWithSpectrumComp, audioData): + comp = coreWithSpectrumComp.selectedComponents[0] + preFrameRender(audioData, comp) + image = comp.frameRender(0) + comp.postFrameRender() + assert imageDataSum(image) == 117 diff --git a/tests/test_comp_waveform.py b/tests/test_comp_waveform.py index eb5800d..d295dbe 100644 --- a/tests/test_comp_waveform.py +++ b/tests/test_comp_waveform.py @@ -1,11 +1,14 @@ from pytestqt import qtbot from pytest import fixture -from . import command +from avp.toolkit.ffmpeg import checkFfmpegVersion +from . import command, imageDataSum, audioData, preFrameRender @fixture def coreWithWaveformComp(qtbot, command): """Fixture providing a Command object with Waveform component added""" + command.settings.setValue("outputWidth", 1920) + command.settings.setValue("outputHeight", 1080) command.core.insertComponent(0, command.core.moduleIndexFor("Waveform"), command) yield command.core @@ -14,3 +17,24 @@ def test_comp_waveform_setColor(coreWithWaveformComp): comp = coreWithWaveformComp.selectedComponents[0] comp.page.lineEdit_color.setText("255,255,255") assert comp.color == (255, 255, 255) + + +def test_comp_waveform_previewRender(coreWithWaveformComp): + comp = coreWithWaveformComp.selectedComponents[0] + comp.page.lineEdit_color.setText("255,255,255") + _, version = checkFfmpegVersion() + if version > 6: + # FFmpeg 8 has different colors from 6 + # TODO check version 7 + assert imageDataSum(comp.previewRender()) == 36114120 + else: + assert imageDataSum(comp.previewRender()) == 37210620 + + +def test_comp_waveform_renderFrame(coreWithWaveformComp, audioData): + comp = coreWithWaveformComp.selectedComponents[0] + comp.page.lineEdit_color.setText("255,255,255") + preFrameRender(audioData, comp) + image = comp.frameRender(0) + comp.postFrameRender() + assert imageDataSum(image) == 8331360 diff --git a/tests/test_core_init.py b/tests/test_core_init.py index e1f2dbb..30477ef 100644 --- a/tests/test_core_init.py +++ b/tests/test_core_init.py @@ -1,10 +1,9 @@ import os from avp.core import Core -from . import getTestDataPath, initCore +from . import getTestDataPath, settings -def test_component_names(): - initCore() +def test_component_names(settings): core = Core() assert core.compNames == [ "Classic Visualizer", @@ -19,8 +18,7 @@ def test_component_names(): ] -def test_moduleindex(): - initCore() +def test_moduleindex(settings): core = Core() assert core.moduleIndexFor("Classic Visualizer") == 0 diff --git a/tests/test_mainwindow_undostack.py b/tests/test_mainwindow_comp_actions.py similarity index 96% rename from tests/test_mainwindow_undostack.py rename to tests/test_mainwindow_comp_actions.py index ceaf87e..5d3cc7a 100644 --- a/tests/test_mainwindow_undostack.py +++ b/tests/test_mainwindow_comp_actions.py @@ -1,3 +1,5 @@ +"""Tests of MainWindow undoing certain ComponentActions (changes to component settings)""" + from pytest import fixture from pytestqt import qtbot from avp.gui.mainwindow import MainWindow diff --git a/tests/test_mainwindow_list_actions.py b/tests/test_mainwindow_list_actions.py new file mode 100644 index 0000000..5f8bde4 --- /dev/null +++ b/tests/test_mainwindow_list_actions.py @@ -0,0 +1,52 @@ +"""Tests of `actions.py` - MainWindow component list manipulation via undoable actions""" + +from PyQt6 import QtCore +import os +from pytest import fixture +from pytestqt import qtbot +from . import getTestDataPath, window + + +def test_mainwindow_addComponent(qtbot, window): + window.compMenu.actions()[0].trigger() + assert len(window.core.selectedComponents) == 1 + + +def test_mainwindow_removeComponent(qtbot, window): + window.compMenu.actions()[0].trigger() # add component + window.pushButton_removeComponent.click() # remove it + assert len(window.core.selectedComponents) == 0 + + +def test_mainwindow_moveComponent(qtbot, window): + # add first two components from menu + window.compMenu.actions()[0].trigger() + window.compMenu.actions()[1].trigger() + comp0 = window.core.selectedComponents[0].ui + window.pushButton_listMoveDown.click() + # check if 0 is now 1 + assert window.core.selectedComponents[1].ui == comp0 + + +def test_mainwindow_addComponent_undo(qtbot, window): + window.compMenu.actions()[0].trigger() + window.undoStack.undo() + assert len(window.core.selectedComponents) == 0 + + +def test_mainwindow_removeComponent_undo(qtbot, window): + window.compMenu.actions()[0].trigger() # add component + window.pushButton_removeComponent.click() # remove it + window.undoStack.undo() + assert len(window.core.selectedComponents) == 1 + + +def test_mainwindow_moveComponent_undo(qtbot, window): + # add first two components from menu + window.compMenu.actions()[0].trigger() + window.compMenu.actions()[1].trigger() + comp0 = window.core.selectedComponents[0].ui + window.pushButton_listMoveDown.click() + window.undoStack.undo() + # check if 0 is still 0 after undo + assert window.core.selectedComponents[1].ui != comp0 diff --git a/tests/test_mainwindow_projects.py b/tests/test_mainwindow_projects.py index 6b49799..a6df476 100644 --- a/tests/test_mainwindow_projects.py +++ b/tests/test_mainwindow_projects.py @@ -2,7 +2,15 @@ import os from pytest import fixture from pytestqt import qtbot -from . import getTestDataPath, window +from avp.gui.mainwindow import MainWindow +from . import getTestDataPath, window, settings + + +def test_mainwindow_init_with_project(qtbot, settings): + window = MainWindow(getTestDataPath("config/projects/testproject.avp"), None) + qtbot.addWidget(window) + assert window.core.selectedComponents[0].name == "Classic Visualizer" + assert window.core.selectedComponents[1].name == "Color" def test_mainwindow_clear(qtbot, window): @@ -11,11 +19,8 @@ def test_mainwindow_clear(qtbot, window): def test_mainwindow_presetDir_in_tests(qtbot, window): - # FIXME presetDir gets set to projectDir for some reason - assert ( - os.path.basename(os.path.dirname(window.core.settings.value("presetDir"))) - == "config" - ) + """`presetDir` is the filepath on which "Import Preset" file picker opens""" + assert os.path.basename(window.core.settings.value("presetDir")) == "presets" def test_mainwindow_openProject(qtbot, window): diff --git a/tests/test_toolkit_ffmpeg.py b/tests/test_toolkit_ffmpeg.py index 363eba1..cc56495 100644 --- a/tests/test_toolkit_ffmpeg.py +++ b/tests/test_toolkit_ffmpeg.py @@ -1,8 +1,6 @@ import pytest -from avp.core import Core -from avp.command import Command from avp.toolkit.ffmpeg import createFfmpegCommand -from . import audioData, getTestDataPath, initCore +from . import audioData, getTestDataPath, command def test_readAudioFile_data(audioData): @@ -14,14 +12,14 @@ def test_readAudioFile_duration(audioData): @pytest.mark.parametrize("width, height", ((1920, 1080), (1280, 720))) -def test_createFfmpegCommand(width, height): - initCore() - command = Command() +def test_createFfmpegCommand(command, width, height): command.settings.setValue("outputWidth", width) command.settings.setValue("outputHeight", height) ffmpegCmd = createFfmpegCommand("test.ogg", "/tmp", command.core.selectedComponents) assert ffmpegCmd == [ "ffmpeg", + "-loglevel", + "info", "-thread_queue_size", "512", "-y", diff --git a/uv.lock b/uv.lock index e403a68..461e725 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "audio-visualizer-python" -version = "2.2.3" +version = "2.2.4" source = { editable = "." } dependencies = [ { name = "numpy" },