aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorBrianna Rainey2026-02-12 15:38:54 -0500
committerGitHub2026-02-12 15:38:54 -0500
commitf03a3a686c7304588dd434322c73506531e53595 (patch)
treeee41d920873e9a77c41f4a65857af019e71a4754 /src
parent48a9105eab94e64101470402427564203e1d8970 (diff)
v2.2.4 - Quiet FFmpeg; add "invert" option to Classic Vis; fix CLI parsing for Image component (#96)
* change noisiness of terminal output ffmpeg no longer prints everything into the terminal unless we're in `--verbose` mode. percentage progress text stays on one line while not in verbose mode. * Added hint to run `avp --verbose` if `avp --log` is run with no avp_debug.log file present * Classic Visualizer: add invert option * Image component: fix path commandline option * Image component: restrict file formats in CLI to match GUI * Color component: add tooltip to color2 picker (second color of gradients) * change tests to work with pytest-xdist avp core stores its config (location of `settings.ini`) in temp directories if using multiple workers to run tests, so they don't interfere with each other. when using a single worker, the `tests/data/config` directory is still used * check alt comp names when parsing cmdline * rename `original.py` to `classic.py` * move `component.py` into subpackage * rename comp_original to comp_classic * show traceback if renderFrame() raises exception * do not try to insert non-existent components from project files * add "composite" property for components if a component returns "composite" then it will receive a frame to draw on during calls to previewRender and frameRender * more tests of projects, actions, waveform, spectrum, image, color, classic * do not change presetDir to "projects" within PresetManager
Diffstat (limited to 'src')
-rw-r--r--src/avp/__init__.py2
-rw-r--r--src/avp/cli.py4
-rw-r--r--src/avp/command.py50
-rw-r--r--src/avp/components/classic.py (renamed from src/avp/components/original.py)81
-rw-r--r--src/avp/components/classic.ui (renamed from src/avp/components/original.ui)9
-rw-r--r--src/avp/components/color.py4
-rw-r--r--src/avp/components/color.ui3
-rw-r--r--src/avp/components/image.py16
-rw-r--r--src/avp/components/life.py7
-rw-r--r--src/avp/components/sound.py6
-rw-r--r--src/avp/components/spectrum.py9
-rw-r--r--src/avp/components/text.py10
-rw-r--r--src/avp/components/video.py8
-rw-r--r--src/avp/components/waveform.py8
-rw-r--r--src/avp/core.py12
-rw-r--r--src/avp/gui/actions.py10
-rw-r--r--src/avp/gui/mainwindow.py46
-rw-r--r--src/avp/gui/presetmanager.py4
-rw-r--r--src/avp/gui/preview_thread.py16
-rw-r--r--src/avp/libcomponent/__init__.py4
-rw-r--r--src/avp/libcomponent/actions.py104
-rw-r--r--src/avp/libcomponent/component.py (renamed from src/avp/component.py)439
-rw-r--r--src/avp/libcomponent/exceptions.py63
-rw-r--r--src/avp/libcomponent/metaclass.py257
-rw-r--r--src/avp/toolkit/ffmpeg.py44
-rw-r--r--src/avp/toolkit/visualizer.py4
-rw-r--r--src/avp/video_thread.py43
27 files changed, 685 insertions, 578 deletions
diff --git a/src/avp/__init__.py b/src/avp/__init__.py
index 8783660..184afda 100644
--- a/src/avp/__init__.py
+++ b/src/avp/__init__.py
@@ -3,7 +3,7 @@ import os
import logging
-__version__ = "2.2.3"
+__version__ = "2.2.4"
class Logger(logging.getLoggerClass()):
diff --git a/src/avp/cli.py b/src/avp/cli.py
index 0176f76..7d58fe1 100644
--- a/src/avp/cli.py
+++ b/src/avp/cli.py
@@ -40,10 +40,8 @@ def main() -> int:
screen = app.primaryScreen()
if screen is None:
dpi = None
- log.error("Could not detect DPI")
else:
dpi = screen.physicalDotsPerInchX()
- log.info("Detected screen DPI: %s", dpi)
# Launch program
if mode == "commandline":
@@ -53,6 +51,8 @@ def main() -> int:
mode = main.parseArgs()
log.debug("Finished creating command object")
+ log.info(f"QApplication Platform: {QApplication.platformName()}")
+ log.info(f"Detected screen DPI: {dpi}")
# Both branches here may occur in one execution:
# Commandline parsing could change mode back to GUI
if mode == "GUI":
diff --git a/src/avp/command.py b/src/avp/command.py
index 870391b..b6700a5 100644
--- a/src/avp/command.py
+++ b/src/avp/command.py
@@ -14,7 +14,8 @@ import signal
import shutil
import logging
-from . import core, __version__
+from . import __version__
+from .core import Core
log = logging.getLogger("AVP.Commandline")
@@ -29,11 +30,11 @@ class Command(QtCore.QObject):
def __init__(self):
super().__init__()
- self.core = core.Core()
- core.Core.mode = "commandline"
+ self.core = Core()
+ Core.mode = "commandline"
self.dataDir = self.core.dataDir
self.canceled = False
- self.settings = core.Core.settings
+ self.settings = Core.settings
# ctrl-c stops the export thread
signal.signal(signal.SIGINT, self.stopVideo)
@@ -71,9 +72,10 @@ class Command(QtCore.QObject):
help="copy and shorten recent log files into ~/avp_log.txt",
)
debugCommands.add_argument(
- "--verbose", "-v",
+ "--verbose",
+ "-v",
action="store_true",
- help="create bigger logfiles while program is running",
+ help="send log messages and ffmpeg output to stdout, and create more verbose log files (good to use before --log)",
)
# project/GUI options
@@ -101,8 +103,8 @@ class Command(QtCore.QObject):
args = parser.parse_args()
if args.verbose:
- core.STDOUT_LOGLVL = logging.DEBUG
- core.Core.makeLogger(deleteOldLogs=False, fileLogLvl=logging.DEBUG)
+ Core.stdoutLogLvl = logging.DEBUG
+ Core.makeLogger(deleteOldLogs=False, fileLogLvl=logging.DEBUG)
if args.log:
self.createLogFile()
@@ -168,7 +170,7 @@ class Command(QtCore.QObject):
return "commandline"
elif args.no_preview:
- core.Core.previewEnabled = False
+ Core.previewEnabled = False
elif (
args.projpath is None
@@ -203,20 +205,18 @@ class Command(QtCore.QObject):
@QtCore.pyqtSlot(str)
def progressBarSetText(self, value):
- if "Export " in value:
- # Don't duplicate completion/failure messages
+ if "Export " in value or time.time() - self.lastProgressUpdate < 0.1:
+ # Don't duplicate completion/failure messages or send too many messages
return
- if (
- not value.startswith("Exporting")
- and time.time() - self.lastProgressUpdate >= 0.05
- ):
+
+ if not value.endswith("%"):
# Show most messages very often
print(value)
- elif time.time() - self.lastProgressUpdate >= 2.0:
- # Give user time to read ffmpeg's output during the export
- print("##### %s" % value)
- else:
- return
+ elif log.getEffectiveLevel() > logging.INFO:
+ # if ffmpeg isn't printing export progress for us,
+ # then overwrite previous message with the next one
+ # if this text is our main export progress
+ print(f"{value}\r", end="")
self.lastProgressUpdate = time.time()
@QtCore.pyqtSlot()
@@ -224,6 +224,7 @@ class Command(QtCore.QObject):
self.quit(0)
def quit(self, code):
+ print()
quit(code)
def showMessage(self, **kwargs):
@@ -242,12 +243,14 @@ class Command(QtCore.QObject):
def parseCompName(self, name):
"""Deduces a proper component name out of a commandline arg"""
-
if name.title() in self.core.compNames:
return name.title()
for compName in self.core.compNames:
if name.capitalize() in compName:
return compName
+ for altName, moduleIndex in self.core.altCompNames:
+ if name.title() in altName:
+ return self.core.compNames[moduleIndex]
compFileNames = [
os.path.splitext(os.path.basename(mod.__file__))[0]
@@ -281,16 +284,17 @@ class Command(QtCore.QObject):
print("Log file could not be created (too many exist).")
return
try:
- shutil.copy(os.path.join(core.Core.logDir, "avp_debug.log"), filename)
+ shutil.copy(os.path.join(Core.logDir, "avp_debug.log"), filename)
with open(filename, "a") as f:
f.write(f"{'='*60} debug log ends {'='*60}\n")
except FileNotFoundError:
+ print("No debug log was found. Run `avp --verbose` before `avp --log`.")
with open(filename, "w") as f:
f.write(f"{'='*60} no debug log {'='*60}\n")
def concatenateLogs(logPattern):
nonlocal filename
- renderLogs = glob.glob(os.path.join(core.Core.logDir, logPattern))
+ renderLogs = glob.glob(os.path.join(Core.logDir, logPattern))
with open(filename, "a") as fw:
for renderLog in renderLogs:
with open(renderLog, "r") as fr:
diff --git a/src/avp/components/original.py b/src/avp/components/classic.py
index 0da78dc..72089af 100644
--- a/src/avp/components/original.py
+++ b/src/avp/components/classic.py
@@ -1,21 +1,23 @@
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 @@ class Component(Component):
"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 @@ class Component(Component):
],
)
- 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 @@ class Component(Component):
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 @@ class Component(Component):
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 @@ class Component(Component):
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 @@ class Component(Component):
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
index 8dbdaa2..1ae7faa 100644
--- a/src/avp/components/original.ui
+++ b/src/avp/components/classic.ui
@@ -86,7 +86,7 @@
<item>
<widget class="QLineEdit" name="lineEdit_visColor">
<property name="text">
- <string></string>
+ <string/>
</property>
</widget>
</item>
@@ -233,6 +233,13 @@
</widget>
</item>
<item>
+ <widget class="QCheckBox" name="checkBox_invert">
+ <property name="text">
+ <string>Invert</string>
+ </property>
+ </widget>
+ </item>
+ <item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
diff --git a/src/avp/components/color.py b/src/avp/components/color.py
index cb0960a..826f37f 100644
--- a/src/avp/components/color.py
+++ b/src/avp/components/color.py
@@ -1,14 +1,14 @@
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 @@
<height>32</height>
</size>
</property>
+ <property name="toolTip">
+ <string>End color of gradient. Disabled if fill is solid.</string>
+ </property>
<property name="text">
<string/>
</property>
diff --git a/src/avp/components/image.py b/src/avp/components/image.py
index e012cec..a082092 100644
--- a/src/avp/components/image.py
+++ b/src/avp/components/image.py
@@ -1,14 +1,13 @@
from PIL import Image, ImageOps, ImageEnhance
from PyQt6 import QtWidgets
import os
-from copy import copy
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import BlankFrame, addShadow
from ..toolkit.visualizer import createSpectrumArray
-class Component(Component):
+class Component(BaseComponent):
name = "Image"
version = "2.1.0"
@@ -177,17 +176,22 @@ class Component(Component):
self.mergeUndo = True
def command(self, arg):
+ def fail():
+ print("Not a supported image format")
+ quit(1)
+
if "=" in arg:
key, arg = arg.split("=", 1)
if key == "path" and os.path.exists(arg):
+ if f"*{os.path.splitext(arg)[1]}" not in self.core.imageFormats:
+ fail()
try:
Image.open(arg)
self.page.lineEdit_image.setText(arg)
- self.page.checkBox_stretch.setChecked(True)
+ self.page.comboBox_resizeMode.setCurrentIndex(2)
return
except OSError as e:
- print("Not a supported image format")
- quit(1)
+ fail()
super().command(arg)
def commandHelp(self):
diff --git a/src/avp/components/life.py b/src/avp/components/life.py
index a062617..374b299 100644
--- a/src/avp/components/life.py
+++ b/src/avp/components/life.py
@@ -1,13 +1,12 @@
from PyQt6 import QtCore, QtWidgets
from PyQt6.QtGui import QUndoCommand
-from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter, ImageOps
+from PIL import Image, ImageDraw
import os
-from copy import copy
import math
import logging
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import BlankFrame, scale, addShadow
from ..toolkit.visualizer import createSpectrumArray
@@ -15,7 +14,7 @@ from ..toolkit.visualizer import createSpectrumArray
log = logging.getLogger("AVP.Component.Life")
-class Component(Component):
+class Component(BaseComponent):
name = "Conway's Game of Life"
version = "2.0.1"
diff --git a/src/avp/components/sound.py b/src/avp/components/sound.py
index 2df8e38..c212870 100644
--- a/src/avp/components/sound.py
+++ b/src/avp/components/sound.py
@@ -1,11 +1,11 @@
-from PyQt6 import QtGui, QtCore, QtWidgets
+from PyQt6 import QtWidgets
import os
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import BlankFrame
-class Component(Component):
+class Component(BaseComponent):
name = "Sound"
version = "1.0.0"
diff --git a/src/avp/components/spectrum.py b/src/avp/components/spectrum.py
index 062ebc7..0446865 100644
--- a/src/avp/components/spectrum.py
+++ b/src/avp/components/spectrum.py
@@ -1,14 +1,11 @@
from PIL import Image
-from PyQt6 import QtGui, QtCore, QtWidgets
import os
-import math
import subprocess
-import time
import logging
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import BlankFrame, scale
-from ..toolkit import checkOutput, connectWidget
+from ..toolkit import connectWidget
from ..toolkit.ffmpeg import (
openPipe,
closePipe,
@@ -21,7 +18,7 @@ from ..toolkit.ffmpeg import (
log = logging.getLogger("AVP.Components.Spectrum")
-class Component(Component):
+class Component(BaseComponent):
name = "Spectrum"
version = "1.0.1"
diff --git a/src/avp/components/text.py b/src/avp/components/text.py
index bee117e..d248772 100644
--- a/src/avp/components/text.py
+++ b/src/avp/components/text.py
@@ -1,16 +1,14 @@
-from PIL import ImageEnhance, ImageFilter, ImageChops
-from PyQt6.QtGui import QColor, QFont
-from PyQt6 import QtGui, QtCore, QtWidgets
-import os
+from PyQt6.QtGui import QFont
+from PyQt6 import QtGui, QtCore
import logging
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import FramePainter, addShadow
log = logging.getLogger("AVP.Components.Text")
-class Component(Component):
+class Component(BaseComponent):
name = "Title Text"
version = "1.0.1"
diff --git a/src/avp/components/video.py b/src/avp/components/video.py
index 65a05af..1e9b788 100644
--- a/src/avp/components/video.py
+++ b/src/avp/components/video.py
@@ -1,20 +1,18 @@
from PIL import Image
-from PyQt6 import QtGui, QtCore, QtWidgets
+from PyQt6 import QtWidgets
import os
-import math
import subprocess
import logging
-from ..component import Component
+from ..libcomponent import BaseComponent
from ..toolkit.frame import BlankFrame, scale
from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo
-from ..toolkit import checkOutput
log = logging.getLogger("AVP.Components.Video")
-class Component(Component):
+class Component(BaseComponent):
name = "Video"
version = "1.0.0"
diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py
index e10dec2..bfebc30 100644
--- a/src/avp/components/waveform.py
+++ b/src/avp/components/waveform.py
@@ -3,12 +3,10 @@ from PyQt6.QtGui import QColor
import os
import subprocess
import logging
-from copy import copy
-from ..component import Component
-from ..toolkit.visualizer import transformData, createSpectrumArray
+from ..libcomponent import BaseComponent
+from ..toolkit.visualizer import createSpectrumArray
from ..toolkit.frame import BlankFrame, scale
-from ..toolkit import checkOutput
from ..toolkit.ffmpeg import (
openPipe,
closePipe,
@@ -21,7 +19,7 @@ from ..toolkit.ffmpeg import (
log = logging.getLogger("AVP.Components.Waveform")
-class Component(Component):
+class Component(BaseComponent):
name = "Waveform"
version = "2.0.0"
diff --git a/src/avp/core.py b/src/avp/core.py
index 347a5dd..c8e070b 100644
--- a/src/avp/core.py
+++ b/src/avp/core.py
@@ -14,7 +14,6 @@ from . import toolkit
appName = "Audio Visualizer Python"
log = logging.getLogger("AVP.Core")
-STDOUT_LOGLVL = logging.WARNING
class Core:
@@ -26,6 +25,8 @@ class Core:
This class also stores constants as class variables.
"""
+ stdoutLogLvl = logging.WARNING
+
def __init__(self):
self.importComponents()
self.selectedComponents = []
@@ -77,7 +78,10 @@ class Core:
compPos = len(self.selectedComponents)
if len(self.selectedComponents) > 50:
return -1
- if type(component) is int:
+ if component is None:
+ log.warning("Tried to insert non-existent component")
+ return -1
+ elif type(component) is int:
# create component using module index in self.modules
moduleIndex = int(component)
log.debug("Creating new component from module #%s", str(moduleIndex))
@@ -197,7 +201,7 @@ class Core:
)
continue
if i == -1:
- loader.showMessage(msg="Too many components!")
+ loader.showMessage(msg="Invalid components!")
break
try:
@@ -554,7 +558,7 @@ class Core:
def makeLogger(deleteOldLogs=False, fileLogLvl=None):
# send critical log messages to stdout
logStream = logging.StreamHandler()
- logStream.setLevel(STDOUT_LOGLVL)
+ logStream.setLevel(Core.stdoutLogLvl)
streamFormatter = logging.Formatter("<%(name)s> %(levelname)s: %(message)s")
logStream.setFormatter(streamFormatter)
log = logging.getLogger("AVP")
diff --git a/src/avp/gui/actions.py b/src/avp/gui/actions.py
index 654b2a0..6a01bdd 100644
--- a/src/avp/gui/actions.py
+++ b/src/avp/gui/actions.py
@@ -1,5 +1,5 @@
"""
-QCommand classes for every undoable user action performed in the MainWindow
+QUndoCommand classes for every undoable user action performed in the MainWindow
"""
from PyQt6.QtGui import QUndoCommand
@@ -13,9 +13,9 @@ from ..core import Core
log = logging.getLogger("AVP.Gui.Actions")
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# COMPONENT ACTIONS
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
class AddComponent(QUndoCommand):
@@ -107,9 +107,9 @@ class MoveComponent(QUndoCommand):
self.do(self.newRow, self.row)
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# PRESET ACTIONS
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
class ClearPreset(QUndoCommand):
diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py
index 3221783..fac1e41 100644
--- a/src/avp/gui/mainwindow.py
+++ b/src/avp/gui/mainwindow.py
@@ -25,7 +25,7 @@ from . import preview_thread
from .preview_win import PreviewWindow
from .presetmanager import PresetManager
from .actions import *
-from ..toolkit.ffmpeg import createFfmpegCommand
+from ..toolkit.ffmpeg import createFfmpegCommand, checkFfmpegVersion
from ..toolkit import (
disableWhenEncoding,
disableWhenOpeningProject,
@@ -330,26 +330,13 @@ class MainWindow(QtWidgets.QMainWindow):
)
else:
if not self.settings.value("ffmpegMsgShown"):
- try:
- with open(os.devnull, "w") as f:
- ffmpegVers = checkOutput(
- [self.core.FFMPEG_BIN, "-version"], stderr=f
- )
- ffmpegVers = str(ffmpegVers).split()[2].split(".", 1)[0]
- if ffmpegVers.startswith("n"):
- ffmpegVers = ffmpegVers[1:]
- goodVersion = int(ffmpegVers) > 3
- except Exception:
- goodVersion = False
- else:
- goodVersion = True
-
- if not goodVersion:
- self.showMessage(
- msg="You're using an old version of Ffmpeg. "
- "Some features may not work as expected."
- )
- self.settings.setValue("ffmpegMsgShown", True)
+ ffmpegGoodVersion, ffmpegVersionNum = checkFfmpegVersion()
+ if not ffmpegGoodVersion:
+ self.showMessage(
+ msg="The version of FFmpeg ({ffmpegVersionNum}) is "
+ "not recognized. Some features may not work as expected."
+ )
+ self.settings.setValue("ffmpegMsgShown", True)
# Hotkeys for projects
@@ -734,6 +721,23 @@ class MainWindow(QtWidgets.QMainWindow):
self.progressLabel.setText(value)
else:
self.progressBar_createVideo.setFormat(value)
+ if log.getEffectiveLevel() > logging.INFO:
+ # if ffmpeg is quiet, print progress ourselves
+ if any(
+ [
+ value.startswith("Export C"),
+ value.startswith("Analyzing"),
+ value.startswith("Loading"),
+ ]
+ ):
+ # Don't duplicate completion/failure messages or send too many messages
+ return
+ elif not value.startswith("Exporting"):
+ print(value)
+ else:
+ # overwrite previous message with next one
+ # if the text is our main export progress
+ print(f"\r{value}", end="")
def updateResolution(self):
resIndex = int(self.comboBox_resolution.currentIndex())
diff --git a/src/avp/gui/presetmanager.py b/src/avp/gui/presetmanager.py
index ca0029d..bdcff91 100644
--- a/src/avp/gui/presetmanager.py
+++ b/src/avp/gui/presetmanager.py
@@ -25,9 +25,7 @@ class PresetManager(QtWidgets.QDialog):
self.settings = parent.settings
self.presetDir = parent.presetDir
if not self.settings.value("presetDir"):
- self.settings.setValue(
- "presetDir", os.path.join(parent.dataDir, "projects")
- )
+ self.settings.setValue("presetDir", os.path.join(parent.dataDir, "presets"))
self.findPresets()
diff --git a/src/avp/gui/preview_thread.py b/src/avp/gui/preview_thread.py
index a59652a..8507f45 100644
--- a/src/avp/gui/preview_thread.py
+++ b/src/avp/gui/preview_thread.py
@@ -61,7 +61,10 @@ class Worker(QtCore.QObject):
for component in reversed(components):
try:
component.lockSize(width, height)
- newFrame = component.previewRender()
+ if "composite" in component.properties():
+ newFrame = component.previewRender(frame)
+ else:
+ newFrame = component.previewRender()
component.unlockSize()
frame = Image.alpha_composite(frame, newFrame)
@@ -72,11 +75,12 @@ class Worker(QtCore.QObject):
% (
str(component),
str(e).capitalize(),
- "is None" if newFrame is None else "size was %s*%s; should be %s*%s" % (
- newFrame.width,
- newFrame.height,
- width,
- height),
+ (
+ "is None"
+ if newFrame is None
+ else "size was %s*%s; should be %s*%s"
+ % (newFrame.width, newFrame.height, width, height)
+ ),
)
)
log.critical(errMsg)
diff --git a/src/avp/libcomponent/__init__.py b/src/avp/libcomponent/__init__.py
new file mode 100644
index 0000000..5b04b45
--- /dev/null
+++ b/src/avp/libcomponent/__init__.py
@@ -0,0 +1,4 @@
+from .component import Component as BaseComponent
+from .exceptions import ComponentError
+
+__all__ = [BaseComponent, ComponentError]
diff --git a/src/avp/libcomponent/actions.py b/src/avp/libcomponent/actions.py
new file mode 100644
index 0000000..f534685
--- /dev/null
+++ b/src/avp/libcomponent/actions.py
@@ -0,0 +1,104 @@
+"""
+QUndoCommand class for generic undoable user actions performed to a BaseComponent
+
+See `../life.py` for an example of a component that uses a custom QUndoCommand
+"""
+
+from PyQt6.QtGui import QUndoCommand
+from copy import copy
+import logging
+
+log = logging.getLogger("AVP.ComponentHandler")
+
+
+class ComponentUpdate(QUndoCommand):
+ """Command object for making a component action undoable"""
+
+ def __init__(self, parent, oldWidgetVals, modifiedVals):
+ super().__init__("change %s component #%s" % (parent.name, parent.compPos))
+ self.undone = False
+ self.res = (int(parent.width), int(parent.height))
+ self.parent = parent
+ self.oldWidgetVals = {
+ attr: (
+ copy(val)
+ if attr not in self.parent._relativeWidgets
+ else self.parent.floatValForAttr(attr, val, axis=self.res)
+ )
+ for attr, val in oldWidgetVals.items()
+ if attr in modifiedVals
+ }
+ self.modifiedVals = {
+ attr: (
+ val
+ if attr not in self.parent._relativeWidgets
+ else self.parent.floatValForAttr(attr, val, axis=self.res)
+ )
+ for attr, val in modifiedVals.items()
+ }
+
+ # Because relative widgets change themselves every update based on
+ # their previous value, we must store ALL their values in case of undo
+ self.relativeWidgetValsAfterUndo = {
+ attr: copy(getattr(self.parent, attr))
+ for attr in self.parent._relativeWidgets
+ }
+
+ # Determine if this update is mergeable
+ self.id_ = -1
+ if self.parent.mergeUndo:
+ if len(self.modifiedVals) == 1:
+ attr, val = self.modifiedVals.popitem()
+ self.id_ = sum([ord(letter) for letter in attr[-14:]])
+ self.modifiedVals[attr] = val
+ return
+ log.warning(
+ "%s component settings changed at once. (%s)",
+ len(self.modifiedVals),
+ repr(self.modifiedVals),
+ )
+
+ def id(self):
+ """If 2 consecutive updates have same id, Qt will call mergeWith()"""
+ return self.id_
+
+ def mergeWith(self, other):
+ self.modifiedVals.update(other.modifiedVals)
+ return True
+
+ def setWidgetValues(self, attrDict):
+ """
+ Mask the component's usual method to handle our
+ relative widgets in case the resolution has changed.
+ """
+ newAttrDict = {
+ attr: (
+ val
+ if attr not in self.parent._relativeWidgets
+ else self.parent.pixelValForAttr(attr, val)
+ )
+ for attr, val in attrDict.items()
+ }
+ self.parent.setWidgetValues(newAttrDict)
+
+ def redo(self):
+ if self.undone:
+ log.info("Redoing component update")
+ self.parent.oldAttrs = self.relativeWidgetValsAfterUndo
+ self.setWidgetValues(self.modifiedVals)
+ self.parent.update(auto=True)
+ self.parent.oldAttrs = None
+ if not self.undone:
+ self.relativeWidgetValsAfterRedo = {
+ attr: copy(getattr(self.parent, attr))
+ for attr in self.parent._relativeWidgets
+ }
+ self.parent._sendUpdateSignal()
+
+ def undo(self):
+ log.info("Undoing component update")
+ self.undone = True
+ self.parent.oldAttrs = self.relativeWidgetValsAfterRedo
+ self.setWidgetValues(self.oldWidgetVals)
+ self.parent.update(auto=True)
+ self.parent.oldAttrs = None
diff --git a/src/avp/component.py b/src/avp/libcomponent/component.py
index 5906ab1..1f81e07 100644
--- a/src/avp/component.py
+++ b/src/avp/libcomponent/component.py
@@ -4,274 +4,26 @@ on making a valid component.
"""
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 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
pprint.pformat(preset),
)
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# Render Methods
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
def previewRender(self):
image = BlankFrame(self.width, self.height)
@@ -371,15 +123,18 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
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 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
https://ffmpeg.org/ffmpeg-filters.html
"""
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# Idle Methods
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
def widget(self, parent):
"""
@@ -510,9 +265,9 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
self.commandHelp()
quit(0)
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# "Private" Methods
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
def _preUpdate(self):
"""Happens before subclass update()"""
for attr in self._relativeWidgets:
@@ -826,153 +581,3 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
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 @@ import signal
from queue import PriorityQueue
import logging
-from .. import core
+from ..core import Core
from .common import checkOutput, pipeWrapper
@@ -19,7 +19,7 @@ log = logging.getLogger("AVP.Toolkit.Ffmpeg")
class FfmpegVideo:
- """Opens a pipe to ffmpeg and stores a buffer of raw video frames."""
+ """Opens an input pipe to ffmpeg and stores a buffer of raw video frames."""
# error from the thread used to fill the buffer
threadError = None
@@ -53,7 +53,7 @@ class FfmpegVideo:
kwargs["filter_"] = None
self.command = [
- core.Core.FFMPEG_BIN,
+ Core.FFMPEG_BIN,
"-thread_queue_size",
"512",
"-r",
@@ -98,11 +98,11 @@ class FfmpegVideo:
self.frameBuffer.task_done()
def fillBuffer(self):
- from ..component import ComponentError
+ from ..libcomponent import ComponentError
- if core.Core.logEnabled:
+ if Core.logEnabled:
logFilename = os.path.join(
- core.Core.logDir, "render_%s.log" % str(self.component.compPos)
+ Core.logDir, "render_%s.log" % str(self.component.compPos)
)
log.debug("Creating ffmpeg process (log at %s)", logFilename)
with open(logFilename, "w") as logf:
@@ -176,7 +176,7 @@ def findFfmpeg():
if getattr(sys, "frozen", False):
# The application is frozen
- bin = os.path.join(core.Core.wd, bin)
+ bin = os.path.join(Core.wd, bin)
with open(os.devnull, "w") as f:
try:
@@ -187,7 +187,9 @@ def findFfmpeg():
return bin
-def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
+def createFfmpegCommand(
+ inputFile, outputFile, components, duration=-1, logLevel="info"
+):
"""
Constructs the major ffmpeg command used to export the video
"""
@@ -195,7 +197,6 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
duration = getAudioDuration(inputFile)
safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters
duration = "{0:.3f}".format(duration + 0.1) # used by input sources
- Core = core.Core
# Test if user has libfdk_aac
encoders = checkOutput("%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True)
@@ -243,6 +244,8 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
ffmpegCommand = [
Core.FFMPEG_BIN,
+ "-loglevel",
+ logLevel,
"-thread_queue_size",
"512",
"-y", # overwrite the output file if it already exists.
@@ -415,7 +418,7 @@ def createAudioFilterCommand(extraAudio, duration):
def testAudioStream(filename):
"""Test if an audio stream definitely exists"""
audioTestCommand = [
- core.Core.FFMPEG_BIN,
+ Core.FFMPEG_BIN,
"-i",
filename,
"-vn",
@@ -433,7 +436,7 @@ def testAudioStream(filename):
def getAudioDuration(filename):
"""Try to get duration of audio file as float, or False if not possible"""
- command = [core.Core.FFMPEG_BIN, "-i", filename]
+ command = [Core.FFMPEG_BIN, "-i", filename]
try:
fileInfo = checkOutput(command, stderr=subprocess.STDOUT)
@@ -473,7 +476,7 @@ def readAudioFile(filename, videoWorker):
return
command = [
- core.Core.FFMPEG_BIN,
+ Core.FFMPEG_BIN,
"-i",
filename,
"-f",
@@ -498,7 +501,7 @@ def readAudioFile(filename, videoWorker):
progress = 0
lastPercent = None
while True:
- if core.Core.canceled:
+ if Core.canceled:
return
# read 2 seconds of audio
progress += 4
@@ -543,3 +546,18 @@ def exampleSound(style="white", extra="apulsator=offset_l=0.35:offset_r=0.67"):
src = "0.1*sin(2*PI*(360-2.5/2)*t) | 0.1*sin(2*PI*(360+2.5/2)*t)"
return "aevalsrc='%s', %s%s" % (src, extra, ", " if extra else "")
+
+
+def checkFfmpegVersion():
+ try:
+ with open(os.devnull, "w") as f:
+ ffmpegVers = checkOutput([Core.FFMPEG_BIN, "-version"], stderr=f)
+ ffmpegVers = str(ffmpegVers).split()[2].split(".", 1)[0]
+ if ffmpegVers.startswith("n"):
+ ffmpegVers = ffmpegVers[1:]
+ versionNum = int(ffmpegVers)
+ goodVersion = versionNum > 3
+ except Exception:
+ versionNum = -1
+ goodVersion = False
+ return goodVersion, versionNum
diff --git a/src/avp/toolkit/visualizer.py b/src/avp/toolkit/visualizer.py
index c55a3f3..6477559 100644
--- a/src/avp/toolkit/visualizer.py
+++ b/src/avp/toolkit/visualizer.py
@@ -14,6 +14,7 @@ def createSpectrumArray(
progressBarUpdate,
progressBarSetText,
):
+ lastProgress = 0
lastSpectrum = None
spectrumArray = {}
for i in range(0, len(completeAudioArray), sampleSize):
@@ -33,9 +34,12 @@ def createSpectrumArray(
progress = int(100 * (i / len(completeAudioArray)))
if progress >= 100:
progress = 100
+ if progress == lastProgress:
+ continue
progressText = f"Analyzing audio: {str(progress)}%"
progressBarSetText.emit(progressText)
progressBarUpdate.emit(int(progress))
+ lastProgress = progress
return spectrumArray
diff --git a/src/avp/video_thread.py b/src/avp/video_thread.py
index 967d2fe..ecd8c4c 100644
--- a/src/avp/video_thread.py
+++ b/src/avp/video_thread.py
@@ -12,16 +12,16 @@ from PyQt6 import QtCore, QtGui
from PyQt6.QtCore import pyqtSignal, pyqtSlot
from PIL import Image
from PIL.ImageQt import ImageQt
+
import numpy
import subprocess as sp
import sys
import os
-import time
import signal
import logging
-from .component import ComponentError
-from .toolkit.frame import Checkerboard
+from .libcomponent import ComponentError
+from .toolkit import formatTraceback
from .toolkit.ffmpeg import (
openPipe,
readAudioFile,
@@ -61,7 +61,11 @@ class Worker(QtCore.QObject):
def createFfmpegCommand(self, duration):
try:
ffmpegCommand = createFfmpegCommand(
- self.inputFile, self.outputFile, self.components, duration
+ self.inputFile,
+ self.outputFile,
+ self.components,
+ duration,
+ "info" if log.getEffectiveLevel() < logging.WARNING else "error",
)
except sp.CalledProcessError as e:
# FIXME video_thread should own this error signal, not components
@@ -111,6 +115,7 @@ class Worker(QtCore.QObject):
Also prerenders "static" components like text and merges them if possible
"""
self.staticComponents = {}
+ self.compositeComponents = set()
# Call preFrameRender on each component
canceledByComponent = False
@@ -160,6 +165,8 @@ class Worker(QtCore.QObject):
if "static" in compProps:
log.info("Saving static frame from #%s %s", compNo, comp)
self.staticComponents[compNo] = comp.frameRender(0).copy()
+ elif compNo > 0 and "composite" in compProps:
+ self.compositeComponents.add(compNo)
# Check if any errors occured
log.debug("Checking if a component wishes to cancel the export...")
@@ -208,9 +215,11 @@ class Worker(QtCore.QObject):
self.closePipe()
self.cancelExport()
self.error = True
- msg = "A call to renderFrame in the video thread failed critically."
- log.critical(msg)
- comp._error.emit(msg, str(e))
+ msg = f"{comp.name} renderFrame({int(audioI / self.sampleSize)}) raised an exception."
+ tb = formatTraceback()
+ details = f"{e.__class__.__name__}: {str(e)}\n\n{tb}"
+ log.critical(f"{msg}\n{details}")
+ comp._error.emit(msg, details)
bgI = int(audioI / self.sampleSize)
frame = None
@@ -230,6 +239,9 @@ class Worker(QtCore.QObject):
frame, self.staticComponents[layerNo]
)
+ elif layerNo in self.compositeComponents:
+ # component that uses previous frame to draw
+ frame = Image.alpha_composite(frame, comp.frameRender(bgI, frame))
else:
# animated component
if frame is None: # bottom-most layer
@@ -309,9 +321,9 @@ class Worker(QtCore.QObject):
log.critical("Out_Pipe to FFmpeg couldn't be created!", exc_info=True)
raise
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# START CREATING THE VIDEO
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
progressBarValue = 0
self.progressBarUpdate.emit(progressBarValue)
# Begin piping into ffmpeg!
@@ -335,16 +347,13 @@ class Worker(QtCore.QObject):
completion = (audioI / self.audioArrayLen) * 100
if progressBarValue + 1 <= completion:
progressBarValue = numpy.floor(completion).astype(int)
+ msg = "Exporting video: %s%%" % str(int(progressBarValue))
self.progressBarUpdate.emit(progressBarValue)
- self.progressBarSetText.emit(
- "Exporting video: %s%%" % str(int(progressBarValue))
- )
+ self.progressBarSetText.emit(msg)
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
# Finished creating the video!
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-
- numpy.seterr(all="print")
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~
self.closePipe()
@@ -363,7 +372,7 @@ class Worker(QtCore.QObject):
if self.error:
self.failExport()
else:
- print("Export Complete")
+ print("\nExport Complete")
self.progressBarUpdate.emit(100)
self.progressBarSetText.emit("Export Complete")