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" },