From f975144f25d34f97329b2d4e52891061573cea08 Mon Sep 17 00:00:00 2001
From: Aeliton G. Silva
Date: Mon, 12 Jan 2026 22:39:55 -0300
Subject: Use pyproject.toml + uv_build
This replaces setup.py by a modern pyproject.toml using uv_build
backend.
Dependencies are being also managed by uv, so to install dependencies
and run the project one can execute:
```
uv sync
uv run pytest # optional
python -m avp
```
To build the both source and binary (wheel) distribution package run:
```
uv build
```
Uv can be installed with `pip install uv`.
The directory structure has been changed to reflect best practices.
- src/* -> src/avp/
- src/tests -> ../tests
---
src/__init__.py | 39 --
src/__main__.py | 64 ---
src/avp/__init__.py | 39 ++
src/avp/__main__.py | 8 +
src/avp/cli.py | 64 +++
src/avp/command.py | 316 ++++++++++
src/avp/component.py | 967 +++++++++++++++++++++++++++++++
src/avp/components/__init__.py | 1 +
src/avp/components/__template__.ui | 119 ++++
src/avp/components/color.py | 176 ++++++
src/avp/components/color.ui | 666 +++++++++++++++++++++
src/avp/components/image.py | 129 +++++
src/avp/components/image.ui | 388 +++++++++++++
src/avp/components/life.py | 520 +++++++++++++++++
src/avp/components/life.ui | 405 +++++++++++++
src/avp/components/original.py | 243 ++++++++
src/avp/components/original.ui | 243 ++++++++
src/avp/components/sound.py | 77 +++
src/avp/components/sound.ui | 172 ++++++
src/avp/components/spectrum.py | 368 ++++++++++++
src/avp/components/spectrum.ui | 946 ++++++++++++++++++++++++++++++
src/avp/components/text.py | 218 +++++++
src/avp/components/text.ui | 671 ++++++++++++++++++++++
src/avp/components/video.py | 254 ++++++++
src/avp/components/video.ui | 328 +++++++++++
src/avp/components/waveform.py | 230 ++++++++
src/avp/components/waveform.ui | 383 +++++++++++++
src/avp/core.py | 597 +++++++++++++++++++
src/avp/encoder-options.json | 130 +++++
src/avp/gui/__init__.py | 0
src/avp/gui/actions.py | 196 +++++++
src/avp/gui/background.png | Bin 0 -> 45367 bytes
src/avp/gui/mainwindow.py | 1053 ++++++++++++++++++++++++++++++++++
src/avp/gui/mainwindow.ui | 835 +++++++++++++++++++++++++++
src/avp/gui/presetmanager.py | 349 +++++++++++
src/avp/gui/presetmanager.ui | 150 +++++
src/avp/gui/preview_thread.py | 93 +++
src/avp/gui/preview_win.py | 58 ++
src/avp/toolkit/__init__.py | 1 +
src/avp/toolkit/common.py | 192 +++++++
src/avp/toolkit/ffmpeg.py | 545 ++++++++++++++++++
src/avp/toolkit/frame.py | 117 ++++
src/avp/video_thread.py | 417 ++++++++++++++
src/command.py | 316 ----------
src/component.py | 967 -------------------------------
src/components/__init__.py | 1 -
src/components/__template__.ui | 119 ----
src/components/color.py | 176 ------
src/components/color.ui | 666 ---------------------
src/components/image.py | 129 -----
src/components/image.ui | 388 -------------
src/components/life.py | 520 -----------------
src/components/life.ui | 405 -------------
src/components/original.py | 243 --------
src/components/original.ui | 243 --------
src/components/sound.py | 77 ---
src/components/sound.ui | 172 ------
src/components/spectrum.py | 368 ------------
src/components/spectrum.ui | 946 ------------------------------
src/components/text.py | 218 -------
src/components/text.ui | 671 ----------------------
src/components/video.py | 254 --------
src/components/video.ui | 328 -----------
src/components/waveform.py | 230 --------
src/components/waveform.ui | 383 -------------
src/core.py | 597 -------------------
src/encoder-options.json | 130 -----
src/gui/__init__.py | 0
src/gui/actions.py | 196 -------
src/gui/background.png | Bin 45367 -> 0 bytes
src/gui/mainwindow.py | 1053 ----------------------------------
src/gui/mainwindow.ui | 835 ---------------------------
src/gui/presetmanager.py | 349 -----------
src/gui/presetmanager.ui | 150 -----
src/gui/preview_thread.py | 93 ---
src/gui/preview_win.py | 58 --
src/tests/__init__.py | 27 -
src/tests/data/test.jpg | Bin 48766 -> 0 bytes
src/tests/data/test.ogg | Bin 30043 -> 0 bytes
src/tests/data/test.png | Bin 220 -> 0 bytes
src/tests/test_commandline_export.py | 39 --
src/tests/test_commandline_parser.py | 45 --
src/tests/test_core_init.py | 21 -
src/toolkit/__init__.py | 1 -
src/toolkit/common.py | 192 -------
src/toolkit/ffmpeg.py | 545 ------------------
src/toolkit/frame.py | 117 ----
src/video_thread.py | 417 --------------
88 files changed, 12664 insertions(+), 12788 deletions(-)
delete mode 100644 src/__init__.py
delete mode 100644 src/__main__.py
create mode 100644 src/avp/__init__.py
create mode 100644 src/avp/__main__.py
create mode 100644 src/avp/cli.py
create mode 100644 src/avp/command.py
create mode 100644 src/avp/component.py
create mode 100644 src/avp/components/__init__.py
create mode 100644 src/avp/components/__template__.ui
create mode 100644 src/avp/components/color.py
create mode 100644 src/avp/components/color.ui
create mode 100644 src/avp/components/image.py
create mode 100644 src/avp/components/image.ui
create mode 100644 src/avp/components/life.py
create mode 100644 src/avp/components/life.ui
create mode 100644 src/avp/components/original.py
create mode 100644 src/avp/components/original.ui
create mode 100644 src/avp/components/sound.py
create mode 100644 src/avp/components/sound.ui
create mode 100644 src/avp/components/spectrum.py
create mode 100644 src/avp/components/spectrum.ui
create mode 100644 src/avp/components/text.py
create mode 100644 src/avp/components/text.ui
create mode 100644 src/avp/components/video.py
create mode 100644 src/avp/components/video.ui
create mode 100644 src/avp/components/waveform.py
create mode 100644 src/avp/components/waveform.ui
create mode 100644 src/avp/core.py
create mode 100644 src/avp/encoder-options.json
create mode 100644 src/avp/gui/__init__.py
create mode 100644 src/avp/gui/actions.py
create mode 100644 src/avp/gui/background.png
create mode 100644 src/avp/gui/mainwindow.py
create mode 100644 src/avp/gui/mainwindow.ui
create mode 100644 src/avp/gui/presetmanager.py
create mode 100644 src/avp/gui/presetmanager.ui
create mode 100644 src/avp/gui/preview_thread.py
create mode 100644 src/avp/gui/preview_win.py
create mode 100644 src/avp/toolkit/__init__.py
create mode 100644 src/avp/toolkit/common.py
create mode 100644 src/avp/toolkit/ffmpeg.py
create mode 100644 src/avp/toolkit/frame.py
create mode 100644 src/avp/video_thread.py
delete mode 100644 src/command.py
delete mode 100644 src/component.py
delete mode 100644 src/components/__init__.py
delete mode 100644 src/components/__template__.ui
delete mode 100644 src/components/color.py
delete mode 100644 src/components/color.ui
delete mode 100644 src/components/image.py
delete mode 100644 src/components/image.ui
delete mode 100644 src/components/life.py
delete mode 100644 src/components/life.ui
delete mode 100644 src/components/original.py
delete mode 100644 src/components/original.ui
delete mode 100644 src/components/sound.py
delete mode 100644 src/components/sound.ui
delete mode 100644 src/components/spectrum.py
delete mode 100644 src/components/spectrum.ui
delete mode 100644 src/components/text.py
delete mode 100644 src/components/text.ui
delete mode 100644 src/components/video.py
delete mode 100644 src/components/video.ui
delete mode 100644 src/components/waveform.py
delete mode 100644 src/components/waveform.ui
delete mode 100644 src/core.py
delete mode 100644 src/encoder-options.json
delete mode 100644 src/gui/__init__.py
delete mode 100644 src/gui/actions.py
delete mode 100644 src/gui/background.png
delete mode 100644 src/gui/mainwindow.py
delete mode 100644 src/gui/mainwindow.ui
delete mode 100644 src/gui/presetmanager.py
delete mode 100644 src/gui/presetmanager.ui
delete mode 100644 src/gui/preview_thread.py
delete mode 100644 src/gui/preview_win.py
delete mode 100644 src/tests/__init__.py
delete mode 100644 src/tests/data/test.jpg
delete mode 100644 src/tests/data/test.ogg
delete mode 100644 src/tests/data/test.png
delete mode 100644 src/tests/test_commandline_export.py
delete mode 100644 src/tests/test_commandline_parser.py
delete mode 100644 src/tests/test_core_init.py
delete mode 100644 src/toolkit/__init__.py
delete mode 100644 src/toolkit/common.py
delete mode 100644 src/toolkit/ffmpeg.py
delete mode 100644 src/toolkit/frame.py
delete mode 100644 src/video_thread.py
(limited to 'src')
diff --git a/src/__init__.py b/src/__init__.py
deleted file mode 100644
index ee9bebb..0000000
--- a/src/__init__.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import sys
-import os
-import logging
-
-
-__version__ = "2.1.0"
-
-
-class Logger(logging.getLoggerClass()):
- """
- Custom Logger class to handle custom VERBOSE log level.
- Levels used in this program are as follows:
- VERBOSE Annoyingly frequent debug messages (e.g, in loops)
- DEBUG Ordinary debug information
- INFO Expected events that are expensive or irreversible
- WARNING A non-fatal error or suspicious behaviour
- ERROR Any error that would interrupt the user
- CRITICAL Things that really shouldn't happen at all
- """
-
- def __init__(self, name, level=logging.NOTSET):
- super().__init__(name, level)
- logging.addLevelName(5, "VERBOSE")
-
- def verbose(self, msg, *args, **kwargs):
- if self.isEnabledFor(5):
- self._log(5, msg, args, **kwargs)
-
-
-logging.setLoggerClass(Logger)
-logging.VERBOSE = 5
-
-
-if getattr(sys, "frozen", False):
- # frozen
- wd = os.path.dirname(sys.executable)
-else:
- # unfrozen
- wd = os.path.dirname(os.path.realpath(__file__))
diff --git a/src/__main__.py b/src/__main__.py
deleted file mode 100644
index db48788..0000000
--- a/src/__main__.py
+++ /dev/null
@@ -1,64 +0,0 @@
-from PyQt6.QtWidgets import QApplication
-import sys
-import logging
-import re
-import string
-
-
-log = logging.getLogger("AVP.Main")
-
-
-def main() -> int:
- """Returns an exit code (0 for success)"""
- proj = None
- mode = "GUI"
-
- # Determine whether we're in GUI or commandline mode
- if len(sys.argv) > 2:
- mode = "commandline"
- elif len(sys.argv) == 2:
- if sys.argv[1].startswith("-"):
- mode = "commandline"
- else:
- # remove unsafe punctuation characters such as \/?*&^%$#
- if sys.argv[1].endswith(".avp"):
- # remove file extension
- sys.argv[1] = sys.argv[1][:-4]
- sys.argv[1] = re.sub(f"[{re.escape(string.punctuation)}]", "", sys.argv[1])
- # opening a project file with gui
- proj = sys.argv[1]
-
- # Create Qt Application
- app = QApplication(sys.argv)
- app.setApplicationName("audio-visualizer")
-
- 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":
- from .command import Command
-
- main = Command()
- mode = main.parseArgs()
- log.debug("Finished creating command object")
-
- # Both branches here may occur in one execution:
- # Commandline parsing could change mode back to GUI
- if mode == "GUI":
- from .gui.mainwindow import MainWindow
-
- mainWindow = MainWindow(proj, dpi)
- log.debug("Finished creating MainWindow")
- mainWindow.raise_()
-
- return app.exec()
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/src/avp/__init__.py b/src/avp/__init__.py
new file mode 100644
index 0000000..ee9bebb
--- /dev/null
+++ b/src/avp/__init__.py
@@ -0,0 +1,39 @@
+import sys
+import os
+import logging
+
+
+__version__ = "2.1.0"
+
+
+class Logger(logging.getLoggerClass()):
+ """
+ Custom Logger class to handle custom VERBOSE log level.
+ Levels used in this program are as follows:
+ VERBOSE Annoyingly frequent debug messages (e.g, in loops)
+ DEBUG Ordinary debug information
+ INFO Expected events that are expensive or irreversible
+ WARNING A non-fatal error or suspicious behaviour
+ ERROR Any error that would interrupt the user
+ CRITICAL Things that really shouldn't happen at all
+ """
+
+ def __init__(self, name, level=logging.NOTSET):
+ super().__init__(name, level)
+ logging.addLevelName(5, "VERBOSE")
+
+ def verbose(self, msg, *args, **kwargs):
+ if self.isEnabledFor(5):
+ self._log(5, msg, args, **kwargs)
+
+
+logging.setLoggerClass(Logger)
+logging.VERBOSE = 5
+
+
+if getattr(sys, "frozen", False):
+ # frozen
+ wd = os.path.dirname(sys.executable)
+else:
+ # unfrozen
+ wd = os.path.dirname(os.path.realpath(__file__))
diff --git a/src/avp/__main__.py b/src/avp/__main__.py
new file mode 100644
index 0000000..a27bb88
--- /dev/null
+++ b/src/avp/__main__.py
@@ -0,0 +1,8 @@
+import sys
+from avp.cli import main
+if __name__ == "__main__":
+ if sys.argv[0].endswith("-script.pyw"):
+ sys.argv[0] = sys.argv[0][:-11]
+ elif sys.argv[0].endswith(".exe"):
+ sys.argv[0] = sys.argv[0][:-4]
+ sys.exit(main())
diff --git a/src/avp/cli.py b/src/avp/cli.py
new file mode 100644
index 0000000..02ceee6
--- /dev/null
+++ b/src/avp/cli.py
@@ -0,0 +1,64 @@
+from PyQt6.QtWidgets import QApplication
+import sys
+import logging
+import re
+import string
+
+
+log = logging.getLogger("AVP.Main")
+
+
+def main() -> int:
+ """Returns an exit code (0 for success)"""
+ proj = None
+ mode = "GUI"
+
+ # Determine whether we're in GUI or commandline mode
+ if len(sys.argv) > 2:
+ mode = "commandline"
+ elif len(sys.argv) == 2:
+ if sys.argv[1].startswith("-"):
+ mode = "commandline"
+ else:
+ # remove unsafe punctuation characters such as \/?*&^%$#
+ if sys.argv[1].endswith(".avp"):
+ # remove file extension
+ sys.argv[1] = sys.argv[1][:-4]
+ sys.argv[1] = re.sub(f"[{re.escape(string.punctuation)}]", "", sys.argv[1])
+ # opening a project file with gui
+ proj = sys.argv[1]
+
+ # Create Qt Application
+ app = QApplication(sys.argv)
+ app.setApplicationName("audio-visualizer")
+
+ 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":
+ from .command import Command
+
+ main = Command()
+ mode = main.parseArgs()
+ log.debug("Finished creating command object")
+
+ # Both branches here may occur in one execution:
+ # Commandline parsing could change mode back to GUI
+ if mode == "GUI":
+ from avp.gui.mainwindow import MainWindow
+
+ mainWindow = MainWindow(proj, dpi)
+ log.debug("Finished creating MainWindow")
+ mainWindow.raise_()
+
+ return app.exec()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/avp/command.py b/src/avp/command.py
new file mode 100644
index 0000000..783ac26
--- /dev/null
+++ b/src/avp/command.py
@@ -0,0 +1,316 @@
+"""
+When using commandline mode, this module's object handles interpreting
+the arguments and giving them to Core, which tracks the main program state.
+Then it immediately exports a video.
+"""
+
+from PyQt6 import QtCore
+import argparse
+import os
+import sys
+import time
+import glob
+import signal
+import shutil
+import logging
+
+from . import core
+
+
+log = logging.getLogger("AVP.Commandline")
+
+
+class Command(QtCore.QObject):
+ """
+ This replaces the GUI MainWindow when in commandline mode.
+ """
+
+ createVideo = QtCore.pyqtSignal()
+
+ def __init__(self):
+ super().__init__()
+ self.core = core.Core()
+ core.Core.mode = "commandline"
+ self.dataDir = self.core.dataDir
+ self.canceled = False
+ self.settings = core.Core.settings
+
+ # ctrl-c stops the export thread
+ signal.signal(signal.SIGINT, self.stopVideo)
+
+ def parseArgs(self):
+ parser = argparse.ArgumentParser(
+ prog="avp" if os.path.basename(sys.argv[0]) == "__main__.py" else None,
+ description="Create a visualization for an audio file",
+ epilog="EXAMPLE COMMAND: avp myvideotemplate "
+ "-i ~/Music/song.mp3 -o ~/video.mp4 "
+ "-c 0 image path=~/Pictures/thisWeeksPicture.jpg "
+ '-c 1 video "preset=My Logo" -c 2 vis layout=classic',
+ )
+
+ # input/output automatic-export commands
+ parser.add_argument("-i", "--input", metavar="SOUND", help="input audio file")
+ parser.add_argument(
+ "-o", "--output", metavar="OUTPUT", help="output video file"
+ )
+ parser.add_argument(
+ "--export-project",
+ action="store_true",
+ help="use input and output files from project file if -i or -o is missing",
+ )
+
+ # mutually exclusive debug options
+ debugCommands = parser.add_mutually_exclusive_group()
+ debugCommands.add_argument(
+ "--test",
+ action="store_true",
+ help="run tests and create a report full of debugging info",
+ )
+ debugCommands.add_argument(
+ "--debug",
+ action="store_true",
+ help="create bigger logfiles while program is running",
+ )
+
+ # project/GUI options
+ parser.add_argument(
+ "projpath",
+ metavar="path-to-project",
+ help="open a project file (.avp)",
+ nargs="?",
+ )
+ parser.add_argument(
+ "-c",
+ "--comp",
+ metavar=("LAYER", "ARG"),
+ help="first arg must be component NAME to insert at LAYER."
+ '"help" for information about possible args for a component.',
+ nargs="*",
+ action="append",
+ )
+ parser.add_argument(
+ "--no-preview",
+ action="store_true",
+ help="disable live preview during export",
+ )
+
+ args = parser.parse_args()
+
+ if args.debug:
+ core.FILE_LOGLVL = logging.DEBUG
+ core.STDOUT_LOGLVL = logging.DEBUG
+ core.Core.makeLogger()
+
+ if args.test:
+ self.runTests()
+ quit(0)
+
+ if args.projpath:
+ projPath = args.projpath
+ if not os.path.dirname(projPath):
+ projPath = os.path.join(self.settings.value("projectDir"), projPath)
+ if not projPath.endswith(".avp"):
+ projPath += ".avp"
+ self.core.openProject(self, projPath)
+ self.core.selectedComponents = list(reversed(self.core.selectedComponents))
+ self.core.componentListChanged()
+
+ if args.comp:
+ for comp in args.comp:
+ pos = comp[0]
+ name = comp[1]
+ compargs = comp[2:]
+ try:
+ pos = int(pos)
+ except ValueError:
+ print(pos, "is not a layer number.")
+ quit(1)
+ realName = self.parseCompName(name)
+ if not realName:
+ print(name, "is not a valid component name.")
+ quit(1)
+ modI = self.core.moduleIndexFor(realName)
+ i = self.core.insertComponent(pos, modI, self)
+ if i is None:
+ print(name, "could not be initialized.")
+ quit(1)
+ for arg in compargs:
+ self.core.selectedComponents[i].command(arg)
+
+ if args.export_project and args.projpath:
+ errcode, data = self.core.parseAvFile(projPath)
+ input_ = None
+ output = None
+ for key, value in data["WindowFields"]:
+ if "outputFile" in key:
+ output = value
+ if output and not os.path.dirname(value):
+ output = os.path.join(os.path.expanduser("~"), output)
+ if "audioFile" in key:
+ input_ = value
+
+ # use input/output from project file, overwritten by -i and -o
+ if (not input_ and not args.input) or (not output and not args.output):
+ parser.print_help()
+ quit(1)
+
+ self.createAudioVisualization(
+ input_ if not args.input else args.input,
+ output if not args.output else args.output,
+ )
+ return "commandline"
+
+ elif args.input and args.output:
+ self.createAudioVisualization(args.input, args.output)
+ return "commandline"
+
+ elif args.no_preview:
+ core.Core.previewEnabled = False
+
+ elif (
+ args.projpath is None
+ and "help" not in sys.argv
+ and "--debug" not in sys.argv
+ and "--test" not in sys.argv
+ ):
+ parser.print_help()
+ quit(1)
+
+ return "GUI"
+
+ def createAudioVisualization(self, input, output):
+ if not self.core.selectedComponents:
+ print("No components selected. Adding a default visualizer.")
+ time.sleep(1)
+ self.core.insertComponent(0, 0, self)
+ self.core.selectedComponents = list(reversed(self.core.selectedComponents))
+ self.core.componentListChanged()
+ self.worker = self.core.newVideoWorker(self, input, output)
+ # quit(0) after video is created
+ self.worker.videoCreated.connect(self.videoCreated)
+ self.lastProgressUpdate = time.time()
+ self.worker.progressBarSetText.connect(self.progressBarSetText)
+ self.createVideo.emit()
+
+ def stopVideo(self, *args):
+ self.worker.error = True
+ self.worker.cancelExport()
+ self.worker.cancel()
+
+ @QtCore.pyqtSlot(str)
+ def progressBarSetText(self, value):
+ if "Export " in value:
+ # Don't duplicate completion/failure messages
+ return
+ if (
+ not value.startswith("Exporting")
+ and time.time() - self.lastProgressUpdate >= 0.05
+ ):
+ # 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
+ self.lastProgressUpdate = time.time()
+
+ @QtCore.pyqtSlot()
+ def videoCreated(self):
+ self.quit(0)
+
+ def quit(self, code):
+ quit(code)
+
+ def showMessage(self, **kwargs):
+ print(kwargs["msg"])
+ if "detail" in kwargs:
+ print(kwargs["detail"])
+
+ @QtCore.pyqtSlot(str, str)
+ def videoThreadError(self, msg, detail):
+ print(msg)
+ print(detail)
+ quit(1)
+
+ def drawPreview(self, *args):
+ pass
+
+ 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
+
+ compFileNames = [
+ os.path.splitext(os.path.basename(mod.__file__))[0]
+ for mod in self.core.modules
+ ]
+ for i, compFileName in enumerate(compFileNames):
+ if name.lower() in compFileName:
+ return self.core.compNames[i]
+ return
+
+ return None
+
+ def runTests(self):
+ from . import tests
+
+ test_report = os.path.join(core.Core.logDir, "test_report.log")
+ tests.run(test_report)
+
+ # Choose a numbered location to put the output file
+ logNumber = 0
+
+ def getFilename():
+ """Get a numbered filename for the final test report"""
+ nonlocal logNumber
+ name = os.path.join(os.path.expanduser("~"), "avp_test_report")
+ while True:
+ possibleName = f"{name}{logNumber:0>2}.txt"
+ if os.path.exists(possibleName) and logNumber < 100:
+ logNumber += 1
+ continue
+ break
+ return possibleName
+
+ # Copy latest debug log to chosen test report location
+ filename = getFilename()
+ if logNumber == 100:
+ print("Test Report could not be created.")
+ return
+ try:
+ shutil.copy(os.path.join(core.Core.logDir, "avp_debug.log"), filename)
+ with open(filename, "a") as f:
+ f.write(f"{'='*60} debug log ends {'='*60}\n")
+ except FileNotFoundError:
+ 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))
+ with open(filename, "a") as fw:
+ for renderLog in renderLogs:
+ with open(renderLog, "r") as fr:
+ fw.write(f"{'='*60} {os.path.basename(renderLog)} {'='*60}\n")
+ logContents = fr.readlines()
+ fw.write("".join(logContents[:5]))
+ fw.write("...trimmed...\n")
+ fw.write("".join(logContents[-10:]))
+ fw.write(f"{'='*60} {os.path.basename(renderLog)} {'='*60}\n")
+
+ concatenateLogs("render_*.log")
+ concatenateLogs("preview_*.log")
+
+ # Append actual test report to debug log
+ with open(test_report, "r") as f:
+ output = f.readlines()
+ test_output = "".join(output)
+ print(test_output)
+ with open(filename, "a") as f:
+ f.write(test_output)
+ print(f"Test Report created at {filename}")
diff --git a/src/avp/component.py b/src/avp/component.py
new file mode 100644
index 0000000..01d4e44
--- /dev/null
+++ b/src/avp/component.py
@@ -0,0 +1,967 @@
+"""
+Base classes for components to import. Read comments for some documentation
+on making a valid component.
+"""
+
+from PyQt6 import uic, QtCore, QtWidgets
+from PyQt6.QtGui import QColor, QUndoCommand
+import os
+import sys
+import math
+import time
+import logging
+from copy import copy
+
+from .toolkit.frame import BlankFrame
+from .toolkit import (
+ getWidgetValue,
+ setWidgetValue,
+ connectWidget,
+ rgbFromString,
+ blockSignals,
+)
+
+
+log = logging.getLogger("AVP.ComponentHandler")
+
+
+class ComponentMetaclass(type(QtCore.QObject)):
+ """
+ Checks the validity of each Component class and mutates some attrs.
+ E.g., takes only major version from version string & decorates methods
+ """
+
+ def initializationWrapper(func):
+ def initializationWrapper(self, *args, **kwargs):
+ try:
+ return func(self, *args, **kwargs)
+ except Exception:
+ try:
+ raise ComponentError(self, "initialization process")
+ except ComponentError:
+ return
+
+ return initializationWrapper
+
+ def renderWrapper(func):
+ def renderWrapper(self, *args, **kwargs):
+ try:
+ log.verbose(
+ "### %s #%s renders a preview frame ###",
+ self.__class__.name,
+ str(self.compPos),
+ )
+ return func(self, *args, **kwargs)
+ except Exception as e:
+ try:
+ if e.__class__.__name__.startswith("Component"):
+ raise
+ else:
+ raise ComponentError(self, "renderer")
+ except ComponentError:
+ return BlankFrame()
+
+ return renderWrapper
+
+ def commandWrapper(func):
+ """Intercepts the command() method to check for global args"""
+
+ def commandWrapper(self, arg):
+ if arg.startswith("preset="):
+ _, preset = arg.split("=", 1)
+ path = os.path.join(self.core.getPresetDir(self), preset)
+ if not os.path.exists(path):
+ print('Couldn\'t locate preset "%s"' % preset)
+ quit(1)
+ else:
+ print('Opening "%s" preset on layer %s' % (preset, self.compPos))
+ self.core.openPreset(path, self.compPos, preset)
+ # Don't call the component's command() method
+ return
+ else:
+ return func(self, arg)
+
+ return commandWrapper
+
+ def propertiesWrapper(func):
+ """Intercepts the usual properties if the properties are locked."""
+
+ def propertiesWrapper(self):
+ if self._lockedProperties is not None:
+ return self._lockedProperties
+ else:
+ try:
+ return func(self)
+ except Exception:
+ try:
+ raise ComponentError(self, "properties")
+ except ComponentError:
+ return []
+
+ return propertiesWrapper
+
+ def errorWrapper(func):
+ """Intercepts the usual error message if it is locked."""
+
+ def errorWrapper(self):
+ if self._lockedError is not None:
+ return self._lockedError
+ else:
+ return func(self)
+
+ return errorWrapper
+
+ def loadPresetWrapper(func):
+ """Wraps loadPreset to handle the self.openingPreset boolean"""
+
+ class openingPreset:
+ def __init__(self, comp):
+ self.comp = comp
+
+ def __enter__(self):
+ self.comp.openingPreset = True
+
+ def __exit__(self, *args):
+ self.comp.openingPreset = False
+
+ def presetWrapper(self, *args):
+ with openingPreset(self):
+ try:
+ return func(self, *args)
+ except Exception:
+ try:
+ raise ComponentError(self, "preset loader")
+ except ComponentError:
+ return
+
+ return presetWrapper
+
+ def updateWrapper(func):
+ """
+ Calls _preUpdate before every subclass update().
+ Afterwards, for non-user updates, calls _autoUpdate().
+ For undoable updates triggered by the user, calls _userUpdate()
+ """
+
+ class wrap:
+ def __init__(self, comp, auto):
+ self.comp = comp
+ self.auto = auto
+
+ def __enter__(self):
+ self.comp._preUpdate()
+
+ def __exit__(self, *args):
+ if (
+ self.auto
+ or self.comp.openingPreset
+ or not hasattr(self.comp.parent, "undoStack")
+ ):
+ log.verbose("Automatic update")
+ self.comp._autoUpdate()
+ else:
+ log.verbose("User update")
+ self.comp._userUpdate()
+
+ def updateWrapper(self, **kwargs):
+ auto = kwargs["auto"] if "auto" in kwargs else False
+ with wrap(self, auto):
+ try:
+ return func(self)
+ except Exception:
+ try:
+ raise ComponentError(self, "update method")
+ except ComponentError:
+ return
+
+ return updateWrapper
+
+ def widgetWrapper(func):
+ """Connects all widgets to update method after the subclass's method"""
+
+ class wrap:
+ def __init__(self, comp):
+ self.comp = comp
+
+ def __enter__(self):
+ pass
+
+ def __exit__(self, *args):
+ for widgetList in self.comp._allWidgets.values():
+ for widget in widgetList:
+ log.verbose("Connecting %s", str(widget.__class__.__name__))
+ connectWidget(widget, self.comp.update)
+
+ def widgetWrapper(self, *args, **kwargs):
+ auto = kwargs["auto"] if "auto" in kwargs else False
+ with wrap(self):
+ try:
+ return func(self, *args, **kwargs)
+ except Exception:
+ try:
+ raise ComponentError(self, "widget creation")
+ except ComponentError:
+ return
+
+ return widgetWrapper
+
+ def __new__(cls, name, parents, attrs):
+ if "ui" not in attrs:
+ # Use module name as ui filename by default
+ attrs["ui"] = (
+ "%s.ui" % os.path.splitext(attrs["__module__"].split(".")[-1])[0]
+ )
+
+ decorate = (
+ "names", # Class methods
+ "error",
+ "audio",
+ "properties", # Properties
+ "preFrameRender",
+ "previewRender",
+ "loadPreset",
+ "command",
+ "update",
+ "widget",
+ )
+
+ # Auto-decorate methods
+ for key in decorate:
+ if key not in attrs:
+ continue
+ if key in ("names"):
+ attrs[key] = classmethod(attrs[key])
+ elif key in ("audio"):
+ attrs[key] = property(attrs[key])
+ elif key == "command":
+ attrs[key] = cls.commandWrapper(attrs[key])
+ elif key == "previewRender":
+ attrs[key] = cls.renderWrapper(attrs[key])
+ elif key == "preFrameRender":
+ attrs[key] = cls.initializationWrapper(attrs[key])
+ elif key == "properties":
+ attrs[key] = cls.propertiesWrapper(attrs[key])
+ elif key == "error":
+ attrs[key] = cls.errorWrapper(attrs[key])
+ elif key == "loadPreset":
+ attrs[key] = cls.loadPresetWrapper(attrs[key])
+ elif key == "update":
+ attrs[key] = cls.updateWrapper(attrs[key])
+ elif key == "widget" and parents[0] != QtCore.QObject:
+ attrs[key] = cls.widgetWrapper(attrs[key])
+
+ # Turn version string into a number
+ try:
+ if "version" not in attrs:
+ log.error(
+ "No version attribute in %s. Defaulting to 1",
+ attrs["name"],
+ )
+ attrs["version"] = 1
+ else:
+ attrs["version"] = int(attrs["version"].split(".")[0])
+ except ValueError:
+ log.critical(
+ "%s component has an invalid version string:\n%s",
+ attrs["name"],
+ str(attrs["version"]),
+ )
+ except KeyError:
+ log.critical("%s component has no version string.", attrs["name"])
+ else:
+ return super().__new__(cls, name, parents, attrs)
+ quit(1)
+
+
+class Component(QtCore.QObject, metaclass=ComponentMetaclass):
+ """
+ The base class for components to inherit.
+ """
+
+ name = "Component"
+ # ui = 'name_Of_Non_Default_Ui_File'
+
+ version = "1.0.0"
+ # The major version (before the first dot) is used to determine
+ # preset compatibility; the rest is ignored so it can be non-numeric.
+
+ modified = QtCore.pyqtSignal(int, dict)
+ _error = QtCore.pyqtSignal(str, str)
+
+ def __init__(self, moduleIndex, compPos, core):
+ super().__init__()
+ self.moduleIndex = moduleIndex
+ self.compPos = compPos
+ self.core = core
+
+ # STATUS VARIABLES
+ self.currentPreset = None
+ self._allWidgets = {}
+ self._trackedWidgets = {}
+ self._presetNames = {}
+ self._commandArgs = {}
+ self._colorWidgets = {}
+ self._colorFuncs = {}
+ self._relativeWidgets = {}
+ # Pixel values stored as floats
+ self._relativeValues = {}
+ # Maximum values of spinBoxes at 1080p (Core.resolutions[0])
+ self._relativeMaximums = {}
+
+ # LOCKING VARIABLES
+ self.openingPreset = False
+ self.mergeUndo = True
+ self._lockedProperties = None
+ self._lockedError = None
+ self._lockedSize = None
+ # If set to a dict, values are used as basis to update relative widgets
+ self.oldAttrs = None
+ # Stop lengthy processes in response to this variable
+ self.canceled = False
+
+ def __str__(self):
+ return self.__class__.name
+
+ def __repr__(self):
+ import pprint
+
+ try:
+ preset = self.savePreset()
+ except Exception as e:
+ preset = "%s occurred while saving preset" % str(e)
+
+ return "Component(module %s, pos %s) (%s)\n" "Name: %s v%s\nPreset: %s" % (
+ self.moduleIndex,
+ self.compPos,
+ object.__repr__(self),
+ self.__class__.name,
+ str(self.__class__.version),
+ pprint.pformat(preset),
+ )
+
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # Render Methods
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+
+ def previewRender(self):
+ image = BlankFrame(self.width, self.height)
+ return image
+
+ def preFrameRender(self, **kwargs):
+ """
+ Must call super() when subclassing
+ Triggered only before a video is exported (video_thread.py)
+ self.audioFile = filepath to the main input audio file
+ self.completeAudioArray = a list of audio samples
+ self.sampleSize = number of audio samples per video frame
+ self.progressBarUpdate = signal to set progress bar number
+ self.progressBarSetText = signal to set progress bar text
+ Use the latter two signals to update the MainWindow if needed
+ for a long initialization procedure (i.e., for a visualizer)
+ """
+ for key, value in kwargs.items():
+ setattr(self, key, value)
+
+ def frameRender(self, frameNo):
+ audioArrayIndex = frameNo * self.sampleSize
+ image = BlankFrame(self.width, self.height)
+ return image
+
+ def postFrameRender(self):
+ pass
+
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # Properties
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+
+ def properties(self):
+ """
+ Return a list of properties to signify if your component is
+ non-animated ('static'), returns sound ('audio'), or has
+ encountered an error in configuration ('error').
+ """
+ return []
+
+ def error(self):
+ """
+ Return a string containing an error message, or None for a default.
+ Or tuple of two strings for a message with details.
+ Alternatively use lockError(msgString) within properties()
+ to skip this method entirely.
+ """
+ return
+
+ def audio(self):
+ """
+ Return audio to mix into master as a tuple with two elements:
+ The first element can be:
+ - A string (path to audio file),
+ - Or an object that returns audio data through a pipe
+ The second element must be a dictionary of ffmpeg filters/options
+ to apply to the input stream. See the filter docs for ideas:
+ https://ffmpeg.org/ffmpeg-filters.html
+ """
+
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # Idle Methods
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+
+ def widget(self, parent):
+ """
+ Call super().widget(*args) to create the component widget
+ which also auto-connects any common widgets (e.g., checkBoxes)
+ to self.update(). Then in a subclass connect special actions
+ (e.g., pushButtons to select a file) and initialize
+ """
+ self.parent = parent
+ self.settings = parent.settings
+ log.verbose(
+ "Creating UI for %s #%s's widget",
+ self.__class__.name,
+ self.compPos,
+ )
+ self.page = self.loadUi(self.__class__.ui)
+
+ # Find all normal widgets which will be connected after subclass method
+ self._allWidgets = {
+ "lineEdit": self.page.findChildren(QtWidgets.QLineEdit),
+ "checkBox": self.page.findChildren(QtWidgets.QCheckBox),
+ "spinBox": self.page.findChildren(QtWidgets.QSpinBox),
+ "comboBox": self.page.findChildren(QtWidgets.QComboBox),
+ }
+ self._allWidgets["spinBox"].extend(
+ self.page.findChildren(QtWidgets.QDoubleSpinBox)
+ )
+
+ def update(self):
+ """
+ Starting point for a component update. A subclass should override
+ this method, and the base class will then magically insert a call
+ to either _autoUpdate() or _userUpdate() at the end.
+ """
+
+ def loadPreset(self, presetDict, presetName=None):
+ """
+ Subclasses should take (presetDict, *args) as args.
+ Must use super().loadPreset(presetDict, *args) first,
+ then update self.page widgets using the preset dict.
+ """
+ self.currentPreset = (
+ presetName if presetName is not None else presetDict["preset"]
+ )
+ for attr, widget in self._trackedWidgets.items():
+ key = attr if attr not in self._presetNames else self._presetNames[attr]
+ try:
+ val = presetDict[key]
+ except KeyError as e:
+ log.info(
+ "%s missing value %s. Outdated preset?",
+ self.currentPreset,
+ str(e),
+ )
+ val = getattr(self, key)
+
+ if attr in self._colorWidgets:
+ widget.setText("%s,%s,%s" % val)
+ btnStyle = (
+ "QPushButton { background-color : %s; outline: none; }"
+ % QColor(*val).name()
+ )
+ self._colorWidgets[attr].setStyleSheet(btnStyle)
+ elif attr in self._relativeWidgets:
+ self._relativeValues[attr] = val
+ pixelVal = self.pixelValForAttr(attr, val)
+ setWidgetValue(widget, pixelVal)
+ else:
+ setWidgetValue(widget, val)
+
+ def savePreset(self):
+ saveValueStore = {}
+ for attr, widget in self._trackedWidgets.items():
+ presetAttrName = (
+ attr if attr not in self._presetNames else self._presetNames[attr]
+ )
+ if attr in self._relativeWidgets:
+ try:
+ val = self._relativeValues[attr]
+ except AttributeError:
+ val = self.floatValForAttr(attr)
+ else:
+ val = getattr(self, attr)
+
+ saveValueStore[presetAttrName] = val
+ return saveValueStore
+
+ def commandHelp(self):
+ """Help text as string for this component's commandline arguments"""
+
+ def command(self, arg=""):
+ """
+ Configure a component using an arg from the commandline. This is
+ never called if global args like 'preset=' are found in the arg.
+ So simply check for any non-global args in your component and
+ call super().command() at the end to get a Help message.
+ """
+ print(
+ self.__class__.name,
+ "Usage:\n" "Open a preset for this component:\n" ' "preset=Preset Name"',
+ )
+ self.commandHelp()
+ quit(0)
+
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # "Private" Methods
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ def _preUpdate(self):
+ """Happens before subclass update()"""
+ for attr in self._relativeWidgets:
+ self.updateRelativeWidget(attr)
+
+ def _userUpdate(self):
+ """Happens after subclass update() for an undoable update by user."""
+ oldWidgetVals = {
+ attr: copy(getattr(self, attr)) for attr in self._trackedWidgets
+ }
+ newWidgetVals = {
+ attr: (
+ getWidgetValue(widget)
+ if attr not in self._colorWidgets
+ else rgbFromString(widget.text())
+ )
+ for attr, widget in self._trackedWidgets.items()
+ }
+ modifiedWidgets = {
+ attr: val
+ for attr, val in newWidgetVals.items()
+ if val != oldWidgetVals[attr]
+ }
+ if modifiedWidgets:
+ action = ComponentUpdate(self, oldWidgetVals, modifiedWidgets)
+ self.parent.undoStack.push(action)
+
+ def _autoUpdate(self):
+ """Happens after subclass update() for an internal component update."""
+ newWidgetVals = {
+ attr: getWidgetValue(widget)
+ for attr, widget in self._trackedWidgets.items()
+ }
+ self.setAttrs(newWidgetVals)
+ self._sendUpdateSignal()
+
+ def setAttrs(self, attrDict):
+ """
+ Sets attrs (linked to trackedWidgets) in this component to
+ the values in the attrDict. Mutates certain widget values if needed
+ """
+ for attr, val in attrDict.items():
+ if attr in self._colorWidgets:
+ # Color Widgets must have a tuple & have a button to update
+ if type(val) is tuple:
+ rgbTuple = val
+ else:
+ rgbTuple = rgbFromString(val)
+ btnStyle = (
+ "QPushButton { background-color : %s; outline: none; }"
+ % QColor(*rgbTuple).name()
+ )
+ self._colorWidgets[attr].setStyleSheet(btnStyle)
+ setattr(self, attr, rgbTuple)
+
+ else:
+ # Normal tracked widget
+ setattr(self, attr, val)
+ log.verbose("Setting %s self.%s to %s" % (self.__class__.name, attr, val))
+
+ def setWidgetValues(self, attrDict):
+ """
+ Sets widgets defined by keys in trackedWidgets in this preset to
+ the values in the attrDict.
+ """
+ affectedWidgets = [self._trackedWidgets[attr] for attr in attrDict]
+ with blockSignals(affectedWidgets):
+ for attr, val in attrDict.items():
+ widget = self._trackedWidgets[attr]
+ if attr in self._colorWidgets:
+ val = "%s,%s,%s" % val
+ setWidgetValue(widget, val)
+
+ def _sendUpdateSignal(self):
+ if not self.core.openingProject:
+ self.parent.drawPreview()
+ saveValueStore = self.savePreset()
+ saveValueStore["preset"] = self.currentPreset
+ self.modified.emit(self.compPos, saveValueStore)
+
+ def trackWidgets(self, trackDict, **kwargs):
+ """
+ Name widgets to track in update(), savePreset(), loadPreset(), and
+ command(). Requires a dict of attr names as keys, widgets as values
+
+ Optional args:
+ 'presetNames': preset variable names to replace attr names
+ 'commandArgs': arg keywords that differ from attr names
+ 'colorWidgets': identify attr as RGB tuple & update button CSS
+ 'relativeWidgets': change value proportionally to resolution
+
+ NOTE: Any kwarg key set to None will selectively disable tracking.
+ """
+ self._trackedWidgets = trackDict
+ for kwarg in kwargs:
+ try:
+ if kwarg in (
+ "presetNames",
+ "commandArgs",
+ "colorWidgets",
+ "relativeWidgets",
+ ):
+ setattr(self, "_{}".format(kwarg), kwargs[kwarg])
+ else:
+ raise ComponentError(self, "Nonsensical keywords to trackWidgets.")
+ except ComponentError:
+ continue
+
+ if kwarg == "colorWidgets":
+
+ def makeColorFunc(attr):
+ def pickColor_():
+ self.mergeUndo = False
+ self.pickColor(
+ self._trackedWidgets[attr],
+ self._colorWidgets[attr],
+ )
+ self.mergeUndo = True
+
+ return pickColor_
+
+ self._colorFuncs = {attr: makeColorFunc(attr) for attr in kwargs[kwarg]}
+ for attr, func in self._colorFuncs.items():
+ self._colorWidgets[attr].clicked.connect(func)
+ self._colorWidgets[attr].setStyleSheet(
+ "QPushButton {" "background-color : #FFFFFF; outline: none; }"
+ )
+
+ if kwarg == "relativeWidgets":
+ # store maximum values of spinBoxes to be scaled appropriately
+ for attr in kwargs[kwarg]:
+ self._relativeMaximums[attr] = self._trackedWidgets[attr].maximum()
+ self.updateRelativeWidgetMaximum(attr)
+ setattr(self, attr, getWidgetValue(self._trackedWidgets[attr]))
+
+ self._preUpdate()
+ self._autoUpdate()
+
+ def pickColor(self, textWidget, button):
+ """Use color picker to get color input from the user."""
+ dialog = QtWidgets.QColorDialog()
+ # TODO alpha channel is not actually shown in most color picker widgets?
+ dialog.setOption(
+ QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel, True
+ )
+ color = dialog.getColor()
+ if color.isValid():
+ RGBstring = "%s,%s,%s" % (
+ str(color.red()),
+ str(color.green()),
+ str(color.blue()),
+ )
+ btnStyle = (
+ "QPushButton{background-color: %s; outline: none;}" % color.name()
+ )
+ textWidget.setText(RGBstring)
+ button.setStyleSheet(btnStyle)
+
+ def lockProperties(self, propList):
+ self._lockedProperties = propList
+
+ def lockError(self, msg):
+ self._lockedError = msg
+
+ def lockSize(self, w, h):
+ self._lockedSize = (w, h)
+
+ def unlockProperties(self):
+ self._lockedProperties = None
+
+ def unlockError(self):
+ self._lockedError = None
+
+ def unlockSize(self):
+ self._lockedSize = None
+
+ def loadUi(self, filename):
+ """Load a Qt Designer ui file to use for this component's widget"""
+ return uic.loadUi(os.path.join(self.core.componentsPath, filename))
+
+ @property
+ def width(self):
+ if self._lockedSize is None:
+ return int(self.settings.value("outputWidth"))
+ else:
+ return self._lockedSize[0]
+
+ @property
+ def height(self):
+ if self._lockedSize is None:
+ return int(self.settings.value("outputHeight"))
+ else:
+ return self._lockedSize[1]
+
+ def cancel(self):
+ """Stop any lengthy process in response to this variable."""
+ self.canceled = True
+
+ def reset(self):
+ self.canceled = False
+ self.unlockProperties()
+ self.unlockError()
+
+ def relativeWidgetAxis(func):
+ def relativeWidgetAxis(self, attr, *args, **kwargs):
+ hasVerticalWords = (
+ lambda attr: "height" in attr.lower()
+ or "ypos" in attr.lower()
+ or attr == "y"
+ )
+ if "axis" not in kwargs:
+ axis = self.width
+ if hasVerticalWords(attr):
+ axis = self.height
+ kwargs["axis"] = axis
+ if "axis" in kwargs and type(kwargs["axis"]) is tuple:
+ axis = kwargs["axis"][0]
+ if hasVerticalWords(attr):
+ axis = kwargs["axis"][1]
+ kwargs["axis"] = axis
+ return func(self, attr, *args, **kwargs)
+
+ return relativeWidgetAxis
+
+ @relativeWidgetAxis
+ def pixelValForAttr(self, attr, val=None, **kwargs):
+ if val is None:
+ val = self._relativeValues[attr]
+ if val > 50.0:
+ log.warning(
+ "%s #%s attempted to set %s to dangerously high number %s",
+ self.__class__.name,
+ self.compPos,
+ attr,
+ val,
+ )
+ val = 50.0
+ result = math.ceil(kwargs["axis"] * val)
+ log.verbose(
+ "Converting %s: f%s to px%s using axis %s",
+ attr,
+ val,
+ result,
+ kwargs["axis"],
+ )
+ return result
+
+ @relativeWidgetAxis
+ def floatValForAttr(self, attr, val=None, **kwargs):
+ if val is None:
+ val = self._trackedWidgets[attr].value()
+ return val / kwargs["axis"]
+
+ def setRelativeWidget(self, attr, floatVal):
+ """Set a relative widget using a float"""
+ pixelVal = self.pixelValForAttr(attr, floatVal)
+ with blockSignals(self._trackedWidgets[attr]):
+ self._trackedWidgets[attr].setValue(pixelVal)
+ self.update(auto=True)
+
+ def getOldAttr(self, attr):
+ """
+ Returns previous state of this attr. Used to determine whether
+ a relative widget must be updated. Required because undoing/redoing
+ can make determining the 'previous' value tricky.
+ """
+ if self.oldAttrs is not None:
+ return self.oldAttrs[attr]
+ else:
+ try:
+ return getattr(self, attr)
+ except AttributeError:
+ log.error("Using visible values instead of oldAttrs")
+ return self._trackedWidgets[attr].value()
+
+ def updateRelativeWidget(self, attr):
+ """Called by _preUpdate() for each relativeWidget before each update"""
+ oldUserValue = self.getOldAttr(attr)
+ newUserValue = self._trackedWidgets[attr].value()
+ newRelativeVal = self.floatValForAttr(attr, newUserValue)
+
+ if attr in self._relativeValues:
+ oldRelativeVal = self._relativeValues[attr]
+ if oldUserValue == newUserValue and oldRelativeVal != newRelativeVal:
+ # Float changed without pixel value changing, which
+ # means the pixel value needs to be updated
+ log.debug(
+ "Updating %s #%s's relative widget: %s",
+ self.__class__.name,
+ self.compPos,
+ attr,
+ )
+ with blockSignals(self._trackedWidgets[attr]):
+ self.updateRelativeWidgetMaximum(attr)
+ pixelVal = self.pixelValForAttr(attr, oldRelativeVal)
+ self._trackedWidgets[attr].setValue(pixelVal)
+
+ if attr not in self._relativeValues or oldUserValue != newUserValue:
+ self._relativeValues[attr] = newRelativeVal
+
+ def updateRelativeWidgetMaximum(self, attr):
+ maxRes = int(self.core.resolutions[0].split("x")[0])
+ newMaximumValue = self.width * (self._relativeMaximums[attr] / maxRes)
+ self._trackedWidgets[attr].setMaximum(int(newMaximumValue))
+
+
+class ComponentError(RuntimeError):
+ """Gives the MainWindow a traceback to display, and cancels the export."""
+
+ prevErrors = []
+ lastTime = time.time()
+
+ def __init__(self, caller, name, msg=None):
+ if msg is None and sys.exc_info()[0] is not None:
+ msg = str(sys.exc_info()[1])
+ else:
+ msg = "Unknown error."
+ log.error("ComponentError by %s's %s: %s" % (caller.name, name, msg))
+
+ # Don't create multiple windows for quickly repeated messages
+ if len(ComponentError.prevErrors) > 1:
+ ComponentError.prevErrors.pop()
+ ComponentError.prevErrors.insert(0, name)
+ curTime = time.time()
+ if (
+ name in ComponentError.prevErrors[1:]
+ and curTime - ComponentError.lastTime < 1.0
+ ):
+ return
+ ComponentError.lastTime = time.time()
+
+ from .toolkit import formatTraceback
+
+ if sys.exc_info()[0] is not None:
+ string = "%s component (#%s): %s encountered %s %s: %s" % (
+ caller.__class__.name,
+ str(caller.compPos),
+ name,
+ (
+ "an"
+ if any(
+ [
+ sys.exc_info()[0].__name__.startswith(vowel)
+ for vowel in ("A", "I", "U", "O", "E")
+ ]
+ )
+ else "a"
+ ),
+ sys.exc_info()[0].__name__,
+ str(sys.exc_info()[1]),
+ )
+ detail = formatTraceback(sys.exc_info()[2])
+ else:
+ string = name
+ detail = "Attributes:\n%s" % (
+ "\n".join([m for m in dir(caller) if not m.startswith("_")])
+ )
+
+ super().__init__(string)
+ caller.lockError(string)
+ caller._error.emit(string, detail)
+
+
+class ComponentUpdate(QUndoCommand):
+ """Command object for making a component action undoable"""
+
+ def __init__(self, parent, oldWidgetVals, modifiedVals):
+ super().__init__("change %s component #%s" % (parent.name, parent.compPos))
+ self.undone = False
+ self.res = (int(parent.width), int(parent.height))
+ self.parent = parent
+ self.oldWidgetVals = {
+ attr: (
+ copy(val)
+ if attr not in self.parent._relativeWidgets
+ else self.parent.floatValForAttr(attr, val, axis=self.res)
+ )
+ for attr, val in oldWidgetVals.items()
+ if attr in modifiedVals
+ }
+ self.modifiedVals = {
+ attr: (
+ val
+ if attr not in self.parent._relativeWidgets
+ else self.parent.floatValForAttr(attr, val, axis=self.res)
+ )
+ for attr, val in modifiedVals.items()
+ }
+
+ # Because relative widgets change themselves every update based on
+ # their previous value, we must store ALL their values in case of undo
+ self.relativeWidgetValsAfterUndo = {
+ attr: copy(getattr(self.parent, attr))
+ for attr in self.parent._relativeWidgets
+ }
+
+ # Determine if this update is mergeable
+ self.id_ = -1
+ if len(self.modifiedVals) == 1 and self.parent.mergeUndo:
+ attr, val = self.modifiedVals.popitem()
+ self.id_ = sum([ord(letter) for letter in attr[-14:]])
+ self.modifiedVals[attr] = val
+ else:
+ log.warning(
+ "%s component settings changed at once. (%s)",
+ len(self.modifiedVals),
+ repr(self.modifiedVals),
+ )
+
+ def id(self):
+ """If 2 consecutive updates have same id, Qt will call mergeWith()"""
+ return self.id_
+
+ def mergeWith(self, other):
+ self.modifiedVals.update(other.modifiedVals)
+ return True
+
+ def setWidgetValues(self, attrDict):
+ """
+ Mask the component's usual method to handle our
+ relative widgets in case the resolution has changed.
+ """
+ newAttrDict = {
+ attr: (
+ val
+ if attr not in self.parent._relativeWidgets
+ else self.parent.pixelValForAttr(attr, val)
+ )
+ for attr, val in attrDict.items()
+ }
+ self.parent.setWidgetValues(newAttrDict)
+
+ def redo(self):
+ if self.undone:
+ log.info("Redoing component update")
+ self.parent.oldAttrs = self.relativeWidgetValsAfterUndo
+ self.setWidgetValues(self.modifiedVals)
+ self.parent.update(auto=True)
+ self.parent.oldAttrs = None
+ if not self.undone:
+ self.relativeWidgetValsAfterRedo = {
+ attr: copy(getattr(self.parent, attr))
+ for attr in self.parent._relativeWidgets
+ }
+ self.parent._sendUpdateSignal()
+
+ def undo(self):
+ log.info("Undoing component update")
+ self.undone = True
+ self.parent.oldAttrs = self.relativeWidgetValsAfterRedo
+ self.setWidgetValues(self.oldWidgetVals)
+ self.parent.update(auto=True)
+ self.parent.oldAttrs = None
diff --git a/src/avp/components/__init__.py b/src/avp/components/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/avp/components/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/avp/components/__template__.ui b/src/avp/components/__template__.ui
new file mode 100644
index 0000000..301a2b7
--- /dev/null
+++ b/src/avp/components/__template__.ui
@@ -0,0 +1,119 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 197
+
+
+
+ Form
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/components/color.py b/src/avp/components/color.py
new file mode 100644
index 0000000..1f32c23
--- /dev/null
+++ b/src/avp/components/color.py
@@ -0,0 +1,176 @@
+from PyQt6 import QtGui
+import logging
+
+from ..component import Component
+from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor
+
+
+log = logging.getLogger("AVP.Components.Color")
+
+
+class Component(Component):
+ name = "Color"
+ version = "1.0.0"
+
+ def widget(self, *args):
+ self.x = 0
+ self.y = 0
+ super().widget(*args)
+
+ # disable color #2 until non-default 'fill' option gets changed
+ self.page.lineEdit_color2.setDisabled(True)
+ self.page.pushButton_color2.setDisabled(True)
+ self.page.spinBox_width.setValue(int(self.settings.value("outputWidth")))
+ self.page.spinBox_height.setValue(int(self.settings.value("outputHeight")))
+
+ self.fillLabels = [
+ "Solid",
+ "Linear Gradient",
+ "Radial Gradient",
+ ]
+ for label in self.fillLabels:
+ self.page.comboBox_fill.addItem(label)
+ self.page.comboBox_fill.setCurrentIndex(0)
+
+ self.trackWidgets(
+ {
+ "x": self.page.spinBox_x,
+ "y": self.page.spinBox_y,
+ "sizeWidth": self.page.spinBox_width,
+ "sizeHeight": self.page.spinBox_height,
+ "trans": self.page.checkBox_trans,
+ "spread": self.page.comboBox_spread,
+ "stretch": self.page.checkBox_stretch,
+ "RG_start": self.page.spinBox_radialGradient_start,
+ "LG_start": self.page.spinBox_linearGradient_start,
+ "RG_end": self.page.spinBox_radialGradient_end,
+ "LG_end": self.page.spinBox_linearGradient_end,
+ "RG_centre": self.page.spinBox_radialGradient_spread,
+ "fillType": self.page.comboBox_fill,
+ "color1": self.page.lineEdit_color1,
+ "color2": self.page.lineEdit_color2,
+ },
+ presetNames={
+ "sizeWidth": "width",
+ "sizeHeight": "height",
+ },
+ colorWidgets={
+ "color1": self.page.pushButton_color1,
+ "color2": self.page.pushButton_color2,
+ },
+ relativeWidgets=[
+ "x",
+ "y",
+ "sizeWidth",
+ "sizeHeight",
+ "LG_start",
+ "LG_end",
+ "RG_start",
+ "RG_end",
+ "RG_centre",
+ ],
+ )
+
+ def update(self):
+ fillType = self.page.comboBox_fill.currentIndex()
+ if fillType == 0:
+ self.page.lineEdit_color2.setEnabled(False)
+ self.page.pushButton_color2.setEnabled(False)
+ self.page.checkBox_trans.setEnabled(False)
+ self.page.checkBox_stretch.setEnabled(False)
+ self.page.comboBox_spread.setEnabled(False)
+ else:
+ self.page.lineEdit_color2.setEnabled(True)
+ self.page.pushButton_color2.setEnabled(True)
+ self.page.checkBox_trans.setEnabled(True)
+ self.page.checkBox_stretch.setEnabled(True)
+ self.page.comboBox_spread.setEnabled(True)
+ if self.page.checkBox_trans.isChecked():
+ self.page.lineEdit_color2.setEnabled(False)
+ self.page.pushButton_color2.setEnabled(False)
+ self.page.fillWidget.setCurrentIndex(fillType)
+
+ def previewRender(self):
+ return self.drawFrame(self.width, self.height)
+
+ def properties(self):
+ return ["static"]
+
+ def frameRender(self, frameNo):
+ log.debug("Color component is drawing frame #%s", frameNo)
+ return self.drawFrame(self.width, self.height)
+
+ def drawFrame(self, width, height):
+ r, g, b = self.color1
+ shapeSize = (self.sizeWidth, self.sizeHeight)
+ # in default state, skip all this logic and return a plain fill
+ if (
+ self.fillType == 0
+ and shapeSize == (width, height)
+ and self.x == 0
+ and self.y == 0
+ ):
+ return FloodFrame(width, height, (r, g, b, 255))
+
+ # Return a solid image at x, y
+ if self.fillType == 0:
+ frame = BlankFrame(width, height)
+ image = FloodFrame(self.sizeWidth, self.sizeHeight, (r, g, b, 255))
+ frame.paste(image, box=(self.x, self.y))
+ return frame
+
+ # Now fills that require using Qt...
+ elif self.fillType > 0:
+ image = FramePainter(width, height)
+
+ if self.stretch:
+ w = width
+ h = height
+ else:
+ w = self.sizeWidth
+ h = self.sizeWidth
+
+ if self.fillType == 1: # Linear Gradient
+ brush = QtGui.QLinearGradient(
+ float(self.LG_start),
+ float(self.LG_start),
+ float(self.LG_end + width / 3),
+ float(self.LG_end),
+ )
+
+ elif self.fillType == 2: # Radial Gradient
+ brush = QtGui.QRadialGradient(
+ float(self.RG_start),
+ float(self.RG_end),
+ float(w),
+ float(h),
+ float(self.RG_centre),
+ )
+ spread = QtGui.QGradient.Spread.PadSpread
+ if self.spread == 1:
+ spread = QtGui.QGradient.Spread.ReflectSpread
+ elif self.spread == 2:
+ spread = QtGui.QGradient.Spread.RepeatSpread
+ brush.setSpread(spread)
+ brush.setColorAt(0.0, PaintColor(*self.color1))
+ if self.trans:
+ brush.setColorAt(1.0, PaintColor(0, 0, 0, 0))
+ elif self.fillType == 1 and self.stretch:
+ brush.setColorAt(0.2, PaintColor(*self.color2))
+ else:
+ brush.setColorAt(1.0, PaintColor(*self.color2))
+ image.setBrush(brush)
+ image.drawRect(self.x, self.y, self.sizeWidth, self.sizeHeight)
+
+ return image.finalize()
+
+ def commandHelp(self):
+ print("Specify a color:\n color=255,255,255")
+
+ def command(self, arg):
+ if "=" in arg:
+ key, arg = arg.split("=", 1)
+ if key == "color":
+ self.page.lineEdit_color1.setText(arg)
+ return
+ super().command(arg)
diff --git a/src/avp/components/color.ui b/src/avp/components/color.ui
new file mode 100644
index 0000000..c1713fb
--- /dev/null
+++ b/src/avp/components/color.ui
@@ -0,0 +1,666 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 197
+
+
+
+ Form
+
+
+ -
+
+
+ 4
+
+
-
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 31
+ 0
+
+
+
+ Color #1
+
+
+
+ -
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+ 32
+ 32
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 1
+ 0
+
+
+
+ 0,0,0
+
+
+ 12
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 31
+ 0
+
+
+
+ Color #2
+
+
+
+ -
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+ 32
+ 32
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 1
+ 0
+
+
+
+ 133,133,133
+
+
+ 12
+
+
+
+
+
+ -
+
+
+ 0
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Width
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+
+ 0
+ 0
+
+
+
+ 0
+
+
+ 19200
+
+
+ 0
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Height
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+ 10800
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ X
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+
+ 0
+ 0
+
+
+
+ -10000
+
+
+ 10000
+
+
+ 0
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Y
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+ -10000
+
+
+ 10000
+
+
+
+
+
+ -
+
+
+ 0
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Fill
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ -1
+
+
+ QComboBox::AdjustToContentsOnFirstShow
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Transparent
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Stretch
+
+
+
+ -
+
+
-
+
+ Pad
+
+
+ -
+
+ Reflect
+
+
+ -
+
+ Repeat
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Minimum
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ 0
+
+
+ 2
+
+
+
+
+
+
+ -1
+ 0
+ 561
+ 31
+
+
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Start
+
+
+
+ -
+
+
+ -10000
+
+
+ 10000
+
+
+ 10
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ End
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ -10000
+
+
+ 10000
+
+
+ 10
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+
+
+
+ -1
+ -1
+ 561
+ 31
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Start
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ -10000
+
+
+ 10000
+
+
+ 10
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ End
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ -10000
+
+
+ 10000
+
+
+ 10
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Centre
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ QAbstractSpinBox::PlusMinus
+
+
+ -10000
+
+
+ 10000
+
+
+ 3
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/components/image.py b/src/avp/components/image.py
new file mode 100644
index 0000000..2393611
--- /dev/null
+++ b/src/avp/components/image.py
@@ -0,0 +1,129 @@
+from PIL import Image, ImageDraw, ImageEnhance
+from PyQt6 import QtGui, QtCore, QtWidgets
+import os
+
+from ..component import Component
+from ..toolkit.frame import BlankFrame
+
+
+class Component(Component):
+ name = "Image"
+ version = "1.0.1"
+
+ def widget(self, *args):
+ super().widget(*args)
+ self.page.pushButton_image.clicked.connect(self.pickImage)
+ self.trackWidgets(
+ {
+ "imagePath": self.page.lineEdit_image,
+ "scale": self.page.spinBox_scale,
+ "stretchScale": self.page.spinBox_scale_stretch,
+ "rotate": self.page.spinBox_rotate,
+ "color": self.page.spinBox_color,
+ "xPosition": self.page.spinBox_x,
+ "yPosition": self.page.spinBox_y,
+ "stretched": self.page.checkBox_stretch,
+ "mirror": self.page.checkBox_mirror,
+ },
+ presetNames={
+ "imagePath": "image",
+ "xPosition": "x",
+ "yPosition": "y",
+ },
+ relativeWidgets=["xPosition", "yPosition", "scale"],
+ )
+
+ def previewRender(self):
+ return self.drawFrame(self.width, self.height)
+
+ def properties(self):
+ props = ["static"]
+ if not os.path.exists(self.imagePath):
+ props.append("error")
+ return props
+
+ def error(self):
+ if not self.imagePath:
+ return "There is no image selected."
+ if not os.path.exists(self.imagePath):
+ return "The image selected does not exist!"
+
+ def frameRender(self, frameNo):
+ return self.drawFrame(self.width, self.height)
+
+ def drawFrame(self, width, height):
+ frame = BlankFrame(width, height)
+ if self.imagePath and os.path.exists(self.imagePath):
+ scale = self.scale if not self.stretched else self.stretchScale
+ image = Image.open(self.imagePath)
+
+ # Modify image's appearance
+ if self.color != 100:
+ image = ImageEnhance.Color(image).enhance(float(self.color / 100))
+ if self.mirror:
+ image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
+ if self.stretched and image.size != (width, height):
+ image = image.resize((width, height), Image.Resampling.LANCZOS)
+ if scale != 100:
+ newHeight = int((image.height / 100) * scale)
+ newWidth = int((image.width / 100) * scale)
+ image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS)
+
+ # Paste image at correct position
+ frame.paste(image, box=(self.xPosition, self.yPosition))
+ if self.rotate != 0:
+ frame = frame.rotate(self.rotate)
+
+ return frame
+
+ def pickImage(self):
+ imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
+ filename, _ = QtWidgets.QFileDialog.getOpenFileName(
+ self.page,
+ "Choose Image",
+ imgDir,
+ "Image Files (%s)" % " ".join(self.core.imageFormats),
+ )
+ if filename:
+ self.settings.setValue("componentDir", os.path.dirname(filename))
+ self.mergeUndo = False
+ self.page.lineEdit_image.setText(filename)
+ self.mergeUndo = True
+
+ def command(self, arg):
+ if "=" in arg:
+ key, arg = arg.split("=", 1)
+ if key == "path" and os.path.exists(arg):
+ try:
+ Image.open(arg)
+ self.page.lineEdit_image.setText(arg)
+ self.page.checkBox_stretch.setChecked(True)
+ return
+ except OSError as e:
+ print("Not a supported image format")
+ quit(1)
+ super().command(arg)
+
+ def commandHelp(self):
+ print("Load an image:\n path=/filepath/to/image.png")
+
+ def savePreset(self):
+ # Maintain the illusion that the scale spinbox is one widget
+ scaleBox = self.page.spinBox_scale
+ stretchScaleBox = self.page.spinBox_scale_stretch
+ if self.page.checkBox_stretch.isChecked():
+ scaleBox.setValue(stretchScaleBox.value())
+ else:
+ stretchScaleBox.setValue(scaleBox.value())
+ return super().savePreset()
+
+ def update(self):
+ # Maintain the illusion that the scale spinbox is one widget
+ scaleBox = self.page.spinBox_scale
+ stretchScaleBox = self.page.spinBox_scale_stretch
+ if self.page.checkBox_stretch.isChecked():
+ scaleBox.setVisible(False)
+ stretchScaleBox.setVisible(True)
+ else:
+ scaleBox.setVisible(True)
+ stretchScaleBox.setVisible(False)
diff --git a/src/avp/components/image.ui b/src/avp/components/image.ui
new file mode 100644
index 0000000..2dad127
--- /dev/null
+++ b/src/avp/components/image.ui
@@ -0,0 +1,388 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 197
+
+
+
+ Form
+
+
+ -
+
+
+ 4
+
+
-
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 31
+ 0
+
+
+
+ Image
+
+
+
+ -
+
+
+
+ 1
+ 0
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 1
+ 0
+
+
+
+
+ 32
+ 32
+
+
+
+ ...
+
+
+
+ 32
+ 32
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ X
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+ -10000
+
+
+ 10000
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Y
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+
+ 0
+ 0
+
+
+
+ -1000
+
+
+ 1000
+
+
+ 0
+
+
+
+
+
+ -
+
+
-
+
+
+ Stretch
+
+
+ false
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+ Mirror
+
+
+
+ -
+
+
+ Rotate
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ QAbstractSpinBox::UpDownArrows
+
+
+ °
+
+
+ 0
+
+
+ 359
+
+
+ 0
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 10
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Scale
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ QAbstractSpinBox::UpDownArrows
+
+
+ %
+
+
+ 10
+
+
+ 400
+
+
+ 100
+
+
+
+ -
+
+
+ %
+
+
+ 10
+
+
+ 400
+
+
+ 100
+
+
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Color
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ QAbstractSpinBox::UpDownArrows
+
+
+ %
+
+
+ 0
+
+
+ 999
+
+
+ 1
+
+
+ 100
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/components/life.py b/src/avp/components/life.py
new file mode 100644
index 0000000..5b719d1
--- /dev/null
+++ b/src/avp/components/life.py
@@ -0,0 +1,520 @@
+from PyQt6 import QtGui, QtCore, QtWidgets
+from PyQt6.QtGui import QUndoCommand
+from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter
+import os
+import math
+import logging
+
+
+from ..component import Component
+from ..toolkit.frame import BlankFrame, scale
+
+
+log = logging.getLogger("AVP.Component.Life")
+
+
+class Component(Component):
+ name = "Conway's Game of Life"
+ version = "1.0.0"
+
+ def widget(self, *args):
+ super().widget(*args)
+ self.scale = 32
+ self.updateGridSize()
+ # The initial grid: a "Queen Bee Shuttle"
+ # https://conwaylife.com/wiki/Queen_bee_shuttle
+ self.startingGrid = set(
+ [
+ (3, 7),
+ (3, 8),
+ (4, 7),
+ (4, 8),
+ (8, 7),
+ (9, 6),
+ (9, 8),
+ (10, 5),
+ (10, 9),
+ (11, 6),
+ (11, 7),
+ (11, 8),
+ (12, 4),
+ (12, 5),
+ (12, 9),
+ (12, 10),
+ (23, 6),
+ (23, 7),
+ (24, 6),
+ (24, 7),
+ ]
+ )
+
+ # Amount of 'bleed' (off-canvas coordinates) on each side of the grid
+ self.bleedSize = 40
+
+ self.page.pushButton_pickImage.clicked.connect(self.pickImage)
+ self.trackWidgets(
+ {
+ "tickRate": self.page.spinBox_tickRate,
+ "scale": self.page.spinBox_scale,
+ "color": self.page.lineEdit_color,
+ "shapeType": self.page.comboBox_shapeType,
+ "shadow": self.page.checkBox_shadow,
+ "customImg": self.page.checkBox_customImg,
+ "showGrid": self.page.checkBox_showGrid,
+ "image": self.page.lineEdit_image,
+ },
+ colorWidgets={
+ "color": self.page.pushButton_color,
+ },
+ )
+ self.shiftButtons = (
+ self.page.toolButton_up,
+ self.page.toolButton_down,
+ self.page.toolButton_left,
+ self.page.toolButton_right,
+ )
+
+ def shiftFunc(i):
+ def shift():
+ self.shiftGrid(i)
+
+ return shift
+
+ shiftFuncs = [shiftFunc(i) for i in range(len(self.shiftButtons))]
+ for i, widget in enumerate(self.shiftButtons):
+ widget.clicked.connect(shiftFuncs[i])
+ self.page.spinBox_scale.setValue(self.scale)
+ self.page.spinBox_scale.valueChanged.connect(self.updateGridSize)
+
+ def pickImage(self):
+ imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
+ filename, _ = QtWidgets.QFileDialog.getOpenFileName(
+ self.page,
+ "Choose Image",
+ imgDir,
+ "Image Files (%s)" % " ".join(self.core.imageFormats),
+ )
+ if filename:
+ self.settings.setValue("componentDir", os.path.dirname(filename))
+ self.mergeUndo = False
+ self.page.lineEdit_image.setText(filename)
+ self.mergeUndo = True
+
+ def shiftGrid(self, d):
+ action = ShiftGrid(self, d)
+ self.parent.undoStack.push(action)
+
+ def update(self):
+ self.updateGridSize()
+ if self.page.checkBox_customImg.isChecked():
+ self.page.label_color.setVisible(False)
+ self.page.lineEdit_color.setVisible(False)
+ self.page.pushButton_color.setVisible(False)
+ self.page.label_shape.setVisible(False)
+ self.page.comboBox_shapeType.setVisible(False)
+ self.page.label_image.setVisible(True)
+ self.page.lineEdit_image.setVisible(True)
+ self.page.pushButton_pickImage.setVisible(True)
+ else:
+ self.page.label_color.setVisible(True)
+ self.page.lineEdit_color.setVisible(True)
+ self.page.pushButton_color.setVisible(True)
+ self.page.label_shape.setVisible(True)
+ self.page.comboBox_shapeType.setVisible(True)
+ self.page.label_image.setVisible(False)
+ self.page.lineEdit_image.setVisible(False)
+ self.page.pushButton_pickImage.setVisible(False)
+ enabled = len(self.startingGrid) > 0
+ for widget in self.shiftButtons:
+ widget.setEnabled(enabled)
+
+ def previewClickEvent(self, pos, size, button):
+ pos = (
+ math.ceil((pos[0] / size[0]) * self.gridWidth) - 1,
+ math.ceil((pos[1] / size[1]) * self.gridHeight) - 1,
+ )
+ action = ClickGrid(self, pos, button)
+ self.parent.undoStack.push(action)
+
+ def updateGridSize(self):
+ w, h = self.core.resolutions[-1].split("x")
+ self.gridWidth = int(int(w) / self.scale)
+ self.gridHeight = int(int(h) / self.scale)
+ self.pxWidth = math.ceil(self.width / self.gridWidth)
+ self.pxHeight = math.ceil(self.height / self.gridHeight)
+
+ def previewRender(self):
+ return self.drawGrid(self.startingGrid)
+
+ def preFrameRender(self, *args, **kwargs):
+ super().preFrameRender(*args, **kwargs)
+ self.tickGrids = {0: self.startingGrid}
+
+ def properties(self):
+ if self.customImg and (not self.image or not os.path.exists(self.image)):
+ return ["error"]
+ return []
+
+ def error(self):
+ return "No image selected to represent life."
+
+ def frameRender(self, frameNo):
+ tick = math.floor(frameNo / self.tickRate)
+
+ # Compute grid evolution on this frame if it hasn't been computed yet
+ if tick not in self.tickGrids:
+ self.tickGrids[tick] = self.gridForTick(tick)
+ grid = self.tickGrids[tick]
+
+ # Delete old evolution data which we shouldn't need anymore
+ if tick - 60 in self.tickGrids:
+ del self.tickGrids[tick - 60]
+ return self.drawGrid(grid)
+
+ def drawGrid(self, grid):
+ frame = BlankFrame(self.width, self.height)
+
+ def drawCustomImg():
+ try:
+ img = Image.open(self.image)
+ except Exception:
+ return
+ img = img.resize((self.pxWidth, self.pxHeight), Image.Resampling.LANCZOS)
+ frame.paste(img, box=(drawPtX, drawPtY))
+
+ def drawShape():
+ drawer = ImageDraw.Draw(frame)
+ rect = (
+ (drawPtX, drawPtY),
+ (drawPtX + self.pxWidth, drawPtY + self.pxHeight),
+ )
+ shape = self.page.comboBox_shapeType.currentText().lower()
+
+ # Rectangle
+ if shape == "rectangle":
+ drawer.rectangle(rect, fill=self.color)
+
+ # Elliptical
+ elif shape == "elliptical":
+ drawer.ellipse(rect, fill=self.color)
+
+ tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int)
+ smallerShape = (
+ (
+ drawPtX + tenthX + int(tenthX / 4),
+ drawPtY + tenthY + int(tenthY / 2),
+ ),
+ (
+ drawPtX + self.pxWidth - tenthX - int(tenthX / 4),
+ drawPtY + self.pxHeight - (tenthY + int(tenthY / 2)),
+ ),
+ )
+ outlineShape = (
+ (drawPtX + int(tenthX / 4), drawPtY + int(tenthY / 2)),
+ (
+ drawPtX + self.pxWidth - int(tenthX / 4),
+ drawPtY + self.pxHeight - int(tenthY / 2),
+ ),
+ )
+ # Circle
+ if shape == "circle":
+ drawer.ellipse(outlineShape, fill=self.color)
+ drawer.ellipse(smallerShape, fill=(0, 0, 0, 0))
+
+ # Lilypad
+ elif shape == "lilypad":
+ drawer.pieslice(smallerShape, 290, 250, fill=self.color)
+
+ # Pie
+ elif shape == "pie":
+ drawer.pieslice(outlineShape, 35, 320, fill=self.color)
+
+ hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline
+ tX, tY = scale(33, self.pxWidth, self.pxHeight, int) # thirdline
+ qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline
+
+ # Path
+ if shape == "path":
+ drawer.ellipse(rect, fill=self.color)
+ rects = {
+ direction: False
+ for direction in (
+ "up",
+ "down",
+ "left",
+ "right",
+ )
+ }
+ for cell in self.nearbyCoords(x, y):
+ if cell not in grid:
+ continue
+ if cell[0] == x:
+ if cell[1] < y:
+ rects["up"] = True
+ if cell[1] > y:
+ rects["down"] = True
+ if cell[1] == y:
+ if cell[0] < x:
+ rects["left"] = True
+ if cell[0] > x:
+ rects["right"] = True
+
+ for direction, rect in rects.items():
+ if rect:
+ if direction == "up":
+ sect = (
+ (drawPtX, drawPtY),
+ (drawPtX + self.pxWidth, drawPtY + hY),
+ )
+ elif direction == "down":
+ sect = (
+ (drawPtX, drawPtY + hY),
+ (
+ drawPtX + self.pxWidth,
+ drawPtY + self.pxHeight,
+ ),
+ )
+ elif direction == "left":
+ sect = (
+ (drawPtX, drawPtY),
+ (drawPtX + hX, drawPtY + self.pxHeight),
+ )
+ elif direction == "right":
+ sect = (
+ (drawPtX + hX, drawPtY),
+ (
+ drawPtX + self.pxWidth,
+ drawPtY + self.pxHeight,
+ ),
+ )
+ drawer.rectangle(sect, fill=self.color)
+
+ # Duck
+ elif shape == "duck":
+ duckHead = (
+ (drawPtX + qX, drawPtY + qY),
+ (drawPtX + int(qX * 3), drawPtY + int(tY * 2)),
+ )
+ duckBeak = (
+ (drawPtX + hX, drawPtY + qY),
+ (drawPtX + self.pxWidth + qX, drawPtY + int(qY * 3)),
+ )
+ duckWing = ((drawPtX, drawPtY + hY), rect[1])
+ duckBody = (
+ (drawPtX + int(qX / 4), drawPtY + int(qY * 3)),
+ (drawPtX + int(tX * 2), drawPtY + self.pxHeight),
+ )
+ drawer.ellipse(duckBody, fill=self.color)
+ drawer.ellipse(duckHead, fill=self.color)
+ drawer.pieslice(duckWing, 130, 200, fill=self.color)
+ drawer.pieslice(duckBeak, 145, 200, fill=self.color)
+
+ # Peace
+ elif shape == "peace":
+ line = (
+ (
+ drawPtX + hX - int(tenthX / 2),
+ drawPtY + int(tenthY / 2),
+ ),
+ (
+ drawPtX + hX + int(tenthX / 2),
+ drawPtY + self.pxHeight - int(tenthY / 2),
+ ),
+ )
+ drawer.ellipse(outlineShape, fill=self.color)
+ drawer.ellipse(smallerShape, fill=(0, 0, 0, 0))
+ drawer.rectangle(line, fill=self.color)
+
+ def slantLine(difference):
+ return (
+ (drawPtX + difference),
+ (drawPtY + self.pxHeight - qY),
+ ), (
+ (drawPtX + hX),
+ (drawPtY + hY),
+ )
+
+ drawer.line(slantLine(qX), fill=self.color, width=tenthX)
+ drawer.line(slantLine(self.pxWidth - qX), fill=self.color, width=tenthX)
+
+ for x, y in grid:
+ drawPtX = x * self.pxWidth
+ if drawPtX > self.width:
+ continue
+ drawPtY = y * self.pxHeight
+ if drawPtY > self.height:
+ continue
+
+ if self.customImg:
+ drawCustomImg()
+ else:
+ drawShape()
+
+ if self.shadow:
+ shadImg = ImageEnhance.Contrast(frame).enhance(0.0)
+ shadImg = shadImg.filter(ImageFilter.GaussianBlur(5.00))
+ shadImg = ImageChops.offset(shadImg, -2, 2)
+ shadImg.paste(frame, box=(0, 0), mask=frame)
+ frame = shadImg
+ if self.showGrid:
+ drawer = ImageDraw.Draw(frame)
+ w, h = scale(0.05, self.width, self.height, int)
+ for x in range(self.pxWidth, self.width, self.pxWidth):
+ drawer.rectangle(
+ ((x, 0), (x + w, self.height)),
+ fill=self.color,
+ )
+ for y in range(self.pxHeight, self.height, self.pxHeight):
+ drawer.rectangle(
+ ((0, y), (self.width, y + h)),
+ fill=self.color,
+ )
+
+ return frame
+
+ def gridForTick(self, tick):
+ """
+ Given a tick number over 0, returns a new grid (a set of tuples).
+ This must compute the previous ticks' grids if not already computed
+ """
+ if tick - 1 not in self.tickGrids:
+ self.tickGrids[tick - 1] = self.gridForTick(tick - 1)
+
+ lastGrid = self.tickGrids[tick - 1]
+
+ def neighbours(x, y):
+ return {cell for cell in self.nearbyCoords(x, y) if cell in lastGrid}
+
+ newGrid = set()
+ # Copy cells from the previous grid if they have 2 or 3 neighbouring cells
+ # and if they are within the grid or its bleed area (off-canvas area)
+ for x, y in lastGrid:
+ if (
+ -self.bleedSize > x > self.gridWidth + self.bleedSize
+ or -self.bleedSize > y > self.gridHeight + self.bleedSize
+ ):
+ continue
+ surrounding = len(neighbours(x, y))
+ if surrounding == 2 or surrounding == 3:
+ newGrid.add((x, y))
+
+ # Find positions around living cells which must be checked for reproduction
+ potentialNewCells = {
+ coordTup
+ for origin in lastGrid
+ for coordTup in list(self.nearbyCoords(*origin))
+ }
+ # Check for reproduction
+ for x, y in potentialNewCells:
+ if (x, y) in newGrid:
+ # Ignore non-empty cell
+ continue
+ surrounding = len(neighbours(x, y))
+ if surrounding == 3:
+ newGrid.add((x, y))
+
+ return newGrid
+
+ def savePreset(self):
+ pr = super().savePreset()
+ pr["GRID"] = sorted(self.startingGrid)
+ return pr
+
+ def loadPreset(self, pr, *args):
+ self.startingGrid = set(pr["GRID"])
+ if self.startingGrid:
+ for widget in self.shiftButtons:
+ widget.setEnabled(True)
+ super().loadPreset(pr, *args)
+
+ def nearbyCoords(self, x, y):
+ yield x + 1, y + 1
+ yield x + 1, y - 1
+ yield x - 1, y + 1
+ yield x - 1, y - 1
+ yield x, y + 1
+ yield x, y - 1
+ yield x + 1, y
+ yield x - 1, y
+
+
+class ClickGrid(QUndoCommand):
+ def __init__(self, comp, pos, button):
+ super().__init__("click %s component #%s" % (comp.name, comp.compPos))
+ self.comp = comp
+ self.pos = [pos]
+ if button == QtCore.Qt.MouseButton.RightButton:
+ self.button = 2
+ else:
+ self.button = 1
+
+ def id(self):
+ return self.button
+
+ def mergeWith(self, other):
+ self.pos.extend(other.pos)
+ return True
+
+ def add(self):
+ for pos in self.pos[:]:
+ self.comp.startingGrid.add(pos)
+ self.comp.update(auto=True)
+
+ def remove(self):
+ for pos in self.pos[:]:
+ self.comp.startingGrid.discard(pos)
+ self.comp.update(auto=True)
+
+ def redo(self):
+ if self.button == 1: # Left-click
+ self.add()
+ elif self.button == 2: # Right-click
+ self.remove()
+
+ def undo(self):
+ if self.button == 1: # Left-click
+ self.remove()
+ elif self.button == 2: # Right-click
+ self.add()
+
+
+class ShiftGrid(QUndoCommand):
+ def __init__(self, comp, direction):
+ super().__init__("change %s component #%s" % (comp.name, comp.compPos))
+ self.comp = comp
+ self.direction = direction
+ self.distance = 1
+
+ def id(self):
+ return self.direction
+
+ def mergeWith(self, other):
+ self.distance += other.distance
+ return True
+
+ def newGrid(self, Xchange, Ychange):
+ return {(x + Xchange, y + Ychange) for x, y in self.comp.startingGrid}
+
+ def redo(self):
+ if self.direction == 0:
+ newGrid = self.newGrid(0, -self.distance)
+ elif self.direction == 1:
+ newGrid = self.newGrid(0, self.distance)
+ elif self.direction == 2:
+ newGrid = self.newGrid(-self.distance, 0)
+ elif self.direction == 3:
+ newGrid = self.newGrid(self.distance, 0)
+ self.comp.startingGrid = newGrid
+ self.comp._sendUpdateSignal()
+
+ def undo(self):
+ if self.direction == 0:
+ newGrid = self.newGrid(0, self.distance)
+ elif self.direction == 1:
+ newGrid = self.newGrid(0, -self.distance)
+ elif self.direction == 2:
+ newGrid = self.newGrid(self.distance, 0)
+ elif self.direction == 3:
+ newGrid = self.newGrid(-self.distance, 0)
+ self.comp.startingGrid = newGrid
+ self.comp._sendUpdateSignal()
diff --git a/src/avp/components/life.ui b/src/avp/components/life.ui
new file mode 100644
index 0000000..30cf9d0
--- /dev/null
+++ b/src/avp/components/life.ui
@@ -0,0 +1,405 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 197
+
+
+
+ Form
+
+
+ -
+
+
-
+
+
-
+
+
-
+
+
+ Simulation Speed
+
+
+
+ -
+
+
+ frames per tick
+
+
+ 1
+
+
+ 30
+
+
+ 5
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 16777215
+
+
+
+ 255,255,255
+
+
+
+
+
+ -
+
+
-
+
+
+ Grid Scale
+
+
+
+ -
+
+
+ 22
+
+
+ 128
+
+
+ 32
+
+
+
+ -
+
+
+ Custom Image
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Image
+
+
+
+ -
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 32
+ 32
+
+
+
+ ...
+
+
+
+ -
+
+
+ Color
+
+
+
+ -
+
+
+
+ 0
+ 16777215
+
+
+
+ 0,0,0
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+ false
+
+
+ false
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Shape
+
+
+
+ -
+
+
-
+
+ Path
+
+
+ -
+
+ Rectangle
+
+
+ -
+
+ Elliptical
+
+
+ -
+
+ Circle
+
+
+ -
+
+ Lilypad
+
+
+ -
+
+ Pie
+
+
+ -
+
+ Duck
+
+
+ -
+
+ Peace
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Shadow
+
+
+
+ -
+
+
+ Show Grid
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Up
+
+
+ Qt::UpArrow
+
+
+
+ -
+
+
+ Down
+
+
+ Qt::DownArrow
+
+
+
+ -
+
+
+ Left
+
+
+ Qt::LeftArrow
+
+
+
+ -
+
+
+ Right
+
+
+ Qt::RightArrow
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+ -
+
+
+ <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
+<html><head><meta name="qrichtext" content="1" /><style type="text/css">
+p, li { white-space: pre-wrap; }
+</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;">
+<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Click the preview window to place a cell. Right-click to remove.</span></p>
+<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with less than 2 neighbours will die from underpopulation</p>
+<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with more than 3 neighbours will die from overpopulation.</p>
+<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- An empty space surrounded by 3 live cells will cause reproduction.</p></body></html>
+
+
+ 80
+
+
+ Qt::NoTextInteraction
+
+
+ false
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/components/original.py b/src/avp/components/original.py
new file mode 100644
index 0000000..fad797b
--- /dev/null
+++ b/src/avp/components/original.py
@@ -0,0 +1,243 @@
+import numpy
+from PIL import Image, ImageDraw
+from copy import copy
+
+from ..component import Component
+from ..toolkit.frame import BlankFrame
+
+
+class Component(Component):
+ name = "Classic Visualizer"
+ version = "1.0.0"
+
+ def names(*args):
+ return ["Original Audio Visualization"]
+
+ def properties(self):
+ return ["pcm"]
+
+ def widget(self, *args):
+ self.scale = 20
+ self.y = 0
+ super().widget(*args)
+
+ self.page.comboBox_visLayout.addItem("Classic")
+ self.page.comboBox_visLayout.addItem("Split")
+ self.page.comboBox_visLayout.addItem("Bottom")
+ self.page.comboBox_visLayout.addItem("Top")
+ self.page.comboBox_visLayout.setCurrentIndex(0)
+
+ self.page.lineEdit_visColor.setText("255,255,255")
+
+ self.trackWidgets(
+ {
+ "visColor": self.page.lineEdit_visColor,
+ "layout": self.page.comboBox_visLayout,
+ "scale": self.page.spinBox_scale,
+ "y": self.page.spinBox_y,
+ "smooth": self.page.spinBox_smooth,
+ },
+ colorWidgets={
+ "visColor": self.page.pushButton_visColor,
+ },
+ relativeWidgets=[
+ "y",
+ ],
+ )
+
+ def previewRender(self):
+ spectrum = numpy.fromfunction(
+ lambda x: float(self.scale) / 2500 * (x - 128) ** 2,
+ (255,),
+ dtype="int16",
+ )
+ return self.drawBars(
+ self.width, self.height, spectrum, self.visColor, self.layout
+ )
+
+ def preFrameRender(self, **kwargs):
+ super().preFrameRender(**kwargs)
+ self.smoothConstantDown = 0.08 + 0 if not self.smooth else self.smooth / 15
+ self.smoothConstantUp = 0.8 - 0 if not self.smooth else self.smooth / 15
+ self.lastSpectrum = None
+ self.spectrumArray = {}
+
+ for i in range(0, len(self.completeAudioArray), self.sampleSize):
+ if self.canceled:
+ break
+ self.lastSpectrum = self.transformData(
+ i,
+ self.completeAudioArray,
+ self.sampleSize,
+ self.smoothConstantDown,
+ self.smoothConstantUp,
+ self.lastSpectrum,
+ )
+ self.spectrumArray[i] = copy(self.lastSpectrum)
+
+ progress = int(100 * (i / len(self.completeAudioArray)))
+ if progress >= 100:
+ progress = 100
+ pStr = "Analyzing audio: " + str(progress) + "%"
+ self.progressBarSetText.emit(pStr)
+ self.progressBarUpdate.emit(int(progress))
+
+ def frameRender(self, frameNo):
+ arrayNo = frameNo * self.sampleSize
+ return self.drawBars(
+ self.width,
+ self.height,
+ self.spectrumArray[arrayNo],
+ self.visColor,
+ self.layout,
+ )
+
+ def transformData(
+ self,
+ i,
+ completeAudioArray,
+ sampleSize,
+ smoothConstantDown,
+ smoothConstantUp,
+ lastSpectrum,
+ ):
+ if len(completeAudioArray) < (i + sampleSize):
+ sampleSize = len(completeAudioArray) - i
+
+ window = numpy.hanning(sampleSize)
+ data = completeAudioArray[i : i + sampleSize][::1] * window
+ paddedSampleSize = 2048
+ paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), "constant")
+ spectrum = numpy.fft.fft(paddedData)
+ sample_rate = 44100
+ frequencies = numpy.fft.fftfreq(len(spectrum), 1.0 / sample_rate)
+
+ y = abs(spectrum[0 : int(paddedSampleSize / 2) - 1])
+
+ # filter the noise away
+ # y[y<80] = 0
+
+ y = self.scale * numpy.log10(y)
+ y[numpy.isinf(y)] = 0
+
+ if lastSpectrum is not None:
+ lastSpectrum[y < lastSpectrum] = y[
+ y < lastSpectrum
+ ] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * (
+ 1 - smoothConstantDown
+ )
+
+ lastSpectrum[y >= lastSpectrum] = y[
+ y >= lastSpectrum
+ ] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * (
+ 1 - smoothConstantUp
+ )
+ else:
+ lastSpectrum = y
+
+ x = frequencies[0 : int(paddedSampleSize / 2) - 1]
+
+ return lastSpectrum
+
+ def drawBars(self, width, height, spectrum, color, layout):
+ vH = height - height / 8
+ bF = width / 64
+ bH = bF / 2
+ bQ = bF / 4
+ imTop = BlankFrame(width, height)
+ draw = ImageDraw.Draw(imTop)
+ r, g, b = color
+ color2 = (r, g, b, 125)
+
+ bP = height / 1200
+
+ for j in range(0, 63):
+ x0 = bH + j * bF
+ y0 = vH + bQ
+ y1 = vH + bQ - spectrum[j * 4] * bP - bH
+ x1 = bH + j * bF + bF
+ draw.rectangle(
+ (
+ x0,
+ y0 if y0 < y1 else y1,
+ x1 if x1 > x0 else x0,
+ y1 if y0 < y1 else y0,
+ ),
+ fill=color2,
+ )
+
+ x0 = bH + bQ + j * bF
+ y0 = vH
+ x1 = bH + bQ + j * bF + bH
+ y1 = vH - spectrum[j * 4] * bP
+ draw.rectangle(
+ (
+ x0,
+ y0 if y0 < y1 else y1,
+ x1 if x1 > x0 else x0,
+ y1 if y0 < y1 else y0,
+ ),
+ fill=color,
+ )
+
+ imBottom = imTop.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
+
+ im = BlankFrame(width, height)
+
+ if layout == 0: # Classic
+ y = self.y - int(height / 100 * 43)
+ im.paste(imTop, (0, y), mask=imTop)
+ y = self.y + int(height / 100 * 43)
+ im.paste(imBottom, (0, y), mask=imBottom)
+
+ if layout == 1: # Split
+ y = self.y + int(height / 100 * 10)
+ im.paste(imTop, (0, y), mask=imTop)
+ y = self.y - int(height / 100 * 10)
+ im.paste(imBottom, (0, y), mask=imBottom)
+
+ if layout == 2: # Bottom
+ y = self.y + int(height / 100 * 10)
+ im.paste(imTop, (0, y), mask=imTop)
+
+ if layout == 3: # Top
+ y = self.y - int(height / 100 * 10)
+ im.paste(imBottom, (0, y), mask=imBottom)
+
+ return im
+
+ def command(self, arg):
+ if "=" in arg:
+ key, arg = arg.split("=", 1)
+ try:
+ if key == "color":
+ self.page.lineEdit_visColor.setText(arg)
+ return
+ elif key == "layout":
+ if arg == "classic":
+ self.page.comboBox_visLayout.setCurrentIndex(0)
+ elif arg == "split":
+ self.page.comboBox_visLayout.setCurrentIndex(1)
+ elif arg == "bottom":
+ self.page.comboBox_visLayout.setCurrentIndex(2)
+ elif arg == "top":
+ self.page.comboBox_visLayout.setCurrentIndex(3)
+ return
+ elif key == "scale":
+ arg = int(arg)
+ self.page.spinBox_scale.setValue(arg)
+ return
+ elif key == "y":
+ arg = int(arg)
+ self.page.spinBox_y.setValue(arg)
+ return
+ except ValueError:
+ print("You must enter a number.")
+ quit(1)
+ super().command(arg)
+
+ def commandHelp(self):
+ print("Give a layout name:\n layout=[classic/split/bottom/top]")
+ print("Specify a color:\n color=255,255,255")
+ print("Visualizer scale (20 is default):\n scale=number")
+ print("Y position:\n y=number")
diff --git a/src/avp/components/original.ui b/src/avp/components/original.ui
new file mode 100644
index 0000000..c7b7e22
--- /dev/null
+++ b/src/avp/components/original.ui
@@ -0,0 +1,243 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 178
+
+
+
+
+ 180
+ 0
+
+
+
+ Form
+
+
+ -
+
+
+ 4
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Layout
+
+
+
+ -
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+ Color
+
+
+
+ -
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+ 32
+ 32
+
+
+
+
+ -
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+ Y
+
+
+
+ -
+
+
+ QAbstractSpinBox::UpDownArrows
+
+
+ -5000
+
+
+ 5000
+
+
+ 10
+
+
+ 0
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ 4
+
+
-
+
+
+ Scale
+
+
+
+ -
+
+
+ QAbstractSpinBox::PlusMinus
+
+
+ 1
+
+
+ 20
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Expanding
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ QLayout::SetDefaultConstraint
+
+
+ 4
+
+
-
+
+
+ Sensitivity
+
+
+
+ -
+
+
+ 5
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/components/sound.py b/src/avp/components/sound.py
new file mode 100644
index 0000000..2df8e38
--- /dev/null
+++ b/src/avp/components/sound.py
@@ -0,0 +1,77 @@
+from PyQt6 import QtGui, QtCore, QtWidgets
+import os
+
+from ..component import Component
+from ..toolkit.frame import BlankFrame
+
+
+class Component(Component):
+ name = "Sound"
+ version = "1.0.0"
+
+ def widget(self, *args):
+ super().widget(*args)
+ self.page.pushButton_sound.clicked.connect(self.pickSound)
+ self.trackWidgets(
+ {
+ "sound": self.page.lineEdit_sound,
+ "chorus": self.page.checkBox_chorus,
+ "delay": self.page.spinBox_delay,
+ "volume": self.page.spinBox_volume,
+ },
+ commandArgs={
+ "sound": None,
+ },
+ )
+
+ def properties(self):
+ props = ["static", "audio"]
+ if not os.path.exists(self.sound):
+ props.append("error")
+ return props
+
+ def error(self):
+ if not self.sound:
+ return "No audio file selected."
+ if not os.path.exists(self.sound):
+ return "The audio file selected no longer exists!"
+
+ def audio(self):
+ params = {}
+ if self.delay != 0.0:
+ params["adelay"] = "=%s" % str(int(self.delay * 1000.00))
+ if self.chorus:
+ params["chorus"] = "=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3"
+ if self.volume != 1.0:
+ params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume)
+
+ return (self.sound, params)
+
+ def pickSound(self):
+ sndDir = self.settings.value("componentDir", os.path.expanduser("~"))
+ filename, _ = QtWidgets.QFileDialog.getOpenFileName(
+ self.page,
+ "Choose Sound",
+ sndDir,
+ "Audio Files (%s)" % " ".join(self.core.audioFormats),
+ )
+ if filename:
+ self.settings.setValue("componentDir", os.path.dirname(filename))
+ self.mergeUndo = False
+ self.page.lineEdit_sound.setText(filename)
+ self.mergeUndo = True
+
+ def commandHelp(self):
+ print("Path to audio file:\n path=/filepath/to/sound.ogg")
+
+ def command(self, arg):
+ if "=" in arg:
+ key, arg = arg.split("=", 1)
+ if key == "path":
+ if "*%s" % os.path.splitext(arg)[1] not in self.core.audioFormats:
+ print("Not a supported audio format")
+ quit(1)
+ self.page.lineEdit_sound.setText(arg)
+ return
+
+ super().command(arg)
diff --git a/src/avp/components/sound.ui b/src/avp/components/sound.ui
new file mode 100644
index 0000000..4c11332
--- /dev/null
+++ b/src/avp/components/sound.ui
@@ -0,0 +1,172 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 197
+
+
+
+ Form
+
+
+ -
+
+
+ 4
+
+
-
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 31
+ 0
+
+
+
+ Audio File
+
+
+
+ -
+
+
+
+ 1
+ 0
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 1
+ 0
+
+
+
+
+ 32
+ 32
+
+
+
+ ...
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Volume
+
+
+
+ -
+
+
+ x
+
+
+ 10.000000000000000
+
+
+ 0.100000000000000
+
+
+ 1.000000000000000
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Delay
+
+
+
+ -
+
+
+ s
+
+
+ 9999999.990000000223517
+
+
+ 0.500000000000000
+
+
+
+ -
+
+
+ Chorus
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/components/spectrum.py b/src/avp/components/spectrum.py
new file mode 100644
index 0000000..062ebc7
--- /dev/null
+++ b/src/avp/components/spectrum.py
@@ -0,0 +1,368 @@
+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 ..toolkit.frame import BlankFrame, scale
+from ..toolkit import checkOutput, connectWidget
+from ..toolkit.ffmpeg import (
+ openPipe,
+ closePipe,
+ getAudioDuration,
+ FfmpegVideo,
+ exampleSound,
+)
+
+
+log = logging.getLogger("AVP.Components.Spectrum")
+
+
+class Component(Component):
+ name = "Spectrum"
+ version = "1.0.1"
+
+ def widget(self, *args):
+ self.previewFrame = None
+ super().widget(*args)
+ self._image = BlankFrame(self.width, self.height)
+ self.chunkSize = 4 * self.width * self.height
+ self.changedOptions = True
+ self.previewSize = (214, 120)
+ self.previewPipe = None
+
+ if hasattr(self.parent, "lineEdit_audioFile"):
+ # update preview when audio file changes (if genericPreview is off)
+ self.parent.lineEdit_audioFile.textChanged.connect(self.update)
+
+ self.trackWidgets(
+ {
+ "filterType": self.page.comboBox_filterType,
+ "window": self.page.comboBox_window,
+ "mode": self.page.comboBox_mode,
+ "amplitude": self.page.comboBox_amplitude0,
+ "amplitude1": self.page.comboBox_amplitude1,
+ "amplitude2": self.page.comboBox_amplitude2,
+ "display": self.page.comboBox_display,
+ "zoom": self.page.spinBox_zoom,
+ "tc": self.page.spinBox_tc,
+ "x": self.page.spinBox_x,
+ "y": self.page.spinBox_y,
+ "mirror": self.page.checkBox_mirror,
+ "draw": self.page.checkBox_draw,
+ "scale": self.page.spinBox_scale,
+ "color": self.page.comboBox_color,
+ "compress": self.page.checkBox_compress,
+ "mono": self.page.checkBox_mono,
+ "hue": self.page.spinBox_hue,
+ },
+ relativeWidgets=[
+ "x",
+ "y",
+ ],
+ )
+ for widget in self._trackedWidgets.values():
+ connectWidget(widget, lambda: self.changed())
+
+ def changed(self):
+ self.changedOptions = True
+
+ def update(self):
+ filterType = self.page.comboBox_filterType.currentIndex()
+ self.page.stackedWidget.setCurrentIndex(filterType)
+ if filterType == 3:
+ self.page.spinBox_hue.setEnabled(False)
+ else:
+ self.page.spinBox_hue.setEnabled(True)
+ if filterType == 2 or filterType == 4:
+ self.page.checkBox_mono.setEnabled(False)
+ else:
+ self.page.checkBox_mono.setEnabled(True)
+
+ def previewRender(self):
+ changedSize = self.updateChunksize()
+ if (
+ not changedSize
+ and not self.changedOptions
+ and self.previewFrame is not None
+ ):
+ log.debug("Spectrum #%s is reusing old preview frame" % self.compPos)
+ return self.previewFrame
+
+ frame = self.getPreviewFrame()
+ self.changedOptions = False
+ if not frame:
+ log.warning("Spectrum #%s failed to create a preview frame" % self.compPos)
+ self.previewFrame = None
+ return BlankFrame(self.width, self.height)
+ else:
+ self.previewFrame = frame
+ return frame
+
+ def preFrameRender(self, **kwargs):
+ super().preFrameRender(**kwargs)
+ if self.previewPipe is not None:
+ self.previewPipe.wait()
+ self.updateChunksize()
+ w, h = scale(self.scale, self.width, self.height, str)
+ self.video = FfmpegVideo(
+ inputPath=self.audioFile,
+ filter_=self.makeFfmpegFilter(),
+ width=w,
+ height=h,
+ chunkSize=self.chunkSize,
+ frameRate=int(self.settings.value("outputFrameRate")),
+ parent=self.parent,
+ component=self,
+ )
+
+ def frameRender(self, frameNo):
+ if FfmpegVideo.threadError is not None:
+ raise FfmpegVideo.threadError
+ return self.finalizeFrame(self.video.frame(frameNo))
+
+ def postFrameRender(self):
+ closePipe(self.video.pipe)
+
+ def getPreviewFrame(self):
+ genericPreview = self.settings.value("pref_genericPreview")
+ startPt = 0
+ if not genericPreview:
+ inputFile = self.parent.lineEdit_audioFile.text()
+ if not inputFile or not os.path.exists(inputFile):
+ return
+ duration = getAudioDuration(inputFile)
+ if not duration:
+ return
+ startPt = duration / 3
+
+ command = [
+ self.core.FFMPEG_BIN,
+ "-thread_queue_size",
+ "512",
+ "-r",
+ str(self.settings.value("outputFrameRate")),
+ "-ss",
+ "{0:.3f}".format(startPt),
+ "-i",
+ self.core.junkStream if genericPreview else inputFile,
+ "-f",
+ "image2pipe",
+ "-pix_fmt",
+ "rgba",
+ ]
+ command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt))
+ command.extend(
+ [
+ "-an",
+ "-s:v",
+ "%sx%s" % scale(self.scale, self.width, self.height, str),
+ "-codec:v",
+ "rawvideo",
+ "-",
+ "-frames:v",
+ "1",
+ ]
+ )
+
+ if self.core.logEnabled:
+ logFilename = os.path.join(
+ self.core.logDir, "preview_%s.log" % str(self.compPos)
+ )
+ log.debug("Creating FFmpeg process (log at %s)" % logFilename)
+ with open(logFilename, "w") as logf:
+ logf.write(" ".join(command) + "\n\n")
+ with open(logFilename, "a") as logf:
+ self.previewPipe = openPipe(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=logf,
+ bufsize=10**8,
+ )
+ else:
+ self.previewPipe = openPipe(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ bufsize=10**8,
+ )
+ byteFrame = self.previewPipe.stdout.read(self.chunkSize)
+ closePipe(self.previewPipe)
+
+ frame = self.finalizeFrame(byteFrame)
+ return frame
+
+ def makeFfmpegFilter(self, preview=False, startPt=0):
+ """Makes final FFmpeg filter command"""
+
+ def getFilterComplexCommand():
+ """Inner function that creates the final, complex part of the filter command"""
+ nonlocal self
+ genericPreview = self.settings.value("pref_genericPreview")
+
+ def getFilterComplexCommandForType():
+ """Determine portion of filter command that changes depending on selected type"""
+ nonlocal self
+ if preview:
+ w, h = self.previewSize
+ else:
+ w, h = (self.width, self.height)
+ color = self.page.comboBox_color.currentText().lower()
+
+ if self.filterType == 0: # Spectrum
+ if self.amplitude == 0:
+ amplitude = "sqrt"
+ elif self.amplitude == 1:
+ amplitude = "cbrt"
+ elif self.amplitude == 2:
+ amplitude = "4thrt"
+ elif self.amplitude == 3:
+ amplitude = "5thrt"
+ elif self.amplitude == 4:
+ amplitude = "lin"
+ elif self.amplitude == 5:
+ amplitude = "log"
+ filter_ = (
+ f"showspectrum=s={w}x{h}:"
+ "slide=scroll:"
+ f"win_func={self.page.comboBox_window.currentText()}:"
+ f"color={color}:"
+ f"scale={amplitude},"
+ "colorkey=color=black:"
+ "similarity=0.1:blend=0.5"
+ )
+ elif self.filterType == 1: # Histogram
+ if self.amplitude1 == 0:
+ amplitude = "log"
+ elif self.amplitude1 == 1:
+ amplitude = "lin"
+ if self.display == 0:
+ display = "log"
+ elif self.display == 1:
+ display = "sqrt"
+ elif self.display == 2:
+ display = "cbrt"
+ elif self.display == 3:
+ display = "lin"
+ elif self.display == 4:
+ display = "rlog"
+ filter_ = (
+ f'ahistogram=r={str(self.settings.value("outputFrameRate"))}:'
+ f"s={w}x{h}:"
+ "dmode=separate:"
+ f"ascale={amplitude}:"
+ f"scale={display}"
+ )
+ elif self.filterType == 2: # Vector Scope
+ if self.amplitude2 == 0:
+ amplitude = "log"
+ elif self.amplitude2 == 1:
+ amplitude = "sqrt"
+ elif self.amplitude2 == 2:
+ amplitude = "cbrt"
+ elif self.amplitude2 == 3:
+ amplitude = "lin"
+ m = self.page.comboBox_mode.currentText()
+ filter_ = (
+ f"avectorscope=s={w}x{h}:"
+ f'draw={"line" if self.draw else "dot"}:'
+ f"m={m}:"
+ f"scale={amplitude}:"
+ f"zoom={str(self.zoom)}"
+ )
+ elif self.filterType == 3: # Musical Scale
+ filter_ = (
+ f'showcqt=r={str(self.settings.value("outputFrameRate"))}:'
+ f"s={w}x{h}:"
+ "count=30:"
+ "text=0:"
+ f"tc={str(self.tc)},"
+ "colorkey=color=black:"
+ "similarity=0.1:blend=0.5"
+ )
+ elif self.filterType == 4: # Phase
+ filter_ = (
+ f'aphasemeter=r={str(self.settings.value("outputFrameRate"))}:'
+ f"s={w}x{h}:"
+ "video=1 [atrash][vtmp1]; "
+ "[atrash] anullsink; "
+ "[vtmp1] colorkey=color=black:"
+ "similarity=0.1:blend=0.5, "
+ "crop=in_w/8:in_h:(in_w/8)*7:0 "
+ )
+ return filter_
+
+ if self.filterType < 2:
+ exampleSnd = exampleSound("freq")
+ elif self.filterType == 2 or self.filterType == 4:
+ exampleSnd = exampleSound("stereo")
+ elif self.filterType == 3:
+ exampleSnd = exampleSound("white")
+ compression = "compand=gain=4," if self.compress else ""
+ aformat = (
+ "aformat=channel_layouts=mono,"
+ if self.mono and self.filterType not in (2, 4)
+ else ""
+ )
+ filter_ = getFilterComplexCommandForType()
+ hflip = "hflip, " if self.mirror else ""
+ trim = (
+ "trim=start=%s:end=%s, "
+ % (
+ "{0:.3f}".format(startPt + 12),
+ "{0:.3f}".format(startPt + 12.5),
+ )
+ if preview
+ else ""
+ )
+ scale_ = "scale=%sx%s" % scale(self.scale, self.width, self.height, str)
+ hue = (
+ ", hue=h=%s:s=10" % str(self.hue)
+ if self.hue > 0 and self.filterType != 3
+ else ""
+ )
+ convolution = (
+ ", convolution=-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2"
+ if self.filterType == 3
+ else ""
+ )
+
+ return (
+ f"{exampleSnd if preview and genericPreview else '[0:a] '}"
+ f"{compression}{aformat}{filter_} [v1]; "
+ f"[v1] {hflip}{trim}{scale_}{hue}{convolution} [v]"
+ )
+
+ return [
+ "-filter_complex",
+ getFilterComplexCommand(),
+ "-map",
+ "[v]",
+ ]
+
+ def updateChunksize(self):
+ width, height = scale(self.scale, self.width, self.height, int)
+ oldChunkSize = int(self.chunkSize)
+ self.chunkSize = 4 * width * height
+ changed = self.chunkSize != oldChunkSize
+ return changed
+
+ def finalizeFrame(self, imageData):
+ try:
+ image = Image.frombytes(
+ "RGBA",
+ scale(self.scale, self.width, self.height, int),
+ imageData,
+ )
+ self._image = image
+ except ValueError:
+ image = self._image
+
+ frame = BlankFrame(self.width, self.height)
+ frame.paste(image, box=(self.x, self.y))
+ return frame
diff --git a/src/avp/components/spectrum.ui b/src/avp/components/spectrum.ui
new file mode 100644
index 0000000..c6a8a15
--- /dev/null
+++ b/src/avp/components/spectrum.ui
@@ -0,0 +1,946 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 197
+
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 197
+
+
+
+ Form
+
+
+ -
+
+
+ 4
+
+
-
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Type
+
+
+
+ -
+
+
-
+
+ Spectrum
+
+
+ -
+
+ Histogram
+
+
+ -
+
+ Vector Scope
+
+
+ -
+
+ Musical Scale
+
+
+ -
+
+ Phase
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ X
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+ -10000
+
+
+ 10000
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Y
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+
+ 0
+ 0
+
+
+
+ -10000
+
+
+ 10000
+
+
+ 0
+
+
+
+
+
+ -
+
+
-
+
+
+ Compress
+
+
+
+ -
+
+
+ Mono
+
+
+
+ -
+
+
+ Mirror
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Hue
+
+
+ 4
+
+
+
+ -
+
+
+ °
+
+
+ 359
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Scale
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ QAbstractSpinBox::UpDownArrows
+
+
+ %
+
+
+ 10
+
+
+ 400
+
+
+ 100
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ false
+
+
+ QFrame::NoFrame
+
+
+ QFrame::Plain
+
+
+ 0
+
+
+
+
+
+ 0
+ 0
+ 561
+ 66
+
+
+
+
+ QLayout::SetMaximumSize
+
+
+ 0
+
+
-
+
+
+ QLayout::SetDefaultConstraint
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 31
+ 0
+
+
+
+ Window
+
+
+ 4
+
+
+
+ -
+
+
-
+
+ hann
+
+
+ -
+
+ gauss
+
+
+ -
+
+ tukey
+
+
+ -
+
+ dolph
+
+
+ -
+
+ cauchy
+
+
+ -
+
+ parzen
+
+
+ -
+
+ poisson
+
+
+ -
+
+ rect
+
+
+ -
+
+ bartlett
+
+
+ -
+
+ hanning
+
+
+ -
+
+ hamming
+
+
+ -
+
+ blackman
+
+
+ -
+
+ welch
+
+
+ -
+
+ flattop
+
+
+ -
+
+ bharris
+
+
+ -
+
+ bnuttall
+
+
+ -
+
+ lanczos
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Amplitude
+
+
+ 4
+
+
+
+ -
+
+
-
+
+ Square root
+
+
+ -
+
+ Cubic root
+
+
+ -
+
+ 4thrt
+
+
+ -
+
+ 5thrt
+
+
+ -
+
+ Linear
+
+
+ -
+
+ Logarithmic
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::MinimumExpanding
+
+
+
+ 10
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Color
+
+
+ 4
+
+
+
+ -
+
+
-
+
+ Channel
+
+
+ -
+
+ Intensity
+
+
+ -
+
+ Rainbow
+
+
+ -
+
+ Moreland
+
+
+ -
+
+ Nebulae
+
+
+ -
+
+ Fire
+
+
+ -
+
+ Fiery
+
+
+ -
+
+ Fruit
+
+
+ -
+
+ Cool
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::MinimumExpanding
+
+
+
+ 10
+ 20
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -1
+ -1
+ 561
+ 31
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Display Scale
+
+
+ 4
+
+
+
+ -
+
+
-
+
+ Logarithmic
+
+
+ -
+
+ Square root
+
+
+ -
+
+ Cubic root
+
+
+ -
+
+ Linear
+
+
+ -
+
+ Reverse Log
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Amplitude
+
+
+ 4
+
+
+
+ -
+
+
-
+
+ Logarithmic
+
+
+ -
+
+ Linear
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Minimum
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -1
+ -1
+ 585
+ 64
+
+
+
+ -
+
+
-
+
+
+ Mode
+
+
+
+ -
+
+
-
+
+ lissajous
+
+
+ -
+
+ lissajous_xy
+
+
+ -
+
+ polar
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Amplitude
+
+
+ 4
+
+
+
+ -
+
+
-
+
+ Linear
+
+
+ -
+
+ Square root
+
+
+ -
+
+ Cubic root
+
+
+ -
+
+ Logarithmic
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Zoom
+
+
+ 4
+
+
+
+ -
+
+
+ 1
+
+
+ 10
+
+
+
+ -
+
+
+ Line
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 0
+ 561
+ 31
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Timeclamp
+
+
+ 4
+
+
+
+ -
+
+
+ s
+
+
+ 3
+
+
+ 0.002000000000000
+
+
+ 1.000000000000000
+
+
+ 0.010000000000000
+
+
+ 0.017000000000000
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 0
+ 551
+ 31
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+ QSizePolicy::Fixed
+
+
+
+ 20
+ 10
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/components/text.py b/src/avp/components/text.py
new file mode 100644
index 0000000..40c981a
--- /dev/null
+++ b/src/avp/components/text.py
@@ -0,0 +1,218 @@
+from PIL import ImageEnhance, ImageFilter, ImageChops
+from PyQt6.QtGui import QColor, QFont
+from PyQt6 import QtGui, QtCore, QtWidgets
+import os
+import logging
+
+from ..component import Component
+from ..toolkit.frame import FramePainter, PaintColor
+
+log = logging.getLogger("AVP.Components.Text")
+
+
+class Component(Component):
+ name = "Title Text"
+ version = "1.0.1"
+
+ def widget(self, *args):
+ super().widget(*args)
+ self.title = "Text"
+ self.alignment = 1
+ self.titleFont = QFont()
+ self.fontSize = self.height / 13.5
+
+ self.page.comboBox_textAlign.addItem("Left")
+ self.page.comboBox_textAlign.addItem("Middle")
+ self.page.comboBox_textAlign.addItem("Right")
+ self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment))
+ self.page.spinBox_fontSize.setValue(int(self.fontSize))
+ self.page.lineEdit_title.setText(self.title)
+ self.page.pushButton_center.clicked.connect(self.centerXY)
+
+ self.page.fontComboBox_titleFont.currentFontChanged.connect(
+ self._sendUpdateSignal
+ )
+ # The QFontComboBox must be connected directly to the Qt Signal
+ # which triggers the preview to update.
+ # This unfortunately makes changing the font into a non-undoable action.
+ # Must be something broken in the conversion to a ComponentAction
+
+ self.trackWidgets(
+ {
+ "textColor": self.page.lineEdit_textColor,
+ "title": self.page.lineEdit_title,
+ "alignment": self.page.comboBox_textAlign,
+ "fontSize": self.page.spinBox_fontSize,
+ "xPosition": self.page.spinBox_xTextAlign,
+ "yPosition": self.page.spinBox_yTextAlign,
+ "fontStyle": self.page.comboBox_fontStyle,
+ "stroke": self.page.spinBox_stroke,
+ "strokeColor": self.page.lineEdit_strokeColor,
+ "shadow": self.page.checkBox_shadow,
+ "shadX": self.page.spinBox_shadX,
+ "shadY": self.page.spinBox_shadY,
+ "shadBlur": self.page.spinBox_shadBlur,
+ },
+ colorWidgets={
+ "textColor": self.page.pushButton_textColor,
+ "strokeColor": self.page.pushButton_strokeColor,
+ },
+ relativeWidgets=[
+ "xPosition",
+ "yPosition",
+ "fontSize",
+ "stroke",
+ "shadX",
+ "shadY",
+ "shadBlur",
+ ],
+ )
+ self.centerXY()
+
+ def update(self):
+ self.titleFont = self.page.fontComboBox_titleFont.currentFont()
+ if self.page.checkBox_shadow.isChecked():
+ self.page.label_shadX.setHidden(False)
+ self.page.spinBox_shadX.setHidden(False)
+ self.page.spinBox_shadY.setHidden(False)
+ self.page.label_shadBlur.setHidden(False)
+ self.page.spinBox_shadBlur.setHidden(False)
+ else:
+ self.page.label_shadX.setHidden(True)
+ self.page.spinBox_shadX.setHidden(True)
+ self.page.spinBox_shadY.setHidden(True)
+ self.page.label_shadBlur.setHidden(True)
+ self.page.spinBox_shadBlur.setHidden(True)
+
+ def centerXY(self):
+ self.setRelativeWidget("xPosition", 0.5)
+ self.setRelativeWidget("yPosition", 0.521)
+
+ def getXY(self):
+ """Returns true x, y after considering alignment settings"""
+ fm = QtGui.QFontMetrics(self.titleFont)
+ text_width = fm.boundingRect(self.title).width()
+ x = self.pixelValForAttr("xPosition")
+
+ if self.alignment == 1: # Middle
+ offset = int(text_width / 2)
+ elif self.alignment == 2: # Right
+ offset = text_width
+ else:
+ raise ValueError(f"Alignment value {self.alignment} unknown")
+
+ x -= offset
+
+ return x, self.yPosition
+
+ def loadPreset(self, pr, *args):
+ super().loadPreset(pr, *args)
+
+ font = QFont()
+ font.fromString(pr["titleFont"])
+ self.page.fontComboBox_titleFont.setCurrentFont(font)
+
+ def savePreset(self):
+ saveValueStore = super().savePreset()
+ saveValueStore["titleFont"] = self.titleFont.toString()
+ return saveValueStore
+
+ def previewRender(self):
+ return self.addText(self.width, self.height)
+
+ def properties(self):
+ props = ["static"]
+ if not self.title:
+ props.append("error")
+ return props
+
+ def error(self):
+ return "No text provided."
+
+ def frameRender(self, frameNo):
+ return self.addText(self.width, self.height)
+
+ def addText(self, width, height):
+ font = self.titleFont
+ font.setPixelSize(self.fontSize)
+ font.setStyle(QFont.Style.StyleNormal)
+ font.setWeight(QFont.Weight.Normal)
+ font.setCapitalization(QFont.Capitalization.MixedCase)
+ if self.fontStyle == 1:
+ font.setWeight(QFont.Weight.DemiBold)
+ if self.fontStyle == 2:
+ font.setWeight(QFont.Weight.Bold)
+ elif self.fontStyle == 3:
+ font.setStyle(QFont.Style.StyleItalic)
+ elif self.fontStyle == 4:
+ font.setWeight(QFont.Weight.Bold)
+ font.setStyle(QFont.Style.StyleItalic)
+ elif self.fontStyle == 5:
+ font.setStyle(QFont.Style.StyleOblique)
+ elif self.fontStyle == 6:
+ font.setCapitalization(QFont.Capitalization.SmallCaps)
+
+ image = FramePainter(width, height)
+ x, y = self.getXY()
+ log.debug("Text position translates to %s, %s", x, y)
+ if self.stroke > 0:
+ outliner = QtGui.QPainterPathStroker()
+ outliner.setWidth(self.stroke)
+ path = QtGui.QPainterPath()
+ if self.fontStyle == 6:
+ # PathStroker ignores smallcaps so we need this weird hack
+ path.addText(x, y, font, self.title[0])
+ fm = QtGui.QFontMetrics(font)
+ newX = x + fm.boundingRect(self.title[0]).width()
+ strokeFont = self.page.fontComboBox_titleFont.currentFont()
+ strokeFont.setCapitalization(QFont.Capitalization.SmallCaps)
+ strokeFont.setPixelSize(int((self.fontSize / 7) * 5))
+ strokeFont.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 139)
+ path.addText(newX, y, strokeFont, self.title[1:])
+ else:
+ path.addText(x, y, font, self.title)
+ path = outliner.createStroke(path)
+ image.setPen(QtCore.Qt.PenStyle.NoPen)
+ image.setBrush(PaintColor(*self.strokeColor))
+ image.drawPath(path)
+
+ image.setFont(font)
+ image.setPen(self.textColor)
+ image.drawText(x, y, self.title)
+
+ # turn QImage into Pillow frame
+ frame = image.finalize()
+ if self.shadow:
+ shadImg = ImageEnhance.Contrast(frame).enhance(0.0)
+ shadImg = shadImg.filter(ImageFilter.GaussianBlur(self.shadBlur))
+ shadImg = ImageChops.offset(shadImg, self.shadX, self.shadY)
+ shadImg.paste(frame, box=(0, 0), mask=frame)
+ frame = shadImg
+
+ return frame
+
+ def commandHelp(self):
+ print("Enter a string to use as centred white text:")
+ print(' "title=User Error"')
+ print("Specify a text color:\n color=255,255,255")
+ print("Set custom x, y position:\n x=500 y=500")
+
+ def command(self, arg):
+ if "=" in arg:
+ key, arg = arg.split("=", 1)
+ if key == "color":
+ self.page.lineEdit_textColor.setText(arg)
+ return
+ elif key == "size":
+ self.page.spinBox_fontSize.setValue(int(arg))
+ return
+ elif key == "x":
+ self.page.spinBox_xTextAlign.setValue(int(arg))
+ return
+ elif key == "y":
+ self.page.spinBox_yTextAlign.setValue(int(arg))
+ return
+ elif key == "title":
+ self.page.lineEdit_title.setText(arg)
+ return
+ super().command(arg)
diff --git a/src/avp/components/text.ui b/src/avp/components/text.ui
new file mode 100644
index 0000000..b62e0ed
--- /dev/null
+++ b/src/avp/components/text.ui
@@ -0,0 +1,671 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 197
+
+
+
+ Form
+
+
+ -
+
+
+ 6
+
+
+ QLayout::SetDefaultConstraint
+
+
+ 4
+
+
-
+
+
-
+
+
+ Title
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 0
+
+
+
+ Testing New GUI
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Font
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 0
+
+
+
+
+
+
+ -
+
+
+ 0
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Text Layout
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 100
+ 16777215
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Center Text
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ X
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 50
+ 16777215
+
+
+
+
+ 0
+ 0
+
+
+
+ 0
+
+
+ 999999999
+
+
+ 0
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Y
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 50
+ 16777215
+
+
+
+ 999999999
+
+
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 16777215
+ 16777215
+
+
+
+ Text Color
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+ 32
+ 32
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Font Size
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+
+
+
+
+
+ 1
+
+
+ 500
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Font Style
+
+
+
+ -
+
+
-
+
+ Normal
+
+
+ -
+
+ Semi-Bold
+
+
+ -
+
+ Bold
+
+
+ -
+
+ Italic
+
+
+ -
+
+ Bold Italic
+
+
+ -
+
+ Faux Italic
+
+
+ -
+
+ Small Caps
+
+
+
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 16777215
+
+
+
+ Qt::NoFocus
+
+
+ 255,255,255
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Stroke
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ px
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Stroke Color
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 16777215
+
+
+
+ Qt::NoFocus
+
+
+ 0,0,0
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+ 32
+ 32
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Shadow
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Shadow Offset
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ -1000
+
+
+ 1000
+
+
+ -4
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ -1000
+
+
+ 1000
+
+
+ 8
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Shadow Blur
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ 99.000000000000000
+
+
+ 0.100000000000000
+
+
+ 5.000000000000000
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Minimum
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/components/video.py b/src/avp/components/video.py
new file mode 100644
index 0000000..65a05af
--- /dev/null
+++ b/src/avp/components/video.py
@@ -0,0 +1,254 @@
+from PIL import Image
+from PyQt6 import QtGui, QtCore, QtWidgets
+import os
+import math
+import subprocess
+import logging
+
+from ..component import Component
+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):
+ name = "Video"
+ version = "1.0.0"
+
+ def widget(self, *args):
+ self.videoPath = ""
+ self.badAudio = False
+ self.x = 0
+ self.y = 0
+ self.loopVideo = False
+ super().widget(*args)
+ self._image = BlankFrame(self.width, self.height)
+ self.page.pushButton_video.clicked.connect(self.pickVideo)
+ self.trackWidgets(
+ {
+ "videoPath": self.page.lineEdit_video,
+ "loopVideo": self.page.checkBox_loop,
+ "useAudio": self.page.checkBox_useAudio,
+ "distort": self.page.checkBox_distort,
+ "scale": self.page.spinBox_scale,
+ "volume": self.page.spinBox_volume,
+ "xPosition": self.page.spinBox_x,
+ "yPosition": self.page.spinBox_y,
+ },
+ presetNames={
+ "videoPath": "video",
+ "loopVideo": "loop",
+ "xPosition": "x",
+ "yPosition": "y",
+ },
+ relativeWidgets=[
+ "xPosition",
+ "yPosition",
+ ],
+ )
+
+ def update(self):
+ if self.page.checkBox_useAudio.isChecked():
+ self.page.label_volume.setEnabled(True)
+ self.page.spinBox_volume.setEnabled(True)
+ else:
+ self.page.label_volume.setEnabled(False)
+ self.page.spinBox_volume.setEnabled(False)
+
+ def previewRender(self):
+ self.updateChunksize()
+ frame = self.getPreviewFrame(self.width, self.height)
+ if not frame:
+ return BlankFrame(self.width, self.height)
+ else:
+ return frame
+
+ def properties(self):
+ props = []
+ outputFile = None
+ if hasattr(self.parent, "lineEdit_outputFile"):
+ # check only happens in GUI mode
+ outputFile = self.parent.lineEdit_outputFile.text()
+
+ if not self.videoPath:
+ self.lockError("There is no video selected.")
+ elif not os.path.exists(self.videoPath):
+ self.lockError("The video selected does not exist!")
+ elif outputFile and os.path.realpath(self.videoPath) == os.path.realpath(
+ outputFile
+ ):
+ self.lockError("Input and output paths match.")
+
+ if self.useAudio:
+ props.append("audio")
+ if not testAudioStream(self.videoPath) and self.error() is None:
+ self.lockError("Could not identify an audio stream in this video.")
+
+ return props
+
+ def audio(self):
+ params = {}
+ if self.volume != 1.0:
+ params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume)
+ return (self.videoPath, params)
+
+ def preFrameRender(self, **kwargs):
+ super().preFrameRender(**kwargs)
+ self.updateChunksize()
+ self.video = (
+ FfmpegVideo(
+ inputPath=self.videoPath,
+ filter_=self.makeFfmpegFilter(),
+ width=self.width,
+ height=self.height,
+ chunkSize=self.chunkSize,
+ frameRate=int(self.settings.value("outputFrameRate")),
+ parent=self.parent,
+ loopVideo=self.loopVideo,
+ component=self,
+ )
+ if os.path.exists(self.videoPath)
+ else None
+ )
+
+ def frameRender(self, frameNo):
+ if FfmpegVideo.threadError is not None:
+ raise FfmpegVideo.threadError
+ return self.finalizeFrame(self.video.frame(frameNo))
+
+ def postFrameRender(self):
+ closePipe(self.video.pipe)
+
+ def pickVideo(self):
+ imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
+ filename, _ = QtWidgets.QFileDialog.getOpenFileName(
+ self.page,
+ "Choose Video",
+ imgDir,
+ "Video Files (%s)" % " ".join(self.core.videoFormats),
+ )
+ if filename:
+ self.settings.setValue("componentDir", os.path.dirname(filename))
+ self.mergeUndo = False
+ self.page.lineEdit_video.setText(filename)
+ self.mergeUndo = True
+
+ def getPreviewFrame(self, width, height):
+ if not self.videoPath or not os.path.exists(self.videoPath):
+ return
+
+ command = [
+ self.core.FFMPEG_BIN,
+ "-thread_queue_size",
+ "512",
+ "-i",
+ self.videoPath,
+ "-f",
+ "image2pipe",
+ "-pix_fmt",
+ "rgba",
+ ]
+ command.extend(self.makeFfmpegFilter())
+ command.extend(
+ [
+ "-codec:v",
+ "rawvideo",
+ "-",
+ "-ss",
+ "90",
+ "-frames:v",
+ "1",
+ ]
+ )
+
+ if self.core.logEnabled:
+ logFilename = os.path.join(
+ self.core.logDir, "preview_%s.log" % str(self.compPos)
+ )
+ log.debug("Creating ffmpeg process (log at %s)" % logFilename)
+ with open(logFilename, "w") as logf:
+ logf.write(" ".join(command) + "\n\n")
+ with open(logFilename, "a") as logf:
+ pipe = openPipe(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=logf,
+ bufsize=10**8,
+ )
+ else:
+ pipe = openPipe(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ bufsize=10**8,
+ )
+
+ byteFrame = pipe.stdout.read(self.chunkSize)
+ closePipe(pipe)
+
+ frame = self.finalizeFrame(byteFrame)
+ return frame
+
+ def makeFfmpegFilter(self):
+ return [
+ "-filter_complex",
+ "[0:v] scale=%s:%s" % scale(self.scale, self.width, self.height, str),
+ ]
+
+ def updateChunksize(self):
+ if self.scale != 100 and not self.distort:
+ width, height = scale(self.scale, self.width, self.height, int)
+ else:
+ width, height = self.width, self.height
+ self.chunkSize = 4 * width * height
+
+ def command(self, arg):
+ if "=" in arg:
+ key, arg = arg.split("=", 1)
+ if key == "path" and os.path.exists(arg):
+ if "*%s" % os.path.splitext(arg)[1] in self.core.videoFormats:
+ self.page.lineEdit_video.setText(arg)
+ self.page.spinBox_scale.setValue(100)
+ self.page.checkBox_loop.setChecked(True)
+ return
+ else:
+ print("Not a supported video format")
+ quit(1)
+ elif arg == "audio":
+ if not self.page.lineEdit_video.text():
+ print("'audio' option must follow a video selection")
+ quit(1)
+ self.page.checkBox_useAudio.setChecked(True)
+ return
+ super().command(arg)
+
+ def commandHelp(self):
+ print("Load a video:\n path=/filepath/to/video.mp4")
+ print("Using audio:\n path=/filepath/to/video.mp4 audio")
+
+ def finalizeFrame(self, imageData):
+ try:
+ if self.distort:
+ image = Image.frombytes("RGBA", (self.width, self.height), imageData)
+ else:
+ image = Image.frombytes(
+ "RGBA",
+ scale(self.scale, self.width, self.height, int),
+ imageData,
+ )
+ self._image = image
+ except ValueError:
+ # use last good frame
+ image = self._image
+
+ if self.scale != 100 or self.xPosition != 0 or self.yPosition != 0:
+ frame = BlankFrame(self.width, self.height)
+ frame.paste(image, box=(self.xPosition, self.yPosition))
+ else:
+ frame = image
+ return frame
diff --git a/src/avp/components/video.ui b/src/avp/components/video.ui
new file mode 100644
index 0000000..08d15d3
--- /dev/null
+++ b/src/avp/components/video.ui
@@ -0,0 +1,328 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 197
+
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 197
+
+
+
+ Form
+
+
+ -
+
+
+ 4
+
+
-
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 31
+ 0
+
+
+
+ Video
+
+
+
+ -
+
+
+
+ 1
+ 0
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 1
+ 0
+
+
+
+
+ 32
+ 32
+
+
+
+ ...
+
+
+
+ 32
+ 32
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ X
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+ -10000
+
+
+ 10000
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Y
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+
+ 0
+ 0
+
+
+
+ -10000
+
+
+ 10000
+
+
+ 0
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Loop
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Distort by scale
+
+
+
+ -
+
+
+ Scale
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ QAbstractSpinBox::UpDownArrows
+
+
+ %
+
+
+ 10
+
+
+ 400
+
+
+ 100
+
+
+
+
+
+ -
+
+
-
+
+
+ Use Audio
+
+
+
+ -
+
+
+ Volume
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ x
+
+
+ 0.000000000000000
+
+
+ 10.000000000000000
+
+
+ 0.100000000000000
+
+
+ 1.000000000000000
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/components/waveform.py b/src/avp/components/waveform.py
new file mode 100644
index 0000000..7dc0b99
--- /dev/null
+++ b/src/avp/components/waveform.py
@@ -0,0 +1,230 @@
+from PIL import Image
+from PyQt6 import QtGui, QtCore, QtWidgets
+from PyQt6.QtGui import QColor
+import os
+import math
+import subprocess
+import logging
+
+from ..component import Component
+from ..toolkit.frame import BlankFrame, scale
+from ..toolkit import checkOutput
+from ..toolkit.ffmpeg import (
+ openPipe,
+ closePipe,
+ getAudioDuration,
+ FfmpegVideo,
+ exampleSound,
+)
+
+
+log = logging.getLogger("AVP.Components.Waveform")
+
+
+class Component(Component):
+ name = "Waveform"
+ version = "1.0.0"
+
+ def widget(self, *args):
+ super().widget(*args)
+ self._image = BlankFrame(self.width, self.height)
+
+ self.page.lineEdit_color.setText("255,255,255")
+
+ if hasattr(self.parent, "lineEdit_audioFile"):
+ self.parent.lineEdit_audioFile.textChanged.connect(self.update)
+
+ self.trackWidgets(
+ {
+ "color": self.page.lineEdit_color,
+ "mode": self.page.comboBox_mode,
+ "amplitude": self.page.comboBox_amplitude,
+ "x": self.page.spinBox_x,
+ "y": self.page.spinBox_y,
+ "mirror": self.page.checkBox_mirror,
+ "scale": self.page.spinBox_scale,
+ "opacity": self.page.spinBox_opacity,
+ "compress": self.page.checkBox_compress,
+ "mono": self.page.checkBox_mono,
+ },
+ colorWidgets={
+ "color": self.page.pushButton_color,
+ },
+ relativeWidgets=[
+ "x",
+ "y",
+ ],
+ )
+
+ def previewRender(self):
+ self.updateChunksize()
+ frame = self.getPreviewFrame(self.width, self.height)
+ if not frame:
+ return BlankFrame(self.width, self.height)
+ else:
+ return frame
+
+ def preFrameRender(self, **kwargs):
+ super().preFrameRender(**kwargs)
+ self.updateChunksize()
+ w, h = scale(self.scale, self.width, self.height, str)
+ self.video = FfmpegVideo(
+ inputPath=self.audioFile,
+ filter_=self.makeFfmpegFilter(),
+ width=w,
+ height=h,
+ chunkSize=self.chunkSize,
+ frameRate=int(self.settings.value("outputFrameRate")),
+ parent=self.parent,
+ component=self,
+ debug=True,
+ )
+
+ def frameRender(self, frameNo):
+ if FfmpegVideo.threadError is not None:
+ raise FfmpegVideo.threadError
+ return self.finalizeFrame(self.video.frame(frameNo))
+
+ def postFrameRender(self):
+ closePipe(self.video.pipe)
+
+ def getPreviewFrame(self, width, height):
+ genericPreview = self.settings.value("pref_genericPreview")
+ startPt = 0
+ if not genericPreview:
+ inputFile = self.parent.lineEdit_audioFile.text()
+ if not inputFile or not os.path.exists(inputFile):
+ return
+ duration = getAudioDuration(inputFile)
+ if not duration:
+ return
+ startPt = duration / 3
+ if startPt + 3 > duration:
+ startPt += startPt - 3
+
+ command = [
+ self.core.FFMPEG_BIN,
+ "-thread_queue_size",
+ "512",
+ "-r",
+ str(self.settings.value("outputFrameRate")),
+ "-ss",
+ "{0:.3f}".format(startPt),
+ "-i",
+ self.core.junkStream if genericPreview else inputFile,
+ "-f",
+ "image2pipe",
+ "-pix_fmt",
+ "rgba",
+ ]
+ command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt))
+ command.extend(
+ [
+ "-an",
+ "-s:v",
+ "%sx%s" % scale(self.scale, self.width, self.height, str),
+ "-codec:v",
+ "rawvideo",
+ "-",
+ "-frames:v",
+ "1",
+ ]
+ )
+ if self.core.logEnabled:
+ logFilename = os.path.join(
+ self.core.logDir, "preview_%s.log" % str(self.compPos)
+ )
+ log.debug("Creating ffmpeg log at %s", logFilename)
+ with open(logFilename, "w") as logf:
+ logf.write(" ".join(command) + "\n\n")
+ with open(logFilename, "a") as logf:
+ pipe = openPipe(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=logf,
+ bufsize=10**8,
+ )
+ else:
+ pipe = openPipe(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ bufsize=10**8,
+ )
+ byteFrame = pipe.stdout.read(self.chunkSize)
+ closePipe(pipe)
+
+ frame = self.finalizeFrame(byteFrame)
+ return frame
+
+ def makeFfmpegFilter(self, preview=False, startPt=0):
+ w, h = scale(self.scale, self.width, self.height, str)
+ if self.amplitude == 0:
+ amplitude = "lin"
+ elif self.amplitude == 1:
+ amplitude = "log"
+ elif self.amplitude == 2:
+ amplitude = "sqrt"
+ elif self.amplitude == 3:
+ amplitude = "cbrt"
+ hexcolor = QColor(*self.color).name()
+ opacity = "{0:.1f}".format(self.opacity / 100)
+ genericPreview = self.settings.value("pref_genericPreview")
+ if self.mode < 3:
+ filter_ = (
+ "showwaves="
+ f'r={str(self.settings.value("outputFrameRate"))}:'
+ f's={self.settings.value("outputWidth")}x{self.settings.value("outputHeight")}:'
+ f'mode={self.page.comboBox_mode.currentText().lower() if self.mode != 3 else "p2p"}:'
+ f"colors={hexcolor}@{opacity}:scale={amplitude}"
+ )
+ elif self.mode > 2:
+ filter_ = (
+ f'showfreqs=s={str(self.settings.value("outputWidth"))}x{str(self.settings.value("outputHeight"))}:'
+ f'mode={"line" if self.mode == 4 else "bar"}:'
+ f"colors={hexcolor}@{opacity}"
+ f":ascale={amplitude}:fscale={'log' if self.mono else 'lin'}"
+ )
+
+ baselineHeight = int(self.height * (4 / 1080))
+ return [
+ "-filter_complex",
+ f"{exampleSound('wave', extra='') if preview and genericPreview else '[0:a] '}"
+ f"{'compand=gain=4,' if self.compress else ''}"
+ f"{'aformat=channel_layouts=mono,' if self.mono and self.mode < 3 else ''}"
+ f"{filter_}"
+ f"{', drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=%s:color=%s@%s' % (baselineHeight, hexcolor, opacity) if self.mode < 2 else ''}"
+ f"{', hflip' if self.mirror else''}"
+ " [v1]; "
+ "[v1] scale=%s:%s%s [v]"
+ % (
+ w,
+ h,
+ ", trim=duration=%s" % "{0:.3f}".format(startPt + 3) if preview else "",
+ ),
+ "-map",
+ "[v]",
+ ]
+
+ def updateChunksize(self):
+ width, height = scale(self.scale, self.width, self.height, int)
+ self.chunkSize = 4 * width * height
+
+ def finalizeFrame(self, imageData):
+ try:
+ image = Image.frombytes(
+ "RGBA",
+ scale(self.scale, self.width, self.height, int),
+ imageData,
+ )
+ self._image = image
+ except ValueError:
+ image = self._image
+ if self.scale != 100 or self.x != 0 or self.y != 0:
+ frame = BlankFrame(self.width, self.height)
+ frame.paste(image, box=(self.x, self.y))
+ else:
+ frame = image
+ return frame
diff --git a/src/avp/components/waveform.ui b/src/avp/components/waveform.ui
new file mode 100644
index 0000000..5473f33
--- /dev/null
+++ b/src/avp/components/waveform.ui
@@ -0,0 +1,383 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 586
+ 197
+
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 197
+
+
+
+ Form
+
+
+ -
+
+
+ 4
+
+
-
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 31
+ 0
+
+
+
+ Mode
+
+
+
+ -
+
+
-
+
+ Cline
+
+
+ -
+
+ Line
+
+
+ -
+
+ Point
+
+
+ -
+
+ Frequency Bar
+
+
+ -
+
+ Frequency Line
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ X
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+ -10000
+
+
+ 10000
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Y
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+
+ 0
+ 0
+
+
+
+ -10000
+
+
+ 10000
+
+
+ 0
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Color
+
+
+
+ -
+
+
+ Qt::ImhNone
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+ false
+
+
+ false
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Opacity
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ QAbstractSpinBox::UpDownArrows
+
+
+ %
+
+
+ 0
+
+
+ 100
+
+
+ 100
+
+
+
+ -
+
+
+ Scale
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ QAbstractSpinBox::UpDownArrows
+
+
+ %
+
+
+ 10
+
+
+ 400
+
+
+ 100
+
+
+
+
+
+ -
+
+
-
+
+
+ Compress
+
+
+
+ -
+
+
+ Mono
+
+
+
+ -
+
+
+ Mirror
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Amplitude
+
+
+
+ -
+
+
-
+
+ Linear
+
+
+ -
+
+ Logarithmic
+
+
+ -
+
+ Square root
+
+
+ -
+
+ Cubic root
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/core.py b/src/avp/core.py
new file mode 100644
index 0000000..df6ff63
--- /dev/null
+++ b/src/avp/core.py
@@ -0,0 +1,597 @@
+"""
+Home to the Core class which tracks program state. Used by GUI & commandline
+to create a list of components and create a video thread to export.
+"""
+
+from PyQt6 import QtCore, QtGui, uic
+import sys
+import os
+import json
+from importlib import import_module
+import logging
+
+from . import toolkit
+
+
+log = logging.getLogger("AVP.Core")
+STDOUT_LOGLVL = logging.WARNING
+FILE_LIBLOGLVL = logging.WARNING
+FILE_LOGLVL = logging.INFO
+
+
+class Core:
+ """
+ MainWindow and Command module both use an instance of this class
+ to store the core program state. This object tracks the components,
+ talks to the components, handles opening/creating project files
+ and presets, and creates the video thread to export.
+ This class also stores constants as class variables.
+ """
+
+ def __init__(self):
+ self.importComponents()
+ self.selectedComponents = []
+ self.savedPresets = {} # copies of presets to detect modification
+ self.openingProject = False
+
+ def __repr__(self):
+ return "\n=~=~=~=\n".join([repr(comp) for comp in self.selectedComponents])
+
+ def importComponents(self):
+ def findComponents():
+ for f in os.listdir(Core.componentsPath):
+ name, ext = os.path.splitext(f)
+ if name.startswith("__"):
+ continue
+ elif ext == ".py":
+ yield name
+
+ log.debug("Importing component modules")
+ self.modules = [
+ import_module(".components.%s" % name, __package__)
+ for name in findComponents()
+ ]
+ # store canonical module names and indexes
+ self.moduleIndexes = [i for i in range(len(self.modules))]
+ self.compNames = [mod.Component.name for mod in self.modules]
+ # alphabetize modules by Component name
+ sortedModules = sorted(zip(self.compNames, self.modules))
+ self.compNames = [y[0] for y in sortedModules]
+ self.modules = [y[1] for y in sortedModules]
+
+ # store alternative names for modules
+ self.altCompNames = []
+ for i, mod in enumerate(self.modules):
+ if hasattr(mod.Component, "names"):
+ for name in mod.Component.names():
+ self.altCompNames.append((name, i))
+
+ def componentListChanged(self):
+ for i, component in enumerate(self.selectedComponents):
+ component.compPos = i
+
+ def insertComponent(self, compPos, component, loader):
+ """
+ Creates a new component using these args:
+ (compPos, component obj or moduleIndex, MWindow/Command/Core obj)
+ """
+ if compPos < 0 or compPos > len(self.selectedComponents):
+ compPos = len(self.selectedComponents)
+ if len(self.selectedComponents) > 50:
+ return -1
+ if 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))
+ component = self.modules[moduleIndex].Component(moduleIndex, compPos, self)
+ component.widget(loader)
+ else:
+ moduleIndex = -1
+ log.debug("Inserting previously-created %s component", component.name)
+
+ component._error.connect(loader.videoThreadError)
+ self.selectedComponents.insert(compPos, component)
+ if hasattr(loader, "insertComponent"):
+ loader.insertComponent(compPos)
+
+ self.componentListChanged()
+ self.updateComponent(compPos)
+ return compPos
+
+ def moveComponent(self, startI, endI):
+ comp = self.selectedComponents.pop(startI)
+ self.selectedComponents.insert(endI, comp)
+
+ self.componentListChanged()
+ return endI
+
+ def removeComponent(self, i):
+ self.selectedComponents.pop(i)
+ self.componentListChanged()
+
+ def clearComponents(self):
+ self.selectedComponents = list()
+ self.componentListChanged()
+
+ def updateComponent(self, i):
+ log.debug("Auto-updating %s #%s", self.selectedComponents[i], str(i))
+ self.selectedComponents[i].update(auto=True)
+
+ def moduleIndexFor(self, compName):
+ try:
+ index = self.compNames.index(compName)
+ return self.moduleIndexes[index]
+ except ValueError:
+ for altName, modI in self.altCompNames:
+ if altName == compName:
+ return self.moduleIndexes[modI]
+
+ def clearPreset(self, compIndex):
+ self.selectedComponents[compIndex].currentPreset = None
+
+ def openPreset(self, filepath, compIndex, presetName):
+ """Applies a preset to a specific component"""
+ saveValueStore = self.getPreset(filepath)
+ if not saveValueStore:
+ return False
+ comp = self.selectedComponents[compIndex]
+ comp.loadPreset(saveValueStore, presetName)
+
+ self.savedPresets[presetName] = dict(saveValueStore)
+ return True
+
+ def getPreset(self, filepath):
+ """Returns the preset dict stored at this filepath"""
+ if not os.path.exists(filepath):
+ return False
+ with open(filepath, "r") as f:
+ for line in f:
+ saveValueStore = toolkit.presetFromString(line.strip())
+ break
+ return saveValueStore
+
+ def getPresetDir(self, comp):
+ """Get the preset subdir for a particular version of a component"""
+ return os.path.join(Core.presetDir, comp.name, str(comp.version))
+
+ def openProject(self, loader, filepath):
+ """loader is the object calling this method which must have
+ its own showMessage(**kwargs) method for displaying errors.
+ """
+ if not os.path.exists(filepath):
+ loader.showMessage(msg="Project file not found.")
+ return
+
+ errcode, data = self.parseAvFile(filepath)
+ if errcode == 0:
+ self.openingProject = True
+ try:
+ if hasattr(loader, "window"):
+ for widget, value in data["WindowFields"]:
+ widget = eval("loader.%s" % widget)
+ with toolkit.blockSignals(widget):
+ toolkit.setWidgetValue(widget, value)
+
+ for key, value in data["Settings"]:
+ Core.settings.setValue(key, value)
+ for tup in data["Components"]:
+ name, vers, preset = tup
+ clearThis = False
+ modified = False
+
+ # add loaded named presets to savedPresets dict
+ if "preset" in preset and preset["preset"] is not None:
+ nam = preset["preset"]
+ filepath2 = os.path.join(Core.presetDir, name, str(vers), nam)
+ origSaveValueStore = self.getPreset(filepath2)
+ if origSaveValueStore:
+ self.savedPresets[nam] = dict(origSaveValueStore)
+ modified = not origSaveValueStore == preset
+ else:
+ # saved preset was renamed or deleted
+ clearThis = True
+
+ # create the actual component object & get its index
+ i = self.insertComponent(-1, self.moduleIndexFor(name), loader)
+ if i is None:
+ loader.showMessage(
+ msg=f"Component '{name}' didn't initialize correctly and had to be removed."
+ )
+ continue
+ if i == -1:
+ loader.showMessage(msg="Too many components!")
+ break
+
+ try:
+ if "preset" in preset and preset["preset"] is not None:
+ self.selectedComponents[i].loadPreset(preset)
+ else:
+ self.selectedComponents[i].loadPreset(
+ preset, preset["preset"]
+ )
+ except KeyError as e:
+ log.warning(
+ "%s missing value: %s" % (self.selectedComponents[i], e)
+ )
+
+ if clearThis:
+ self.clearPreset(i)
+ if hasattr(loader, "updateComponentTitle"):
+ loader.updateComponentTitle(i, modified)
+ self.openingProject = False
+ return True
+ except Exception:
+ errcode = 1
+ data = sys.exc_info()
+
+ if errcode == 1:
+ typ, value, tb = data
+ if typ.__name__ == "KeyError":
+ # probably just an old version, still loadable
+ log.warning("Project file missing value: %s" % value)
+ return
+ if hasattr(loader, "createNewProject"):
+ loader.createNewProject(prompt=False)
+ msg = "%s: %s\n\n" % (typ.__name__, value)
+ msg += toolkit.formatTraceback(tb)
+ loader.showMessage(
+ msg="Project file '%s' is corrupted." % filepath,
+ showCancel=False,
+ icon="Warning",
+ detail=msg,
+ )
+ self.openingProject = False
+ return False
+
+ def parseAvFile(self, filepath):
+ """
+ Parses an avp (project) or avl (preset package) file.
+ Returns dictionary with section names as the keys, each one
+ contains a list of tuples: (compName, version, compPresetDict)
+ """
+ log.debug("Parsing av file: %s", filepath)
+ validSections = ("Components", "Settings", "WindowFields")
+ data = {sect: [] for sect in validSections}
+ try:
+ with open(filepath, "r") as f:
+
+ def parseLine(line):
+ """Decides if a file line is a section header"""
+ line = line.strip()
+ newSection = ""
+
+ if (
+ line.startswith("[")
+ and line.endswith("]")
+ and line[1:-1] in validSections
+ ):
+ newSection = line[1:-1]
+
+ return line, newSection
+
+ section = ""
+ i = 0
+ for line in f:
+ line, newSection = parseLine(line)
+ if newSection:
+ section = str(newSection)
+ continue
+ if line and section == "Components":
+ if i == 0:
+ lastCompName = str(line)
+ i += 1
+ elif i == 1:
+ lastCompVers = str(line)
+ i += 1
+ elif i == 2:
+ lastCompPreset = toolkit.presetFromString(line)
+ data[section].append(
+ (lastCompName, lastCompVers, lastCompPreset)
+ )
+ i = 0
+ elif line and section:
+ key, value = line.split("=", 1)
+ data[section].append((key, value.strip()))
+
+ return 0, data
+ except Exception:
+ return 1, sys.exc_info()
+
+ def importPreset(self, filepath):
+ errcode, data = self.parseAvFile(filepath)
+ returnList = []
+ if errcode == 0:
+ name, vers, preset = data["Components"][0]
+ presetName = (
+ preset["preset"]
+ if preset["preset"]
+ else os.path.basename(filepath)[:-4]
+ )
+ newPath = os.path.join(Core.presetDir, name, vers, presetName)
+ if os.path.exists(newPath):
+ return False, newPath
+ preset["preset"] = presetName
+ self.createPresetFile(name, vers, presetName, preset)
+ return True, presetName
+ elif errcode == 1:
+ # TODO: an error message
+ return False, ""
+
+ def exportPreset(self, exportPath, compName, vers, origName):
+ internalPath = os.path.join(Core.presetDir, compName, str(vers), origName)
+ if not os.path.exists(internalPath):
+ return
+ if os.path.exists(exportPath):
+ os.remove(exportPath)
+ with open(internalPath, "r") as f:
+ internalData = [line for line in f]
+ try:
+ saveValueStore = toolkit.presetFromString(internalData[0].strip())
+ self.createPresetFile(compName, vers, origName, saveValueStore, exportPath)
+ return True
+ except Exception:
+ return False
+
+ def createPresetFile(self, compName, vers, presetName, saveValueStore, filepath=""):
+ """Create a preset file (.avl) at filepath using args.
+ Or if filepath is empty, create an internal preset using args"""
+ if not filepath:
+ dirname = os.path.join(Core.presetDir, compName, str(vers))
+ if not os.path.exists(dirname):
+ os.makedirs(dirname)
+ filepath = os.path.join(dirname, presetName)
+ internal = True
+ else:
+ if not filepath.endswith(".avl"):
+ filepath += ".avl"
+ internal = False
+
+ with open(filepath, "w") as f:
+ if not internal:
+ f.write("[Components]\n")
+ f.write("%s\n" % compName)
+ f.write("%s\n" % str(vers))
+ f.write(toolkit.presetToString(saveValueStore))
+
+ def createProjectFile(self, filepath, window=None):
+ """Create a project file (.avp) using the current program state"""
+ log.info("Creating %s", filepath)
+ settingsKeys = [
+ "componentDir",
+ "inputDir",
+ "outputDir",
+ "presetDir",
+ "projectDir",
+ ]
+ try:
+ if not filepath.endswith(".avp"):
+ filepath += ".avp"
+ if os.path.exists(filepath):
+ os.remove(filepath)
+
+ with open(filepath, "w") as f:
+ f.write("[Components]\n")
+ for comp in self.selectedComponents:
+ saveValueStore = comp.savePreset()
+ saveValueStore["preset"] = comp.currentPreset
+ f.write("%s\n" % str(comp))
+ f.write("%s\n" % str(comp.version))
+ f.write("%s\n" % toolkit.presetToString(saveValueStore))
+
+ f.write("\n[Settings]\n")
+ for key in Core.settings.allKeys():
+ if key in settingsKeys:
+ f.write("%s=%s\n" % (key, Core.settings.value(key)))
+
+ if window:
+ f.write("\n[WindowFields]\n")
+ f.write(
+ "lineEdit_audioFile=%s\n"
+ "lineEdit_outputFile=%s\n"
+ % (
+ window.lineEdit_audioFile.text(),
+ window.lineEdit_outputFile.text(),
+ )
+ )
+ return True
+ except Exception:
+ return False
+
+ def newVideoWorker(self, loader, audioFile, outputPath):
+ """loader is MainWindow or Command object which must own the thread"""
+ from . import video_thread
+
+ self.videoThread = QtCore.QThread(loader)
+ videoWorker = video_thread.Worker(
+ loader, audioFile, outputPath, self.selectedComponents
+ )
+ videoWorker.moveToThread(self.videoThread)
+ videoWorker.videoCreated.connect(self.stopVideoThread)
+
+ self.videoThread.start()
+ return videoWorker
+
+ def stopVideoThread(self):
+ self.videoThread.quit()
+ self.videoThread.wait()
+
+ def cancel(self):
+ Core.canceled = True
+
+ def reset(self):
+ Core.canceled = False
+
+ @classmethod
+ def storeSettings(cls):
+ """Store settings/paths to directories as class variables"""
+ from .__init__ import wd
+ from .toolkit.ffmpeg import findFfmpeg
+
+ cls.wd = wd
+ dataDir = QtCore.QStandardPaths.writableLocation(
+ QtCore.QStandardPaths.StandardLocation.AppConfigLocation
+ )
+ # Windows: C:/Users//AppData/Local/audio-visualizer
+ # macOS: ~/Library/Preferences/audio-visualizer
+ # Linux: ~/.config/audio-visualizer
+ with open(os.path.join(wd, "encoder-options.json")) as json_file:
+ encoderOptions = json.load(json_file)
+
+ # Locate FFmpeg
+ ffmpegBin = findFfmpeg()
+ if not ffmpegBin:
+ print("Could not find FFmpeg")
+
+ settings = {
+ "canceled": False,
+ "FFMPEG_BIN": ffmpegBin,
+ "dataDir": dataDir,
+ "settings": QtCore.QSettings(
+ os.path.join(dataDir, "settings.ini"),
+ QtCore.QSettings.Format.IniFormat,
+ ),
+ "presetDir": os.path.join(dataDir, "presets"),
+ "componentsPath": os.path.join(wd, "components"),
+ "junkStream": os.path.join(wd, "gui", "background.png"),
+ "encoderOptions": encoderOptions,
+ "resolutions": [
+ "1920x1080",
+ "1280x720",
+ "854x480",
+ ],
+ "logDir": os.path.join(dataDir, "log"),
+ "logEnabled": False,
+ "previewEnabled": True,
+ }
+
+ settings["videoFormats"] = toolkit.appendUppercase(
+ [
+ "*.mp4",
+ "*.mov",
+ "*.mkv",
+ "*.avi",
+ "*.webm",
+ "*.flv",
+ ]
+ )
+ settings["audioFormats"] = toolkit.appendUppercase(
+ [
+ "*.mp3",
+ "*.wav",
+ "*.ogg",
+ "*.fla",
+ "*.flac",
+ "*.aac",
+ ]
+ )
+ settings["imageFormats"] = toolkit.appendUppercase(
+ [
+ "*.png",
+ "*.jpg",
+ "*.tif",
+ "*.tiff",
+ "*.gif",
+ "*.bmp",
+ "*.ico",
+ "*.xbm",
+ "*.xpm",
+ ]
+ )
+
+ # Register all settings as class variables
+ for classvar, val in settings.items():
+ setattr(cls, classvar, val)
+
+ cls.loadDefaultSettings()
+ if not os.path.exists(cls.dataDir):
+ os.makedirs(cls.dataDir)
+ for neededDirectory in (
+ cls.presetDir,
+ cls.logDir,
+ cls.settings.value("projectDir"),
+ ):
+ if not os.path.exists(neededDirectory):
+ os.mkdir(neededDirectory)
+ cls.makeLogger(deleteOldLogs=True)
+
+ @classmethod
+ def loadDefaultSettings(cls):
+ # settings that get saved into the ini file
+ cls.defaultSettings = {
+ "outputWidth": 1280,
+ "outputHeight": 720,
+ "outputFrameRate": 30,
+ "outputAudioCodec": "AAC",
+ "outputAudioBitrate": "192",
+ "outputVideoCodec": "H264",
+ "outputVideoBitrate": "2500",
+ "outputVideoFormat": "yuv420p",
+ "outputPreset": "medium",
+ "outputFormat": "mp4",
+ "outputContainer": "MP4",
+ "projectDir": os.path.join(cls.dataDir, "projects"),
+ "pref_insertCompAtTop": True,
+ "pref_genericPreview": True,
+ "pref_undoLimit": 10,
+ }
+
+ for parm, value in cls.defaultSettings.items():
+ if cls.settings.value(parm) is None:
+ cls.settings.setValue(parm, value)
+
+ # Allow manual editing of prefs. (Surprisingly necessary as Qt seems to
+ # store True as 'true' but interprets a manually-added 'true' as str.)
+ for key in cls.settings.allKeys():
+ if not key.startswith("pref_"):
+ continue
+ val = cls.settings.value(key)
+ try:
+ val = int(val)
+ except ValueError:
+ if val == "true":
+ val = True
+ elif val == "false":
+ val = False
+ cls.settings.setValue(key, val)
+
+ @staticmethod
+ def makeLogger(deleteOldLogs=False):
+ # send critical log messages to stdout
+ logStream = logging.StreamHandler()
+ logStream.setLevel(STDOUT_LOGLVL)
+ streamFormatter = logging.Formatter("<%(name)s> %(levelname)s: %(message)s")
+ logStream.setFormatter(streamFormatter)
+ log = logging.getLogger("AVP")
+ log.addHandler(logStream)
+
+ if FILE_LOGLVL is not None:
+ # write log files as well!
+ Core.logEnabled = True
+ logFilename = os.path.join(Core.logDir, "avp_debug.log")
+ libLogFilename = os.path.join(Core.logDir, "global_debug.log")
+
+ if deleteOldLogs:
+ for log_ in (logFilename, libLogFilename):
+ if os.path.exists(log_):
+ os.remove(log_)
+
+ logFile = logging.FileHandler(logFilename, delay=True)
+ logFile.setLevel(FILE_LOGLVL)
+ libLogFile = logging.FileHandler(libLogFilename, delay=True)
+ libLogFile.setLevel(FILE_LIBLOGLVL)
+ fileFormatter = logging.Formatter(
+ "[%(asctime)s] %(threadName)-10.10s %(name)-23.23s %(levelname)s: "
+ "%(message)s"
+ )
+ logFile.setFormatter(fileFormatter)
+ libLogFile.setFormatter(fileFormatter)
+
+ libLog = logging.getLogger()
+ log.addHandler(logFile)
+ libLog.addHandler(libLogFile)
+ # lowest level must be explicitly set on the root Logger
+ libLog.setLevel(0)
+
+
+# always store settings in class variables even if a Core object is not created
+Core.storeSettings()
diff --git a/src/avp/encoder-options.json b/src/avp/encoder-options.json
new file mode 100644
index 0000000..78bc940
--- /dev/null
+++ b/src/avp/encoder-options.json
@@ -0,0 +1,130 @@
+{
+ "containers":[
+ {
+ "name": "MP4",
+ "container": "mp4",
+ "default-vcodec": "H264",
+ "default-acodec": "AAC",
+ "video-codecs": [
+ "H264",
+ "H264 (nvenc)",
+ "MPEG4"
+ ],
+ "audio-codecs": [
+ "AAC",
+ "AC3",
+ "MP3"
+ ]
+ },
+ {
+ "name": "MOV",
+ "container": "mov",
+ "default-vcodec": "H264",
+ "default-acodec": "AAC",
+ "video-codecs": [
+ "H264",
+ "H264 (nvenc)",
+ "MPEG4",
+ "XVID"
+ ],
+ "audio-codecs": [
+ "AAC",
+ "AC3",
+ "MP3",
+ "PCM s16 LE"
+ ]
+ },
+ {
+ "name": "MKV",
+ "container": "matroska",
+ "default-vcodec": "H264",
+ "default-acodec": "AAC",
+ "video-codecs": [
+ "H264",
+ "H264 (nvenc)",
+ "MPEG4",
+ "MPEG2",
+ "DV",
+ "WMV"
+ ],
+ "audio-codecs": [
+ "AAC",
+ "AC3",
+ "MP3",
+ "PCM s16 LE",
+ "WMA"
+ ]
+ },
+ {
+ "name": "AVI",
+ "container": "avi",
+ "default-vcodec": "H264",
+ "default-acodec": "AAC",
+ "video-codecs": [
+ "H264",
+ "H264 (nvenc)",
+ "MPEG4",
+ "MPEG2",
+ "DV",
+ "WMV"
+ ],
+ "audio-codecs": [
+ "AAC",
+ "AC3",
+ "MP3",
+ "PCM s16 LE",
+ "WMA"
+ ]
+ },
+ {
+ "name": "WEBM",
+ "container": "webm",
+ "default-vcodec": "VP9",
+ "default-acodec": "Vorbis",
+ "video-codecs": [
+ "VP9",
+ "VP8"
+ ],
+ "audio-codecs": [
+ "Vorbis"
+ ]
+ },
+ {
+ "name": "FLV",
+ "container": "flv",
+ "default-vcodec": "FLV",
+ "default-acodec": "Vorbis",
+ "video-codecs": [
+ "Sorenson (flv)",
+ "H264",
+ "H264 (nvenc)",
+ "MPEG4"
+ ],
+ "audio-codecs": [
+ "MP3",
+ "PCM s16 LE",
+ "Vorbis"
+ ]
+ }
+ ],
+ "video-codecs":{
+ "H264": ["libx264"],
+ "H264 (nvenc)": ["h264_nvenc", "nvenc_h264"],
+ "MPEG4": ["mpeg4"],
+ "VP9": ["libvpx-vp9"],
+ "VP8": ["libvpx"],
+ "XVID": ["libxvid"],
+ "Sorenson (flv)": ["flv"],
+ "MPEG2": ["mp2video"],
+ "DV": ["dvvideo"],
+ "WMV": ["wmv2"]
+ },
+ "audio-codecs": {
+ "AAC": ["libfdk_aac", "aac"],
+ "AC3": ["ac3"],
+ "MP3": ["libmp3lame"],
+ "PCM s16 LE": ["pcm_s16le"],
+ "WMA": ["wmav2"],
+ "Vorbis": ["libvorbis"]
+ }
+}
\ No newline at end of file
diff --git a/src/avp/gui/__init__.py b/src/avp/gui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/avp/gui/actions.py b/src/avp/gui/actions.py
new file mode 100644
index 0000000..654b2a0
--- /dev/null
+++ b/src/avp/gui/actions.py
@@ -0,0 +1,196 @@
+"""
+QCommand classes for every undoable user action performed in the MainWindow
+"""
+
+from PyQt6.QtGui import QUndoCommand
+import os
+import logging
+from copy import copy
+
+from ..core import Core
+
+
+log = logging.getLogger("AVP.Gui.Actions")
+
+
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+# COMPONENT ACTIONS
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+
+
+class AddComponent(QUndoCommand):
+ def __init__(self, parent, compI, moduleI):
+ super().__init__(
+ "create new %s component" % parent.core.modules[moduleI].Component.name
+ )
+ self.parent = parent
+ self.moduleI = moduleI
+ self.compI = compI
+ self.comp = None
+ self.valid = True
+
+ def redo(self):
+ if self.comp is None:
+ i = self.parent.core.insertComponent(self.compI, self.moduleI, self.parent)
+ if i != self.compI:
+ self.valid = False
+ if i is not None:
+ log.error(
+ f"Expected new component index to be {self.compI} but received {i}"
+ )
+ else:
+ # inserting previously-created component
+ self.parent.core.insertComponent(self.compI, self.comp, self.parent)
+
+ def undo(self):
+ if not self.valid:
+ return
+ self.comp = self.parent.core.selectedComponents[self.compI]
+ self.parent._removeComponent(self.compI)
+
+
+class RemoveComponent(QUndoCommand):
+ def __init__(self, parent, selectedRows):
+ super().__init__("remove component")
+ self.parent = parent
+ componentList = self.parent.listWidget_componentList
+ self.selectedRows = [componentList.row(selected) for selected in selectedRows]
+ self.components = [parent.core.selectedComponents[i] for i in self.selectedRows]
+
+ def redo(self):
+ self.parent._removeComponent(self.selectedRows[0])
+
+ def undo(self):
+ componentList = self.parent.listWidget_componentList
+ for index, comp in zip(self.selectedRows, self.components):
+ self.parent.core.insertComponent(index, comp, self.parent)
+ self.parent.drawPreview()
+
+
+class MoveComponent(QUndoCommand):
+ def __init__(self, parent, row, newRow, tag):
+ super().__init__("move component %s" % tag)
+ self.parent = parent
+ self.row = row
+ self.newRow = newRow
+ self.id_ = ord(tag[0])
+
+ def id(self):
+ """If 2 consecutive updates have same id, Qt will call mergeWith()"""
+ return self.id_
+
+ def mergeWith(self, other):
+ self.newRow = other.newRow
+ return True
+
+ def do(self, rowa, rowb):
+ componentList = self.parent.listWidget_componentList
+
+ page = self.parent.pages.pop(rowa)
+ self.parent.pages.insert(rowb, page)
+
+ item = componentList.takeItem(rowa)
+ componentList.insertItem(rowb, item)
+
+ stackedWidget = self.parent.stackedWidget
+ widget = stackedWidget.removeWidget(page)
+ stackedWidget.insertWidget(rowb, page)
+ componentList.setCurrentRow(rowb)
+ stackedWidget.setCurrentIndex(rowb)
+ self.parent.core.moveComponent(rowa, rowb)
+ self.parent.drawPreview(True)
+
+ def redo(self):
+ self.do(self.row, self.newRow)
+
+ def undo(self):
+ self.do(self.newRow, self.row)
+
+
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+# PRESET ACTIONS
+# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+
+
+class ClearPreset(QUndoCommand):
+ def __init__(self, parent, compI):
+ super().__init__("clear preset")
+ self.parent = parent
+ self.compI = compI
+ self.component = self.parent.core.selectedComponents[compI]
+ self.store = self.component.savePreset()
+ self.store["preset"] = self.component.currentPreset
+
+ def redo(self):
+ self.parent.core.clearPreset(self.compI)
+ self.parent.updateComponentTitle(self.compI, False)
+
+ def undo(self):
+ self.parent.core.selectedComponents[self.compI].loadPreset(self.store)
+ self.parent.updateComponentTitle(self.compI, self.store)
+
+
+class OpenPreset(QUndoCommand):
+ def __init__(self, parent, presetName, compI):
+ super().__init__("open %s preset" % presetName)
+ self.parent = parent
+ self.presetName = presetName
+ self.compI = compI
+
+ comp = self.parent.core.selectedComponents[compI]
+ self.store = comp.savePreset()
+ self.store["preset"] = copy(comp.currentPreset)
+
+ def redo(self):
+ self.parent._openPreset(self.presetName, self.compI)
+
+ def undo(self):
+ self.parent.core.selectedComponents[self.compI].loadPreset(self.store)
+ self.parent.parent.updateComponentTitle(self.compI, self.store)
+
+
+class RenamePreset(QUndoCommand):
+ def __init__(self, parent, path, oldName, newName):
+ super().__init__("rename preset")
+ self.parent = parent
+ self.path = path
+ self.oldName = oldName
+ self.newName = newName
+
+ def redo(self):
+ self.parent.renamePreset(self.path, self.oldName, self.newName)
+
+ def undo(self):
+ self.parent.renamePreset(self.path, self.newName, self.oldName)
+
+
+class DeletePreset(QUndoCommand):
+ def __init__(self, parent, compName, vers, presetFile):
+ self.parent = parent
+ self.preset = (compName, vers, presetFile)
+ self.path = os.path.join(Core.presetDir, compName, str(vers), presetFile)
+ self.store = self.parent.core.getPreset(self.path)
+ self.presetName = self.store["preset"]
+ super().__init__("delete %s preset (%s)" % (self.presetName, compName))
+ self.loadedPresets = [
+ i
+ for i, comp in enumerate(self.parent.core.selectedComponents)
+ if self.presetName == str(comp.currentPreset)
+ ]
+
+ def redo(self):
+ os.remove(self.path)
+ for i in self.loadedPresets:
+ self.parent.core.clearPreset(i)
+ self.parent.parent.updateComponentTitle(i, False)
+ self.parent.findPresets()
+ self.parent.drawPresetList()
+
+ def undo(self):
+ self.parent.createNewPreset(*self.preset, self.store)
+ selectedComponents = self.parent.core.selectedComponents
+ for i in self.loadedPresets:
+ selectedComponents[i].currentPreset = self.presetName
+ self.parent.parent.updateComponentTitle(i)
+ self.parent.findPresets()
+ self.parent.drawPresetList()
diff --git a/src/avp/gui/background.png b/src/avp/gui/background.png
new file mode 100644
index 0000000..fb58593
Binary files /dev/null and b/src/avp/gui/background.png differ
diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py
new file mode 100644
index 0000000..b0a564b
--- /dev/null
+++ b/src/avp/gui/mainwindow.py
@@ -0,0 +1,1053 @@
+"""
+When using GUI mode, this module's object (the main window) takes
+user input to construct a program state (stored in the Core object).
+This shows a preview of the video being created and allows for saving
+projects and exporting the video at a later time.
+"""
+
+from PyQt6 import QtCore, QtWidgets, uic
+import PyQt6.QtWidgets as QtWidgets
+from PyQt6.QtGui import QUndoStack, QShortcut
+from PIL import Image
+from queue import Queue
+import sys
+import os
+import signal
+import filecmp
+import time
+import logging
+
+from ..core import Core
+from . import preview_thread
+from .preview_win import PreviewWindow
+from .presetmanager import PresetManager
+from .actions import *
+from ..toolkit import (
+ disableWhenEncoding,
+ disableWhenOpeningProject,
+ checkOutput,
+ blockSignals,
+)
+
+
+appName = "Audio Visualizer"
+log = logging.getLogger("AVP.Gui.MainWindow")
+
+
+class MyQUndoStack(QUndoStack):
+ # FIXME move this class
+ @property
+ def encoding(self):
+ return self.parent().encoding
+
+ @disableWhenEncoding
+ def undo(self, *args, **kwargs):
+ super().undo(*args, **kwargs)
+
+ @disableWhenEncoding
+ def redo(self, *args, **kwargs):
+ super().redo(*args, **kwargs)
+
+
+class MainWindow(QtWidgets.QMainWindow):
+ """
+ The MainWindow wraps many Core methods in order to update the GUI
+ accordingly. E.g., instead of self.core.openProject(), it will use
+ self.openProject() and update the window titlebar within the wrapper.
+
+ MainWindow manages the autosave feature, although Core has the
+ primary functions for opening and creating project files.
+ """
+
+ createVideo = QtCore.pyqtSignal()
+ newTask = QtCore.pyqtSignal(list) # for the preview window
+ processTask = QtCore.pyqtSignal()
+
+ def __init__(self, project, dpi):
+ super().__init__()
+ log.debug("Main thread id: {}".format(int(QtCore.QThread.currentThreadId())))
+ uic.loadUi(os.path.join(Core.wd, "gui", "mainwindow.ui"), self)
+
+ if dpi:
+ self.resize(
+ int(self.width() * (dpi / 144)),
+ int(self.height() * (dpi / 144)),
+ )
+
+ self.core = Core()
+ Core.mode = "GUI"
+ # widgets of component settings
+ self.pages = []
+ self.lastAutosave = time.time()
+ # list of previous five autosave times, used to reduce update spam
+ self.autosaveTimes = []
+ self.autosaveCooldown = 0.2
+ self.encoding = False
+
+ # Find settings created by Core object
+ self.dataDir = Core.dataDir
+ self.presetDir = Core.presetDir
+ self.autosavePath = os.path.join(self.dataDir, "autosave.avp")
+ self.settings = Core.settings
+
+ # Create stack of undoable user actions
+ self.undoStack = MyQUndoStack(self)
+ undoLimit = self.settings.value("pref_undoLimit")
+ self.undoStack.setUndoLimit(undoLimit)
+
+ # Create Undo Dialog - A standard QUndoView on a standard QDialog
+ self.undoDialog = QtWidgets.QDialog(self)
+ self.undoDialog.setWindowTitle("Undo History")
+ undoView = QtWidgets.QUndoView(self.undoStack)
+ layout = QtWidgets.QVBoxLayout()
+ layout.addWidget(undoView)
+ self.undoDialog.setLayout(layout)
+
+ # Create Preset Manager
+ self.presetManager = PresetManager(self)
+
+ # Create the preview window and its thread, queues, and timers
+ log.debug("Creating preview window")
+ self.previewWindow = PreviewWindow(
+ self, os.path.join(Core.wd, "gui", "background.png")
+ )
+ self.verticalLayout_previewWrapper.addWidget(self.previewWindow)
+
+ log.debug("Starting preview thread")
+ self.previewQueue = Queue()
+ self.previewThread = QtCore.QThread(self)
+ self.previewWorker = preview_thread.Worker(
+ self.core, self.settings, self.previewQueue
+ )
+ self.previewWorker.moveToThread(self.previewThread)
+ self.newTask.connect(self.previewWorker.createPreviewImage)
+ self.processTask.connect(self.previewWorker.process)
+ self.previewWorker.error.connect(self.previewWindow.threadError)
+ self.previewWorker.imageCreated.connect(self.showPreviewImage)
+ self.previewThread.start()
+ self.previewThread.finished.connect(
+ lambda: log.info("Preview thread finished.")
+ )
+
+ timeout = 500
+ log.debug("Preview timer set to trigger when idle for %sms" % str(timeout))
+ self.timer = QtCore.QTimer(self)
+ self.timer.timeout.connect(self.processTask.emit)
+ self.timer.start(timeout)
+
+ # Begin decorating the window and connecting events
+ componentList = self.listWidget_componentList
+
+ # Undo Feature
+ def toggleUndoButtonEnabled(*_):
+ """Enable/disable undo button depending on whether UndoStack contains Actions"""
+ try:
+ undoButton.setEnabled(self.undoStack.count())
+ except RuntimeError:
+ # program is probably in midst of exiting
+ pass
+
+ style = self.pushButton_undo.style()
+ undoButton = self.pushButton_undo
+ undoButton.setIcon(
+ style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_FileDialogBack)
+ )
+ undoButton.clicked.connect(self.undoStack.undo)
+ undoButton.setEnabled(False)
+ self.undoStack.cleanChanged.connect(toggleUndoButtonEnabled)
+ self.undoMenu = QtWidgets.QMenu()
+ self.undoMenu.addAction(self.undoStack.createUndoAction(self))
+ self.undoMenu.addAction(self.undoStack.createRedoAction(self))
+ action = self.undoMenu.addAction("Show History...")
+ action.triggered.connect(lambda _: self.showUndoStack())
+ undoButton.setMenu(self.undoMenu)
+ # end of Undo Feature
+
+ style = self.pushButton_listMoveUp.style()
+ self.pushButton_listMoveUp.setIcon(
+ style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowUp)
+ )
+ style = self.pushButton_listMoveDown.style()
+ self.pushButton_listMoveDown.setIcon(
+ style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowDown)
+ )
+ style = self.pushButton_removeComponent.style()
+ self.pushButton_removeComponent.setIcon(
+ style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogDiscardButton)
+ )
+
+ if sys.platform == "darwin":
+ log.debug("Darwin detected: showing progress label below progress bar")
+ self.progressBar_createVideo.setTextVisible(False)
+ else:
+ self.progressLabel.setHidden(True)
+
+ self.toolButton_selectAudioFile.clicked.connect(self.openInputFileDialog)
+
+ self.toolButton_selectOutputFile.clicked.connect(self.openOutputFileDialog)
+
+ def changedField():
+ self.autosave()
+ self.updateWindowTitle()
+
+ self.lineEdit_audioFile.textChanged.connect(changedField)
+ self.lineEdit_outputFile.textChanged.connect(changedField)
+
+ self.progressBar_createVideo.setValue(0)
+
+ self.pushButton_createVideo.clicked.connect(self.createAudioVisualization)
+
+ self.pushButton_Cancel.clicked.connect(self.stopVideo)
+
+ for i, container in enumerate(Core.encoderOptions["containers"]):
+ self.comboBox_videoContainer.addItem(container["name"])
+ if container["name"] == self.settings.value("outputContainer"):
+ selectedContainer = i
+
+ self.comboBox_videoContainer.setCurrentIndex(selectedContainer)
+ self.comboBox_videoContainer.currentIndexChanged.connect(self.updateCodecs)
+
+ self.updateCodecs()
+
+ for i in range(self.comboBox_videoCodec.count()):
+ codec = self.comboBox_videoCodec.itemText(i)
+ if codec == self.settings.value("outputVideoCodec"):
+ self.comboBox_videoCodec.setCurrentIndex(i)
+
+ for i in range(self.comboBox_audioCodec.count()):
+ codec = self.comboBox_audioCodec.itemText(i)
+ if codec == self.settings.value("outputAudioCodec"):
+ self.comboBox_audioCodec.setCurrentIndex(i)
+
+ self.comboBox_videoCodec.currentIndexChanged.connect(self.updateCodecSettings)
+
+ self.comboBox_audioCodec.currentIndexChanged.connect(self.updateCodecSettings)
+
+ vBitrate = int(self.settings.value("outputVideoBitrate"))
+ aBitrate = int(self.settings.value("outputAudioBitrate"))
+
+ self.spinBox_vBitrate.setValue(vBitrate)
+ self.spinBox_aBitrate.setValue(aBitrate)
+ self.spinBox_vBitrate.valueChanged.connect(self.updateCodecSettings)
+ self.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings)
+
+ # Make component buttons
+ self.compMenu = QtWidgets.QMenu()
+ for i, comp in enumerate(self.core.modules):
+ action = self.compMenu.addAction(comp.Component.name)
+ action.triggered.connect(lambda _, item=i: self.addComponent(0, item))
+
+ self.pushButton_addComponent.setMenu(self.compMenu)
+
+ componentList.dropEvent = self.dragComponent
+ componentList.itemSelectionChanged.connect(self.changeComponentWidget)
+ componentList.itemSelectionChanged.connect(
+ self.presetManager.clearPresetListSelection
+ )
+ self.pushButton_removeComponent.clicked.connect(lambda: self.removeComponent())
+
+ componentList.setContextMenuPolicy(
+ QtCore.Qt.ContextMenuPolicy.CustomContextMenu
+ )
+ componentList.customContextMenuRequested.connect(self.componentContextMenu)
+
+ currentRes = (
+ str(self.settings.value("outputWidth"))
+ + "x"
+ + str(self.settings.value("outputHeight"))
+ )
+ for i, res in enumerate(Core.resolutions):
+ self.comboBox_resolution.addItem(res)
+ if res == currentRes:
+ currentRes = i
+ self.comboBox_resolution.setCurrentIndex(currentRes)
+ self.comboBox_resolution.currentIndexChanged.connect(
+ self.updateResolution
+ )
+
+ self.pushButton_listMoveUp.clicked.connect(lambda: self.moveComponent(-1))
+ self.pushButton_listMoveDown.clicked.connect(lambda: self.moveComponent(1))
+
+ # Configure the Projects Menu
+ self.projectMenu = QtWidgets.QMenu()
+ self.menuButton_newProject = self.projectMenu.addAction("New Project")
+ self.menuButton_newProject.triggered.connect(lambda: self.createNewProject())
+ self.menuButton_openProject = self.projectMenu.addAction("Open Project")
+ self.menuButton_openProject.triggered.connect(
+ lambda: self.openOpenProjectDialog()
+ )
+
+ action = self.projectMenu.addAction("Save Project")
+ action.triggered.connect(self.saveCurrentProject)
+
+ action = self.projectMenu.addAction("Save Project As")
+ action.triggered.connect(self.openSaveProjectDialog)
+
+ self.pushButton_projects.setMenu(self.projectMenu)
+
+ # Configure the Presets Button
+ self.pushButton_presets.clicked.connect(self.openPresetManager)
+
+ self.updateWindowTitle()
+ log.debug("Showing main window")
+ self.show()
+
+ if project and project != self.autosavePath:
+ if not project.endswith(".avp"):
+ project += ".avp"
+ # open a project from the commandline
+ if not os.path.dirname(project):
+ project = os.path.join(self.settings.value("projectDir"), project)
+ self.currentProject = project
+ self.settings.setValue("currentProject", project)
+ if os.path.exists(self.autosavePath):
+ os.remove(self.autosavePath)
+ else:
+ # open the last currentProject from settings
+ self.currentProject = self.settings.value("currentProject")
+
+ # delete autosave if it's identical to this project
+ if self.autosaveExists(identical=True):
+ os.remove(self.autosavePath)
+
+ if self.currentProject and os.path.exists(self.autosavePath):
+ ch = self.showMessage(
+ msg="Restore unsaved changes in project '%s'?"
+ % os.path.basename(self.currentProject)[:-4],
+ showCancel=True,
+ )
+ if ch:
+ self.saveProjectChanges()
+ else:
+ os.remove(self.autosavePath)
+
+ self.openProject(self.currentProject, prompt=False)
+ self.drawPreview(True)
+
+ log.info("Pillow version %s", Image.__version__)
+
+ # verify Ffmpeg version
+ if not self.core.FFMPEG_BIN:
+ self.showMessage(
+ msg="FFmpeg could not be found. This is a critical error. "
+ "Install FFmpeg, or download it and place the program executable "
+ "in the same folder as this program.",
+ icon="Critical",
+ )
+ else:
+ if not self.settings.value("ffmpegMsgShown"):
+ try:
+ with open(os.devnull, "w") as f:
+ ffmpegVers = checkOutput(
+ [self.core.FFMPEG_BIN, "-version"], stderr=f
+ )
+ goodVersion = str(ffmpegVers).split()[2].startswith("4")
+ 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)
+
+ # Hotkeys for projects
+
+ QShortcut("Ctrl+S", self, self.saveCurrentProject)
+ QShortcut("Ctrl+A", self, self.openSaveProjectDialog)
+ QShortcut("Ctrl+O", self, self.openOpenProjectDialog)
+ QShortcut("Ctrl+N", self, self.createNewProject)
+
+ # Hotkeys for undo/redo
+ QShortcut("Ctrl+Z", self, self.undoStack.undo)
+ QShortcut("Ctrl+Y", self, self.undoStack.redo)
+ QShortcut("Ctrl+Shift+Z", self, self.undoStack.redo)
+
+ # Hotkeys for component list
+ for inskey in ("Ctrl+T", QtCore.Qt.Key.Key_Insert):
+ QShortcut(
+ inskey,
+ self,
+ activated=lambda: self.pushButton_addComponent.click(),
+ )
+ for delkey in ("Ctrl+R", QtCore.Qt.Key.Key_Delete):
+ QShortcut(delkey, self.listWidget_componentList, self.removeComponent)
+ QShortcut(
+ "Ctrl+Space",
+ self,
+ activated=lambda: self.listWidget_componentList.setFocus(),
+ )
+ QShortcut("Ctrl+Shift+S", self, self.presetManager.openSavePresetDialog)
+ QShortcut("Ctrl+Shift+C", self, self.presetManager.clearPreset)
+
+ QShortcut(
+ "Ctrl+Up",
+ self.listWidget_componentList,
+ activated=lambda: self.moveComponent(-1),
+ )
+ QShortcut(
+ "Ctrl+Down",
+ self.listWidget_componentList,
+ activated=lambda: self.moveComponent(1),
+ )
+ QShortcut(
+ "Ctrl+Home",
+ self.listWidget_componentList,
+ activated=lambda: self.moveComponent("top"),
+ )
+ QShortcut(
+ "Ctrl+End",
+ self.listWidget_componentList,
+ activated=lambda: self.moveComponent("bottom"),
+ )
+
+ QShortcut("Ctrl+Shift+F", self, self.showFfmpegCommand)
+ QShortcut("Ctrl+Shift+U", self, self.showUndoStack)
+
+ if log.isEnabledFor(logging.DEBUG):
+ QShortcut("Ctrl+Alt+Shift+R", self, self.drawPreview)
+ QShortcut("Ctrl+Alt+Shift+A", self, lambda: log.debug(repr(self)))
+
+ # Close MainWindow when receiving Ctrl+C from terminal
+ signal.signal(signal.SIGINT, lambda *args: self.close())
+
+ # Add initial components if none are in the list
+ if not self.core.selectedComponents:
+ self.core.insertComponent(0, 0, self)
+ self.core.insertComponent(1, 1, self)
+
+ def __repr__(self):
+ return (
+ "%s\n"
+ "\n%s\n"
+ "#####\n"
+ "Preview thread is %s\n"
+ % (
+ super().__repr__(),
+ (
+ "core not initialized"
+ if not hasattr(self, "core")
+ else repr(self.core)
+ ),
+ (
+ "live"
+ if hasattr(self, "previewThread") and self.previewThread.isRunning()
+ else "dead"
+ ),
+ )
+ )
+
+ def closeEvent(self, event):
+ log.info("Ending the preview thread")
+ self.timer.stop()
+ self.previewThread.quit()
+ self.previewThread.wait()
+ return super().closeEvent(event)
+
+ @disableWhenOpeningProject
+ def updateWindowTitle(self):
+ log.debug("Setting main window's title")
+ windowTitle = appName
+ try:
+ if self.currentProject:
+ windowTitle += (
+ " - %s" % os.path.splitext(os.path.basename(self.currentProject))[0]
+ )
+ if self.autosaveExists(identical=False):
+ windowTitle += "*"
+ except AttributeError:
+ pass
+ log.verbose(f'Window title is "{windowTitle}"')
+ self.setWindowTitle(windowTitle)
+
+ @QtCore.pyqtSlot(int, dict)
+ def updateComponentTitle(self, pos, presetStore=False):
+ """
+ Sets component title to modified or unmodified when given boolean.
+ If given a preset dict, compares it against the component to
+ determine if it is modified.
+ A component with no preset is always unmodified.
+ """
+ if type(presetStore) is dict:
+ name = presetStore["preset"]
+ if name is None or name not in self.core.savedPresets:
+ modified = False
+ else:
+ modified = presetStore != self.core.savedPresets[name]
+
+ modified = bool(presetStore)
+ if pos < 0:
+ pos = len(self.core.selectedComponents) - 1
+ name = self.core.selectedComponents[pos].name
+ title = str(name)
+ if self.core.selectedComponents[pos].currentPreset:
+ title += " - %s" % self.core.selectedComponents[pos].currentPreset
+ if modified:
+ title += "*"
+ if type(presetStore) is bool:
+ log.debug(
+ "Forcing %s #%s's modified status to %s: %s",
+ name,
+ pos,
+ modified,
+ title,
+ )
+ else:
+ log.debug("Setting %s #%s's title: %s", name, pos, title)
+ self.listWidget_componentList.item(pos).setText(title)
+
+ def updateCodecs(self):
+ containerWidget = self.comboBox_videoContainer
+ vCodecWidget = self.comboBox_videoCodec
+ aCodecWidget = self.comboBox_audioCodec
+ index = containerWidget.currentIndex()
+ name = containerWidget.itemText(index)
+ self.settings.setValue("outputContainer", name)
+
+ vCodecWidget.clear()
+ aCodecWidget.clear()
+
+ for container in Core.encoderOptions["containers"]:
+ if container["name"] == name:
+ for vCodec in container["video-codecs"]:
+ vCodecWidget.addItem(vCodec)
+ for aCodec in container["audio-codecs"]:
+ aCodecWidget.addItem(aCodec)
+
+ def updateCodecSettings(self):
+ """Updates settings.ini to match encoder option widgets"""
+ vCodecWidget = self.comboBox_videoCodec
+ vBitrateWidget = self.spinBox_vBitrate
+ aBitrateWidget = self.spinBox_aBitrate
+ aCodecWidget = self.comboBox_audioCodec
+ currentVideoCodec = vCodecWidget.currentIndex()
+ currentVideoCodec = vCodecWidget.itemText(currentVideoCodec)
+ currentVideoBitrate = vBitrateWidget.value()
+ currentAudioCodec = aCodecWidget.currentIndex()
+ currentAudioCodec = aCodecWidget.itemText(currentAudioCodec)
+ currentAudioBitrate = aBitrateWidget.value()
+ self.settings.setValue("outputVideoCodec", currentVideoCodec)
+ self.settings.setValue("outputAudioCodec", currentAudioCodec)
+ self.settings.setValue("outputVideoBitrate", currentVideoBitrate)
+ self.settings.setValue("outputAudioBitrate", currentAudioBitrate)
+
+ @disableWhenOpeningProject
+ def autosave(self, force=False):
+ if not self.currentProject:
+ if os.path.exists(self.autosavePath):
+ os.remove(self.autosavePath)
+ elif force or time.time() - self.lastAutosave >= self.autosaveCooldown:
+ self.core.createProjectFile(self.autosavePath, self)
+ self.lastAutosave = time.time()
+ if len(self.autosaveTimes) >= 5:
+ # Do some math to reduce autosave spam. This gives a smooth
+ # curve up to 5 seconds cooldown and maintains that for 30 secs
+ # if a component is continuously updated
+ timeDiff = self.lastAutosave - self.autosaveTimes.pop()
+ if not force and timeDiff >= 1.0 and timeDiff <= 10.0:
+ if self.autosaveCooldown / 4.0 < 0.5:
+ self.autosaveCooldown += 1.0
+ self.autosaveCooldown = (5.0 * (self.autosaveCooldown / 5.0)) + (
+ self.autosaveCooldown / 5.0
+ ) * 2
+ elif force or timeDiff >= self.autosaveCooldown * 5:
+ self.autosaveCooldown = 0.2
+ self.autosaveTimes.insert(0, self.lastAutosave)
+ else:
+ log.debug("Autosave rejected by cooldown")
+
+ def autosaveExists(self, identical=True):
+ """Determines if creating the autosave should be blocked."""
+ try:
+ if (
+ self.currentProject
+ and os.path.exists(self.autosavePath)
+ and filecmp.cmp(self.autosavePath, self.currentProject) == identical
+ ):
+ log.debug(
+ "Autosave found %s to be identical" % "not" if not identical else ""
+ )
+ return True
+ except FileNotFoundError:
+ log.error("Project file couldn't be located: %s", self.currentProject)
+ return identical
+ return False
+
+ def saveProjectChanges(self):
+ """Overwrites project file with autosave file"""
+ try:
+ os.remove(self.currentProject)
+ os.rename(self.autosavePath, self.currentProject)
+ return True
+ except (FileNotFoundError, IsADirectoryError) as e:
+ self.showMessage(msg="Project file couldn't be saved.", detail=str(e))
+ return False
+
+ def openInputFileDialog(self):
+ inputDir = self.settings.value("inputDir", os.path.expanduser("~"))
+
+ fileName, _ = QtWidgets.QFileDialog.getOpenFileName(
+ self,
+ "Open Audio File",
+ inputDir,
+ "Audio Files (%s)" % " ".join(Core.audioFormats),
+ )
+
+ if fileName:
+ self.settings.setValue("inputDir", os.path.dirname(fileName))
+ self.lineEdit_audioFile.setText(fileName)
+
+ def openOutputFileDialog(self):
+ outputDir = self.settings.value("outputDir", os.path.expanduser("~"))
+
+ fileName, _ = QtWidgets.QFileDialog.getSaveFileName(
+ self,
+ "Set Output Video File",
+ outputDir,
+ "Video Files (%s);; All Files (*)" % " ".join(Core.videoFormats),
+ )
+
+ if fileName:
+ self.settings.setValue("outputDir", os.path.dirname(fileName))
+ self.lineEdit_outputFile.setText(fileName)
+
+ def stopVideo(self):
+ log.info("Export cancelled")
+ self.videoWorker.cancel()
+ self.canceled = True
+
+ def createAudioVisualization(self):
+ # create output video if mandatory settings are filled in
+ audioFile = self.lineEdit_audioFile.text()
+ outputPath = self.lineEdit_outputFile.text()
+
+ if audioFile and outputPath and self.core.selectedComponents:
+ if not os.path.dirname(outputPath):
+ outputPath = os.path.join(os.path.expanduser("~"), outputPath)
+ if outputPath and os.path.isdir(outputPath):
+ self.showMessage(
+ msg="Chosen filename matches a directory, which "
+ "cannot be overwritten. Please choose a different "
+ "filename or move the directory.",
+ icon="Warning",
+ )
+ return
+ else:
+ if not audioFile or not outputPath:
+ self.showMessage(
+ msg="You must select an audio file and output filename."
+ )
+ elif not self.core.selectedComponents:
+ self.showMessage(msg="Not enough components.")
+ return
+
+ self.canceled = False
+ self.progressBarUpdated(-1)
+ self.videoWorker = self.core.newVideoWorker(self, audioFile, outputPath)
+ self.videoWorker.progressBarUpdate.connect(self.progressBarUpdated)
+ self.videoWorker.progressBarSetText.connect(self.progressBarSetText)
+ self.videoWorker.imageCreated.connect(self.showPreviewImage)
+ self.videoWorker.encoding.connect(self.changeEncodingStatus)
+ self.createVideo.emit()
+
+ @QtCore.pyqtSlot(str, str)
+ def videoThreadError(self, msg, detail):
+ try:
+ self.stopVideo()
+ except AttributeError as e:
+ if "videoWorker" not in str(e):
+ raise
+ self.showMessage(
+ msg=msg,
+ detail=detail,
+ icon="Critical",
+ )
+ log.info("%s", repr(self))
+
+ def changeEncodingStatus(self, status):
+ self.encoding = status
+ if status:
+ # Disable many widgets when starting to export
+ self.pushButton_createVideo.setEnabled(False)
+ self.pushButton_Cancel.setEnabled(True)
+ self.comboBox_resolution.setEnabled(False)
+ self.stackedWidget.setEnabled(False)
+ self.tab_encoderSettings.setEnabled(False)
+ self.label_audioFile.setEnabled(False)
+ self.toolButton_selectAudioFile.setEnabled(False)
+ self.label_outputFile.setEnabled(False)
+ self.toolButton_selectOutputFile.setEnabled(False)
+ self.lineEdit_audioFile.setEnabled(False)
+ self.lineEdit_outputFile.setEnabled(False)
+ self.listWidget_componentList.setEnabled(False)
+ self.pushButton_addComponent.setEnabled(False)
+ self.pushButton_removeComponent.setEnabled(False)
+ self.pushButton_listMoveDown.setEnabled(False)
+ self.pushButton_listMoveUp.setEnabled(False)
+ self.pushButton_undo.setEnabled(False)
+ self.menuButton_newProject.setEnabled(False)
+ self.menuButton_openProject.setEnabled(False)
+ # Close undo history dialog if open
+ self.undoDialog.close()
+ # Show label under progress bar on macOS
+ if sys.platform == "darwin":
+ self.progressLabel.setHidden(False)
+ else:
+ self.pushButton_createVideo.setEnabled(True)
+ self.pushButton_Cancel.setEnabled(False)
+ self.comboBox_resolution.setEnabled(True)
+ self.stackedWidget.setEnabled(True)
+ self.tab_encoderSettings.setEnabled(True)
+ self.label_audioFile.setEnabled(True)
+ self.toolButton_selectAudioFile.setEnabled(True)
+ self.lineEdit_audioFile.setEnabled(True)
+ self.label_outputFile.setEnabled(True)
+ self.toolButton_selectOutputFile.setEnabled(True)
+ self.lineEdit_outputFile.setEnabled(True)
+ self.pushButton_addComponent.setEnabled(True)
+ self.pushButton_removeComponent.setEnabled(True)
+ self.pushButton_listMoveDown.setEnabled(True)
+ self.pushButton_listMoveUp.setEnabled(True)
+ self.pushButton_undo.setEnabled(True)
+ self.menuButton_newProject.setEnabled(True)
+ self.menuButton_openProject.setEnabled(True)
+ self.listWidget_componentList.setEnabled(True)
+ self.progressLabel.setHidden(True)
+ self.drawPreview(True)
+
+ @QtCore.pyqtSlot(int)
+ def progressBarUpdated(self, value):
+ self.progressBar_createVideo.setValue(value)
+
+ @QtCore.pyqtSlot(str)
+ def progressBarSetText(self, value):
+ if sys.platform == "darwin":
+ self.progressLabel.setText(value)
+ else:
+ self.progressBar_createVideo.setFormat(value)
+
+ def updateResolution(self):
+ resIndex = int(self.comboBox_resolution.currentIndex())
+ res = Core.resolutions[resIndex].split("x")
+ changed = res[0] != self.settings.value("outputWidth")
+ self.settings.setValue("outputWidth", res[0])
+ self.settings.setValue("outputHeight", res[1])
+ if changed:
+ for i in range(len(self.core.selectedComponents)):
+ self.core.updateComponent(i)
+
+ def drawPreview(self, force=False, **kwargs):
+ """Use autosave keyword arg to force saving or not saving if needed"""
+ self.newTask.emit(self.core.selectedComponents)
+ # self.processTask.emit()
+ if force or "autosave" in kwargs:
+ if force or kwargs["autosave"]:
+ self.autosave(True)
+ else:
+ self.autosave()
+ self.updateWindowTitle()
+
+ @QtCore.pyqtSlot("QImage")
+ def showPreviewImage(self, image):
+ self.previewWindow.changePixmap(image)
+
+ @disableWhenEncoding
+ def showUndoStack(self):
+ self.undoDialog.show()
+
+ def showFfmpegCommand(self):
+ from textwrap import wrap
+ from ..toolkit.ffmpeg import createFfmpegCommand
+
+ command = createFfmpegCommand(
+ self.lineEdit_audioFile.text(),
+ self.lineEdit_outputFile.text(),
+ self.core.selectedComponents,
+ )
+ command = " ".join(command)
+ log.info(f"FFmpeg command: {command}")
+ lines = wrap(command, 49)
+ self.showMessage(msg=f"Current FFmpeg command:\n\n{' '.join(lines)}")
+
+ def addComponent(self, compPos, moduleIndex):
+ """Creates an undoable action that adds a new component."""
+ action = AddComponent(self, compPos, moduleIndex)
+ self.undoStack.push(action)
+
+ def insertComponent(self, index):
+ """Triggered by Core to finish initializing a new component."""
+ if not hasattr(self.core.selectedComponents[index], "page"):
+ log.error("Component failed to initialize")
+ return
+ componentList = self.listWidget_componentList
+ stackedWidget = self.stackedWidget
+
+ componentList.insertItem(index, self.core.selectedComponents[index].name)
+ componentList.setCurrentRow(index)
+
+ # connect to signal that adds an asterisk when modified
+ self.core.selectedComponents[index].modified.connect(self.updateComponentTitle)
+
+ self.pages.insert(index, self.core.selectedComponents[index].page)
+ stackedWidget.insertWidget(index, self.pages[index])
+ stackedWidget.setCurrentIndex(index)
+
+ return index
+
+ def removeComponent(self):
+ componentList = self.listWidget_componentList
+ selected = componentList.selectedItems()
+ if selected:
+ action = RemoveComponent(self, selected)
+ self.undoStack.push(action)
+
+ def _removeComponent(self, index):
+ stackedWidget = self.stackedWidget
+ componentList = self.listWidget_componentList
+ stackedWidget.removeWidget(self.pages[index])
+ componentList.takeItem(index)
+ self.core.removeComponent(index)
+ self.pages.pop(index)
+ self.changeComponentWidget()
+ self.drawPreview()
+
+ @disableWhenEncoding
+ def moveComponent(self, change):
+ """Moves a component relatively from its current position"""
+ componentList = self.listWidget_componentList
+ tag = change
+ if change == "top":
+ change = -componentList.currentRow()
+ elif change == "bottom":
+ change = len(componentList) - componentList.currentRow() - 1
+ else:
+ tag = "down" if change == 1 else "up"
+
+ row = componentList.currentRow()
+ newRow = row + change
+ if newRow > -1 and newRow < componentList.count():
+ action = MoveComponent(self, row, newRow, tag)
+ self.undoStack.push(action)
+
+ def getComponentListMousePos(self, position):
+ """
+ Given a QPos, returns the component index under the mouse cursor
+ or -1 if no component is there.
+ """
+ componentList = self.listWidget_componentList
+
+ if hasattr(position, "toPointF"):
+ position = position.toPointF()
+ position = position.toPoint()
+
+ modelIndexes = [
+ componentList.model().index(i) for i in range(componentList.count())
+ ]
+ rects = [componentList.visualRect(modelIndex) for modelIndex in modelIndexes]
+ mousePos = [rect.contains(position) for rect in rects]
+ if not any(mousePos):
+ # Not clicking a component
+ mousePos = -1
+ else:
+ mousePos = mousePos.index(True)
+ log.debug("Click component list row %s" % mousePos)
+ return mousePos
+
+ @disableWhenEncoding
+ def dragComponent(self, event):
+ """Used as Qt drop event for the component listwidget"""
+ componentList = self.listWidget_componentList
+ mousePos = self.getComponentListMousePos(event.position())
+
+ if mousePos > -1:
+ change = (componentList.currentRow() - mousePos) * -1
+ else:
+ change = componentList.count() - componentList.currentRow() - 1
+ self.moveComponent(change)
+
+ def changeComponentWidget(self):
+ selected = self.listWidget_componentList.selectedItems()
+ if selected:
+ index = self.listWidget_componentList.row(selected[0])
+ self.stackedWidget.setCurrentIndex(index)
+
+ def openPresetManager(self):
+ """Preset manager for importing, exporting, renaming, deleting"""
+ self.presetManager.show_()
+
+ def clear(self):
+ """Get a blank slate"""
+ self.core.clearComponents()
+ self.listWidget_componentList.clear()
+ for widget in self.pages:
+ self.stackedWidget.removeWidget(widget)
+ self.pages = []
+ for field in (self.lineEdit_audioFile, self.lineEdit_outputFile):
+ with blockSignals(field):
+ field.setText("")
+ self.progressBarUpdated(0)
+ self.progressBarSetText("")
+ self.undoStack.clear()
+
+ @disableWhenEncoding
+ def createNewProject(self, prompt=True):
+ if prompt:
+ self.openSaveChangesDialog("starting a new project")
+
+ self.clear()
+ self.currentProject = None
+ self.settings.setValue("currentProject", None)
+ self.drawPreview(True)
+
+ def saveCurrentProject(self):
+ if self.currentProject:
+ self.core.createProjectFile(self.currentProject, self)
+ try:
+ os.remove(self.autosavePath)
+ except FileNotFoundError:
+ pass
+ self.updateWindowTitle()
+ else:
+ self.openSaveProjectDialog()
+
+ def openSaveChangesDialog(self, phrase):
+ success = True
+ if self.autosaveExists(identical=False):
+ ch = self.showMessage(
+ msg="You have unsaved changes in project '%s'. "
+ "Save before %s?"
+ % (os.path.basename(self.currentProject)[:-4], phrase),
+ showCancel=True,
+ )
+ if ch:
+ success = self.saveProjectChanges()
+
+ if success and os.path.exists(self.autosavePath):
+ os.remove(self.autosavePath)
+
+ def openSaveProjectDialog(self):
+ filename, _ = QtWidgets.QFileDialog.getSaveFileName(
+ self,
+ "Create Project File",
+ self.settings.value("projectDir"),
+ "Project Files (*.avp)",
+ )
+ if not filename:
+ return
+ if not filename.endswith(".avp"):
+ filename += ".avp"
+ self.settings.setValue("projectDir", os.path.dirname(filename))
+ self.settings.setValue("currentProject", filename)
+ self.currentProject = filename
+ self.core.createProjectFile(filename, self)
+ self.updateWindowTitle()
+
+ @disableWhenEncoding
+ def openOpenProjectDialog(self):
+ filename, _ = QtWidgets.QFileDialog.getOpenFileName(
+ self,
+ "Open Project File",
+ self.settings.value("projectDir"),
+ "Project Files (*.avp)",
+ )
+ self.openProject(filename)
+
+ def openProject(self, filepath, prompt=True):
+ if (
+ not filepath
+ or not os.path.exists(filepath)
+ or not filepath.endswith(".avp")
+ ):
+ return
+
+ self.clear()
+ # ask to save any changes that are about to get deleted
+ if prompt:
+ self.openSaveChangesDialog("opening another project")
+
+ self.currentProject = filepath
+ self.settings.setValue("currentProject", filepath)
+ self.settings.setValue("projectDir", os.path.dirname(filepath))
+ # actually load the project using core method
+ self.core.openProject(self, filepath)
+ self.drawPreview(autosave=False)
+ self.updateWindowTitle()
+
+ def showMessage(self, **kwargs):
+ parent = kwargs["parent"] if "parent" in kwargs else self
+ msg = QtWidgets.QMessageBox(parent)
+ msg.setWindowTitle(appName)
+ msg.setModal(True)
+ msg.setText(kwargs["msg"])
+ msg.setIcon(
+ eval("QtWidgets.QMessageBox.Icon.%s" % kwargs["icon"])
+ if "icon" in kwargs
+ else QtWidgets.QMessageBox.Icon.Information
+ )
+ msg.setDetailedText(kwargs["detail"] if "detail" in kwargs else None)
+ if "showCancel" in kwargs and kwargs["showCancel"]:
+ msg.setStandardButtons(
+ QtWidgets.QMessageBox.StandardButton.Ok
+ | QtWidgets.QMessageBox.StandardButton.Cancel
+ )
+ else:
+ msg.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok)
+ ch = msg.exec()
+ if ch == 1024:
+ return True
+ return False
+
+ @disableWhenEncoding
+ def componentContextMenu(self, QPos):
+ """Appears when right-clicking the component list"""
+ componentList = self.listWidget_componentList
+ self.menu = QtWidgets.QMenu()
+ parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0))
+
+ index = self.getComponentListMousePos(QPos)
+ if index > -1:
+ # Show preset menu if clicking a component
+ self.presetManager.findPresets()
+ menuItem = self.menu.addAction("Save Preset")
+ menuItem.triggered.connect(self.presetManager.openSavePresetDialog)
+
+ # submenu for opening presets
+ try:
+ presets = self.presetManager.presets[
+ str(self.core.selectedComponents[index])
+ ]
+ self.presetSubmenu = QtWidgets.QMenu("Open Preset")
+ self.menu.addMenu(self.presetSubmenu)
+
+ for version, presetName in presets:
+ menuItem = self.presetSubmenu.addAction(presetName)
+ menuItem.triggered.connect(
+ lambda _, presetName=presetName: self.presetManager.openPreset(
+ presetName
+ )
+ )
+ except KeyError:
+ pass
+
+ if self.core.selectedComponents[index].currentPreset:
+ menuItem = self.menu.addAction("Clear Preset")
+ menuItem.triggered.connect(self.presetManager.clearPreset)
+ self.menu.addSeparator()
+
+ # "Add Component" submenu
+ self.submenu = QtWidgets.QMenu("Add")
+ self.menu.addMenu(self.submenu)
+ insertCompAtTop = self.settings.value("pref_insertCompAtTop")
+ for i, comp in enumerate(self.core.modules):
+ menuItem = self.submenu.addAction(comp.Component.name)
+ menuItem.triggered.connect(
+ lambda _, item=i: self.addComponent(
+ 0 if insertCompAtTop else index, item
+ )
+ )
+
+ self.menu.move(parentPosition + QPos)
+ self.menu.show()
diff --git a/src/avp/gui/mainwindow.ui b/src/avp/gui/mainwindow.ui
new file mode 100644
index 0000000..cd8454d
--- /dev/null
+++ b/src/avp/gui/mainwindow.ui
@@ -0,0 +1,835 @@
+
+
+ MainWindow
+
+
+
+ 0
+ 0
+ 1008
+ 575
+
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 0
+
+
+
+ Qt::StrongFocus
+
+
+ MainWindow
+
+
+
+
+ 0
+ 0
+
+
+
+ false
+
+
+
+ 9
+
+
+ 0
+
+ -
+
+
-
+
+
+ Qt::Vertical
+
+
+ QSizePolicy::MinimumExpanding
+
+
+
+ 0
+ 360
+
+
+
+
+ -
+
+
+ QLayout::SetDefaultConstraint
+
+
+ 0
+
+
-
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::MinimumExpanding
+
+
+
+ 420
+ 0
+
+
+
+
+
+
+ -
+
+
+ QLayout::SetMinimumSize
+
+
+ 3
+
+
-
+
+
+ QLayout::SetMinimumSize
+
+
+ 3
+
+
-
+
+
+ QLayout::SetMinimumSize
+
+
-
+
+
+ Undo
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 140
+ 20
+
+
+
+
+ -
+
+
+ Projects
+
+
+
+ -
+
+
+ Presets
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Minimum
+
+
+
+ 20
+ 2
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 0
+
+
+
+
+ 16777215
+ 16777215
+
+
+
+ true
+
+
+ QFrame::StyledPanel
+
+
+ QFrame::Sunken
+
+
+ 1
+
+
+ true
+
+
+ true
+
+
+ false
+
+
+ QAbstractItemView::InternalMove
+
+
+ Qt::MoveAction
+
+
+
+ -
+
+
-
+
+
+ Add
+
+
+
+ -
+
+
+ Remove
+
+
+
+ -
+
+
+ Up
+
+
+
+ -
+
+
+ Down
+
+
+
+
+
+
+
+ -
+
+
+ 4
+
+
+ 2
+
+
+
+
+
+
+
+ -
+
+
+ QLayout::SetFixedSize
+
+
+ 4
+
+
+ 0
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 500
+ 0
+
+
+
+
+ 16777215
+ 180
+
+
+
+ QTabWidget::North
+
+
+ QTabWidget::Rounded
+
+
+ 0
+
+
+
+ Export Video
+
+
+
+ 10
+
+
-
+
+
+ 0
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 85
+ 0
+
+
+
+
+ 80
+ 16777215
+
+
+
+
+ 80
+ 0
+
+
+
+ Audio File
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 28
+
+
+
+
+ 16777215
+ 28
+
+
+
+
+ 0
+ 0
+
+
+
+
+ -
+
+
+
+ 0
+ 28
+
+
+
+
+ 16777215
+ 28
+
+
+
+ ...
+
+
+
+
+
+ -
+
+
-
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 85
+ 0
+
+
+
+
+ 0
+ 0
+
+
+
+ Output File
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 28
+
+
+
+
+ 16777215
+ 28
+
+
+
+
+ -
+
+
+
+ 0
+ 28
+
+
+
+
+ 16777215
+ 28
+
+
+
+ ...
+
+
+
+
+
+
+
+ -
+
+
+ 0
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ 24
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Minimum
+
+
+
+ 10
+ 20
+
+
+
+
+ -
+
+
+ Create Video
+
+
+
+ -
+
+
+ false
+
+
+ Cancel
+
+
+
+
+
+ -
+
+
+
+
+
+ true
+
+
+ Qt::AlignCenter
+
+
+ -1
+
+
+
+
+
+
+ progressLabel
+
+
+
+ Encoder Settings
+
+
+
+ 10
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 85
+ 0
+
+
+
+ Container
+
+
+
+ -
+
+
+
+ 150
+ 0
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Minimum
+
+
+
+ 5
+ 5
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Resolution
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 0
+
+
+
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 85
+ 0
+
+
+
+ Video Codec
+
+
+
+ -
+
+
+
+ 150
+ 0
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 5
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Video Bitrate (Kbps)
+
+
+
+ -
+
+
+ 99999
+
+
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 85
+ 0
+
+
+
+ Audio Codec
+
+
+
+ -
+
+
+
+ 150
+ 0
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+
+ 5
+ 10
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Audio Bitrate (Kbps)
+
+
+
+ -
+
+
+ 9999
+
+
+
+
+
+
+
+
+
+ -
+
+
+ QLayout::SetDefaultConstraint
+
+
-
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::MinimumExpanding
+
+
+
+ 500
+ 0
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 180
+
+
+
+
+ 16777215
+ 180
+
+
+
+ -1
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/gui/presetmanager.py b/src/avp/gui/presetmanager.py
new file mode 100644
index 0000000..980a969
--- /dev/null
+++ b/src/avp/gui/presetmanager.py
@@ -0,0 +1,349 @@
+"""
+Preset manager object handles all interactions with presets, including
+the context menu accessed from MainWindow.
+"""
+
+from PyQt6 import QtCore, QtWidgets, uic
+import string
+import os
+import logging
+
+from ..toolkit import badName
+from ..core import Core
+from .actions import *
+
+
+log = logging.getLogger("AVP.Gui.PresetManager")
+
+
+class PresetManager(QtWidgets.QDialog):
+ def __init__(self, parent):
+ super().__init__()
+ uic.loadUi(os.path.join(Core.wd, "gui", "presetmanager.ui"), self)
+ self.parent = parent
+ self.core = parent.core
+ 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.findPresets()
+
+ # window
+ self.lastFilter = "*"
+ self.presetRows = [] # list of (comp, vers, name) tuples
+
+ self.setWindowFlags(QtCore.Qt.WindowType.WindowStaysOnTopHint)
+
+ # connect button signals
+ self.pushButton_delete.clicked.connect(self.openDeletePresetDialog)
+ self.pushButton_rename.clicked.connect(self.openRenamePresetDialog)
+ self.pushButton_import.clicked.connect(self.openImportDialog)
+ self.pushButton_export.clicked.connect(self.openExportDialog)
+ self.pushButton_close.clicked.connect(self.close)
+
+ # create filter box and preset list
+ self.drawFilterList()
+ self.comboBox_filter.currentIndexChanged.connect(
+ lambda: self.drawPresetList(
+ self.comboBox_filter.currentText(), self.lineEdit_search.text()
+ )
+ )
+
+ # make auto-completion for search bar
+ self.autocomplete = QtCore.QStringListModel()
+ completer = QtWidgets.QCompleter()
+ completer.setModel(self.autocomplete)
+ self.lineEdit_search.setCompleter(completer)
+ self.lineEdit_search.textChanged.connect(
+ lambda: self.drawPresetList(
+ self.comboBox_filter.currentText(), self.lineEdit_search.text()
+ )
+ )
+ self.drawPresetList("*")
+
+ def show_(self):
+ """Open a new preset manager window from the mainwindow"""
+ self.findPresets()
+ self.drawFilterList()
+ self.drawPresetList("*")
+ self.show()
+
+ def findPresets(self):
+ log.debug("Searching %s for presets", self.presetDir)
+ parseList = []
+ for dirpath, dirnames, filenames in os.walk(self.presetDir):
+ # anything without a subdirectory must be a preset folder
+ if dirnames:
+ continue
+ for preset in filenames:
+ compName = os.path.basename(os.path.dirname(dirpath))
+ if compName not in self.core.compNames:
+ continue
+ compVers = os.path.basename(dirpath)
+ try:
+ parseList.append((compName, int(compVers), preset))
+ except ValueError:
+ continue
+ self.presets = {
+ compName: [
+ (vers, preset) for name, vers, preset in parseList if name == compName
+ ]
+ for compName, _, __ in parseList
+ }
+
+ def drawPresetList(self, compFilter=None, presetFilter=""):
+ self.listWidget_presets.clear()
+ if compFilter:
+ self.lastFilter = str(compFilter)
+ else:
+ compFilter = str(self.lastFilter)
+ self.presetRows = []
+ presetNames = []
+ for component, presets in self.presets.items():
+ if compFilter != "*" and component != compFilter:
+ continue
+ for vers, preset in presets:
+ if not presetFilter or presetFilter in preset:
+ self.listWidget_presets.addItem("%s: %s" % (component, preset))
+ self.presetRows.append((component, vers, preset))
+ if preset not in presetNames:
+ presetNames.append(preset)
+ self.autocomplete.setStringList(presetNames)
+
+ def drawFilterList(self):
+ self.comboBox_filter.clear()
+ self.comboBox_filter.addItem("*")
+ for component in self.presets:
+ self.comboBox_filter.addItem(component)
+
+ def clearPreset(self, compI=None):
+ """Functions on mainwindow level from the context menu"""
+ compI = self.parent.listWidget_componentList.currentRow()
+ action = ClearPreset(self.parent, compI)
+ self.parent.undoStack.push(action)
+
+ def openSavePresetDialog(self):
+ """Functions on mainwindow level from the context menu"""
+ selectedComponents = self.core.selectedComponents
+ componentList = self.parent.listWidget_componentList
+
+ if componentList.currentRow() == -1:
+ return
+ while True:
+ index = componentList.currentRow()
+ currentPreset = selectedComponents[index].currentPreset
+ newName, OK = QtWidgets.QInputDialog.getText(
+ self.parent,
+ "Audio Visualizer",
+ "New Preset Name:",
+ QtWidgets.QLineEdit.EchoMode.Normal,
+ currentPreset,
+ )
+ if OK:
+ if badName(newName):
+ self.warnMessage(self.parent)
+ continue
+ if newName:
+ if index != -1:
+ selectedComponents[index].currentPreset = newName
+ saveValueStore = selectedComponents[index].savePreset()
+ saveValueStore["preset"] = newName
+ componentName = str(selectedComponents[index]).strip()
+ vers = selectedComponents[index].version
+ self.createNewPreset(
+ componentName,
+ vers,
+ newName,
+ saveValueStore,
+ window=self.parent,
+ )
+ self.findPresets()
+ self.drawPresetList()
+ self.openPreset(newName, index)
+ break
+
+ def createNewPreset(self, compName, vers, filename, saveValueStore, **kwargs):
+ path = os.path.join(self.presetDir, compName, str(vers), filename)
+ if self.presetExists(path, **kwargs):
+ return
+ self.core.createPresetFile(compName, vers, filename, saveValueStore)
+
+ def presetExists(self, path, **kwargs):
+ if os.path.exists(path):
+ window = kwargs.get("window", self)
+ ch = self.parent.showMessage(
+ msg="%s already exists! Overwrite it?" % os.path.basename(path),
+ showCancel=True,
+ icon="Warning",
+ parent=window,
+ )
+ if not ch:
+ # user clicked cancel
+ return True
+
+ return False
+
+ def openPreset(self, presetName, compPos=None):
+ componentList = self.parent.listWidget_componentList
+ index = compPos if compPos is not None else componentList.currentRow()
+ if index == -1:
+ return
+ action = OpenPreset(self, presetName, index)
+ self.parent.undoStack.push(action)
+
+ def _openPreset(self, presetName, index):
+ selectedComponents = self.core.selectedComponents
+
+ componentName = selectedComponents[index].name.strip()
+ version = selectedComponents[index].version
+ dirname = os.path.join(self.presetDir, componentName, str(version))
+ filepath = os.path.join(dirname, presetName)
+ self.core.openPreset(filepath, index, presetName)
+
+ self.parent.updateComponentTitle(index)
+ self.parent.drawPreview()
+
+ def openDeletePresetDialog(self):
+ row = self.getPresetRow()
+ if row == -1:
+ return
+ comp, vers, name = self.presetRows[row]
+ ch = self.parent.showMessage(
+ msg="Really delete %s?" % name,
+ showCancel=True,
+ icon="Warning",
+ parent=self,
+ )
+ if not ch:
+ return
+ self.deletePreset(comp, vers, name)
+
+ def deletePreset(self, comp, vers, name):
+ action = DeletePreset(self, comp, vers, name)
+ self.parent.undoStack.push(action)
+
+ def warnMessage(self, window=None):
+ self.parent.showMessage(
+ msg="Preset names must contain only letters, " "numbers, and spaces.",
+ parent=window if window else self,
+ )
+
+ def getPresetRow(self):
+ row = self.listWidget_presets.currentRow()
+ if row > -1:
+ return row
+
+ # check if component selected in MainWindow has preset loaded
+ componentList = self.parent.listWidget_componentList
+ compIndex = componentList.currentRow()
+ if compIndex == -1:
+ return compIndex
+
+ preset = self.core.selectedComponents[compIndex].currentPreset
+ if preset is None:
+ return -1
+ else:
+ rowTuple = (
+ self.core.selectedComponents[compIndex].name,
+ self.core.selectedComponents[compIndex].version,
+ preset,
+ )
+ for i, tup in enumerate(self.presetRows):
+ if rowTuple == tup:
+ index = i
+ break
+ else:
+ return -1
+ return index
+
+ def openRenamePresetDialog(self):
+ presetList = self.listWidget_presets
+ index = self.getPresetRow()
+ if index == -1:
+ return
+
+ while True:
+ newName, OK = QtWidgets.QInputDialog.getText(
+ self,
+ "Preset Manager",
+ "Rename Preset:",
+ QtWidgets.QLineEdit.EchoMode.Normal,
+ self.presetRows[index][2],
+ )
+ if OK:
+ if badName(newName):
+ self.warnMessage()
+ continue
+ if newName:
+ comp, vers, oldName = self.presetRows[index]
+ path = os.path.join(self.presetDir, comp, str(vers))
+ newPath = os.path.join(path, newName)
+ if self.presetExists(newPath):
+ return
+ action = RenamePreset(self, path, oldName, newName)
+ self.parent.undoStack.push(action)
+ break
+
+ def renamePreset(self, path, oldName, newName):
+ oldPath = os.path.join(path, oldName)
+ newPath = os.path.join(path, newName)
+ if os.path.exists(newPath):
+ os.remove(newPath)
+ os.rename(oldPath, newPath)
+ self.findPresets()
+ self.drawPresetList()
+ path = os.path.dirname(newPath)
+ for i, comp in enumerate(self.core.selectedComponents):
+ if self.core.getPresetDir(comp) == path and comp.currentPreset == oldName:
+ self.core.openPreset(newPath, i, newName)
+ self.parent.updateComponentTitle(i, False)
+ self.parent.drawPreview()
+
+ def openImportDialog(self):
+ filename, _ = QtWidgets.QFileDialog.getOpenFileName(
+ self,
+ "Import Preset File",
+ self.settings.value("presetDir"),
+ "Preset Files (*.avl)",
+ )
+ if filename:
+ # get installed path & ask user to overwrite if needed
+ path = ""
+ while True:
+ if path:
+ if self.presetExists(path):
+ break
+ else:
+ if os.path.exists(path):
+ os.remove(path)
+ success, path = self.core.importPreset(filename)
+ if success:
+ break
+
+ self.findPresets()
+ self.drawPresetList()
+ self.settings.setValue("presetDir", os.path.dirname(filename))
+
+ def openExportDialog(self):
+ index = self.getPresetRow()
+ if index == -1:
+ return
+ filename, _ = QtWidgets.QFileDialog.getSaveFileName(
+ self,
+ "Export Preset",
+ self.settings.value("presetDir"),
+ "Preset Files (*.avl)",
+ )
+ if filename:
+ comp, vers, name = self.presetRows[index]
+ if not self.core.exportPreset(filename, comp, vers, name):
+ self.parent.showMessage(
+ msg="Couldn't export %s." % filename, parent=self
+ )
+ self.settings.setValue("presetDir", os.path.dirname(filename))
+
+ def clearPresetListSelection(self):
+ self.listWidget_presets.setCurrentRow(-1)
diff --git a/src/avp/gui/presetmanager.ui b/src/avp/gui/presetmanager.ui
new file mode 100644
index 0000000..5257b1c
--- /dev/null
+++ b/src/avp/gui/presetmanager.ui
@@ -0,0 +1,150 @@
+
+
+ presetmanager
+
+
+ Qt::NonModal
+
+
+ true
+
+
+
+ 0
+ 0
+ 497
+ 377
+
+
+
+ Preset Manager
+
+
+ -
+
+
-
+
+
+
+
+
+ Filter by name
+
+
+
+ -
+
+
+
+ 200
+ 0
+
+
+
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ true
+
+
+
+
+
+ -
+
+
+ QLayout::SetMinimumSize
+
+
-
+
+
+ Import
+
+
+
+ -
+
+
+ Export
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ true
+
+
+ Rename
+
+
+
+ -
+
+
+ Delete
+
+
+
+
+
+ -
+
+
-
+
+
+ <html><head/><body><p><span style=" font-size:10pt; font-style:italic;">Right-click components in the main window to create presets</span></p></body></html>
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
diff --git a/src/avp/gui/preview_thread.py b/src/avp/gui/preview_thread.py
new file mode 100644
index 0000000..1d78516
--- /dev/null
+++ b/src/avp/gui/preview_thread.py
@@ -0,0 +1,93 @@
+"""
+Thread that runs to create QImages for MainWindow's preview label.
+Processes a queue of component lists.
+"""
+
+from PyQt6 import QtCore, QtGui, uic
+from PyQt6.QtCore import pyqtSignal, pyqtSlot
+from PIL import Image
+from PIL.ImageQt import ImageQt
+from queue import Queue, Empty
+import os
+import logging
+
+from ..toolkit.frame import Checkerboard
+from ..toolkit import disableWhenOpeningProject
+
+
+log = logging.getLogger("AVP.Gui.PreviewThread")
+
+
+class Worker(QtCore.QObject):
+
+ imageCreated = pyqtSignal(QtGui.QImage)
+ error = pyqtSignal(str)
+
+ def __init__(self, core, settings, queue):
+ super().__init__()
+ self.core = core
+ self.settings = settings
+ width = int(self.settings.value("outputWidth"))
+ height = int(self.settings.value("outputHeight"))
+ self.queue = queue
+ self.background = Checkerboard(width, height)
+
+ @disableWhenOpeningProject
+ @pyqtSlot(list)
+ def createPreviewImage(self, components):
+ dic = {
+ "components": components,
+ }
+ self.queue.put(dic)
+ log.debug("Preview thread id: {}".format(int(QtCore.QThread.currentThreadId())))
+
+ @pyqtSlot()
+ def process(self):
+ try:
+ nextPreviewInformation = self.queue.get(block=False)
+ while self.queue.qsize() >= 2:
+ try:
+ self.queue.get(block=False)
+ except Empty:
+ continue
+ width = int(self.settings.value("outputWidth"))
+ height = int(self.settings.value("outputHeight"))
+ if self.background.width != width or self.background.height != height:
+ self.background = Checkerboard(width, height)
+
+ frame = self.background.copy()
+ log.info("Creating new preview frame")
+ components = nextPreviewInformation["components"]
+ for component in reversed(components):
+ try:
+ component.lockSize(width, height)
+ newFrame = component.previewRender()
+ component.unlockSize()
+ frame = Image.alpha_composite(frame, newFrame)
+
+ except ValueError as e:
+ errMsg = (
+ "Bad frame returned by %s's preview renderer. "
+ "%s. New frame size was %s*%s; should be %s*%s."
+ % (
+ str(component),
+ str(e).capitalize(),
+ newFrame.width,
+ newFrame.height,
+ width,
+ height,
+ )
+ )
+ log.critical(errMsg)
+ self.error.emit(errMsg)
+ break
+ except RuntimeError as e:
+ log.error(str(e))
+ else:
+ # We must store a reference to this QImage
+ # or else Qt will garbage-collect it on the C++ side
+ self.frame = ImageQt(frame)
+ self.imageCreated.emit(QtGui.QImage(self.frame))
+
+ except Empty:
+ True
diff --git a/src/avp/gui/preview_win.py b/src/avp/gui/preview_win.py
new file mode 100644
index 0000000..f52f8a3
--- /dev/null
+++ b/src/avp/gui/preview_win.py
@@ -0,0 +1,58 @@
+from PyQt6 import QtCore, QtGui, QtWidgets
+import logging
+
+log = logging.getLogger("AVP.Gui.PreviewWindow")
+
+
+class PreviewWindow(QtWidgets.QLabel):
+ """
+ Paints the preview QLabel in MainWindow and maintains the aspect ratio
+ when the window is resized.
+ """
+
+ def __init__(self, parent, img):
+ super().__init__()
+ self.parent = parent
+ # FIXME
+ # self.setFrameStyle(QtWidgets.QFrame.StyledPanel)
+ self.pixmap = QtGui.QPixmap(img)
+
+ def paintEvent(self, event):
+ size = self.size()
+ painter = QtGui.QPainter(self)
+ point = QtCore.QPoint(0, 0)
+ scaledPix = self.pixmap.scaled(
+ size,
+ QtCore.Qt.AspectRatioMode.KeepAspectRatio,
+ transformMode=QtCore.Qt.TransformationMode.SmoothTransformation,
+ )
+
+ # start painting the label from left upper corner
+ point.setX(int((size.width() - scaledPix.width()) / 2))
+ point.setY(int((size.height() - scaledPix.height()) / 2))
+ painter.drawPixmap(point, scaledPix)
+
+ def changePixmap(self, img):
+ self.pixmap = QtGui.QPixmap(img)
+ self.repaint()
+
+ def mousePressEvent(self, event):
+ if self.parent.encoding:
+ return
+
+ i = self.parent.listWidget_componentList.currentRow()
+ if i >= 0:
+ component = self.parent.core.selectedComponents[i]
+ if not hasattr(component, "previewClickEvent"):
+ return
+ qpoint = event.position().toPoint()
+ pos = (qpoint.x(), qpoint.y())
+ size = (self.width(), self.height())
+ butt = event.button()
+ log.info("Click event for #%s: %s button %s" % (i, pos, butt))
+ component.previewClickEvent(pos, size, butt)
+
+ @QtCore.pyqtSlot(str)
+ def threadError(self, msg):
+ self.parent.showMessage(msg=msg, icon="Critical", parent=self)
+ log.info("%", repr(self.parent))
diff --git a/src/avp/toolkit/__init__.py b/src/avp/toolkit/__init__.py
new file mode 100644
index 0000000..55e5f84
--- /dev/null
+++ b/src/avp/toolkit/__init__.py
@@ -0,0 +1 @@
+from .common import *
diff --git a/src/avp/toolkit/common.py b/src/avp/toolkit/common.py
new file mode 100644
index 0000000..e35aba2
--- /dev/null
+++ b/src/avp/toolkit/common.py
@@ -0,0 +1,192 @@
+"""
+Common functions
+"""
+
+from PyQt6 import QtWidgets
+import string
+import os
+import sys
+import subprocess
+import logging
+from copy import copy
+from collections import OrderedDict
+
+
+log = logging.getLogger("AVP.Toolkit.Common")
+
+
+class blockSignals:
+ """
+ Context manager to temporarily block list of QtWidgets from updating,
+ and guarantee restoring the previous state afterwards.
+ """
+
+ def __init__(self, widgets):
+ if type(widgets) is dict:
+ self.widgets = concatDictVals(widgets)
+ else:
+ self.widgets = widgets if hasattr(widgets, "__iter__") else [widgets]
+
+ def __enter__(self):
+ log.verbose(
+ "Blocking signals for %s",
+ ", ".join([str(w.__class__.__name__) for w in self.widgets]),
+ )
+ self.oldStates = [w.signalsBlocked() for w in self.widgets]
+ for w in self.widgets:
+ w.blockSignals(True)
+
+ def __exit__(self, *args):
+ log.verbose("Resetting blockSignals to %s", str(bool(sum(self.oldStates))))
+ for w, state in zip(self.widgets, self.oldStates):
+ w.blockSignals(state)
+
+
+def concatDictVals(d):
+ """Concatenates all values in given dict into one list."""
+ key, value = d.popitem()
+ d[key] = value
+ final = copy(value)
+ if type(final) is not list:
+ final = [final]
+ final.extend([val for val in d.values()])
+ else:
+ value.extend([item for val in d.values() for item in val])
+ return final
+
+
+def badName(name):
+ """Returns whether a name contains non-alphanumeric chars"""
+ return any([letter in string.punctuation for letter in name])
+
+
+def alphabetizeDict(dictionary):
+ """Alphabetizes a dict into OrderedDict"""
+ return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0]))
+
+
+def presetToString(dictionary):
+ """Returns string repr of a preset"""
+ return repr(alphabetizeDict(dictionary))
+
+
+def presetFromString(string):
+ """Turns a string repr of OrderedDict into a regular dict"""
+ return dict(eval(string))
+
+
+def appendUppercase(lst):
+ for form, i in zip(lst, range(len(lst))):
+ lst.append(form.upper())
+ return lst
+
+
+def pipeWrapper(func):
+ """A decorator to insert proper kwargs into Popen objects."""
+
+ def pipeWrapper(commandList, **kwargs):
+ if sys.platform == "win32":
+ # Stop CMD window from appearing on Windows
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ kwargs["startupinfo"] = startupinfo
+
+ if "bufsize" not in kwargs:
+ kwargs["bufsize"] = 10**8
+ if "stdin" not in kwargs:
+ kwargs["stdin"] = subprocess.DEVNULL
+ return func(commandList, **kwargs)
+
+ return pipeWrapper
+
+
+@pipeWrapper
+def checkOutput(commandList, **kwargs):
+ return subprocess.check_output(commandList, **kwargs)
+
+
+def disableWhenEncoding(func):
+ def decorator(self, *args, **kwargs):
+ if self.encoding:
+ return
+ else:
+ return func(self, *args, **kwargs)
+
+ return decorator
+
+
+def disableWhenOpeningProject(func):
+ def decorator(self, *args, **kwargs):
+ if self.core.openingProject:
+ return
+ else:
+ return func(self, *args, **kwargs)
+
+ return decorator
+
+
+def rgbFromString(string):
+ """Turns an RGB string like "255, 255, 255" into a tuple"""
+ try:
+ tup = tuple([int(i) for i in string.split(",")])
+ if len(tup) != 3:
+ raise ValueError
+ for i in tup:
+ if i > 255 or i < 0:
+ raise ValueError
+ return tup
+ except:
+ return (255, 255, 255)
+
+
+def formatTraceback(tb=None):
+ import traceback
+
+ if tb is None:
+ import sys
+
+ tb = sys.exc_info()[2]
+ return "Traceback:\n%s" % "\n".join(traceback.format_tb(tb))
+
+
+def connectWidget(widget, func):
+ if type(widget) == QtWidgets.QLineEdit:
+ widget.textChanged.connect(func)
+ elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox:
+ widget.valueChanged.connect(func)
+ elif type(widget) == QtWidgets.QCheckBox:
+ widget.stateChanged.connect(func)
+ elif type(widget) == QtWidgets.QComboBox:
+ widget.currentIndexChanged.connect(func)
+ else:
+ log.warning("Failed to connect %s ", str(widget.__class__.__name__))
+ return False
+ return True
+
+
+def setWidgetValue(widget, val):
+ """Generic setValue method for use with any typical QtWidget"""
+ log.verbose("Setting %s to %s" % (str(widget.__class__.__name__), val))
+ if type(widget) == QtWidgets.QLineEdit:
+ widget.setText(val)
+ elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox:
+ widget.setValue(val)
+ elif type(widget) == QtWidgets.QCheckBox:
+ widget.setChecked(val)
+ elif type(widget) == QtWidgets.QComboBox:
+ widget.setCurrentIndex(val)
+ else:
+ log.warning("Failed to set %s ", str(widget.__class__.__name__))
+ return False
+ return True
+
+
+def getWidgetValue(widget):
+ if type(widget) == QtWidgets.QLineEdit:
+ return widget.text()
+ elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox:
+ return widget.value()
+ elif type(widget) == QtWidgets.QCheckBox:
+ return widget.isChecked()
+ elif type(widget) == QtWidgets.QComboBox:
+ return widget.currentIndex()
diff --git a/src/avp/toolkit/ffmpeg.py b/src/avp/toolkit/ffmpeg.py
new file mode 100644
index 0000000..5aedff3
--- /dev/null
+++ b/src/avp/toolkit/ffmpeg.py
@@ -0,0 +1,545 @@
+"""
+Tools for using ffmpeg
+"""
+
+import numpy
+import sys
+import os
+import subprocess
+import threading
+import signal
+from queue import PriorityQueue
+import logging
+
+from .. import core
+from .common import checkOutput, pipeWrapper
+
+
+log = logging.getLogger("AVP.Toolkit.Ffmpeg")
+
+
+class FfmpegVideo:
+ """Opens a pipe to ffmpeg and stores a buffer of raw video frames."""
+
+ # error from the thread used to fill the buffer
+ threadError = None
+
+ def __init__(self, **kwargs):
+ mandatoryArgs = [
+ "inputPath",
+ "filter_",
+ "width",
+ "height",
+ "frameRate", # frames per second
+ "chunkSize", # number of bytes in one frame
+ "parent", # mainwindow object
+ "component", # component object
+ ]
+ for arg in mandatoryArgs:
+ setattr(self, arg, kwargs[arg])
+
+ self.frameNo = -1
+ self.currentFrame = "None"
+ self.map_ = None
+
+ if "loopVideo" in kwargs and kwargs["loopVideo"]:
+ self.loopValue = "-1"
+ else:
+ self.loopValue = "0"
+ if "filter_" in kwargs:
+ if kwargs["filter_"][0] != "-filter_complex":
+ kwargs["filter_"].insert(0, "-filter_complex")
+ else:
+ kwargs["filter_"] = None
+
+ self.command = [
+ core.Core.FFMPEG_BIN,
+ "-thread_queue_size",
+ "512",
+ "-r",
+ str(self.frameRate),
+ "-stream_loop",
+ str(self.loopValue),
+ "-i",
+ self.inputPath,
+ "-f",
+ "image2pipe",
+ "-pix_fmt",
+ "rgba",
+ ]
+ if type(kwargs["filter_"]) is list:
+ self.command.extend(kwargs["filter_"])
+ self.command.extend(
+ [
+ "-codec:v",
+ "rawvideo",
+ "-",
+ ]
+ )
+
+ self.frameBuffer = PriorityQueue()
+ self.frameBuffer.maxsize = self.frameRate
+ self.finishedFrames = {}
+
+ self.thread = threading.Thread(
+ target=self.fillBuffer, name="FFmpeg Frame-Fetcher"
+ )
+ self.thread.daemon = True
+ self.thread.start()
+
+ def frame(self, num):
+ while True:
+ if num in self.finishedFrames:
+ image = self.finishedFrames.pop(num)
+ return image
+
+ i, image = self.frameBuffer.get()
+ self.finishedFrames[i] = image
+ self.frameBuffer.task_done()
+
+ def fillBuffer(self):
+ from ..component import ComponentError
+
+ if core.Core.logEnabled:
+ logFilename = os.path.join(
+ core.Core.logDir, "render_%s.log" % str(self.component.compPos)
+ )
+ log.debug("Creating ffmpeg process (log at %s)", logFilename)
+ with open(logFilename, "w") as logf:
+ logf.write(" ".join(self.command) + "\n\n")
+ with open(logFilename, "a") as logf:
+ self.pipe = openPipe(
+ self.command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=logf,
+ bufsize=10**8,
+ )
+ else:
+ self.pipe = openPipe(
+ self.command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ bufsize=10**8,
+ )
+
+ while True:
+ if self.parent.canceled:
+ break
+ self.frameNo += 1
+
+ # If we run out of frames, use the last good frame and loop.
+ try:
+ if len(self.currentFrame) == 0:
+ self.frameBuffer.put((self.frameNo - 1, self.lastFrame))
+ continue
+ except AttributeError:
+ FfmpegVideo.threadError = ComponentError(
+ self.component,
+ "video",
+ "Video seemed playable but wasn't.",
+ )
+ break
+
+ try:
+ self.currentFrame = self.pipe.stdout.read(self.chunkSize)
+ except ValueError as e:
+ if str(e) == "PyMemoryView_FromBuffer(): info->buf must not be NULL":
+ log.debug(
+ "Ignored 'info->buf must not be NULL' error from FFmpeg pipe"
+ )
+ return
+ else:
+ FfmpegVideo.threadError = ComponentError(self.component, "video")
+
+ if len(self.currentFrame) != 0:
+ self.frameBuffer.put((self.frameNo, self.currentFrame))
+ self.lastFrame = self.currentFrame
+
+
+@pipeWrapper
+def openPipe(commandList, **kwargs):
+ return subprocess.Popen(commandList, **kwargs)
+
+
+def closePipe(pipe):
+ pipe.stdout.close()
+ pipe.send_signal(signal.SIGTERM)
+
+
+def findFfmpeg():
+ if sys.platform == "win32":
+ bin = "ffmpeg.exe"
+ else:
+ bin = "ffmpeg"
+
+ if getattr(sys, "frozen", False):
+ # The application is frozen
+ bin = os.path.join(core.Core.wd, bin)
+
+ with open(os.devnull, "w") as f:
+ try:
+ checkOutput([bin, "-version"], stderr=f)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ bin = ""
+
+ return bin
+
+
+def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
+ """
+ Constructs the major ffmpeg command used to export the video
+ """
+ if 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)
+ encoders = encoders.decode("utf-8")
+
+ acodec = Core.settings.value("outputAudioCodec")
+
+ options = Core.encoderOptions
+ containerName = Core.settings.value("outputContainer")
+ vcodec = Core.settings.value("outputVideoCodec")
+ vbitrate = str(Core.settings.value("outputVideoBitrate")) + "k"
+ acodec = Core.settings.value("outputAudioCodec")
+ abitrate = str(Core.settings.value("outputAudioBitrate")) + "k"
+
+ for cont in options["containers"]:
+ if cont["name"] == containerName:
+ container = cont["container"]
+ break
+
+ vencoders = options["video-codecs"][vcodec]
+ aencoders = options["audio-codecs"][acodec]
+
+ def error():
+ nonlocal encoders, encoder
+ log.critical(
+ "Selected encoder (%s) is not supported by Ffmpeg. The supported encoders are: %s",
+ encoder,
+ encoders,
+ )
+ return []
+
+ for encoder in vencoders:
+ if encoder in encoders:
+ vencoder = encoder
+ break
+ else:
+ return error()
+
+ for encoder in aencoders:
+ if encoder in encoders:
+ aencoder = encoder
+ break
+ else:
+ return error()
+
+ ffmpegCommand = [
+ Core.FFMPEG_BIN,
+ "-thread_queue_size",
+ "512",
+ "-y", # overwrite the output file if it already exists.
+ # INPUT VIDEO
+ "-f",
+ "rawvideo",
+ "-vcodec",
+ "rawvideo",
+ "-s",
+ f'{Core.settings.value("outputWidth")}x{Core.settings.value("outputHeight")}',
+ "-pix_fmt",
+ "rgba",
+ "-r",
+ str(Core.settings.value("outputFrameRate")),
+ "-t",
+ duration,
+ "-an", # the video input has no sound
+ "-i",
+ "-", # the video input comes from a pipe
+ # INPUT SOUND
+ "-t",
+ duration,
+ "-i",
+ inputFile,
+ ]
+
+ extraAudio = [comp.audio for comp in components if "audio" in comp.properties()]
+ segment = createAudioFilterCommand(extraAudio, safeDuration)
+ ffmpegCommand.extend(segment)
+ # Map audio from the filters or the single audio input, and map video from the pipe
+ ffmpegCommand.extend(
+ [
+ "-map",
+ "0:v",
+ "-map",
+ "[a]" if segment else "1:a",
+ ]
+ )
+
+ ffmpegCommand.extend(
+ [
+ # OUTPUT
+ "-vcodec",
+ vencoder,
+ "-acodec",
+ aencoder,
+ "-b:v",
+ vbitrate,
+ "-b:a",
+ abitrate,
+ "-pix_fmt",
+ Core.settings.value("outputVideoFormat"),
+ "-preset",
+ Core.settings.value("outputPreset"),
+ "-f",
+ container,
+ ]
+ )
+
+ if acodec == "aac":
+ ffmpegCommand.append("-strict")
+ ffmpegCommand.append("-2")
+
+ ffmpegCommand.append(outputFile)
+ return ffmpegCommand
+
+
+def createAudioFilterCommand(extraAudio, duration):
+ """Add extra inputs and any needed filters to the main ffmpeg command."""
+ # NOTE: Global filters are currently hard-coded here for debugging use
+ globalFilters = 0 # increase to add global filters
+
+ if not extraAudio and not globalFilters:
+ return []
+
+ ffmpegCommand = []
+ # Add -i options for extra input files
+ extraFilters = {}
+ for streamNo, params in enumerate(reversed(extraAudio)):
+ extraInputFile, params = params
+ ffmpegCommand.extend(
+ [
+ "-t",
+ duration,
+ # Tell ffmpeg about shorter clips (seemingly not needed)
+ # streamDuration = getAudioDuration(extraInputFile)
+ # if streamDuration and streamDuration > float(safeDuration)
+ # else "{0:.3f}".format(streamDuration),
+ "-i",
+ extraInputFile,
+ ]
+ )
+ # Construct dataset of extra filters we'll need to add later
+ for ffmpegFilter in params:
+ if streamNo + 2 not in extraFilters:
+ extraFilters[streamNo + 2] = []
+ extraFilters[streamNo + 2].append((ffmpegFilter, params[ffmpegFilter]))
+
+ # Start creating avfilters! Popen-style, so don't use semicolons;
+ extraFilterCommand = []
+
+ if globalFilters <= 0:
+ # Dictionary of last-used tmp labels for a given stream number
+ tmpInputs = {streamNo: -1 for streamNo in extraFilters}
+ else:
+ # Insert blank entries for global filters into extraFilters
+ # so the per-stream filters know what input to source later
+ for streamNo in range(len(extraAudio), 0, -1):
+ if streamNo + 1 not in extraFilters:
+ extraFilters[streamNo + 1] = []
+ # Also filter the primary audio track
+ extraFilters[1] = []
+ tmpInputs = {streamNo: globalFilters - 1 for streamNo in extraFilters}
+
+ # Add the global filters!
+ # NOTE: list length must = globalFilters, currently hardcoded
+ if tmpInputs:
+ extraFilterCommand.extend(
+ [
+ "[%s:a] ashowinfo [%stmp0]" % (str(streamNo), str(streamNo))
+ for streamNo in tmpInputs
+ ]
+ )
+
+ # Now add the per-stream filters!
+ for streamNo, paramList in extraFilters.items():
+ for param in paramList:
+ source = (
+ "[%s:a]" % str(streamNo)
+ if tmpInputs[streamNo] == -1
+ else "[%stmp%s]" % (str(streamNo), str(tmpInputs[streamNo]))
+ )
+ tmpInputs[streamNo] = tmpInputs[streamNo] + 1
+ extraFilterCommand.append(
+ "%s %s%s [%stmp%s]"
+ % (
+ source,
+ param[0],
+ param[1],
+ str(streamNo),
+ str(tmpInputs[streamNo]),
+ )
+ )
+
+ # Join all the filters together and combine into 1 stream
+ extraFilterCommand = "; ".join(extraFilterCommand) + "; " if tmpInputs else ""
+ ffmpegCommand.extend(
+ [
+ "-filter_complex",
+ extraFilterCommand
+ + "%s amix=inputs=%s:duration=first [a]"
+ % (
+ "".join(
+ [
+ (
+ "[%stmp%s]" % (str(i), tmpInputs[i])
+ if i in extraFilters
+ else "[%s:a]" % str(i)
+ )
+ for i in range(1, len(extraAudio) + 2)
+ ]
+ ),
+ str(len(extraAudio) + 1),
+ ),
+ ]
+ )
+ return ffmpegCommand
+
+
+def testAudioStream(filename):
+ """Test if an audio stream definitely exists"""
+ audioTestCommand = [
+ core.Core.FFMPEG_BIN,
+ "-i",
+ filename,
+ "-vn",
+ "-f",
+ "null",
+ "-",
+ ]
+ try:
+ checkOutput(audioTestCommand, stderr=subprocess.DEVNULL)
+ except subprocess.CalledProcessError:
+ return False
+ else:
+ return True
+
+
+def getAudioDuration(filename):
+ """Try to get duration of audio file as float, or False if not possible"""
+ command = [core.Core.FFMPEG_BIN, "-i", filename]
+
+ try:
+ fileInfo = checkOutput(command, stderr=subprocess.STDOUT)
+ except subprocess.CalledProcessError as ex:
+ fileInfo = ex.output
+ except (FileNotFoundError, PermissionError):
+ # ffmpeg is possibly not installed
+ return False
+
+ try:
+ info = fileInfo.decode("utf-8").split("\n")
+ except UnicodeDecodeError as e:
+ log.error("Unicode error:", str(e))
+ return False
+
+ for line in info:
+ if "Duration" in line:
+ d = line.split(",")[0]
+ d = d.split(" ")[3]
+ d = d.split(":")
+ duration = float(d[0]) * 3600 + float(d[1]) * 60 + float(d[2])
+ break
+ else:
+ # String not found in output
+ return False
+ return duration
+
+
+def readAudioFile(filename, videoWorker):
+ """
+ Creates the completeAudioArray given to components
+ and used to draw the classic visualizer.
+ """
+ duration = getAudioDuration(filename)
+ if not duration:
+ log.error(f"Audio file {filename} doesn't exist or unreadable.")
+ return
+
+ command = [
+ core.Core.FFMPEG_BIN,
+ "-i",
+ filename,
+ "-f",
+ "s16le",
+ "-acodec",
+ "pcm_s16le",
+ "-ar",
+ "44100", # ouput will have 44100 Hz
+ "-ac",
+ "1", # mono (set to '2' for stereo)
+ "-",
+ ]
+ in_pipe = openPipe(
+ command,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ bufsize=10**8,
+ )
+
+ completeAudioArray = numpy.empty(0, dtype="int16")
+
+ progress = 0
+ lastPercent = None
+ while True:
+ if core.Core.canceled:
+ return
+ # read 2 seconds of audio
+ progress += 4
+ raw_audio = in_pipe.stdout.read(88200 * 4)
+ if len(raw_audio) == 0:
+ break
+ audio_array = numpy.frombuffer(raw_audio, dtype="int16")
+ completeAudioArray = numpy.append(completeAudioArray, audio_array)
+
+ percent = int(100 * (progress / duration))
+ if percent >= 100:
+ percent = 100
+
+ if lastPercent != percent:
+ string = "Loading audio file: " + str(percent) + "%"
+ videoWorker.progressBarSetText.emit(string)
+ videoWorker.progressBarUpdate.emit(percent)
+
+ lastPercent = percent
+
+ in_pipe.kill()
+ in_pipe.wait()
+
+ # add 0s the end
+ completeAudioArrayCopy = numpy.zeros(len(completeAudioArray) + 44100, dtype="int16")
+ completeAudioArrayCopy[: len(completeAudioArray)] = completeAudioArray
+ completeAudioArray = completeAudioArrayCopy
+
+ return (completeAudioArray, duration)
+
+
+def exampleSound(style="white", extra="apulsator=offset_l=0.35:offset_r=0.67"):
+ """Help generate an example sound for use in creating a preview"""
+
+ if style == "white":
+ src = "-2+random(0)"
+ elif style == "freq":
+ src = "sin(1000*t*PI*t)"
+ elif style == "wave":
+ src = "sin(random(0)*2*PI*t)*tan(random(0)*2*PI*t)"
+ elif style == "stereo":
+ 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 "")
diff --git a/src/avp/toolkit/frame.py b/src/avp/toolkit/frame.py
new file mode 100644
index 0000000..94537a6
--- /dev/null
+++ b/src/avp/toolkit/frame.py
@@ -0,0 +1,117 @@
+"""
+Common tools for drawing compatible frames in a Component's frameRender()
+"""
+
+from PyQt6 import QtGui
+from PIL import Image
+from PIL.ImageQt import ImageQt
+from PyQt6 import QtCore
+import sys
+import os
+import math
+import logging
+from .. import core
+
+
+log = logging.getLogger("AVP.Toolkit.Frame")
+
+
+class FramePainter(QtGui.QPainter):
+ """
+ A QPainter for a blank frame, which can be converted into a
+ Pillow image with finalize()
+ """
+
+ def __init__(self, width, height):
+ image = BlankFrame(width, height)
+ log.debug("Creating QImage from PIL image object")
+ self.image = ImageQt(image)
+ super().__init__(self.image)
+
+ def setPen(self, penStyle):
+ if type(penStyle) is tuple:
+ super().setPen(PaintColor(*penStyle))
+ else:
+ super().setPen(penStyle)
+
+ def finalize(self):
+ log.verbose("Finalizing FramePainter")
+ buffer = QtCore.QBuffer()
+ buffer.open(QtCore.QBuffer.OpenModeFlag.ReadWrite)
+ self.image.save(buffer, "PNG")
+ import io
+
+ frame = Image.open(io.BytesIO(buffer.data()))
+ buffer.close()
+ self.end()
+ return frame
+ imBytes = self.image.bits().asstring(self.image.byteCount())
+ frame = Image.frombytes(
+ "RGBA", (self.image.width(), self.image.height()), imBytes
+ )
+ self.end()
+ return frame
+
+
+class PaintColor(QtGui.QColor):
+ """
+ Subclass of QtGui.QColor with an added scale() method
+ Previously this class reversed the painter colour to solve
+ hardware issues related to endianness,
+ but Qt appears to deal with this itself nowadays
+ """
+
+ def __init__(self, r, g, b, a=255):
+ super().__init__(r, g, b, a)
+
+
+def scale(scalePercent, width, height, returntype=None):
+ width = (float(width) / 100.0) * float(scalePercent)
+ height = (float(height) / 100.0) * float(scalePercent)
+ if returntype == str:
+ return (str(math.ceil(width)), str(math.ceil(height)))
+ elif returntype == int:
+ return (math.ceil(width), math.ceil(height))
+ else:
+ return (width, height)
+
+
+def defaultSize(framefunc):
+ """Makes width/height arguments optional"""
+
+ def decorator(*args):
+ if len(args) < 2:
+ newArgs = list(args)
+ if len(args) == 0 or len(args) == 1:
+ height = int(core.Core.settings.value("outputHeight"))
+ newArgs.append(height)
+ if len(args) == 0:
+ width = int(core.Core.settings.value("outputWidth"))
+ newArgs.insert(0, width)
+ args = tuple(newArgs)
+ return framefunc(*args)
+
+ return decorator
+
+
+def FloodFrame(width, height, RgbaTuple):
+ return Image.new("RGBA", (width, height), RgbaTuple)
+
+
+@defaultSize
+def BlankFrame(width, height):
+ """The base frame used by each component to start drawing."""
+ return FloodFrame(width, height, (0, 0, 0, 0))
+
+
+@defaultSize
+def Checkerboard(width, height):
+ """
+ A checkerboard to represent transparency to the user.
+ """
+ # TODO: Would be cool to generate this image with numpy instead.
+ log.debug("Creating new %s*%s checkerboard" % (width, height))
+ image = FloodFrame(1920, 1080, (0, 0, 0, 0))
+ image.paste(Image.open(os.path.join(core.Core.wd, "gui", "background.png")), (0, 0))
+ image = image.resize((width, height))
+ return image
diff --git a/src/avp/video_thread.py b/src/avp/video_thread.py
new file mode 100644
index 0000000..5d72409
--- /dev/null
+++ b/src/avp/video_thread.py
@@ -0,0 +1,417 @@
+"""
+Worker thread created to export a video. It has a slot to begin export using
+an input file, output path, and component list.
+
+Signals are emitted to update MainWindow's progress bar, detail text, and preview.
+A Command object takes the place of MainWindow while in commandline mode.
+
+Export can be cancelled with cancel()
+"""
+
+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 .toolkit.ffmpeg import (
+ openPipe,
+ readAudioFile,
+ getAudioDuration,
+ createFfmpegCommand,
+)
+
+
+log = logging.getLogger("AVP.VideoThread")
+
+
+class Worker(QtCore.QObject):
+
+ imageCreated = pyqtSignal("QImage")
+ videoCreated = pyqtSignal()
+ progressBarUpdate = pyqtSignal(int)
+ progressBarSetText = pyqtSignal(str)
+ encoding = pyqtSignal(bool)
+
+ def __init__(self, parent, inputFile, outputFile, components):
+ super().__init__()
+ self.core = parent.core
+ self.settings = parent.settings
+ self.modules = parent.core.modules
+ parent.createVideo.connect(self.createVideo)
+ self.previewEnabled = type(parent.core).previewEnabled
+
+ self.components = components
+ self.outputFile = outputFile
+ self.inputFile = inputFile
+
+ self.hertz = 44100
+ self.sampleSize = 1470 # 44100 / 30 = 1470
+ self.canceled = False
+ self.error = False
+
+ def createFfmpegCommand(self, duration):
+ try:
+ ffmpegCommand = createFfmpegCommand(
+ self.inputFile, self.outputFile, self.components, duration
+ )
+ except sp.CalledProcessError as e:
+ # FIXME video_thread should own this error signal, not components
+ self.components[0]._error.emit(
+ "Ffmpeg could not be found. Is it installed?", str(e)
+ )
+ self.error = True
+ return
+
+ if not ffmpegCommand:
+ # FIXME video_thread should own this error signal, not components
+ self.components[0]._error.emit(
+ "The FFmpeg command could not be generated.", ""
+ )
+ log.critical(
+ "Cancelling render process due to failure while generating the ffmpeg command."
+ )
+ self.failExport()
+ return
+ return ffmpegCommand
+
+ def determineAudioLength(self):
+ """
+ Returns audio length which determines length of final video, or False if failure occurs
+ """
+ if any(
+ [True if "pcm" in comp.properties() else False for comp in self.components]
+ ):
+ self.progressBarSetText.emit("Loading audio file...")
+ audioFileTraits = readAudioFile(self.inputFile, self)
+ if audioFileTraits is None:
+ self.cancelExport()
+ return False
+ self.completeAudioArray, duration = audioFileTraits
+ self.audioArrayLen = len(self.completeAudioArray)
+ else:
+ duration = getAudioDuration(self.inputFile)
+ self.completeAudioArray = []
+ self.audioArrayLen = int(
+ ((duration * self.hertz) + self.hertz) - self.sampleSize
+ )
+ return duration
+
+ def preFrameRender(self):
+ """
+ Initializes components that need to pre-compute stuff.
+ Also prerenders "static" components like text and merges them if possible
+ """
+ self.staticComponents = {}
+
+ # Call preFrameRender on each component
+ canceledByComponent = False
+ initText = ", ".join(
+ [
+ "%s) %s" % (num, str(component))
+ for num, component in enumerate(reversed(self.components))
+ ]
+ )
+ print("Loaded Components:", initText)
+ log.info("Calling preFrameRender for %s", initText)
+ for compNo, comp in enumerate(reversed(self.components)):
+ try:
+ comp.preFrameRender(
+ audioFile=self.inputFile,
+ completeAudioArray=self.completeAudioArray,
+ audioArrayLen=self.audioArrayLen,
+ sampleSize=self.sampleSize,
+ progressBarUpdate=self.progressBarUpdate,
+ progressBarSetText=self.progressBarSetText,
+ )
+ except ComponentError:
+ log.warning(
+ "#%s %s encountered an error in its preFrameRender method",
+ compNo,
+ comp,
+ )
+
+ compProps = comp.properties()
+ if "error" in compProps or comp._lockedError is not None:
+ self.cancel()
+ self.canceled = True
+ canceledByComponent = True
+ compError = (
+ comp.error() if type(comp.error()) is tuple else (comp.error(), "")
+ )
+ errMsg = (
+ "Component #%s (%s) encountered an error!"
+ % (str(compNo), comp.name)
+ if comp.error() is None
+ else "Export cancelled by component #%s (%s): %s"
+ % (str(compNo), comp.name, compError[0])
+ )
+ log.error(errMsg)
+ comp._error.emit(errMsg, compError[1])
+ break
+ if "static" in compProps:
+ log.info("Saving static frame from #%s %s", compNo, comp)
+ self.staticComponents[compNo] = comp.frameRender(0).copy()
+
+ # Check if any errors occured
+ log.debug("Checking if a component wishes to cancel the export...")
+ if self.canceled:
+ if canceledByComponent:
+ log.error(
+ "Export cancelled by component #%s (%s): %s",
+ compNo,
+ comp.name,
+ (
+ "No message."
+ if comp.error() is None
+ else (
+ comp.error()
+ if type(comp.error()) is str
+ else comp.error()[0]
+ )
+ ),
+ )
+ self.cancelExport()
+
+ # Merge static frames that can be merged to reduce workload
+ def mergeConsecutiveStaticComponentFrames(self):
+ log.info("Merging consecutive static component frames")
+ for compNo in range(len(self.components)):
+ if (
+ compNo not in self.staticComponents
+ or compNo + 1 not in self.staticComponents
+ ):
+ continue
+ self.staticComponents[compNo + 1] = Image.alpha_composite(
+ self.staticComponents.pop(compNo),
+ self.staticComponents[compNo + 1],
+ )
+ self.staticComponents[compNo] = None
+
+ mergeConsecutiveStaticComponentFrames(self)
+
+ def frameRender(self, audioI):
+ """
+ Renders a frame composited together from the frames returned by each component
+ audioI is a multiple of self.sampleSize, which can be divided to determine frameNo
+ """
+
+ 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))
+
+ bgI = int(audioI / self.sampleSize)
+ frame = None
+ for layerNo, comp in enumerate(reversed((self.components))):
+ if self.canceled:
+ break
+ try:
+ if layerNo in self.staticComponents:
+ if self.staticComponents[layerNo] is None:
+ # this layer was merged into a following layer
+ continue
+ # static component
+ if frame is None: # bottom-most layer
+ frame = self.staticComponents[layerNo]
+ else:
+ frame = Image.alpha_composite(
+ frame, self.staticComponents[layerNo]
+ )
+
+ else:
+ # animated component
+ if frame is None: # bottom-most layer
+ frame = comp.frameRender(bgI)
+ else:
+ frame = Image.alpha_composite(frame, comp.frameRender(bgI))
+ except Exception as e:
+ err()
+ return frame
+
+ def showPreview(self, frame):
+ """
+ Receives a final frame that will be piped to FFmpeg,
+ adds it to the MainWindow for the live preview
+ """
+ # We must store a reference to this QImage
+ # or else Qt will garbage-collect it on the C++ side
+ self.latestPreview = ImageQt(frame)
+ self.imageCreated.emit(QtGui.QImage(self.latestPreview))
+
+ @pyqtSlot()
+ def createVideo(self):
+ """
+ 1. Numpy is set to ignore division errors during this method
+ 2. Determine length of final video
+ 3. Call preFrameRender on each component
+ 4. Create the main FFmpeg command
+ 5. Open the out_pipe to FFmpeg process
+ 6. Iterate over the audio data array and call frameRender on the components to get frames
+ 7. Close the out_pipe
+ 8. Call postFrameRender on each component
+ """
+ log.debug("Video worker received signal to createVideo")
+ log.debug("Video thread id: {}".format(int(QtCore.QThread.currentThreadId())))
+ numpy.seterr(divide="ignore")
+ self.encoding.emit(True)
+ self.extraAudio = []
+ self.width = int(self.settings.value("outputWidth"))
+ self.height = int(self.settings.value("outputHeight"))
+
+ # Set core.Core.canceled to False and call .reset() on each component
+ self.reset()
+ # Initialize progress bar to 0
+ progressBarValue = 0
+ self.progressBarUpdate.emit(progressBarValue)
+
+ # Determine longest length of audio which will be the final video's duration
+ log.debug("Determining length of audio...")
+ duration = self.determineAudioLength()
+ if not duration:
+ return
+
+ # Call preFrameRender on each component to perform initialization
+ self.progressBarUpdate.emit(0)
+ self.progressBarSetText.emit("Starting components...")
+ self.preFrameRender()
+ if self.canceled:
+ return
+
+ # Create FFmpeg command
+ ffmpegCommand = self.createFfmpegCommand(duration)
+ if not ffmpegCommand:
+ return
+ cmd = " ".join(ffmpegCommand)
+ print("###### FFMPEG COMMAND ######\n%s" % cmd)
+ print("############################")
+ log.info(cmd)
+
+ # Open pipe to FFmpeg
+ log.info("Opening pipe to FFmpeg")
+ try:
+ self.out_pipe = openPipe(
+ ffmpegCommand,
+ stdin=sp.PIPE,
+ stdout=sys.stdout,
+ stderr=sys.stdout,
+ )
+ except sp.CalledProcessError:
+ 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!
+ self.progressBarSetText.emit("Exporting video...")
+ for audioI in range(0, self.audioArrayLen, self.sampleSize):
+ if self.canceled:
+ break
+ # fetch the next frame & add to the FFmpeg pipe
+ frame = self.frameRender(audioI)
+
+ # Update live preview
+ if self.previewEnabled:
+ self.showPreview(frame)
+
+ try:
+ self.out_pipe.stdin.write(frame.tobytes())
+ except Exception:
+ break
+
+ # increase progress bar value
+ completion = (audioI / self.audioArrayLen) * 100
+ if progressBarValue + 1 <= completion:
+ progressBarValue = numpy.floor(completion).astype(int)
+ self.progressBarUpdate.emit(progressBarValue)
+ self.progressBarSetText.emit(
+ "Exporting video: %s%%" % str(int(progressBarValue))
+ )
+
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+ # Finished creating the video!
+ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
+
+ numpy.seterr(all="print")
+
+ self.closePipe()
+
+ for comp in reversed(self.components):
+ comp.postFrameRender()
+
+ if self.canceled:
+ print("Export Canceled")
+ try:
+ os.remove(self.outputFile)
+ except Exception:
+ pass
+ self.progressBarUpdate.emit(0)
+ self.progressBarSetText.emit("Export Canceled")
+ else:
+ if self.error:
+ self.failExport()
+ else:
+ print("Export Complete")
+ self.progressBarUpdate.emit(100)
+ self.progressBarSetText.emit("Export Complete")
+
+ self.error = False
+ self.canceled = False
+ self.encoding.emit(False)
+ self.videoCreated.emit()
+
+ def closePipe(self):
+ try:
+ self.out_pipe.stdin.close()
+ except (BrokenPipeError, OSError):
+ log.debug("Broken pipe to FFmpeg!")
+ if self.out_pipe.stderr is not None:
+ log.error(self.out_pipe.stderr.read())
+ self.out_pipe.stderr.close()
+ self.error = True
+ self.out_pipe.wait()
+
+ def cancelExport(self, message="Export Canceled"):
+ self.progressBarUpdate.emit(0)
+ self.progressBarSetText.emit(message)
+ self.encoding.emit(False)
+ self.videoCreated.emit()
+
+ def failExport(self):
+ self.cancelExport("Export Failed")
+
+ def updateProgress(self, pStr, pVal):
+ self.progressBarValue.emit(pVal)
+ self.progressBarSetText.emit(pStr)
+
+ def cancel(self):
+ self.canceled = True
+ self.core.cancel()
+
+ for comp in self.components:
+ comp.cancel()
+
+ try:
+ self.out_pipe.send_signal(signal.SIGTERM)
+ except Exception:
+ pass
+
+ def reset(self):
+ self.core.reset()
+ self.canceled = False
+ for comp in self.components:
+ comp.reset()
diff --git a/src/command.py b/src/command.py
deleted file mode 100644
index 783ac26..0000000
--- a/src/command.py
+++ /dev/null
@@ -1,316 +0,0 @@
-"""
-When using commandline mode, this module's object handles interpreting
-the arguments and giving them to Core, which tracks the main program state.
-Then it immediately exports a video.
-"""
-
-from PyQt6 import QtCore
-import argparse
-import os
-import sys
-import time
-import glob
-import signal
-import shutil
-import logging
-
-from . import core
-
-
-log = logging.getLogger("AVP.Commandline")
-
-
-class Command(QtCore.QObject):
- """
- This replaces the GUI MainWindow when in commandline mode.
- """
-
- createVideo = QtCore.pyqtSignal()
-
- def __init__(self):
- super().__init__()
- self.core = core.Core()
- core.Core.mode = "commandline"
- self.dataDir = self.core.dataDir
- self.canceled = False
- self.settings = core.Core.settings
-
- # ctrl-c stops the export thread
- signal.signal(signal.SIGINT, self.stopVideo)
-
- def parseArgs(self):
- parser = argparse.ArgumentParser(
- prog="avp" if os.path.basename(sys.argv[0]) == "__main__.py" else None,
- description="Create a visualization for an audio file",
- epilog="EXAMPLE COMMAND: avp myvideotemplate "
- "-i ~/Music/song.mp3 -o ~/video.mp4 "
- "-c 0 image path=~/Pictures/thisWeeksPicture.jpg "
- '-c 1 video "preset=My Logo" -c 2 vis layout=classic',
- )
-
- # input/output automatic-export commands
- parser.add_argument("-i", "--input", metavar="SOUND", help="input audio file")
- parser.add_argument(
- "-o", "--output", metavar="OUTPUT", help="output video file"
- )
- parser.add_argument(
- "--export-project",
- action="store_true",
- help="use input and output files from project file if -i or -o is missing",
- )
-
- # mutually exclusive debug options
- debugCommands = parser.add_mutually_exclusive_group()
- debugCommands.add_argument(
- "--test",
- action="store_true",
- help="run tests and create a report full of debugging info",
- )
- debugCommands.add_argument(
- "--debug",
- action="store_true",
- help="create bigger logfiles while program is running",
- )
-
- # project/GUI options
- parser.add_argument(
- "projpath",
- metavar="path-to-project",
- help="open a project file (.avp)",
- nargs="?",
- )
- parser.add_argument(
- "-c",
- "--comp",
- metavar=("LAYER", "ARG"),
- help="first arg must be component NAME to insert at LAYER."
- '"help" for information about possible args for a component.',
- nargs="*",
- action="append",
- )
- parser.add_argument(
- "--no-preview",
- action="store_true",
- help="disable live preview during export",
- )
-
- args = parser.parse_args()
-
- if args.debug:
- core.FILE_LOGLVL = logging.DEBUG
- core.STDOUT_LOGLVL = logging.DEBUG
- core.Core.makeLogger()
-
- if args.test:
- self.runTests()
- quit(0)
-
- if args.projpath:
- projPath = args.projpath
- if not os.path.dirname(projPath):
- projPath = os.path.join(self.settings.value("projectDir"), projPath)
- if not projPath.endswith(".avp"):
- projPath += ".avp"
- self.core.openProject(self, projPath)
- self.core.selectedComponents = list(reversed(self.core.selectedComponents))
- self.core.componentListChanged()
-
- if args.comp:
- for comp in args.comp:
- pos = comp[0]
- name = comp[1]
- compargs = comp[2:]
- try:
- pos = int(pos)
- except ValueError:
- print(pos, "is not a layer number.")
- quit(1)
- realName = self.parseCompName(name)
- if not realName:
- print(name, "is not a valid component name.")
- quit(1)
- modI = self.core.moduleIndexFor(realName)
- i = self.core.insertComponent(pos, modI, self)
- if i is None:
- print(name, "could not be initialized.")
- quit(1)
- for arg in compargs:
- self.core.selectedComponents[i].command(arg)
-
- if args.export_project and args.projpath:
- errcode, data = self.core.parseAvFile(projPath)
- input_ = None
- output = None
- for key, value in data["WindowFields"]:
- if "outputFile" in key:
- output = value
- if output and not os.path.dirname(value):
- output = os.path.join(os.path.expanduser("~"), output)
- if "audioFile" in key:
- input_ = value
-
- # use input/output from project file, overwritten by -i and -o
- if (not input_ and not args.input) or (not output and not args.output):
- parser.print_help()
- quit(1)
-
- self.createAudioVisualization(
- input_ if not args.input else args.input,
- output if not args.output else args.output,
- )
- return "commandline"
-
- elif args.input and args.output:
- self.createAudioVisualization(args.input, args.output)
- return "commandline"
-
- elif args.no_preview:
- core.Core.previewEnabled = False
-
- elif (
- args.projpath is None
- and "help" not in sys.argv
- and "--debug" not in sys.argv
- and "--test" not in sys.argv
- ):
- parser.print_help()
- quit(1)
-
- return "GUI"
-
- def createAudioVisualization(self, input, output):
- if not self.core.selectedComponents:
- print("No components selected. Adding a default visualizer.")
- time.sleep(1)
- self.core.insertComponent(0, 0, self)
- self.core.selectedComponents = list(reversed(self.core.selectedComponents))
- self.core.componentListChanged()
- self.worker = self.core.newVideoWorker(self, input, output)
- # quit(0) after video is created
- self.worker.videoCreated.connect(self.videoCreated)
- self.lastProgressUpdate = time.time()
- self.worker.progressBarSetText.connect(self.progressBarSetText)
- self.createVideo.emit()
-
- def stopVideo(self, *args):
- self.worker.error = True
- self.worker.cancelExport()
- self.worker.cancel()
-
- @QtCore.pyqtSlot(str)
- def progressBarSetText(self, value):
- if "Export " in value:
- # Don't duplicate completion/failure messages
- return
- if (
- not value.startswith("Exporting")
- and time.time() - self.lastProgressUpdate >= 0.05
- ):
- # 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
- self.lastProgressUpdate = time.time()
-
- @QtCore.pyqtSlot()
- def videoCreated(self):
- self.quit(0)
-
- def quit(self, code):
- quit(code)
-
- def showMessage(self, **kwargs):
- print(kwargs["msg"])
- if "detail" in kwargs:
- print(kwargs["detail"])
-
- @QtCore.pyqtSlot(str, str)
- def videoThreadError(self, msg, detail):
- print(msg)
- print(detail)
- quit(1)
-
- def drawPreview(self, *args):
- pass
-
- 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
-
- compFileNames = [
- os.path.splitext(os.path.basename(mod.__file__))[0]
- for mod in self.core.modules
- ]
- for i, compFileName in enumerate(compFileNames):
- if name.lower() in compFileName:
- return self.core.compNames[i]
- return
-
- return None
-
- def runTests(self):
- from . import tests
-
- test_report = os.path.join(core.Core.logDir, "test_report.log")
- tests.run(test_report)
-
- # Choose a numbered location to put the output file
- logNumber = 0
-
- def getFilename():
- """Get a numbered filename for the final test report"""
- nonlocal logNumber
- name = os.path.join(os.path.expanduser("~"), "avp_test_report")
- while True:
- possibleName = f"{name}{logNumber:0>2}.txt"
- if os.path.exists(possibleName) and logNumber < 100:
- logNumber += 1
- continue
- break
- return possibleName
-
- # Copy latest debug log to chosen test report location
- filename = getFilename()
- if logNumber == 100:
- print("Test Report could not be created.")
- return
- try:
- shutil.copy(os.path.join(core.Core.logDir, "avp_debug.log"), filename)
- with open(filename, "a") as f:
- f.write(f"{'='*60} debug log ends {'='*60}\n")
- except FileNotFoundError:
- 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))
- with open(filename, "a") as fw:
- for renderLog in renderLogs:
- with open(renderLog, "r") as fr:
- fw.write(f"{'='*60} {os.path.basename(renderLog)} {'='*60}\n")
- logContents = fr.readlines()
- fw.write("".join(logContents[:5]))
- fw.write("...trimmed...\n")
- fw.write("".join(logContents[-10:]))
- fw.write(f"{'='*60} {os.path.basename(renderLog)} {'='*60}\n")
-
- concatenateLogs("render_*.log")
- concatenateLogs("preview_*.log")
-
- # Append actual test report to debug log
- with open(test_report, "r") as f:
- output = f.readlines()
- test_output = "".join(output)
- print(test_output)
- with open(filename, "a") as f:
- f.write(test_output)
- print(f"Test Report created at {filename}")
diff --git a/src/component.py b/src/component.py
deleted file mode 100644
index 01d4e44..0000000
--- a/src/component.py
+++ /dev/null
@@ -1,967 +0,0 @@
-"""
-Base classes for components to import. Read comments for some documentation
-on making a valid component.
-"""
-
-from PyQt6 import uic, QtCore, QtWidgets
-from PyQt6.QtGui import QColor, QUndoCommand
-import os
-import sys
-import math
-import time
-import logging
-from copy import copy
-
-from .toolkit.frame import BlankFrame
-from .toolkit import (
- getWidgetValue,
- setWidgetValue,
- connectWidget,
- rgbFromString,
- blockSignals,
-)
-
-
-log = logging.getLogger("AVP.ComponentHandler")
-
-
-class ComponentMetaclass(type(QtCore.QObject)):
- """
- Checks the validity of each Component class and mutates some attrs.
- E.g., takes only major version from version string & decorates methods
- """
-
- def initializationWrapper(func):
- def initializationWrapper(self, *args, **kwargs):
- try:
- return func(self, *args, **kwargs)
- except Exception:
- try:
- raise ComponentError(self, "initialization process")
- except ComponentError:
- return
-
- return initializationWrapper
-
- def renderWrapper(func):
- def renderWrapper(self, *args, **kwargs):
- try:
- log.verbose(
- "### %s #%s renders a preview frame ###",
- self.__class__.name,
- str(self.compPos),
- )
- return func(self, *args, **kwargs)
- except Exception as e:
- try:
- if e.__class__.__name__.startswith("Component"):
- raise
- else:
- raise ComponentError(self, "renderer")
- except ComponentError:
- return BlankFrame()
-
- return renderWrapper
-
- def commandWrapper(func):
- """Intercepts the command() method to check for global args"""
-
- def commandWrapper(self, arg):
- if arg.startswith("preset="):
- _, preset = arg.split("=", 1)
- path = os.path.join(self.core.getPresetDir(self), preset)
- if not os.path.exists(path):
- print('Couldn\'t locate preset "%s"' % preset)
- quit(1)
- else:
- print('Opening "%s" preset on layer %s' % (preset, self.compPos))
- self.core.openPreset(path, self.compPos, preset)
- # Don't call the component's command() method
- return
- else:
- return func(self, arg)
-
- return commandWrapper
-
- def propertiesWrapper(func):
- """Intercepts the usual properties if the properties are locked."""
-
- def propertiesWrapper(self):
- if self._lockedProperties is not None:
- return self._lockedProperties
- else:
- try:
- return func(self)
- except Exception:
- try:
- raise ComponentError(self, "properties")
- except ComponentError:
- return []
-
- return propertiesWrapper
-
- def errorWrapper(func):
- """Intercepts the usual error message if it is locked."""
-
- def errorWrapper(self):
- if self._lockedError is not None:
- return self._lockedError
- else:
- return func(self)
-
- return errorWrapper
-
- def loadPresetWrapper(func):
- """Wraps loadPreset to handle the self.openingPreset boolean"""
-
- class openingPreset:
- def __init__(self, comp):
- self.comp = comp
-
- def __enter__(self):
- self.comp.openingPreset = True
-
- def __exit__(self, *args):
- self.comp.openingPreset = False
-
- def presetWrapper(self, *args):
- with openingPreset(self):
- try:
- return func(self, *args)
- except Exception:
- try:
- raise ComponentError(self, "preset loader")
- except ComponentError:
- return
-
- return presetWrapper
-
- def updateWrapper(func):
- """
- Calls _preUpdate before every subclass update().
- Afterwards, for non-user updates, calls _autoUpdate().
- For undoable updates triggered by the user, calls _userUpdate()
- """
-
- class wrap:
- def __init__(self, comp, auto):
- self.comp = comp
- self.auto = auto
-
- def __enter__(self):
- self.comp._preUpdate()
-
- def __exit__(self, *args):
- if (
- self.auto
- or self.comp.openingPreset
- or not hasattr(self.comp.parent, "undoStack")
- ):
- log.verbose("Automatic update")
- self.comp._autoUpdate()
- else:
- log.verbose("User update")
- self.comp._userUpdate()
-
- def updateWrapper(self, **kwargs):
- auto = kwargs["auto"] if "auto" in kwargs else False
- with wrap(self, auto):
- try:
- return func(self)
- except Exception:
- try:
- raise ComponentError(self, "update method")
- except ComponentError:
- return
-
- return updateWrapper
-
- def widgetWrapper(func):
- """Connects all widgets to update method after the subclass's method"""
-
- class wrap:
- def __init__(self, comp):
- self.comp = comp
-
- def __enter__(self):
- pass
-
- def __exit__(self, *args):
- for widgetList in self.comp._allWidgets.values():
- for widget in widgetList:
- log.verbose("Connecting %s", str(widget.__class__.__name__))
- connectWidget(widget, self.comp.update)
-
- def widgetWrapper(self, *args, **kwargs):
- auto = kwargs["auto"] if "auto" in kwargs else False
- with wrap(self):
- try:
- return func(self, *args, **kwargs)
- except Exception:
- try:
- raise ComponentError(self, "widget creation")
- except ComponentError:
- return
-
- return widgetWrapper
-
- def __new__(cls, name, parents, attrs):
- if "ui" not in attrs:
- # Use module name as ui filename by default
- attrs["ui"] = (
- "%s.ui" % os.path.splitext(attrs["__module__"].split(".")[-1])[0]
- )
-
- decorate = (
- "names", # Class methods
- "error",
- "audio",
- "properties", # Properties
- "preFrameRender",
- "previewRender",
- "loadPreset",
- "command",
- "update",
- "widget",
- )
-
- # Auto-decorate methods
- for key in decorate:
- if key not in attrs:
- continue
- if key in ("names"):
- attrs[key] = classmethod(attrs[key])
- elif key in ("audio"):
- attrs[key] = property(attrs[key])
- elif key == "command":
- attrs[key] = cls.commandWrapper(attrs[key])
- elif key == "previewRender":
- attrs[key] = cls.renderWrapper(attrs[key])
- elif key == "preFrameRender":
- attrs[key] = cls.initializationWrapper(attrs[key])
- elif key == "properties":
- attrs[key] = cls.propertiesWrapper(attrs[key])
- elif key == "error":
- attrs[key] = cls.errorWrapper(attrs[key])
- elif key == "loadPreset":
- attrs[key] = cls.loadPresetWrapper(attrs[key])
- elif key == "update":
- attrs[key] = cls.updateWrapper(attrs[key])
- elif key == "widget" and parents[0] != QtCore.QObject:
- attrs[key] = cls.widgetWrapper(attrs[key])
-
- # Turn version string into a number
- try:
- if "version" not in attrs:
- log.error(
- "No version attribute in %s. Defaulting to 1",
- attrs["name"],
- )
- attrs["version"] = 1
- else:
- attrs["version"] = int(attrs["version"].split(".")[0])
- except ValueError:
- log.critical(
- "%s component has an invalid version string:\n%s",
- attrs["name"],
- str(attrs["version"]),
- )
- except KeyError:
- log.critical("%s component has no version string.", attrs["name"])
- else:
- return super().__new__(cls, name, parents, attrs)
- quit(1)
-
-
-class Component(QtCore.QObject, metaclass=ComponentMetaclass):
- """
- The base class for components to inherit.
- """
-
- name = "Component"
- # ui = 'name_Of_Non_Default_Ui_File'
-
- version = "1.0.0"
- # The major version (before the first dot) is used to determine
- # preset compatibility; the rest is ignored so it can be non-numeric.
-
- modified = QtCore.pyqtSignal(int, dict)
- _error = QtCore.pyqtSignal(str, str)
-
- def __init__(self, moduleIndex, compPos, core):
- super().__init__()
- self.moduleIndex = moduleIndex
- self.compPos = compPos
- self.core = core
-
- # STATUS VARIABLES
- self.currentPreset = None
- self._allWidgets = {}
- self._trackedWidgets = {}
- self._presetNames = {}
- self._commandArgs = {}
- self._colorWidgets = {}
- self._colorFuncs = {}
- self._relativeWidgets = {}
- # Pixel values stored as floats
- self._relativeValues = {}
- # Maximum values of spinBoxes at 1080p (Core.resolutions[0])
- self._relativeMaximums = {}
-
- # LOCKING VARIABLES
- self.openingPreset = False
- self.mergeUndo = True
- self._lockedProperties = None
- self._lockedError = None
- self._lockedSize = None
- # If set to a dict, values are used as basis to update relative widgets
- self.oldAttrs = None
- # Stop lengthy processes in response to this variable
- self.canceled = False
-
- def __str__(self):
- return self.__class__.name
-
- def __repr__(self):
- import pprint
-
- try:
- preset = self.savePreset()
- except Exception as e:
- preset = "%s occurred while saving preset" % str(e)
-
- return "Component(module %s, pos %s) (%s)\n" "Name: %s v%s\nPreset: %s" % (
- self.moduleIndex,
- self.compPos,
- object.__repr__(self),
- self.__class__.name,
- str(self.__class__.version),
- pprint.pformat(preset),
- )
-
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
- # Render Methods
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-
- def previewRender(self):
- image = BlankFrame(self.width, self.height)
- return image
-
- def preFrameRender(self, **kwargs):
- """
- Must call super() when subclassing
- Triggered only before a video is exported (video_thread.py)
- self.audioFile = filepath to the main input audio file
- self.completeAudioArray = a list of audio samples
- self.sampleSize = number of audio samples per video frame
- self.progressBarUpdate = signal to set progress bar number
- self.progressBarSetText = signal to set progress bar text
- Use the latter two signals to update the MainWindow if needed
- for a long initialization procedure (i.e., for a visualizer)
- """
- for key, value in kwargs.items():
- setattr(self, key, value)
-
- def frameRender(self, frameNo):
- audioArrayIndex = frameNo * self.sampleSize
- image = BlankFrame(self.width, self.height)
- return image
-
- def postFrameRender(self):
- pass
-
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
- # Properties
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-
- def properties(self):
- """
- Return a list of properties to signify if your component is
- non-animated ('static'), returns sound ('audio'), or has
- encountered an error in configuration ('error').
- """
- return []
-
- def error(self):
- """
- Return a string containing an error message, or None for a default.
- Or tuple of two strings for a message with details.
- Alternatively use lockError(msgString) within properties()
- to skip this method entirely.
- """
- return
-
- def audio(self):
- """
- Return audio to mix into master as a tuple with two elements:
- The first element can be:
- - A string (path to audio file),
- - Or an object that returns audio data through a pipe
- The second element must be a dictionary of ffmpeg filters/options
- to apply to the input stream. See the filter docs for ideas:
- https://ffmpeg.org/ffmpeg-filters.html
- """
-
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
- # Idle Methods
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-
- def widget(self, parent):
- """
- Call super().widget(*args) to create the component widget
- which also auto-connects any common widgets (e.g., checkBoxes)
- to self.update(). Then in a subclass connect special actions
- (e.g., pushButtons to select a file) and initialize
- """
- self.parent = parent
- self.settings = parent.settings
- log.verbose(
- "Creating UI for %s #%s's widget",
- self.__class__.name,
- self.compPos,
- )
- self.page = self.loadUi(self.__class__.ui)
-
- # Find all normal widgets which will be connected after subclass method
- self._allWidgets = {
- "lineEdit": self.page.findChildren(QtWidgets.QLineEdit),
- "checkBox": self.page.findChildren(QtWidgets.QCheckBox),
- "spinBox": self.page.findChildren(QtWidgets.QSpinBox),
- "comboBox": self.page.findChildren(QtWidgets.QComboBox),
- }
- self._allWidgets["spinBox"].extend(
- self.page.findChildren(QtWidgets.QDoubleSpinBox)
- )
-
- def update(self):
- """
- Starting point for a component update. A subclass should override
- this method, and the base class will then magically insert a call
- to either _autoUpdate() or _userUpdate() at the end.
- """
-
- def loadPreset(self, presetDict, presetName=None):
- """
- Subclasses should take (presetDict, *args) as args.
- Must use super().loadPreset(presetDict, *args) first,
- then update self.page widgets using the preset dict.
- """
- self.currentPreset = (
- presetName if presetName is not None else presetDict["preset"]
- )
- for attr, widget in self._trackedWidgets.items():
- key = attr if attr not in self._presetNames else self._presetNames[attr]
- try:
- val = presetDict[key]
- except KeyError as e:
- log.info(
- "%s missing value %s. Outdated preset?",
- self.currentPreset,
- str(e),
- )
- val = getattr(self, key)
-
- if attr in self._colorWidgets:
- widget.setText("%s,%s,%s" % val)
- btnStyle = (
- "QPushButton { background-color : %s; outline: none; }"
- % QColor(*val).name()
- )
- self._colorWidgets[attr].setStyleSheet(btnStyle)
- elif attr in self._relativeWidgets:
- self._relativeValues[attr] = val
- pixelVal = self.pixelValForAttr(attr, val)
- setWidgetValue(widget, pixelVal)
- else:
- setWidgetValue(widget, val)
-
- def savePreset(self):
- saveValueStore = {}
- for attr, widget in self._trackedWidgets.items():
- presetAttrName = (
- attr if attr not in self._presetNames else self._presetNames[attr]
- )
- if attr in self._relativeWidgets:
- try:
- val = self._relativeValues[attr]
- except AttributeError:
- val = self.floatValForAttr(attr)
- else:
- val = getattr(self, attr)
-
- saveValueStore[presetAttrName] = val
- return saveValueStore
-
- def commandHelp(self):
- """Help text as string for this component's commandline arguments"""
-
- def command(self, arg=""):
- """
- Configure a component using an arg from the commandline. This is
- never called if global args like 'preset=' are found in the arg.
- So simply check for any non-global args in your component and
- call super().command() at the end to get a Help message.
- """
- print(
- self.__class__.name,
- "Usage:\n" "Open a preset for this component:\n" ' "preset=Preset Name"',
- )
- self.commandHelp()
- quit(0)
-
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
- # "Private" Methods
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
- def _preUpdate(self):
- """Happens before subclass update()"""
- for attr in self._relativeWidgets:
- self.updateRelativeWidget(attr)
-
- def _userUpdate(self):
- """Happens after subclass update() for an undoable update by user."""
- oldWidgetVals = {
- attr: copy(getattr(self, attr)) for attr in self._trackedWidgets
- }
- newWidgetVals = {
- attr: (
- getWidgetValue(widget)
- if attr not in self._colorWidgets
- else rgbFromString(widget.text())
- )
- for attr, widget in self._trackedWidgets.items()
- }
- modifiedWidgets = {
- attr: val
- for attr, val in newWidgetVals.items()
- if val != oldWidgetVals[attr]
- }
- if modifiedWidgets:
- action = ComponentUpdate(self, oldWidgetVals, modifiedWidgets)
- self.parent.undoStack.push(action)
-
- def _autoUpdate(self):
- """Happens after subclass update() for an internal component update."""
- newWidgetVals = {
- attr: getWidgetValue(widget)
- for attr, widget in self._trackedWidgets.items()
- }
- self.setAttrs(newWidgetVals)
- self._sendUpdateSignal()
-
- def setAttrs(self, attrDict):
- """
- Sets attrs (linked to trackedWidgets) in this component to
- the values in the attrDict. Mutates certain widget values if needed
- """
- for attr, val in attrDict.items():
- if attr in self._colorWidgets:
- # Color Widgets must have a tuple & have a button to update
- if type(val) is tuple:
- rgbTuple = val
- else:
- rgbTuple = rgbFromString(val)
- btnStyle = (
- "QPushButton { background-color : %s; outline: none; }"
- % QColor(*rgbTuple).name()
- )
- self._colorWidgets[attr].setStyleSheet(btnStyle)
- setattr(self, attr, rgbTuple)
-
- else:
- # Normal tracked widget
- setattr(self, attr, val)
- log.verbose("Setting %s self.%s to %s" % (self.__class__.name, attr, val))
-
- def setWidgetValues(self, attrDict):
- """
- Sets widgets defined by keys in trackedWidgets in this preset to
- the values in the attrDict.
- """
- affectedWidgets = [self._trackedWidgets[attr] for attr in attrDict]
- with blockSignals(affectedWidgets):
- for attr, val in attrDict.items():
- widget = self._trackedWidgets[attr]
- if attr in self._colorWidgets:
- val = "%s,%s,%s" % val
- setWidgetValue(widget, val)
-
- def _sendUpdateSignal(self):
- if not self.core.openingProject:
- self.parent.drawPreview()
- saveValueStore = self.savePreset()
- saveValueStore["preset"] = self.currentPreset
- self.modified.emit(self.compPos, saveValueStore)
-
- def trackWidgets(self, trackDict, **kwargs):
- """
- Name widgets to track in update(), savePreset(), loadPreset(), and
- command(). Requires a dict of attr names as keys, widgets as values
-
- Optional args:
- 'presetNames': preset variable names to replace attr names
- 'commandArgs': arg keywords that differ from attr names
- 'colorWidgets': identify attr as RGB tuple & update button CSS
- 'relativeWidgets': change value proportionally to resolution
-
- NOTE: Any kwarg key set to None will selectively disable tracking.
- """
- self._trackedWidgets = trackDict
- for kwarg in kwargs:
- try:
- if kwarg in (
- "presetNames",
- "commandArgs",
- "colorWidgets",
- "relativeWidgets",
- ):
- setattr(self, "_{}".format(kwarg), kwargs[kwarg])
- else:
- raise ComponentError(self, "Nonsensical keywords to trackWidgets.")
- except ComponentError:
- continue
-
- if kwarg == "colorWidgets":
-
- def makeColorFunc(attr):
- def pickColor_():
- self.mergeUndo = False
- self.pickColor(
- self._trackedWidgets[attr],
- self._colorWidgets[attr],
- )
- self.mergeUndo = True
-
- return pickColor_
-
- self._colorFuncs = {attr: makeColorFunc(attr) for attr in kwargs[kwarg]}
- for attr, func in self._colorFuncs.items():
- self._colorWidgets[attr].clicked.connect(func)
- self._colorWidgets[attr].setStyleSheet(
- "QPushButton {" "background-color : #FFFFFF; outline: none; }"
- )
-
- if kwarg == "relativeWidgets":
- # store maximum values of spinBoxes to be scaled appropriately
- for attr in kwargs[kwarg]:
- self._relativeMaximums[attr] = self._trackedWidgets[attr].maximum()
- self.updateRelativeWidgetMaximum(attr)
- setattr(self, attr, getWidgetValue(self._trackedWidgets[attr]))
-
- self._preUpdate()
- self._autoUpdate()
-
- def pickColor(self, textWidget, button):
- """Use color picker to get color input from the user."""
- dialog = QtWidgets.QColorDialog()
- # TODO alpha channel is not actually shown in most color picker widgets?
- dialog.setOption(
- QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel, True
- )
- color = dialog.getColor()
- if color.isValid():
- RGBstring = "%s,%s,%s" % (
- str(color.red()),
- str(color.green()),
- str(color.blue()),
- )
- btnStyle = (
- "QPushButton{background-color: %s; outline: none;}" % color.name()
- )
- textWidget.setText(RGBstring)
- button.setStyleSheet(btnStyle)
-
- def lockProperties(self, propList):
- self._lockedProperties = propList
-
- def lockError(self, msg):
- self._lockedError = msg
-
- def lockSize(self, w, h):
- self._lockedSize = (w, h)
-
- def unlockProperties(self):
- self._lockedProperties = None
-
- def unlockError(self):
- self._lockedError = None
-
- def unlockSize(self):
- self._lockedSize = None
-
- def loadUi(self, filename):
- """Load a Qt Designer ui file to use for this component's widget"""
- return uic.loadUi(os.path.join(self.core.componentsPath, filename))
-
- @property
- def width(self):
- if self._lockedSize is None:
- return int(self.settings.value("outputWidth"))
- else:
- return self._lockedSize[0]
-
- @property
- def height(self):
- if self._lockedSize is None:
- return int(self.settings.value("outputHeight"))
- else:
- return self._lockedSize[1]
-
- def cancel(self):
- """Stop any lengthy process in response to this variable."""
- self.canceled = True
-
- def reset(self):
- self.canceled = False
- self.unlockProperties()
- self.unlockError()
-
- def relativeWidgetAxis(func):
- def relativeWidgetAxis(self, attr, *args, **kwargs):
- hasVerticalWords = (
- lambda attr: "height" in attr.lower()
- or "ypos" in attr.lower()
- or attr == "y"
- )
- if "axis" not in kwargs:
- axis = self.width
- if hasVerticalWords(attr):
- axis = self.height
- kwargs["axis"] = axis
- if "axis" in kwargs and type(kwargs["axis"]) is tuple:
- axis = kwargs["axis"][0]
- if hasVerticalWords(attr):
- axis = kwargs["axis"][1]
- kwargs["axis"] = axis
- return func(self, attr, *args, **kwargs)
-
- return relativeWidgetAxis
-
- @relativeWidgetAxis
- def pixelValForAttr(self, attr, val=None, **kwargs):
- if val is None:
- val = self._relativeValues[attr]
- if val > 50.0:
- log.warning(
- "%s #%s attempted to set %s to dangerously high number %s",
- self.__class__.name,
- self.compPos,
- attr,
- val,
- )
- val = 50.0
- result = math.ceil(kwargs["axis"] * val)
- log.verbose(
- "Converting %s: f%s to px%s using axis %s",
- attr,
- val,
- result,
- kwargs["axis"],
- )
- return result
-
- @relativeWidgetAxis
- def floatValForAttr(self, attr, val=None, **kwargs):
- if val is None:
- val = self._trackedWidgets[attr].value()
- return val / kwargs["axis"]
-
- def setRelativeWidget(self, attr, floatVal):
- """Set a relative widget using a float"""
- pixelVal = self.pixelValForAttr(attr, floatVal)
- with blockSignals(self._trackedWidgets[attr]):
- self._trackedWidgets[attr].setValue(pixelVal)
- self.update(auto=True)
-
- def getOldAttr(self, attr):
- """
- Returns previous state of this attr. Used to determine whether
- a relative widget must be updated. Required because undoing/redoing
- can make determining the 'previous' value tricky.
- """
- if self.oldAttrs is not None:
- return self.oldAttrs[attr]
- else:
- try:
- return getattr(self, attr)
- except AttributeError:
- log.error("Using visible values instead of oldAttrs")
- return self._trackedWidgets[attr].value()
-
- def updateRelativeWidget(self, attr):
- """Called by _preUpdate() for each relativeWidget before each update"""
- oldUserValue = self.getOldAttr(attr)
- newUserValue = self._trackedWidgets[attr].value()
- newRelativeVal = self.floatValForAttr(attr, newUserValue)
-
- if attr in self._relativeValues:
- oldRelativeVal = self._relativeValues[attr]
- if oldUserValue == newUserValue and oldRelativeVal != newRelativeVal:
- # Float changed without pixel value changing, which
- # means the pixel value needs to be updated
- log.debug(
- "Updating %s #%s's relative widget: %s",
- self.__class__.name,
- self.compPos,
- attr,
- )
- with blockSignals(self._trackedWidgets[attr]):
- self.updateRelativeWidgetMaximum(attr)
- pixelVal = self.pixelValForAttr(attr, oldRelativeVal)
- self._trackedWidgets[attr].setValue(pixelVal)
-
- if attr not in self._relativeValues or oldUserValue != newUserValue:
- self._relativeValues[attr] = newRelativeVal
-
- def updateRelativeWidgetMaximum(self, attr):
- maxRes = int(self.core.resolutions[0].split("x")[0])
- newMaximumValue = self.width * (self._relativeMaximums[attr] / maxRes)
- self._trackedWidgets[attr].setMaximum(int(newMaximumValue))
-
-
-class ComponentError(RuntimeError):
- """Gives the MainWindow a traceback to display, and cancels the export."""
-
- prevErrors = []
- lastTime = time.time()
-
- def __init__(self, caller, name, msg=None):
- if msg is None and sys.exc_info()[0] is not None:
- msg = str(sys.exc_info()[1])
- else:
- msg = "Unknown error."
- log.error("ComponentError by %s's %s: %s" % (caller.name, name, msg))
-
- # Don't create multiple windows for quickly repeated messages
- if len(ComponentError.prevErrors) > 1:
- ComponentError.prevErrors.pop()
- ComponentError.prevErrors.insert(0, name)
- curTime = time.time()
- if (
- name in ComponentError.prevErrors[1:]
- and curTime - ComponentError.lastTime < 1.0
- ):
- return
- ComponentError.lastTime = time.time()
-
- from .toolkit import formatTraceback
-
- if sys.exc_info()[0] is not None:
- string = "%s component (#%s): %s encountered %s %s: %s" % (
- caller.__class__.name,
- str(caller.compPos),
- name,
- (
- "an"
- if any(
- [
- sys.exc_info()[0].__name__.startswith(vowel)
- for vowel in ("A", "I", "U", "O", "E")
- ]
- )
- else "a"
- ),
- sys.exc_info()[0].__name__,
- str(sys.exc_info()[1]),
- )
- detail = formatTraceback(sys.exc_info()[2])
- else:
- string = name
- detail = "Attributes:\n%s" % (
- "\n".join([m for m in dir(caller) if not m.startswith("_")])
- )
-
- super().__init__(string)
- caller.lockError(string)
- caller._error.emit(string, detail)
-
-
-class ComponentUpdate(QUndoCommand):
- """Command object for making a component action undoable"""
-
- def __init__(self, parent, oldWidgetVals, modifiedVals):
- super().__init__("change %s component #%s" % (parent.name, parent.compPos))
- self.undone = False
- self.res = (int(parent.width), int(parent.height))
- self.parent = parent
- self.oldWidgetVals = {
- attr: (
- copy(val)
- if attr not in self.parent._relativeWidgets
- else self.parent.floatValForAttr(attr, val, axis=self.res)
- )
- for attr, val in oldWidgetVals.items()
- if attr in modifiedVals
- }
- self.modifiedVals = {
- attr: (
- val
- if attr not in self.parent._relativeWidgets
- else self.parent.floatValForAttr(attr, val, axis=self.res)
- )
- for attr, val in modifiedVals.items()
- }
-
- # Because relative widgets change themselves every update based on
- # their previous value, we must store ALL their values in case of undo
- self.relativeWidgetValsAfterUndo = {
- attr: copy(getattr(self.parent, attr))
- for attr in self.parent._relativeWidgets
- }
-
- # Determine if this update is mergeable
- self.id_ = -1
- if len(self.modifiedVals) == 1 and self.parent.mergeUndo:
- attr, val = self.modifiedVals.popitem()
- self.id_ = sum([ord(letter) for letter in attr[-14:]])
- self.modifiedVals[attr] = val
- else:
- 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/components/__init__.py b/src/components/__init__.py
deleted file mode 100644
index 8b13789..0000000
--- a/src/components/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/src/components/__template__.ui b/src/components/__template__.ui
deleted file mode 100644
index 301a2b7..0000000
--- a/src/components/__template__.ui
+++ /dev/null
@@ -1,119 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 197
-
-
-
- Form
-
-
- -
-
-
-
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
diff --git a/src/components/color.py b/src/components/color.py
deleted file mode 100644
index 1f32c23..0000000
--- a/src/components/color.py
+++ /dev/null
@@ -1,176 +0,0 @@
-from PyQt6 import QtGui
-import logging
-
-from ..component import Component
-from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor
-
-
-log = logging.getLogger("AVP.Components.Color")
-
-
-class Component(Component):
- name = "Color"
- version = "1.0.0"
-
- def widget(self, *args):
- self.x = 0
- self.y = 0
- super().widget(*args)
-
- # disable color #2 until non-default 'fill' option gets changed
- self.page.lineEdit_color2.setDisabled(True)
- self.page.pushButton_color2.setDisabled(True)
- self.page.spinBox_width.setValue(int(self.settings.value("outputWidth")))
- self.page.spinBox_height.setValue(int(self.settings.value("outputHeight")))
-
- self.fillLabels = [
- "Solid",
- "Linear Gradient",
- "Radial Gradient",
- ]
- for label in self.fillLabels:
- self.page.comboBox_fill.addItem(label)
- self.page.comboBox_fill.setCurrentIndex(0)
-
- self.trackWidgets(
- {
- "x": self.page.spinBox_x,
- "y": self.page.spinBox_y,
- "sizeWidth": self.page.spinBox_width,
- "sizeHeight": self.page.spinBox_height,
- "trans": self.page.checkBox_trans,
- "spread": self.page.comboBox_spread,
- "stretch": self.page.checkBox_stretch,
- "RG_start": self.page.spinBox_radialGradient_start,
- "LG_start": self.page.spinBox_linearGradient_start,
- "RG_end": self.page.spinBox_radialGradient_end,
- "LG_end": self.page.spinBox_linearGradient_end,
- "RG_centre": self.page.spinBox_radialGradient_spread,
- "fillType": self.page.comboBox_fill,
- "color1": self.page.lineEdit_color1,
- "color2": self.page.lineEdit_color2,
- },
- presetNames={
- "sizeWidth": "width",
- "sizeHeight": "height",
- },
- colorWidgets={
- "color1": self.page.pushButton_color1,
- "color2": self.page.pushButton_color2,
- },
- relativeWidgets=[
- "x",
- "y",
- "sizeWidth",
- "sizeHeight",
- "LG_start",
- "LG_end",
- "RG_start",
- "RG_end",
- "RG_centre",
- ],
- )
-
- def update(self):
- fillType = self.page.comboBox_fill.currentIndex()
- if fillType == 0:
- self.page.lineEdit_color2.setEnabled(False)
- self.page.pushButton_color2.setEnabled(False)
- self.page.checkBox_trans.setEnabled(False)
- self.page.checkBox_stretch.setEnabled(False)
- self.page.comboBox_spread.setEnabled(False)
- else:
- self.page.lineEdit_color2.setEnabled(True)
- self.page.pushButton_color2.setEnabled(True)
- self.page.checkBox_trans.setEnabled(True)
- self.page.checkBox_stretch.setEnabled(True)
- self.page.comboBox_spread.setEnabled(True)
- if self.page.checkBox_trans.isChecked():
- self.page.lineEdit_color2.setEnabled(False)
- self.page.pushButton_color2.setEnabled(False)
- self.page.fillWidget.setCurrentIndex(fillType)
-
- def previewRender(self):
- return self.drawFrame(self.width, self.height)
-
- def properties(self):
- return ["static"]
-
- def frameRender(self, frameNo):
- log.debug("Color component is drawing frame #%s", frameNo)
- return self.drawFrame(self.width, self.height)
-
- def drawFrame(self, width, height):
- r, g, b = self.color1
- shapeSize = (self.sizeWidth, self.sizeHeight)
- # in default state, skip all this logic and return a plain fill
- if (
- self.fillType == 0
- and shapeSize == (width, height)
- and self.x == 0
- and self.y == 0
- ):
- return FloodFrame(width, height, (r, g, b, 255))
-
- # Return a solid image at x, y
- if self.fillType == 0:
- frame = BlankFrame(width, height)
- image = FloodFrame(self.sizeWidth, self.sizeHeight, (r, g, b, 255))
- frame.paste(image, box=(self.x, self.y))
- return frame
-
- # Now fills that require using Qt...
- elif self.fillType > 0:
- image = FramePainter(width, height)
-
- if self.stretch:
- w = width
- h = height
- else:
- w = self.sizeWidth
- h = self.sizeWidth
-
- if self.fillType == 1: # Linear Gradient
- brush = QtGui.QLinearGradient(
- float(self.LG_start),
- float(self.LG_start),
- float(self.LG_end + width / 3),
- float(self.LG_end),
- )
-
- elif self.fillType == 2: # Radial Gradient
- brush = QtGui.QRadialGradient(
- float(self.RG_start),
- float(self.RG_end),
- float(w),
- float(h),
- float(self.RG_centre),
- )
- spread = QtGui.QGradient.Spread.PadSpread
- if self.spread == 1:
- spread = QtGui.QGradient.Spread.ReflectSpread
- elif self.spread == 2:
- spread = QtGui.QGradient.Spread.RepeatSpread
- brush.setSpread(spread)
- brush.setColorAt(0.0, PaintColor(*self.color1))
- if self.trans:
- brush.setColorAt(1.0, PaintColor(0, 0, 0, 0))
- elif self.fillType == 1 and self.stretch:
- brush.setColorAt(0.2, PaintColor(*self.color2))
- else:
- brush.setColorAt(1.0, PaintColor(*self.color2))
- image.setBrush(brush)
- image.drawRect(self.x, self.y, self.sizeWidth, self.sizeHeight)
-
- return image.finalize()
-
- def commandHelp(self):
- print("Specify a color:\n color=255,255,255")
-
- def command(self, arg):
- if "=" in arg:
- key, arg = arg.split("=", 1)
- if key == "color":
- self.page.lineEdit_color1.setText(arg)
- return
- super().command(arg)
diff --git a/src/components/color.ui b/src/components/color.ui
deleted file mode 100644
index c1713fb..0000000
--- a/src/components/color.ui
+++ /dev/null
@@ -1,666 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 197
-
-
-
- Form
-
-
- -
-
-
- 4
-
-
-
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 31
- 0
-
-
-
- Color #1
-
-
-
- -
-
-
-
- 32
- 32
-
-
-
-
-
-
-
- 32
- 32
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 1
- 0
-
-
-
- 0,0,0
-
-
- 12
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 31
- 0
-
-
-
- Color #2
-
-
-
- -
-
-
-
- 32
- 32
-
-
-
-
-
-
-
- 32
- 32
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 1
- 0
-
-
-
- 133,133,133
-
-
- 12
-
-
-
-
-
- -
-
-
- 0
-
-
-
-
-
-
- 0
- 0
-
-
-
- Width
-
-
- Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
-
- 0
- 0
-
-
-
- 0
-
-
- 19200
-
-
- 0
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Height
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
- 10800
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- X
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
-
- 0
- 0
-
-
-
- -10000
-
-
- 10000
-
-
- 0
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Y
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
- -10000
-
-
- 10000
-
-
-
-
-
- -
-
-
- 0
-
-
-
-
-
-
- 0
- 0
-
-
-
- Fill
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- -1
-
-
- QComboBox::AdjustToContentsOnFirstShow
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Transparent
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Stretch
-
-
-
- -
-
-
-
-
- Pad
-
-
- -
-
- Reflect
-
-
- -
-
- Repeat
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Minimum
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
- 0
-
-
- 2
-
-
-
-
-
-
- -1
- 0
- 561
- 31
-
-
-
-
-
-
-
-
- 0
- 0
-
-
-
- Start
-
-
-
- -
-
-
- -10000
-
-
- 10000
-
-
- 10
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- End
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- -10000
-
-
- 10000
-
-
- 10
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
-
-
-
-
-
- -1
- -1
- 561
- 31
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Start
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- -10000
-
-
- 10000
-
-
- 10
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- End
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- -10000
-
-
- 10000
-
-
- 10
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Centre
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- QAbstractSpinBox::PlusMinus
-
-
- -10000
-
-
- 10000
-
-
- 3
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/image.py b/src/components/image.py
deleted file mode 100644
index 2393611..0000000
--- a/src/components/image.py
+++ /dev/null
@@ -1,129 +0,0 @@
-from PIL import Image, ImageDraw, ImageEnhance
-from PyQt6 import QtGui, QtCore, QtWidgets
-import os
-
-from ..component import Component
-from ..toolkit.frame import BlankFrame
-
-
-class Component(Component):
- name = "Image"
- version = "1.0.1"
-
- def widget(self, *args):
- super().widget(*args)
- self.page.pushButton_image.clicked.connect(self.pickImage)
- self.trackWidgets(
- {
- "imagePath": self.page.lineEdit_image,
- "scale": self.page.spinBox_scale,
- "stretchScale": self.page.spinBox_scale_stretch,
- "rotate": self.page.spinBox_rotate,
- "color": self.page.spinBox_color,
- "xPosition": self.page.spinBox_x,
- "yPosition": self.page.spinBox_y,
- "stretched": self.page.checkBox_stretch,
- "mirror": self.page.checkBox_mirror,
- },
- presetNames={
- "imagePath": "image",
- "xPosition": "x",
- "yPosition": "y",
- },
- relativeWidgets=["xPosition", "yPosition", "scale"],
- )
-
- def previewRender(self):
- return self.drawFrame(self.width, self.height)
-
- def properties(self):
- props = ["static"]
- if not os.path.exists(self.imagePath):
- props.append("error")
- return props
-
- def error(self):
- if not self.imagePath:
- return "There is no image selected."
- if not os.path.exists(self.imagePath):
- return "The image selected does not exist!"
-
- def frameRender(self, frameNo):
- return self.drawFrame(self.width, self.height)
-
- def drawFrame(self, width, height):
- frame = BlankFrame(width, height)
- if self.imagePath and os.path.exists(self.imagePath):
- scale = self.scale if not self.stretched else self.stretchScale
- image = Image.open(self.imagePath)
-
- # Modify image's appearance
- if self.color != 100:
- image = ImageEnhance.Color(image).enhance(float(self.color / 100))
- if self.mirror:
- image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
- if self.stretched and image.size != (width, height):
- image = image.resize((width, height), Image.Resampling.LANCZOS)
- if scale != 100:
- newHeight = int((image.height / 100) * scale)
- newWidth = int((image.width / 100) * scale)
- image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS)
-
- # Paste image at correct position
- frame.paste(image, box=(self.xPosition, self.yPosition))
- if self.rotate != 0:
- frame = frame.rotate(self.rotate)
-
- return frame
-
- def pickImage(self):
- imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
- filename, _ = QtWidgets.QFileDialog.getOpenFileName(
- self.page,
- "Choose Image",
- imgDir,
- "Image Files (%s)" % " ".join(self.core.imageFormats),
- )
- if filename:
- self.settings.setValue("componentDir", os.path.dirname(filename))
- self.mergeUndo = False
- self.page.lineEdit_image.setText(filename)
- self.mergeUndo = True
-
- def command(self, arg):
- if "=" in arg:
- key, arg = arg.split("=", 1)
- if key == "path" and os.path.exists(arg):
- try:
- Image.open(arg)
- self.page.lineEdit_image.setText(arg)
- self.page.checkBox_stretch.setChecked(True)
- return
- except OSError as e:
- print("Not a supported image format")
- quit(1)
- super().command(arg)
-
- def commandHelp(self):
- print("Load an image:\n path=/filepath/to/image.png")
-
- def savePreset(self):
- # Maintain the illusion that the scale spinbox is one widget
- scaleBox = self.page.spinBox_scale
- stretchScaleBox = self.page.spinBox_scale_stretch
- if self.page.checkBox_stretch.isChecked():
- scaleBox.setValue(stretchScaleBox.value())
- else:
- stretchScaleBox.setValue(scaleBox.value())
- return super().savePreset()
-
- def update(self):
- # Maintain the illusion that the scale spinbox is one widget
- scaleBox = self.page.spinBox_scale
- stretchScaleBox = self.page.spinBox_scale_stretch
- if self.page.checkBox_stretch.isChecked():
- scaleBox.setVisible(False)
- stretchScaleBox.setVisible(True)
- else:
- scaleBox.setVisible(True)
- stretchScaleBox.setVisible(False)
diff --git a/src/components/image.ui b/src/components/image.ui
deleted file mode 100644
index 2dad127..0000000
--- a/src/components/image.ui
+++ /dev/null
@@ -1,388 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 197
-
-
-
- Form
-
-
- -
-
-
- 4
-
-
-
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 31
- 0
-
-
-
- Image
-
-
-
- -
-
-
-
- 1
- 0
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 1
- 0
-
-
-
-
- 32
- 32
-
-
-
- ...
-
-
-
- 32
- 32
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- X
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
- -10000
-
-
- 10000
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Y
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
-
- 0
- 0
-
-
-
- -1000
-
-
- 1000
-
-
- 0
-
-
-
-
-
- -
-
-
-
-
-
- Stretch
-
-
- false
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
- Mirror
-
-
-
- -
-
-
- Rotate
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- QAbstractSpinBox::UpDownArrows
-
-
- °
-
-
- 0
-
-
- 359
-
-
- 0
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 10
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Scale
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- QAbstractSpinBox::UpDownArrows
-
-
- %
-
-
- 10
-
-
- 400
-
-
- 100
-
-
-
- -
-
-
- %
-
-
- 10
-
-
- 400
-
-
- 100
-
-
-
-
-
- -
-
-
-
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Color
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- QAbstractSpinBox::UpDownArrows
-
-
- %
-
-
- 0
-
-
- 999
-
-
- 1
-
-
- 100
-
-
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
diff --git a/src/components/life.py b/src/components/life.py
deleted file mode 100644
index 5b719d1..0000000
--- a/src/components/life.py
+++ /dev/null
@@ -1,520 +0,0 @@
-from PyQt6 import QtGui, QtCore, QtWidgets
-from PyQt6.QtGui import QUndoCommand
-from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter
-import os
-import math
-import logging
-
-
-from ..component import Component
-from ..toolkit.frame import BlankFrame, scale
-
-
-log = logging.getLogger("AVP.Component.Life")
-
-
-class Component(Component):
- name = "Conway's Game of Life"
- version = "1.0.0"
-
- def widget(self, *args):
- super().widget(*args)
- self.scale = 32
- self.updateGridSize()
- # The initial grid: a "Queen Bee Shuttle"
- # https://conwaylife.com/wiki/Queen_bee_shuttle
- self.startingGrid = set(
- [
- (3, 7),
- (3, 8),
- (4, 7),
- (4, 8),
- (8, 7),
- (9, 6),
- (9, 8),
- (10, 5),
- (10, 9),
- (11, 6),
- (11, 7),
- (11, 8),
- (12, 4),
- (12, 5),
- (12, 9),
- (12, 10),
- (23, 6),
- (23, 7),
- (24, 6),
- (24, 7),
- ]
- )
-
- # Amount of 'bleed' (off-canvas coordinates) on each side of the grid
- self.bleedSize = 40
-
- self.page.pushButton_pickImage.clicked.connect(self.pickImage)
- self.trackWidgets(
- {
- "tickRate": self.page.spinBox_tickRate,
- "scale": self.page.spinBox_scale,
- "color": self.page.lineEdit_color,
- "shapeType": self.page.comboBox_shapeType,
- "shadow": self.page.checkBox_shadow,
- "customImg": self.page.checkBox_customImg,
- "showGrid": self.page.checkBox_showGrid,
- "image": self.page.lineEdit_image,
- },
- colorWidgets={
- "color": self.page.pushButton_color,
- },
- )
- self.shiftButtons = (
- self.page.toolButton_up,
- self.page.toolButton_down,
- self.page.toolButton_left,
- self.page.toolButton_right,
- )
-
- def shiftFunc(i):
- def shift():
- self.shiftGrid(i)
-
- return shift
-
- shiftFuncs = [shiftFunc(i) for i in range(len(self.shiftButtons))]
- for i, widget in enumerate(self.shiftButtons):
- widget.clicked.connect(shiftFuncs[i])
- self.page.spinBox_scale.setValue(self.scale)
- self.page.spinBox_scale.valueChanged.connect(self.updateGridSize)
-
- def pickImage(self):
- imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
- filename, _ = QtWidgets.QFileDialog.getOpenFileName(
- self.page,
- "Choose Image",
- imgDir,
- "Image Files (%s)" % " ".join(self.core.imageFormats),
- )
- if filename:
- self.settings.setValue("componentDir", os.path.dirname(filename))
- self.mergeUndo = False
- self.page.lineEdit_image.setText(filename)
- self.mergeUndo = True
-
- def shiftGrid(self, d):
- action = ShiftGrid(self, d)
- self.parent.undoStack.push(action)
-
- def update(self):
- self.updateGridSize()
- if self.page.checkBox_customImg.isChecked():
- self.page.label_color.setVisible(False)
- self.page.lineEdit_color.setVisible(False)
- self.page.pushButton_color.setVisible(False)
- self.page.label_shape.setVisible(False)
- self.page.comboBox_shapeType.setVisible(False)
- self.page.label_image.setVisible(True)
- self.page.lineEdit_image.setVisible(True)
- self.page.pushButton_pickImage.setVisible(True)
- else:
- self.page.label_color.setVisible(True)
- self.page.lineEdit_color.setVisible(True)
- self.page.pushButton_color.setVisible(True)
- self.page.label_shape.setVisible(True)
- self.page.comboBox_shapeType.setVisible(True)
- self.page.label_image.setVisible(False)
- self.page.lineEdit_image.setVisible(False)
- self.page.pushButton_pickImage.setVisible(False)
- enabled = len(self.startingGrid) > 0
- for widget in self.shiftButtons:
- widget.setEnabled(enabled)
-
- def previewClickEvent(self, pos, size, button):
- pos = (
- math.ceil((pos[0] / size[0]) * self.gridWidth) - 1,
- math.ceil((pos[1] / size[1]) * self.gridHeight) - 1,
- )
- action = ClickGrid(self, pos, button)
- self.parent.undoStack.push(action)
-
- def updateGridSize(self):
- w, h = self.core.resolutions[-1].split("x")
- self.gridWidth = int(int(w) / self.scale)
- self.gridHeight = int(int(h) / self.scale)
- self.pxWidth = math.ceil(self.width / self.gridWidth)
- self.pxHeight = math.ceil(self.height / self.gridHeight)
-
- def previewRender(self):
- return self.drawGrid(self.startingGrid)
-
- def preFrameRender(self, *args, **kwargs):
- super().preFrameRender(*args, **kwargs)
- self.tickGrids = {0: self.startingGrid}
-
- def properties(self):
- if self.customImg and (not self.image or not os.path.exists(self.image)):
- return ["error"]
- return []
-
- def error(self):
- return "No image selected to represent life."
-
- def frameRender(self, frameNo):
- tick = math.floor(frameNo / self.tickRate)
-
- # Compute grid evolution on this frame if it hasn't been computed yet
- if tick not in self.tickGrids:
- self.tickGrids[tick] = self.gridForTick(tick)
- grid = self.tickGrids[tick]
-
- # Delete old evolution data which we shouldn't need anymore
- if tick - 60 in self.tickGrids:
- del self.tickGrids[tick - 60]
- return self.drawGrid(grid)
-
- def drawGrid(self, grid):
- frame = BlankFrame(self.width, self.height)
-
- def drawCustomImg():
- try:
- img = Image.open(self.image)
- except Exception:
- return
- img = img.resize((self.pxWidth, self.pxHeight), Image.Resampling.LANCZOS)
- frame.paste(img, box=(drawPtX, drawPtY))
-
- def drawShape():
- drawer = ImageDraw.Draw(frame)
- rect = (
- (drawPtX, drawPtY),
- (drawPtX + self.pxWidth, drawPtY + self.pxHeight),
- )
- shape = self.page.comboBox_shapeType.currentText().lower()
-
- # Rectangle
- if shape == "rectangle":
- drawer.rectangle(rect, fill=self.color)
-
- # Elliptical
- elif shape == "elliptical":
- drawer.ellipse(rect, fill=self.color)
-
- tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int)
- smallerShape = (
- (
- drawPtX + tenthX + int(tenthX / 4),
- drawPtY + tenthY + int(tenthY / 2),
- ),
- (
- drawPtX + self.pxWidth - tenthX - int(tenthX / 4),
- drawPtY + self.pxHeight - (tenthY + int(tenthY / 2)),
- ),
- )
- outlineShape = (
- (drawPtX + int(tenthX / 4), drawPtY + int(tenthY / 2)),
- (
- drawPtX + self.pxWidth - int(tenthX / 4),
- drawPtY + self.pxHeight - int(tenthY / 2),
- ),
- )
- # Circle
- if shape == "circle":
- drawer.ellipse(outlineShape, fill=self.color)
- drawer.ellipse(smallerShape, fill=(0, 0, 0, 0))
-
- # Lilypad
- elif shape == "lilypad":
- drawer.pieslice(smallerShape, 290, 250, fill=self.color)
-
- # Pie
- elif shape == "pie":
- drawer.pieslice(outlineShape, 35, 320, fill=self.color)
-
- hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline
- tX, tY = scale(33, self.pxWidth, self.pxHeight, int) # thirdline
- qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline
-
- # Path
- if shape == "path":
- drawer.ellipse(rect, fill=self.color)
- rects = {
- direction: False
- for direction in (
- "up",
- "down",
- "left",
- "right",
- )
- }
- for cell in self.nearbyCoords(x, y):
- if cell not in grid:
- continue
- if cell[0] == x:
- if cell[1] < y:
- rects["up"] = True
- if cell[1] > y:
- rects["down"] = True
- if cell[1] == y:
- if cell[0] < x:
- rects["left"] = True
- if cell[0] > x:
- rects["right"] = True
-
- for direction, rect in rects.items():
- if rect:
- if direction == "up":
- sect = (
- (drawPtX, drawPtY),
- (drawPtX + self.pxWidth, drawPtY + hY),
- )
- elif direction == "down":
- sect = (
- (drawPtX, drawPtY + hY),
- (
- drawPtX + self.pxWidth,
- drawPtY + self.pxHeight,
- ),
- )
- elif direction == "left":
- sect = (
- (drawPtX, drawPtY),
- (drawPtX + hX, drawPtY + self.pxHeight),
- )
- elif direction == "right":
- sect = (
- (drawPtX + hX, drawPtY),
- (
- drawPtX + self.pxWidth,
- drawPtY + self.pxHeight,
- ),
- )
- drawer.rectangle(sect, fill=self.color)
-
- # Duck
- elif shape == "duck":
- duckHead = (
- (drawPtX + qX, drawPtY + qY),
- (drawPtX + int(qX * 3), drawPtY + int(tY * 2)),
- )
- duckBeak = (
- (drawPtX + hX, drawPtY + qY),
- (drawPtX + self.pxWidth + qX, drawPtY + int(qY * 3)),
- )
- duckWing = ((drawPtX, drawPtY + hY), rect[1])
- duckBody = (
- (drawPtX + int(qX / 4), drawPtY + int(qY * 3)),
- (drawPtX + int(tX * 2), drawPtY + self.pxHeight),
- )
- drawer.ellipse(duckBody, fill=self.color)
- drawer.ellipse(duckHead, fill=self.color)
- drawer.pieslice(duckWing, 130, 200, fill=self.color)
- drawer.pieslice(duckBeak, 145, 200, fill=self.color)
-
- # Peace
- elif shape == "peace":
- line = (
- (
- drawPtX + hX - int(tenthX / 2),
- drawPtY + int(tenthY / 2),
- ),
- (
- drawPtX + hX + int(tenthX / 2),
- drawPtY + self.pxHeight - int(tenthY / 2),
- ),
- )
- drawer.ellipse(outlineShape, fill=self.color)
- drawer.ellipse(smallerShape, fill=(0, 0, 0, 0))
- drawer.rectangle(line, fill=self.color)
-
- def slantLine(difference):
- return (
- (drawPtX + difference),
- (drawPtY + self.pxHeight - qY),
- ), (
- (drawPtX + hX),
- (drawPtY + hY),
- )
-
- drawer.line(slantLine(qX), fill=self.color, width=tenthX)
- drawer.line(slantLine(self.pxWidth - qX), fill=self.color, width=tenthX)
-
- for x, y in grid:
- drawPtX = x * self.pxWidth
- if drawPtX > self.width:
- continue
- drawPtY = y * self.pxHeight
- if drawPtY > self.height:
- continue
-
- if self.customImg:
- drawCustomImg()
- else:
- drawShape()
-
- if self.shadow:
- shadImg = ImageEnhance.Contrast(frame).enhance(0.0)
- shadImg = shadImg.filter(ImageFilter.GaussianBlur(5.00))
- shadImg = ImageChops.offset(shadImg, -2, 2)
- shadImg.paste(frame, box=(0, 0), mask=frame)
- frame = shadImg
- if self.showGrid:
- drawer = ImageDraw.Draw(frame)
- w, h = scale(0.05, self.width, self.height, int)
- for x in range(self.pxWidth, self.width, self.pxWidth):
- drawer.rectangle(
- ((x, 0), (x + w, self.height)),
- fill=self.color,
- )
- for y in range(self.pxHeight, self.height, self.pxHeight):
- drawer.rectangle(
- ((0, y), (self.width, y + h)),
- fill=self.color,
- )
-
- return frame
-
- def gridForTick(self, tick):
- """
- Given a tick number over 0, returns a new grid (a set of tuples).
- This must compute the previous ticks' grids if not already computed
- """
- if tick - 1 not in self.tickGrids:
- self.tickGrids[tick - 1] = self.gridForTick(tick - 1)
-
- lastGrid = self.tickGrids[tick - 1]
-
- def neighbours(x, y):
- return {cell for cell in self.nearbyCoords(x, y) if cell in lastGrid}
-
- newGrid = set()
- # Copy cells from the previous grid if they have 2 or 3 neighbouring cells
- # and if they are within the grid or its bleed area (off-canvas area)
- for x, y in lastGrid:
- if (
- -self.bleedSize > x > self.gridWidth + self.bleedSize
- or -self.bleedSize > y > self.gridHeight + self.bleedSize
- ):
- continue
- surrounding = len(neighbours(x, y))
- if surrounding == 2 or surrounding == 3:
- newGrid.add((x, y))
-
- # Find positions around living cells which must be checked for reproduction
- potentialNewCells = {
- coordTup
- for origin in lastGrid
- for coordTup in list(self.nearbyCoords(*origin))
- }
- # Check for reproduction
- for x, y in potentialNewCells:
- if (x, y) in newGrid:
- # Ignore non-empty cell
- continue
- surrounding = len(neighbours(x, y))
- if surrounding == 3:
- newGrid.add((x, y))
-
- return newGrid
-
- def savePreset(self):
- pr = super().savePreset()
- pr["GRID"] = sorted(self.startingGrid)
- return pr
-
- def loadPreset(self, pr, *args):
- self.startingGrid = set(pr["GRID"])
- if self.startingGrid:
- for widget in self.shiftButtons:
- widget.setEnabled(True)
- super().loadPreset(pr, *args)
-
- def nearbyCoords(self, x, y):
- yield x + 1, y + 1
- yield x + 1, y - 1
- yield x - 1, y + 1
- yield x - 1, y - 1
- yield x, y + 1
- yield x, y - 1
- yield x + 1, y
- yield x - 1, y
-
-
-class ClickGrid(QUndoCommand):
- def __init__(self, comp, pos, button):
- super().__init__("click %s component #%s" % (comp.name, comp.compPos))
- self.comp = comp
- self.pos = [pos]
- if button == QtCore.Qt.MouseButton.RightButton:
- self.button = 2
- else:
- self.button = 1
-
- def id(self):
- return self.button
-
- def mergeWith(self, other):
- self.pos.extend(other.pos)
- return True
-
- def add(self):
- for pos in self.pos[:]:
- self.comp.startingGrid.add(pos)
- self.comp.update(auto=True)
-
- def remove(self):
- for pos in self.pos[:]:
- self.comp.startingGrid.discard(pos)
- self.comp.update(auto=True)
-
- def redo(self):
- if self.button == 1: # Left-click
- self.add()
- elif self.button == 2: # Right-click
- self.remove()
-
- def undo(self):
- if self.button == 1: # Left-click
- self.remove()
- elif self.button == 2: # Right-click
- self.add()
-
-
-class ShiftGrid(QUndoCommand):
- def __init__(self, comp, direction):
- super().__init__("change %s component #%s" % (comp.name, comp.compPos))
- self.comp = comp
- self.direction = direction
- self.distance = 1
-
- def id(self):
- return self.direction
-
- def mergeWith(self, other):
- self.distance += other.distance
- return True
-
- def newGrid(self, Xchange, Ychange):
- return {(x + Xchange, y + Ychange) for x, y in self.comp.startingGrid}
-
- def redo(self):
- if self.direction == 0:
- newGrid = self.newGrid(0, -self.distance)
- elif self.direction == 1:
- newGrid = self.newGrid(0, self.distance)
- elif self.direction == 2:
- newGrid = self.newGrid(-self.distance, 0)
- elif self.direction == 3:
- newGrid = self.newGrid(self.distance, 0)
- self.comp.startingGrid = newGrid
- self.comp._sendUpdateSignal()
-
- def undo(self):
- if self.direction == 0:
- newGrid = self.newGrid(0, self.distance)
- elif self.direction == 1:
- newGrid = self.newGrid(0, -self.distance)
- elif self.direction == 2:
- newGrid = self.newGrid(self.distance, 0)
- elif self.direction == 3:
- newGrid = self.newGrid(-self.distance, 0)
- self.comp.startingGrid = newGrid
- self.comp._sendUpdateSignal()
diff --git a/src/components/life.ui b/src/components/life.ui
deleted file mode 100644
index 30cf9d0..0000000
--- a/src/components/life.ui
+++ /dev/null
@@ -1,405 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 197
-
-
-
- Form
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
- Simulation Speed
-
-
-
- -
-
-
- frames per tick
-
-
- 1
-
-
- 30
-
-
- 5
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
-
- 0
- 16777215
-
-
-
- 255,255,255
-
-
-
-
-
- -
-
-
-
-
-
- Grid Scale
-
-
-
- -
-
-
- 22
-
-
- 128
-
-
- 32
-
-
-
- -
-
-
- Custom Image
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
- Image
-
-
-
- -
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 32
- 32
-
-
-
- ...
-
-
-
- -
-
-
- Color
-
-
-
- -
-
-
-
- 0
- 16777215
-
-
-
- 0,0,0
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 32
- 32
-
-
-
-
-
-
- false
-
-
- false
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Shape
-
-
-
- -
-
-
-
-
- Path
-
-
- -
-
- Rectangle
-
-
- -
-
- Elliptical
-
-
- -
-
- Circle
-
-
- -
-
- Lilypad
-
-
- -
-
- Pie
-
-
- -
-
- Duck
-
-
- -
-
- Peace
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
- Shadow
-
-
-
- -
-
-
- Show Grid
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
- Up
-
-
- Qt::UpArrow
-
-
-
- -
-
-
- Down
-
-
- Qt::DownArrow
-
-
-
- -
-
-
- Left
-
-
- Qt::LeftArrow
-
-
-
- -
-
-
- Right
-
-
- Qt::RightArrow
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
-
-
- -
-
-
- <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
-<html><head><meta name="qrichtext" content="1" /><style type="text/css">
-p, li { white-space: pre-wrap; }
-</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;">
-<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Click the preview window to place a cell. Right-click to remove.</span></p>
-<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with less than 2 neighbours will die from underpopulation</p>
-<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with more than 3 neighbours will die from overpopulation.</p>
-<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- An empty space surrounded by 3 live cells will cause reproduction.</p></body></html>
-
-
- 80
-
-
- Qt::NoTextInteraction
-
-
- false
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
diff --git a/src/components/original.py b/src/components/original.py
deleted file mode 100644
index fad797b..0000000
--- a/src/components/original.py
+++ /dev/null
@@ -1,243 +0,0 @@
-import numpy
-from PIL import Image, ImageDraw
-from copy import copy
-
-from ..component import Component
-from ..toolkit.frame import BlankFrame
-
-
-class Component(Component):
- name = "Classic Visualizer"
- version = "1.0.0"
-
- def names(*args):
- return ["Original Audio Visualization"]
-
- def properties(self):
- return ["pcm"]
-
- def widget(self, *args):
- self.scale = 20
- self.y = 0
- super().widget(*args)
-
- self.page.comboBox_visLayout.addItem("Classic")
- self.page.comboBox_visLayout.addItem("Split")
- self.page.comboBox_visLayout.addItem("Bottom")
- self.page.comboBox_visLayout.addItem("Top")
- self.page.comboBox_visLayout.setCurrentIndex(0)
-
- self.page.lineEdit_visColor.setText("255,255,255")
-
- self.trackWidgets(
- {
- "visColor": self.page.lineEdit_visColor,
- "layout": self.page.comboBox_visLayout,
- "scale": self.page.spinBox_scale,
- "y": self.page.spinBox_y,
- "smooth": self.page.spinBox_smooth,
- },
- colorWidgets={
- "visColor": self.page.pushButton_visColor,
- },
- relativeWidgets=[
- "y",
- ],
- )
-
- def previewRender(self):
- spectrum = numpy.fromfunction(
- lambda x: float(self.scale) / 2500 * (x - 128) ** 2,
- (255,),
- dtype="int16",
- )
- return self.drawBars(
- self.width, self.height, spectrum, self.visColor, self.layout
- )
-
- def preFrameRender(self, **kwargs):
- super().preFrameRender(**kwargs)
- self.smoothConstantDown = 0.08 + 0 if not self.smooth else self.smooth / 15
- self.smoothConstantUp = 0.8 - 0 if not self.smooth else self.smooth / 15
- self.lastSpectrum = None
- self.spectrumArray = {}
-
- for i in range(0, len(self.completeAudioArray), self.sampleSize):
- if self.canceled:
- break
- self.lastSpectrum = self.transformData(
- i,
- self.completeAudioArray,
- self.sampleSize,
- self.smoothConstantDown,
- self.smoothConstantUp,
- self.lastSpectrum,
- )
- self.spectrumArray[i] = copy(self.lastSpectrum)
-
- progress = int(100 * (i / len(self.completeAudioArray)))
- if progress >= 100:
- progress = 100
- pStr = "Analyzing audio: " + str(progress) + "%"
- self.progressBarSetText.emit(pStr)
- self.progressBarUpdate.emit(int(progress))
-
- def frameRender(self, frameNo):
- arrayNo = frameNo * self.sampleSize
- return self.drawBars(
- self.width,
- self.height,
- self.spectrumArray[arrayNo],
- self.visColor,
- self.layout,
- )
-
- def transformData(
- self,
- i,
- completeAudioArray,
- sampleSize,
- smoothConstantDown,
- smoothConstantUp,
- lastSpectrum,
- ):
- if len(completeAudioArray) < (i + sampleSize):
- sampleSize = len(completeAudioArray) - i
-
- window = numpy.hanning(sampleSize)
- data = completeAudioArray[i : i + sampleSize][::1] * window
- paddedSampleSize = 2048
- paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), "constant")
- spectrum = numpy.fft.fft(paddedData)
- sample_rate = 44100
- frequencies = numpy.fft.fftfreq(len(spectrum), 1.0 / sample_rate)
-
- y = abs(spectrum[0 : int(paddedSampleSize / 2) - 1])
-
- # filter the noise away
- # y[y<80] = 0
-
- y = self.scale * numpy.log10(y)
- y[numpy.isinf(y)] = 0
-
- if lastSpectrum is not None:
- lastSpectrum[y < lastSpectrum] = y[
- y < lastSpectrum
- ] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * (
- 1 - smoothConstantDown
- )
-
- lastSpectrum[y >= lastSpectrum] = y[
- y >= lastSpectrum
- ] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * (
- 1 - smoothConstantUp
- )
- else:
- lastSpectrum = y
-
- x = frequencies[0 : int(paddedSampleSize / 2) - 1]
-
- return lastSpectrum
-
- def drawBars(self, width, height, spectrum, color, layout):
- vH = height - height / 8
- bF = width / 64
- bH = bF / 2
- bQ = bF / 4
- imTop = BlankFrame(width, height)
- draw = ImageDraw.Draw(imTop)
- r, g, b = color
- color2 = (r, g, b, 125)
-
- bP = height / 1200
-
- for j in range(0, 63):
- x0 = bH + j * bF
- y0 = vH + bQ
- y1 = vH + bQ - spectrum[j * 4] * bP - bH
- x1 = bH + j * bF + bF
- draw.rectangle(
- (
- x0,
- y0 if y0 < y1 else y1,
- x1 if x1 > x0 else x0,
- y1 if y0 < y1 else y0,
- ),
- fill=color2,
- )
-
- x0 = bH + bQ + j * bF
- y0 = vH
- x1 = bH + bQ + j * bF + bH
- y1 = vH - spectrum[j * 4] * bP
- draw.rectangle(
- (
- x0,
- y0 if y0 < y1 else y1,
- x1 if x1 > x0 else x0,
- y1 if y0 < y1 else y0,
- ),
- fill=color,
- )
-
- imBottom = imTop.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
-
- im = BlankFrame(width, height)
-
- if layout == 0: # Classic
- y = self.y - int(height / 100 * 43)
- im.paste(imTop, (0, y), mask=imTop)
- y = self.y + int(height / 100 * 43)
- im.paste(imBottom, (0, y), mask=imBottom)
-
- if layout == 1: # Split
- y = self.y + int(height / 100 * 10)
- im.paste(imTop, (0, y), mask=imTop)
- y = self.y - int(height / 100 * 10)
- im.paste(imBottom, (0, y), mask=imBottom)
-
- if layout == 2: # Bottom
- y = self.y + int(height / 100 * 10)
- im.paste(imTop, (0, y), mask=imTop)
-
- if layout == 3: # Top
- y = self.y - int(height / 100 * 10)
- im.paste(imBottom, (0, y), mask=imBottom)
-
- return im
-
- def command(self, arg):
- if "=" in arg:
- key, arg = arg.split("=", 1)
- try:
- if key == "color":
- self.page.lineEdit_visColor.setText(arg)
- return
- elif key == "layout":
- if arg == "classic":
- self.page.comboBox_visLayout.setCurrentIndex(0)
- elif arg == "split":
- self.page.comboBox_visLayout.setCurrentIndex(1)
- elif arg == "bottom":
- self.page.comboBox_visLayout.setCurrentIndex(2)
- elif arg == "top":
- self.page.comboBox_visLayout.setCurrentIndex(3)
- return
- elif key == "scale":
- arg = int(arg)
- self.page.spinBox_scale.setValue(arg)
- return
- elif key == "y":
- arg = int(arg)
- self.page.spinBox_y.setValue(arg)
- return
- except ValueError:
- print("You must enter a number.")
- quit(1)
- super().command(arg)
-
- def commandHelp(self):
- print("Give a layout name:\n layout=[classic/split/bottom/top]")
- print("Specify a color:\n color=255,255,255")
- print("Visualizer scale (20 is default):\n scale=number")
- print("Y position:\n y=number")
diff --git a/src/components/original.ui b/src/components/original.ui
deleted file mode 100644
index c7b7e22..0000000
--- a/src/components/original.ui
+++ /dev/null
@@ -1,243 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 178
-
-
-
-
- 180
- 0
-
-
-
- Form
-
-
- -
-
-
- 4
-
-
-
-
-
-
- 0
- 0
-
-
-
- Layout
-
-
-
- -
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
- Color
-
-
-
- -
-
-
-
- 32
- 32
-
-
-
-
-
-
-
- 32
- 32
-
-
-
-
- -
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
- Y
-
-
-
- -
-
-
- QAbstractSpinBox::UpDownArrows
-
-
- -5000
-
-
- 5000
-
-
- 10
-
-
- 0
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
- 4
-
-
-
-
-
- Scale
-
-
-
- -
-
-
- QAbstractSpinBox::PlusMinus
-
-
- 1
-
-
- 20
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Expanding
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
- QLayout::SetDefaultConstraint
-
-
- 4
-
-
-
-
-
- Sensitivity
-
-
-
- -
-
-
- 5
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
diff --git a/src/components/sound.py b/src/components/sound.py
deleted file mode 100644
index 2df8e38..0000000
--- a/src/components/sound.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from PyQt6 import QtGui, QtCore, QtWidgets
-import os
-
-from ..component import Component
-from ..toolkit.frame import BlankFrame
-
-
-class Component(Component):
- name = "Sound"
- version = "1.0.0"
-
- def widget(self, *args):
- super().widget(*args)
- self.page.pushButton_sound.clicked.connect(self.pickSound)
- self.trackWidgets(
- {
- "sound": self.page.lineEdit_sound,
- "chorus": self.page.checkBox_chorus,
- "delay": self.page.spinBox_delay,
- "volume": self.page.spinBox_volume,
- },
- commandArgs={
- "sound": None,
- },
- )
-
- def properties(self):
- props = ["static", "audio"]
- if not os.path.exists(self.sound):
- props.append("error")
- return props
-
- def error(self):
- if not self.sound:
- return "No audio file selected."
- if not os.path.exists(self.sound):
- return "The audio file selected no longer exists!"
-
- def audio(self):
- params = {}
- if self.delay != 0.0:
- params["adelay"] = "=%s" % str(int(self.delay * 1000.00))
- if self.chorus:
- params["chorus"] = "=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3"
- if self.volume != 1.0:
- params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume)
-
- return (self.sound, params)
-
- def pickSound(self):
- sndDir = self.settings.value("componentDir", os.path.expanduser("~"))
- filename, _ = QtWidgets.QFileDialog.getOpenFileName(
- self.page,
- "Choose Sound",
- sndDir,
- "Audio Files (%s)" % " ".join(self.core.audioFormats),
- )
- if filename:
- self.settings.setValue("componentDir", os.path.dirname(filename))
- self.mergeUndo = False
- self.page.lineEdit_sound.setText(filename)
- self.mergeUndo = True
-
- def commandHelp(self):
- print("Path to audio file:\n path=/filepath/to/sound.ogg")
-
- def command(self, arg):
- if "=" in arg:
- key, arg = arg.split("=", 1)
- if key == "path":
- if "*%s" % os.path.splitext(arg)[1] not in self.core.audioFormats:
- print("Not a supported audio format")
- quit(1)
- self.page.lineEdit_sound.setText(arg)
- return
-
- super().command(arg)
diff --git a/src/components/sound.ui b/src/components/sound.ui
deleted file mode 100644
index 4c11332..0000000
--- a/src/components/sound.ui
+++ /dev/null
@@ -1,172 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 197
-
-
-
- Form
-
-
- -
-
-
- 4
-
-
-
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 31
- 0
-
-
-
- Audio File
-
-
-
- -
-
-
-
- 1
- 0
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 1
- 0
-
-
-
-
- 32
- 32
-
-
-
- ...
-
-
-
- 32
- 32
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
- Volume
-
-
-
- -
-
-
- x
-
-
- 10.000000000000000
-
-
- 0.100000000000000
-
-
- 1.000000000000000
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Delay
-
-
-
- -
-
-
- s
-
-
- 9999999.990000000223517
-
-
- 0.500000000000000
-
-
-
- -
-
-
- Chorus
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
diff --git a/src/components/spectrum.py b/src/components/spectrum.py
deleted file mode 100644
index 062ebc7..0000000
--- a/src/components/spectrum.py
+++ /dev/null
@@ -1,368 +0,0 @@
-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 ..toolkit.frame import BlankFrame, scale
-from ..toolkit import checkOutput, connectWidget
-from ..toolkit.ffmpeg import (
- openPipe,
- closePipe,
- getAudioDuration,
- FfmpegVideo,
- exampleSound,
-)
-
-
-log = logging.getLogger("AVP.Components.Spectrum")
-
-
-class Component(Component):
- name = "Spectrum"
- version = "1.0.1"
-
- def widget(self, *args):
- self.previewFrame = None
- super().widget(*args)
- self._image = BlankFrame(self.width, self.height)
- self.chunkSize = 4 * self.width * self.height
- self.changedOptions = True
- self.previewSize = (214, 120)
- self.previewPipe = None
-
- if hasattr(self.parent, "lineEdit_audioFile"):
- # update preview when audio file changes (if genericPreview is off)
- self.parent.lineEdit_audioFile.textChanged.connect(self.update)
-
- self.trackWidgets(
- {
- "filterType": self.page.comboBox_filterType,
- "window": self.page.comboBox_window,
- "mode": self.page.comboBox_mode,
- "amplitude": self.page.comboBox_amplitude0,
- "amplitude1": self.page.comboBox_amplitude1,
- "amplitude2": self.page.comboBox_amplitude2,
- "display": self.page.comboBox_display,
- "zoom": self.page.spinBox_zoom,
- "tc": self.page.spinBox_tc,
- "x": self.page.spinBox_x,
- "y": self.page.spinBox_y,
- "mirror": self.page.checkBox_mirror,
- "draw": self.page.checkBox_draw,
- "scale": self.page.spinBox_scale,
- "color": self.page.comboBox_color,
- "compress": self.page.checkBox_compress,
- "mono": self.page.checkBox_mono,
- "hue": self.page.spinBox_hue,
- },
- relativeWidgets=[
- "x",
- "y",
- ],
- )
- for widget in self._trackedWidgets.values():
- connectWidget(widget, lambda: self.changed())
-
- def changed(self):
- self.changedOptions = True
-
- def update(self):
- filterType = self.page.comboBox_filterType.currentIndex()
- self.page.stackedWidget.setCurrentIndex(filterType)
- if filterType == 3:
- self.page.spinBox_hue.setEnabled(False)
- else:
- self.page.spinBox_hue.setEnabled(True)
- if filterType == 2 or filterType == 4:
- self.page.checkBox_mono.setEnabled(False)
- else:
- self.page.checkBox_mono.setEnabled(True)
-
- def previewRender(self):
- changedSize = self.updateChunksize()
- if (
- not changedSize
- and not self.changedOptions
- and self.previewFrame is not None
- ):
- log.debug("Spectrum #%s is reusing old preview frame" % self.compPos)
- return self.previewFrame
-
- frame = self.getPreviewFrame()
- self.changedOptions = False
- if not frame:
- log.warning("Spectrum #%s failed to create a preview frame" % self.compPos)
- self.previewFrame = None
- return BlankFrame(self.width, self.height)
- else:
- self.previewFrame = frame
- return frame
-
- def preFrameRender(self, **kwargs):
- super().preFrameRender(**kwargs)
- if self.previewPipe is not None:
- self.previewPipe.wait()
- self.updateChunksize()
- w, h = scale(self.scale, self.width, self.height, str)
- self.video = FfmpegVideo(
- inputPath=self.audioFile,
- filter_=self.makeFfmpegFilter(),
- width=w,
- height=h,
- chunkSize=self.chunkSize,
- frameRate=int(self.settings.value("outputFrameRate")),
- parent=self.parent,
- component=self,
- )
-
- def frameRender(self, frameNo):
- if FfmpegVideo.threadError is not None:
- raise FfmpegVideo.threadError
- return self.finalizeFrame(self.video.frame(frameNo))
-
- def postFrameRender(self):
- closePipe(self.video.pipe)
-
- def getPreviewFrame(self):
- genericPreview = self.settings.value("pref_genericPreview")
- startPt = 0
- if not genericPreview:
- inputFile = self.parent.lineEdit_audioFile.text()
- if not inputFile or not os.path.exists(inputFile):
- return
- duration = getAudioDuration(inputFile)
- if not duration:
- return
- startPt = duration / 3
-
- command = [
- self.core.FFMPEG_BIN,
- "-thread_queue_size",
- "512",
- "-r",
- str(self.settings.value("outputFrameRate")),
- "-ss",
- "{0:.3f}".format(startPt),
- "-i",
- self.core.junkStream if genericPreview else inputFile,
- "-f",
- "image2pipe",
- "-pix_fmt",
- "rgba",
- ]
- command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt))
- command.extend(
- [
- "-an",
- "-s:v",
- "%sx%s" % scale(self.scale, self.width, self.height, str),
- "-codec:v",
- "rawvideo",
- "-",
- "-frames:v",
- "1",
- ]
- )
-
- if self.core.logEnabled:
- logFilename = os.path.join(
- self.core.logDir, "preview_%s.log" % str(self.compPos)
- )
- log.debug("Creating FFmpeg process (log at %s)" % logFilename)
- with open(logFilename, "w") as logf:
- logf.write(" ".join(command) + "\n\n")
- with open(logFilename, "a") as logf:
- self.previewPipe = openPipe(
- command,
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=logf,
- bufsize=10**8,
- )
- else:
- self.previewPipe = openPipe(
- command,
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- bufsize=10**8,
- )
- byteFrame = self.previewPipe.stdout.read(self.chunkSize)
- closePipe(self.previewPipe)
-
- frame = self.finalizeFrame(byteFrame)
- return frame
-
- def makeFfmpegFilter(self, preview=False, startPt=0):
- """Makes final FFmpeg filter command"""
-
- def getFilterComplexCommand():
- """Inner function that creates the final, complex part of the filter command"""
- nonlocal self
- genericPreview = self.settings.value("pref_genericPreview")
-
- def getFilterComplexCommandForType():
- """Determine portion of filter command that changes depending on selected type"""
- nonlocal self
- if preview:
- w, h = self.previewSize
- else:
- w, h = (self.width, self.height)
- color = self.page.comboBox_color.currentText().lower()
-
- if self.filterType == 0: # Spectrum
- if self.amplitude == 0:
- amplitude = "sqrt"
- elif self.amplitude == 1:
- amplitude = "cbrt"
- elif self.amplitude == 2:
- amplitude = "4thrt"
- elif self.amplitude == 3:
- amplitude = "5thrt"
- elif self.amplitude == 4:
- amplitude = "lin"
- elif self.amplitude == 5:
- amplitude = "log"
- filter_ = (
- f"showspectrum=s={w}x{h}:"
- "slide=scroll:"
- f"win_func={self.page.comboBox_window.currentText()}:"
- f"color={color}:"
- f"scale={amplitude},"
- "colorkey=color=black:"
- "similarity=0.1:blend=0.5"
- )
- elif self.filterType == 1: # Histogram
- if self.amplitude1 == 0:
- amplitude = "log"
- elif self.amplitude1 == 1:
- amplitude = "lin"
- if self.display == 0:
- display = "log"
- elif self.display == 1:
- display = "sqrt"
- elif self.display == 2:
- display = "cbrt"
- elif self.display == 3:
- display = "lin"
- elif self.display == 4:
- display = "rlog"
- filter_ = (
- f'ahistogram=r={str(self.settings.value("outputFrameRate"))}:'
- f"s={w}x{h}:"
- "dmode=separate:"
- f"ascale={amplitude}:"
- f"scale={display}"
- )
- elif self.filterType == 2: # Vector Scope
- if self.amplitude2 == 0:
- amplitude = "log"
- elif self.amplitude2 == 1:
- amplitude = "sqrt"
- elif self.amplitude2 == 2:
- amplitude = "cbrt"
- elif self.amplitude2 == 3:
- amplitude = "lin"
- m = self.page.comboBox_mode.currentText()
- filter_ = (
- f"avectorscope=s={w}x{h}:"
- f'draw={"line" if self.draw else "dot"}:'
- f"m={m}:"
- f"scale={amplitude}:"
- f"zoom={str(self.zoom)}"
- )
- elif self.filterType == 3: # Musical Scale
- filter_ = (
- f'showcqt=r={str(self.settings.value("outputFrameRate"))}:'
- f"s={w}x{h}:"
- "count=30:"
- "text=0:"
- f"tc={str(self.tc)},"
- "colorkey=color=black:"
- "similarity=0.1:blend=0.5"
- )
- elif self.filterType == 4: # Phase
- filter_ = (
- f'aphasemeter=r={str(self.settings.value("outputFrameRate"))}:'
- f"s={w}x{h}:"
- "video=1 [atrash][vtmp1]; "
- "[atrash] anullsink; "
- "[vtmp1] colorkey=color=black:"
- "similarity=0.1:blend=0.5, "
- "crop=in_w/8:in_h:(in_w/8)*7:0 "
- )
- return filter_
-
- if self.filterType < 2:
- exampleSnd = exampleSound("freq")
- elif self.filterType == 2 or self.filterType == 4:
- exampleSnd = exampleSound("stereo")
- elif self.filterType == 3:
- exampleSnd = exampleSound("white")
- compression = "compand=gain=4," if self.compress else ""
- aformat = (
- "aformat=channel_layouts=mono,"
- if self.mono and self.filterType not in (2, 4)
- else ""
- )
- filter_ = getFilterComplexCommandForType()
- hflip = "hflip, " if self.mirror else ""
- trim = (
- "trim=start=%s:end=%s, "
- % (
- "{0:.3f}".format(startPt + 12),
- "{0:.3f}".format(startPt + 12.5),
- )
- if preview
- else ""
- )
- scale_ = "scale=%sx%s" % scale(self.scale, self.width, self.height, str)
- hue = (
- ", hue=h=%s:s=10" % str(self.hue)
- if self.hue > 0 and self.filterType != 3
- else ""
- )
- convolution = (
- ", convolution=-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2:-2 -1 0 -1 1 1 0 1 2"
- if self.filterType == 3
- else ""
- )
-
- return (
- f"{exampleSnd if preview and genericPreview else '[0:a] '}"
- f"{compression}{aformat}{filter_} [v1]; "
- f"[v1] {hflip}{trim}{scale_}{hue}{convolution} [v]"
- )
-
- return [
- "-filter_complex",
- getFilterComplexCommand(),
- "-map",
- "[v]",
- ]
-
- def updateChunksize(self):
- width, height = scale(self.scale, self.width, self.height, int)
- oldChunkSize = int(self.chunkSize)
- self.chunkSize = 4 * width * height
- changed = self.chunkSize != oldChunkSize
- return changed
-
- def finalizeFrame(self, imageData):
- try:
- image = Image.frombytes(
- "RGBA",
- scale(self.scale, self.width, self.height, int),
- imageData,
- )
- self._image = image
- except ValueError:
- image = self._image
-
- frame = BlankFrame(self.width, self.height)
- frame.paste(image, box=(self.x, self.y))
- return frame
diff --git a/src/components/spectrum.ui b/src/components/spectrum.ui
deleted file mode 100644
index c6a8a15..0000000
--- a/src/components/spectrum.ui
+++ /dev/null
@@ -1,946 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 197
-
-
-
-
- 0
- 0
-
-
-
-
- 0
- 197
-
-
-
- Form
-
-
- -
-
-
- 4
-
-
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
- Type
-
-
-
- -
-
-
-
-
- Spectrum
-
-
- -
-
- Histogram
-
-
- -
-
- Vector Scope
-
-
- -
-
- Musical Scale
-
-
- -
-
- Phase
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- X
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
- -10000
-
-
- 10000
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Y
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
-
- 0
- 0
-
-
-
- -10000
-
-
- 10000
-
-
- 0
-
-
-
-
-
- -
-
-
-
-
-
- Compress
-
-
-
- -
-
-
- Mono
-
-
-
- -
-
-
- Mirror
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Hue
-
-
- 4
-
-
-
- -
-
-
- °
-
-
- 359
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Scale
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- QAbstractSpinBox::UpDownArrows
-
-
- %
-
-
- 10
-
-
- 400
-
-
- 100
-
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- false
-
-
- QFrame::NoFrame
-
-
- QFrame::Plain
-
-
- 0
-
-
-
-
-
- 0
- 0
- 561
- 66
-
-
-
-
- QLayout::SetMaximumSize
-
-
- 0
-
-
-
-
-
- QLayout::SetDefaultConstraint
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 31
- 0
-
-
-
- Window
-
-
- 4
-
-
-
- -
-
-
-
-
- hann
-
-
- -
-
- gauss
-
-
- -
-
- tukey
-
-
- -
-
- dolph
-
-
- -
-
- cauchy
-
-
- -
-
- parzen
-
-
- -
-
- poisson
-
-
- -
-
- rect
-
-
- -
-
- bartlett
-
-
- -
-
- hanning
-
-
- -
-
- hamming
-
-
- -
-
- blackman
-
-
- -
-
- welch
-
-
- -
-
- flattop
-
-
- -
-
- bharris
-
-
- -
-
- bnuttall
-
-
- -
-
- lanczos
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Amplitude
-
-
- 4
-
-
-
- -
-
-
-
-
- Square root
-
-
- -
-
- Cubic root
-
-
- -
-
- 4thrt
-
-
- -
-
- 5thrt
-
-
- -
-
- Linear
-
-
- -
-
- Logarithmic
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::MinimumExpanding
-
-
-
- 10
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
- Color
-
-
- 4
-
-
-
- -
-
-
-
-
- Channel
-
-
- -
-
- Intensity
-
-
- -
-
- Rainbow
-
-
- -
-
- Moreland
-
-
- -
-
- Nebulae
-
-
- -
-
- Fire
-
-
- -
-
- Fiery
-
-
- -
-
- Fruit
-
-
- -
-
- Cool
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::MinimumExpanding
-
-
-
- 10
- 20
-
-
-
-
-
-
-
-
-
-
-
-
-
- -1
- -1
- 561
- 31
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
- Display Scale
-
-
- 4
-
-
-
- -
-
-
-
-
- Logarithmic
-
-
- -
-
- Square root
-
-
- -
-
- Cubic root
-
-
- -
-
- Linear
-
-
- -
-
- Reverse Log
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Amplitude
-
-
- 4
-
-
-
- -
-
-
-
-
- Logarithmic
-
-
- -
-
- Linear
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Minimum
-
-
-
- 40
- 20
-
-
-
-
-
-
-
-
-
-
-
-
-
- -1
- -1
- 585
- 64
-
-
-
- -
-
-
-
-
-
- Mode
-
-
-
- -
-
-
-
-
- lissajous
-
-
- -
-
- lissajous_xy
-
-
- -
-
- polar
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Amplitude
-
-
- 4
-
-
-
- -
-
-
-
-
- Linear
-
-
- -
-
- Square root
-
-
- -
-
- Cubic root
-
-
- -
-
- Logarithmic
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
- Zoom
-
-
- 4
-
-
-
- -
-
-
- 1
-
-
- 10
-
-
-
- -
-
-
- Line
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
- 0
- 561
- 31
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
- Timeclamp
-
-
- 4
-
-
-
- -
-
-
- s
-
-
- 3
-
-
- 0.002000000000000
-
-
- 1.000000000000000
-
-
- 0.010000000000000
-
-
- 0.017000000000000
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
- 0
- 551
- 31
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
- QSizePolicy::Fixed
-
-
-
- 20
- 10
-
-
-
-
-
-
-
-
-
diff --git a/src/components/text.py b/src/components/text.py
deleted file mode 100644
index 40c981a..0000000
--- a/src/components/text.py
+++ /dev/null
@@ -1,218 +0,0 @@
-from PIL import ImageEnhance, ImageFilter, ImageChops
-from PyQt6.QtGui import QColor, QFont
-from PyQt6 import QtGui, QtCore, QtWidgets
-import os
-import logging
-
-from ..component import Component
-from ..toolkit.frame import FramePainter, PaintColor
-
-log = logging.getLogger("AVP.Components.Text")
-
-
-class Component(Component):
- name = "Title Text"
- version = "1.0.1"
-
- def widget(self, *args):
- super().widget(*args)
- self.title = "Text"
- self.alignment = 1
- self.titleFont = QFont()
- self.fontSize = self.height / 13.5
-
- self.page.comboBox_textAlign.addItem("Left")
- self.page.comboBox_textAlign.addItem("Middle")
- self.page.comboBox_textAlign.addItem("Right")
- self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment))
- self.page.spinBox_fontSize.setValue(int(self.fontSize))
- self.page.lineEdit_title.setText(self.title)
- self.page.pushButton_center.clicked.connect(self.centerXY)
-
- self.page.fontComboBox_titleFont.currentFontChanged.connect(
- self._sendUpdateSignal
- )
- # The QFontComboBox must be connected directly to the Qt Signal
- # which triggers the preview to update.
- # This unfortunately makes changing the font into a non-undoable action.
- # Must be something broken in the conversion to a ComponentAction
-
- self.trackWidgets(
- {
- "textColor": self.page.lineEdit_textColor,
- "title": self.page.lineEdit_title,
- "alignment": self.page.comboBox_textAlign,
- "fontSize": self.page.spinBox_fontSize,
- "xPosition": self.page.spinBox_xTextAlign,
- "yPosition": self.page.spinBox_yTextAlign,
- "fontStyle": self.page.comboBox_fontStyle,
- "stroke": self.page.spinBox_stroke,
- "strokeColor": self.page.lineEdit_strokeColor,
- "shadow": self.page.checkBox_shadow,
- "shadX": self.page.spinBox_shadX,
- "shadY": self.page.spinBox_shadY,
- "shadBlur": self.page.spinBox_shadBlur,
- },
- colorWidgets={
- "textColor": self.page.pushButton_textColor,
- "strokeColor": self.page.pushButton_strokeColor,
- },
- relativeWidgets=[
- "xPosition",
- "yPosition",
- "fontSize",
- "stroke",
- "shadX",
- "shadY",
- "shadBlur",
- ],
- )
- self.centerXY()
-
- def update(self):
- self.titleFont = self.page.fontComboBox_titleFont.currentFont()
- if self.page.checkBox_shadow.isChecked():
- self.page.label_shadX.setHidden(False)
- self.page.spinBox_shadX.setHidden(False)
- self.page.spinBox_shadY.setHidden(False)
- self.page.label_shadBlur.setHidden(False)
- self.page.spinBox_shadBlur.setHidden(False)
- else:
- self.page.label_shadX.setHidden(True)
- self.page.spinBox_shadX.setHidden(True)
- self.page.spinBox_shadY.setHidden(True)
- self.page.label_shadBlur.setHidden(True)
- self.page.spinBox_shadBlur.setHidden(True)
-
- def centerXY(self):
- self.setRelativeWidget("xPosition", 0.5)
- self.setRelativeWidget("yPosition", 0.521)
-
- def getXY(self):
- """Returns true x, y after considering alignment settings"""
- fm = QtGui.QFontMetrics(self.titleFont)
- text_width = fm.boundingRect(self.title).width()
- x = self.pixelValForAttr("xPosition")
-
- if self.alignment == 1: # Middle
- offset = int(text_width / 2)
- elif self.alignment == 2: # Right
- offset = text_width
- else:
- raise ValueError(f"Alignment value {self.alignment} unknown")
-
- x -= offset
-
- return x, self.yPosition
-
- def loadPreset(self, pr, *args):
- super().loadPreset(pr, *args)
-
- font = QFont()
- font.fromString(pr["titleFont"])
- self.page.fontComboBox_titleFont.setCurrentFont(font)
-
- def savePreset(self):
- saveValueStore = super().savePreset()
- saveValueStore["titleFont"] = self.titleFont.toString()
- return saveValueStore
-
- def previewRender(self):
- return self.addText(self.width, self.height)
-
- def properties(self):
- props = ["static"]
- if not self.title:
- props.append("error")
- return props
-
- def error(self):
- return "No text provided."
-
- def frameRender(self, frameNo):
- return self.addText(self.width, self.height)
-
- def addText(self, width, height):
- font = self.titleFont
- font.setPixelSize(self.fontSize)
- font.setStyle(QFont.Style.StyleNormal)
- font.setWeight(QFont.Weight.Normal)
- font.setCapitalization(QFont.Capitalization.MixedCase)
- if self.fontStyle == 1:
- font.setWeight(QFont.Weight.DemiBold)
- if self.fontStyle == 2:
- font.setWeight(QFont.Weight.Bold)
- elif self.fontStyle == 3:
- font.setStyle(QFont.Style.StyleItalic)
- elif self.fontStyle == 4:
- font.setWeight(QFont.Weight.Bold)
- font.setStyle(QFont.Style.StyleItalic)
- elif self.fontStyle == 5:
- font.setStyle(QFont.Style.StyleOblique)
- elif self.fontStyle == 6:
- font.setCapitalization(QFont.Capitalization.SmallCaps)
-
- image = FramePainter(width, height)
- x, y = self.getXY()
- log.debug("Text position translates to %s, %s", x, y)
- if self.stroke > 0:
- outliner = QtGui.QPainterPathStroker()
- outliner.setWidth(self.stroke)
- path = QtGui.QPainterPath()
- if self.fontStyle == 6:
- # PathStroker ignores smallcaps so we need this weird hack
- path.addText(x, y, font, self.title[0])
- fm = QtGui.QFontMetrics(font)
- newX = x + fm.boundingRect(self.title[0]).width()
- strokeFont = self.page.fontComboBox_titleFont.currentFont()
- strokeFont.setCapitalization(QFont.Capitalization.SmallCaps)
- strokeFont.setPixelSize(int((self.fontSize / 7) * 5))
- strokeFont.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 139)
- path.addText(newX, y, strokeFont, self.title[1:])
- else:
- path.addText(x, y, font, self.title)
- path = outliner.createStroke(path)
- image.setPen(QtCore.Qt.PenStyle.NoPen)
- image.setBrush(PaintColor(*self.strokeColor))
- image.drawPath(path)
-
- image.setFont(font)
- image.setPen(self.textColor)
- image.drawText(x, y, self.title)
-
- # turn QImage into Pillow frame
- frame = image.finalize()
- if self.shadow:
- shadImg = ImageEnhance.Contrast(frame).enhance(0.0)
- shadImg = shadImg.filter(ImageFilter.GaussianBlur(self.shadBlur))
- shadImg = ImageChops.offset(shadImg, self.shadX, self.shadY)
- shadImg.paste(frame, box=(0, 0), mask=frame)
- frame = shadImg
-
- return frame
-
- def commandHelp(self):
- print("Enter a string to use as centred white text:")
- print(' "title=User Error"')
- print("Specify a text color:\n color=255,255,255")
- print("Set custom x, y position:\n x=500 y=500")
-
- def command(self, arg):
- if "=" in arg:
- key, arg = arg.split("=", 1)
- if key == "color":
- self.page.lineEdit_textColor.setText(arg)
- return
- elif key == "size":
- self.page.spinBox_fontSize.setValue(int(arg))
- return
- elif key == "x":
- self.page.spinBox_xTextAlign.setValue(int(arg))
- return
- elif key == "y":
- self.page.spinBox_yTextAlign.setValue(int(arg))
- return
- elif key == "title":
- self.page.lineEdit_title.setText(arg)
- return
- super().command(arg)
diff --git a/src/components/text.ui b/src/components/text.ui
deleted file mode 100644
index b62e0ed..0000000
--- a/src/components/text.ui
+++ /dev/null
@@ -1,671 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 197
-
-
-
- Form
-
-
- -
-
-
- 6
-
-
- QLayout::SetDefaultConstraint
-
-
- 4
-
-
-
-
-
-
-
-
- Title
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 0
- 0
-
-
-
- Testing New GUI
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Font
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 0
- 0
-
-
-
-
-
-
- -
-
-
- 0
-
-
-
-
-
-
- 0
- 0
-
-
-
- Text Layout
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 100
- 16777215
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Center Text
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- X
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 50
- 16777215
-
-
-
-
- 0
- 0
-
-
-
- 0
-
-
- 999999999
-
-
- 0
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Y
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 50
- 16777215
-
-
-
- 999999999
-
-
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 16777215
- 16777215
-
-
-
- Text Color
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 32
- 32
-
-
-
-
-
-
-
- 32
- 32
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Font Size
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
-
-
-
-
-
- 1
-
-
- 500
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Font Style
-
-
-
- -
-
-
-
-
- Normal
-
-
- -
-
- Semi-Bold
-
-
- -
-
- Bold
-
-
- -
-
- Italic
-
-
- -
-
- Bold Italic
-
-
- -
-
- Faux Italic
-
-
- -
-
- Small Caps
-
-
-
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 0
- 16777215
-
-
-
- Qt::NoFocus
-
-
- 255,255,255
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Stroke
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- px
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Stroke Color
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 0
- 16777215
-
-
-
- Qt::NoFocus
-
-
- 0,0,0
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 32
- 32
-
-
-
-
-
-
-
- 32
- 32
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
- Shadow
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Shadow Offset
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- -1000
-
-
- 1000
-
-
- -4
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- -1000
-
-
- 1000
-
-
- 8
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Shadow Blur
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- 99.000000000000000
-
-
- 0.100000000000000
-
-
- 5.000000000000000
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Minimum
-
-
-
- 40
- 20
-
-
-
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
diff --git a/src/components/video.py b/src/components/video.py
deleted file mode 100644
index 65a05af..0000000
--- a/src/components/video.py
+++ /dev/null
@@ -1,254 +0,0 @@
-from PIL import Image
-from PyQt6 import QtGui, QtCore, QtWidgets
-import os
-import math
-import subprocess
-import logging
-
-from ..component import Component
-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):
- name = "Video"
- version = "1.0.0"
-
- def widget(self, *args):
- self.videoPath = ""
- self.badAudio = False
- self.x = 0
- self.y = 0
- self.loopVideo = False
- super().widget(*args)
- self._image = BlankFrame(self.width, self.height)
- self.page.pushButton_video.clicked.connect(self.pickVideo)
- self.trackWidgets(
- {
- "videoPath": self.page.lineEdit_video,
- "loopVideo": self.page.checkBox_loop,
- "useAudio": self.page.checkBox_useAudio,
- "distort": self.page.checkBox_distort,
- "scale": self.page.spinBox_scale,
- "volume": self.page.spinBox_volume,
- "xPosition": self.page.spinBox_x,
- "yPosition": self.page.spinBox_y,
- },
- presetNames={
- "videoPath": "video",
- "loopVideo": "loop",
- "xPosition": "x",
- "yPosition": "y",
- },
- relativeWidgets=[
- "xPosition",
- "yPosition",
- ],
- )
-
- def update(self):
- if self.page.checkBox_useAudio.isChecked():
- self.page.label_volume.setEnabled(True)
- self.page.spinBox_volume.setEnabled(True)
- else:
- self.page.label_volume.setEnabled(False)
- self.page.spinBox_volume.setEnabled(False)
-
- def previewRender(self):
- self.updateChunksize()
- frame = self.getPreviewFrame(self.width, self.height)
- if not frame:
- return BlankFrame(self.width, self.height)
- else:
- return frame
-
- def properties(self):
- props = []
- outputFile = None
- if hasattr(self.parent, "lineEdit_outputFile"):
- # check only happens in GUI mode
- outputFile = self.parent.lineEdit_outputFile.text()
-
- if not self.videoPath:
- self.lockError("There is no video selected.")
- elif not os.path.exists(self.videoPath):
- self.lockError("The video selected does not exist!")
- elif outputFile and os.path.realpath(self.videoPath) == os.path.realpath(
- outputFile
- ):
- self.lockError("Input and output paths match.")
-
- if self.useAudio:
- props.append("audio")
- if not testAudioStream(self.videoPath) and self.error() is None:
- self.lockError("Could not identify an audio stream in this video.")
-
- return props
-
- def audio(self):
- params = {}
- if self.volume != 1.0:
- params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume)
- return (self.videoPath, params)
-
- def preFrameRender(self, **kwargs):
- super().preFrameRender(**kwargs)
- self.updateChunksize()
- self.video = (
- FfmpegVideo(
- inputPath=self.videoPath,
- filter_=self.makeFfmpegFilter(),
- width=self.width,
- height=self.height,
- chunkSize=self.chunkSize,
- frameRate=int(self.settings.value("outputFrameRate")),
- parent=self.parent,
- loopVideo=self.loopVideo,
- component=self,
- )
- if os.path.exists(self.videoPath)
- else None
- )
-
- def frameRender(self, frameNo):
- if FfmpegVideo.threadError is not None:
- raise FfmpegVideo.threadError
- return self.finalizeFrame(self.video.frame(frameNo))
-
- def postFrameRender(self):
- closePipe(self.video.pipe)
-
- def pickVideo(self):
- imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
- filename, _ = QtWidgets.QFileDialog.getOpenFileName(
- self.page,
- "Choose Video",
- imgDir,
- "Video Files (%s)" % " ".join(self.core.videoFormats),
- )
- if filename:
- self.settings.setValue("componentDir", os.path.dirname(filename))
- self.mergeUndo = False
- self.page.lineEdit_video.setText(filename)
- self.mergeUndo = True
-
- def getPreviewFrame(self, width, height):
- if not self.videoPath or not os.path.exists(self.videoPath):
- return
-
- command = [
- self.core.FFMPEG_BIN,
- "-thread_queue_size",
- "512",
- "-i",
- self.videoPath,
- "-f",
- "image2pipe",
- "-pix_fmt",
- "rgba",
- ]
- command.extend(self.makeFfmpegFilter())
- command.extend(
- [
- "-codec:v",
- "rawvideo",
- "-",
- "-ss",
- "90",
- "-frames:v",
- "1",
- ]
- )
-
- if self.core.logEnabled:
- logFilename = os.path.join(
- self.core.logDir, "preview_%s.log" % str(self.compPos)
- )
- log.debug("Creating ffmpeg process (log at %s)" % logFilename)
- with open(logFilename, "w") as logf:
- logf.write(" ".join(command) + "\n\n")
- with open(logFilename, "a") as logf:
- pipe = openPipe(
- command,
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=logf,
- bufsize=10**8,
- )
- else:
- pipe = openPipe(
- command,
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- bufsize=10**8,
- )
-
- byteFrame = pipe.stdout.read(self.chunkSize)
- closePipe(pipe)
-
- frame = self.finalizeFrame(byteFrame)
- return frame
-
- def makeFfmpegFilter(self):
- return [
- "-filter_complex",
- "[0:v] scale=%s:%s" % scale(self.scale, self.width, self.height, str),
- ]
-
- def updateChunksize(self):
- if self.scale != 100 and not self.distort:
- width, height = scale(self.scale, self.width, self.height, int)
- else:
- width, height = self.width, self.height
- self.chunkSize = 4 * width * height
-
- def command(self, arg):
- if "=" in arg:
- key, arg = arg.split("=", 1)
- if key == "path" and os.path.exists(arg):
- if "*%s" % os.path.splitext(arg)[1] in self.core.videoFormats:
- self.page.lineEdit_video.setText(arg)
- self.page.spinBox_scale.setValue(100)
- self.page.checkBox_loop.setChecked(True)
- return
- else:
- print("Not a supported video format")
- quit(1)
- elif arg == "audio":
- if not self.page.lineEdit_video.text():
- print("'audio' option must follow a video selection")
- quit(1)
- self.page.checkBox_useAudio.setChecked(True)
- return
- super().command(arg)
-
- def commandHelp(self):
- print("Load a video:\n path=/filepath/to/video.mp4")
- print("Using audio:\n path=/filepath/to/video.mp4 audio")
-
- def finalizeFrame(self, imageData):
- try:
- if self.distort:
- image = Image.frombytes("RGBA", (self.width, self.height), imageData)
- else:
- image = Image.frombytes(
- "RGBA",
- scale(self.scale, self.width, self.height, int),
- imageData,
- )
- self._image = image
- except ValueError:
- # use last good frame
- image = self._image
-
- if self.scale != 100 or self.xPosition != 0 or self.yPosition != 0:
- frame = BlankFrame(self.width, self.height)
- frame.paste(image, box=(self.xPosition, self.yPosition))
- else:
- frame = image
- return frame
diff --git a/src/components/video.ui b/src/components/video.ui
deleted file mode 100644
index 08d15d3..0000000
--- a/src/components/video.ui
+++ /dev/null
@@ -1,328 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 197
-
-
-
-
- 0
- 0
-
-
-
-
- 0
- 197
-
-
-
- Form
-
-
- -
-
-
- 4
-
-
-
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 31
- 0
-
-
-
- Video
-
-
-
- -
-
-
-
- 1
- 0
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 1
- 0
-
-
-
-
- 32
- 32
-
-
-
- ...
-
-
-
- 32
- 32
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- X
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
- -10000
-
-
- 10000
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Y
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
-
- 0
- 0
-
-
-
- -10000
-
-
- 10000
-
-
- 0
-
-
-
-
-
-
-
- -
-
-
-
-
-
- Loop
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Distort by scale
-
-
-
- -
-
-
- Scale
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- QAbstractSpinBox::UpDownArrows
-
-
- %
-
-
- 10
-
-
- 400
-
-
- 100
-
-
-
-
-
- -
-
-
-
-
-
- Use Audio
-
-
-
- -
-
-
- Volume
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- x
-
-
- 0.000000000000000
-
-
- 10.000000000000000
-
-
- 0.100000000000000
-
-
- 1.000000000000000
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
diff --git a/src/components/waveform.py b/src/components/waveform.py
deleted file mode 100644
index 7dc0b99..0000000
--- a/src/components/waveform.py
+++ /dev/null
@@ -1,230 +0,0 @@
-from PIL import Image
-from PyQt6 import QtGui, QtCore, QtWidgets
-from PyQt6.QtGui import QColor
-import os
-import math
-import subprocess
-import logging
-
-from ..component import Component
-from ..toolkit.frame import BlankFrame, scale
-from ..toolkit import checkOutput
-from ..toolkit.ffmpeg import (
- openPipe,
- closePipe,
- getAudioDuration,
- FfmpegVideo,
- exampleSound,
-)
-
-
-log = logging.getLogger("AVP.Components.Waveform")
-
-
-class Component(Component):
- name = "Waveform"
- version = "1.0.0"
-
- def widget(self, *args):
- super().widget(*args)
- self._image = BlankFrame(self.width, self.height)
-
- self.page.lineEdit_color.setText("255,255,255")
-
- if hasattr(self.parent, "lineEdit_audioFile"):
- self.parent.lineEdit_audioFile.textChanged.connect(self.update)
-
- self.trackWidgets(
- {
- "color": self.page.lineEdit_color,
- "mode": self.page.comboBox_mode,
- "amplitude": self.page.comboBox_amplitude,
- "x": self.page.spinBox_x,
- "y": self.page.spinBox_y,
- "mirror": self.page.checkBox_mirror,
- "scale": self.page.spinBox_scale,
- "opacity": self.page.spinBox_opacity,
- "compress": self.page.checkBox_compress,
- "mono": self.page.checkBox_mono,
- },
- colorWidgets={
- "color": self.page.pushButton_color,
- },
- relativeWidgets=[
- "x",
- "y",
- ],
- )
-
- def previewRender(self):
- self.updateChunksize()
- frame = self.getPreviewFrame(self.width, self.height)
- if not frame:
- return BlankFrame(self.width, self.height)
- else:
- return frame
-
- def preFrameRender(self, **kwargs):
- super().preFrameRender(**kwargs)
- self.updateChunksize()
- w, h = scale(self.scale, self.width, self.height, str)
- self.video = FfmpegVideo(
- inputPath=self.audioFile,
- filter_=self.makeFfmpegFilter(),
- width=w,
- height=h,
- chunkSize=self.chunkSize,
- frameRate=int(self.settings.value("outputFrameRate")),
- parent=self.parent,
- component=self,
- debug=True,
- )
-
- def frameRender(self, frameNo):
- if FfmpegVideo.threadError is not None:
- raise FfmpegVideo.threadError
- return self.finalizeFrame(self.video.frame(frameNo))
-
- def postFrameRender(self):
- closePipe(self.video.pipe)
-
- def getPreviewFrame(self, width, height):
- genericPreview = self.settings.value("pref_genericPreview")
- startPt = 0
- if not genericPreview:
- inputFile = self.parent.lineEdit_audioFile.text()
- if not inputFile or not os.path.exists(inputFile):
- return
- duration = getAudioDuration(inputFile)
- if not duration:
- return
- startPt = duration / 3
- if startPt + 3 > duration:
- startPt += startPt - 3
-
- command = [
- self.core.FFMPEG_BIN,
- "-thread_queue_size",
- "512",
- "-r",
- str(self.settings.value("outputFrameRate")),
- "-ss",
- "{0:.3f}".format(startPt),
- "-i",
- self.core.junkStream if genericPreview else inputFile,
- "-f",
- "image2pipe",
- "-pix_fmt",
- "rgba",
- ]
- command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt))
- command.extend(
- [
- "-an",
- "-s:v",
- "%sx%s" % scale(self.scale, self.width, self.height, str),
- "-codec:v",
- "rawvideo",
- "-",
- "-frames:v",
- "1",
- ]
- )
- if self.core.logEnabled:
- logFilename = os.path.join(
- self.core.logDir, "preview_%s.log" % str(self.compPos)
- )
- log.debug("Creating ffmpeg log at %s", logFilename)
- with open(logFilename, "w") as logf:
- logf.write(" ".join(command) + "\n\n")
- with open(logFilename, "a") as logf:
- pipe = openPipe(
- command,
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=logf,
- bufsize=10**8,
- )
- else:
- pipe = openPipe(
- command,
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- bufsize=10**8,
- )
- byteFrame = pipe.stdout.read(self.chunkSize)
- closePipe(pipe)
-
- frame = self.finalizeFrame(byteFrame)
- return frame
-
- def makeFfmpegFilter(self, preview=False, startPt=0):
- w, h = scale(self.scale, self.width, self.height, str)
- if self.amplitude == 0:
- amplitude = "lin"
- elif self.amplitude == 1:
- amplitude = "log"
- elif self.amplitude == 2:
- amplitude = "sqrt"
- elif self.amplitude == 3:
- amplitude = "cbrt"
- hexcolor = QColor(*self.color).name()
- opacity = "{0:.1f}".format(self.opacity / 100)
- genericPreview = self.settings.value("pref_genericPreview")
- if self.mode < 3:
- filter_ = (
- "showwaves="
- f'r={str(self.settings.value("outputFrameRate"))}:'
- f's={self.settings.value("outputWidth")}x{self.settings.value("outputHeight")}:'
- f'mode={self.page.comboBox_mode.currentText().lower() if self.mode != 3 else "p2p"}:'
- f"colors={hexcolor}@{opacity}:scale={amplitude}"
- )
- elif self.mode > 2:
- filter_ = (
- f'showfreqs=s={str(self.settings.value("outputWidth"))}x{str(self.settings.value("outputHeight"))}:'
- f'mode={"line" if self.mode == 4 else "bar"}:'
- f"colors={hexcolor}@{opacity}"
- f":ascale={amplitude}:fscale={'log' if self.mono else 'lin'}"
- )
-
- baselineHeight = int(self.height * (4 / 1080))
- return [
- "-filter_complex",
- f"{exampleSound('wave', extra='') if preview and genericPreview else '[0:a] '}"
- f"{'compand=gain=4,' if self.compress else ''}"
- f"{'aformat=channel_layouts=mono,' if self.mono and self.mode < 3 else ''}"
- f"{filter_}"
- f"{', drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=%s:color=%s@%s' % (baselineHeight, hexcolor, opacity) if self.mode < 2 else ''}"
- f"{', hflip' if self.mirror else''}"
- " [v1]; "
- "[v1] scale=%s:%s%s [v]"
- % (
- w,
- h,
- ", trim=duration=%s" % "{0:.3f}".format(startPt + 3) if preview else "",
- ),
- "-map",
- "[v]",
- ]
-
- def updateChunksize(self):
- width, height = scale(self.scale, self.width, self.height, int)
- self.chunkSize = 4 * width * height
-
- def finalizeFrame(self, imageData):
- try:
- image = Image.frombytes(
- "RGBA",
- scale(self.scale, self.width, self.height, int),
- imageData,
- )
- self._image = image
- except ValueError:
- image = self._image
- if self.scale != 100 or self.x != 0 or self.y != 0:
- frame = BlankFrame(self.width, self.height)
- frame.paste(image, box=(self.x, self.y))
- else:
- frame = image
- return frame
diff --git a/src/components/waveform.ui b/src/components/waveform.ui
deleted file mode 100644
index 5473f33..0000000
--- a/src/components/waveform.ui
+++ /dev/null
@@ -1,383 +0,0 @@
-
-
- Form
-
-
-
- 0
- 0
- 586
- 197
-
-
-
-
- 0
- 0
-
-
-
-
- 0
- 197
-
-
-
- Form
-
-
- -
-
-
- 4
-
-
-
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 31
- 0
-
-
-
- Mode
-
-
-
- -
-
-
-
-
- Cline
-
-
- -
-
- Line
-
-
- -
-
- Point
-
-
- -
-
- Frequency Bar
-
-
- -
-
- Frequency Line
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- X
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
- -10000
-
-
- 10000
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Y
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 80
- 16777215
-
-
-
-
- 0
- 0
-
-
-
- -10000
-
-
- 10000
-
-
- 0
-
-
-
-
-
-
-
- -
-
-
-
-
-
- Color
-
-
-
- -
-
-
- Qt::ImhNone
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 32
- 32
-
-
-
-
-
-
- false
-
-
- false
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Opacity
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- QAbstractSpinBox::UpDownArrows
-
-
- %
-
-
- 0
-
-
- 100
-
-
- 100
-
-
-
- -
-
-
- Scale
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
-
- -
-
-
- QAbstractSpinBox::UpDownArrows
-
-
- %
-
-
- 10
-
-
- 400
-
-
- 100
-
-
-
-
-
- -
-
-
-
-
-
- Compress
-
-
-
- -
-
-
- Mono
-
-
-
- -
-
-
- Mirror
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Amplitude
-
-
-
- -
-
-
-
-
- Linear
-
-
- -
-
- Logarithmic
-
-
- -
-
- Square root
-
-
- -
-
- Cubic root
-
-
-
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
-
-
diff --git a/src/core.py b/src/core.py
deleted file mode 100644
index df6ff63..0000000
--- a/src/core.py
+++ /dev/null
@@ -1,597 +0,0 @@
-"""
-Home to the Core class which tracks program state. Used by GUI & commandline
-to create a list of components and create a video thread to export.
-"""
-
-from PyQt6 import QtCore, QtGui, uic
-import sys
-import os
-import json
-from importlib import import_module
-import logging
-
-from . import toolkit
-
-
-log = logging.getLogger("AVP.Core")
-STDOUT_LOGLVL = logging.WARNING
-FILE_LIBLOGLVL = logging.WARNING
-FILE_LOGLVL = logging.INFO
-
-
-class Core:
- """
- MainWindow and Command module both use an instance of this class
- to store the core program state. This object tracks the components,
- talks to the components, handles opening/creating project files
- and presets, and creates the video thread to export.
- This class also stores constants as class variables.
- """
-
- def __init__(self):
- self.importComponents()
- self.selectedComponents = []
- self.savedPresets = {} # copies of presets to detect modification
- self.openingProject = False
-
- def __repr__(self):
- return "\n=~=~=~=\n".join([repr(comp) for comp in self.selectedComponents])
-
- def importComponents(self):
- def findComponents():
- for f in os.listdir(Core.componentsPath):
- name, ext = os.path.splitext(f)
- if name.startswith("__"):
- continue
- elif ext == ".py":
- yield name
-
- log.debug("Importing component modules")
- self.modules = [
- import_module(".components.%s" % name, __package__)
- for name in findComponents()
- ]
- # store canonical module names and indexes
- self.moduleIndexes = [i for i in range(len(self.modules))]
- self.compNames = [mod.Component.name for mod in self.modules]
- # alphabetize modules by Component name
- sortedModules = sorted(zip(self.compNames, self.modules))
- self.compNames = [y[0] for y in sortedModules]
- self.modules = [y[1] for y in sortedModules]
-
- # store alternative names for modules
- self.altCompNames = []
- for i, mod in enumerate(self.modules):
- if hasattr(mod.Component, "names"):
- for name in mod.Component.names():
- self.altCompNames.append((name, i))
-
- def componentListChanged(self):
- for i, component in enumerate(self.selectedComponents):
- component.compPos = i
-
- def insertComponent(self, compPos, component, loader):
- """
- Creates a new component using these args:
- (compPos, component obj or moduleIndex, MWindow/Command/Core obj)
- """
- if compPos < 0 or compPos > len(self.selectedComponents):
- compPos = len(self.selectedComponents)
- if len(self.selectedComponents) > 50:
- return -1
- if 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))
- component = self.modules[moduleIndex].Component(moduleIndex, compPos, self)
- component.widget(loader)
- else:
- moduleIndex = -1
- log.debug("Inserting previously-created %s component", component.name)
-
- component._error.connect(loader.videoThreadError)
- self.selectedComponents.insert(compPos, component)
- if hasattr(loader, "insertComponent"):
- loader.insertComponent(compPos)
-
- self.componentListChanged()
- self.updateComponent(compPos)
- return compPos
-
- def moveComponent(self, startI, endI):
- comp = self.selectedComponents.pop(startI)
- self.selectedComponents.insert(endI, comp)
-
- self.componentListChanged()
- return endI
-
- def removeComponent(self, i):
- self.selectedComponents.pop(i)
- self.componentListChanged()
-
- def clearComponents(self):
- self.selectedComponents = list()
- self.componentListChanged()
-
- def updateComponent(self, i):
- log.debug("Auto-updating %s #%s", self.selectedComponents[i], str(i))
- self.selectedComponents[i].update(auto=True)
-
- def moduleIndexFor(self, compName):
- try:
- index = self.compNames.index(compName)
- return self.moduleIndexes[index]
- except ValueError:
- for altName, modI in self.altCompNames:
- if altName == compName:
- return self.moduleIndexes[modI]
-
- def clearPreset(self, compIndex):
- self.selectedComponents[compIndex].currentPreset = None
-
- def openPreset(self, filepath, compIndex, presetName):
- """Applies a preset to a specific component"""
- saveValueStore = self.getPreset(filepath)
- if not saveValueStore:
- return False
- comp = self.selectedComponents[compIndex]
- comp.loadPreset(saveValueStore, presetName)
-
- self.savedPresets[presetName] = dict(saveValueStore)
- return True
-
- def getPreset(self, filepath):
- """Returns the preset dict stored at this filepath"""
- if not os.path.exists(filepath):
- return False
- with open(filepath, "r") as f:
- for line in f:
- saveValueStore = toolkit.presetFromString(line.strip())
- break
- return saveValueStore
-
- def getPresetDir(self, comp):
- """Get the preset subdir for a particular version of a component"""
- return os.path.join(Core.presetDir, comp.name, str(comp.version))
-
- def openProject(self, loader, filepath):
- """loader is the object calling this method which must have
- its own showMessage(**kwargs) method for displaying errors.
- """
- if not os.path.exists(filepath):
- loader.showMessage(msg="Project file not found.")
- return
-
- errcode, data = self.parseAvFile(filepath)
- if errcode == 0:
- self.openingProject = True
- try:
- if hasattr(loader, "window"):
- for widget, value in data["WindowFields"]:
- widget = eval("loader.%s" % widget)
- with toolkit.blockSignals(widget):
- toolkit.setWidgetValue(widget, value)
-
- for key, value in data["Settings"]:
- Core.settings.setValue(key, value)
- for tup in data["Components"]:
- name, vers, preset = tup
- clearThis = False
- modified = False
-
- # add loaded named presets to savedPresets dict
- if "preset" in preset and preset["preset"] is not None:
- nam = preset["preset"]
- filepath2 = os.path.join(Core.presetDir, name, str(vers), nam)
- origSaveValueStore = self.getPreset(filepath2)
- if origSaveValueStore:
- self.savedPresets[nam] = dict(origSaveValueStore)
- modified = not origSaveValueStore == preset
- else:
- # saved preset was renamed or deleted
- clearThis = True
-
- # create the actual component object & get its index
- i = self.insertComponent(-1, self.moduleIndexFor(name), loader)
- if i is None:
- loader.showMessage(
- msg=f"Component '{name}' didn't initialize correctly and had to be removed."
- )
- continue
- if i == -1:
- loader.showMessage(msg="Too many components!")
- break
-
- try:
- if "preset" in preset and preset["preset"] is not None:
- self.selectedComponents[i].loadPreset(preset)
- else:
- self.selectedComponents[i].loadPreset(
- preset, preset["preset"]
- )
- except KeyError as e:
- log.warning(
- "%s missing value: %s" % (self.selectedComponents[i], e)
- )
-
- if clearThis:
- self.clearPreset(i)
- if hasattr(loader, "updateComponentTitle"):
- loader.updateComponentTitle(i, modified)
- self.openingProject = False
- return True
- except Exception:
- errcode = 1
- data = sys.exc_info()
-
- if errcode == 1:
- typ, value, tb = data
- if typ.__name__ == "KeyError":
- # probably just an old version, still loadable
- log.warning("Project file missing value: %s" % value)
- return
- if hasattr(loader, "createNewProject"):
- loader.createNewProject(prompt=False)
- msg = "%s: %s\n\n" % (typ.__name__, value)
- msg += toolkit.formatTraceback(tb)
- loader.showMessage(
- msg="Project file '%s' is corrupted." % filepath,
- showCancel=False,
- icon="Warning",
- detail=msg,
- )
- self.openingProject = False
- return False
-
- def parseAvFile(self, filepath):
- """
- Parses an avp (project) or avl (preset package) file.
- Returns dictionary with section names as the keys, each one
- contains a list of tuples: (compName, version, compPresetDict)
- """
- log.debug("Parsing av file: %s", filepath)
- validSections = ("Components", "Settings", "WindowFields")
- data = {sect: [] for sect in validSections}
- try:
- with open(filepath, "r") as f:
-
- def parseLine(line):
- """Decides if a file line is a section header"""
- line = line.strip()
- newSection = ""
-
- if (
- line.startswith("[")
- and line.endswith("]")
- and line[1:-1] in validSections
- ):
- newSection = line[1:-1]
-
- return line, newSection
-
- section = ""
- i = 0
- for line in f:
- line, newSection = parseLine(line)
- if newSection:
- section = str(newSection)
- continue
- if line and section == "Components":
- if i == 0:
- lastCompName = str(line)
- i += 1
- elif i == 1:
- lastCompVers = str(line)
- i += 1
- elif i == 2:
- lastCompPreset = toolkit.presetFromString(line)
- data[section].append(
- (lastCompName, lastCompVers, lastCompPreset)
- )
- i = 0
- elif line and section:
- key, value = line.split("=", 1)
- data[section].append((key, value.strip()))
-
- return 0, data
- except Exception:
- return 1, sys.exc_info()
-
- def importPreset(self, filepath):
- errcode, data = self.parseAvFile(filepath)
- returnList = []
- if errcode == 0:
- name, vers, preset = data["Components"][0]
- presetName = (
- preset["preset"]
- if preset["preset"]
- else os.path.basename(filepath)[:-4]
- )
- newPath = os.path.join(Core.presetDir, name, vers, presetName)
- if os.path.exists(newPath):
- return False, newPath
- preset["preset"] = presetName
- self.createPresetFile(name, vers, presetName, preset)
- return True, presetName
- elif errcode == 1:
- # TODO: an error message
- return False, ""
-
- def exportPreset(self, exportPath, compName, vers, origName):
- internalPath = os.path.join(Core.presetDir, compName, str(vers), origName)
- if not os.path.exists(internalPath):
- return
- if os.path.exists(exportPath):
- os.remove(exportPath)
- with open(internalPath, "r") as f:
- internalData = [line for line in f]
- try:
- saveValueStore = toolkit.presetFromString(internalData[0].strip())
- self.createPresetFile(compName, vers, origName, saveValueStore, exportPath)
- return True
- except Exception:
- return False
-
- def createPresetFile(self, compName, vers, presetName, saveValueStore, filepath=""):
- """Create a preset file (.avl) at filepath using args.
- Or if filepath is empty, create an internal preset using args"""
- if not filepath:
- dirname = os.path.join(Core.presetDir, compName, str(vers))
- if not os.path.exists(dirname):
- os.makedirs(dirname)
- filepath = os.path.join(dirname, presetName)
- internal = True
- else:
- if not filepath.endswith(".avl"):
- filepath += ".avl"
- internal = False
-
- with open(filepath, "w") as f:
- if not internal:
- f.write("[Components]\n")
- f.write("%s\n" % compName)
- f.write("%s\n" % str(vers))
- f.write(toolkit.presetToString(saveValueStore))
-
- def createProjectFile(self, filepath, window=None):
- """Create a project file (.avp) using the current program state"""
- log.info("Creating %s", filepath)
- settingsKeys = [
- "componentDir",
- "inputDir",
- "outputDir",
- "presetDir",
- "projectDir",
- ]
- try:
- if not filepath.endswith(".avp"):
- filepath += ".avp"
- if os.path.exists(filepath):
- os.remove(filepath)
-
- with open(filepath, "w") as f:
- f.write("[Components]\n")
- for comp in self.selectedComponents:
- saveValueStore = comp.savePreset()
- saveValueStore["preset"] = comp.currentPreset
- f.write("%s\n" % str(comp))
- f.write("%s\n" % str(comp.version))
- f.write("%s\n" % toolkit.presetToString(saveValueStore))
-
- f.write("\n[Settings]\n")
- for key in Core.settings.allKeys():
- if key in settingsKeys:
- f.write("%s=%s\n" % (key, Core.settings.value(key)))
-
- if window:
- f.write("\n[WindowFields]\n")
- f.write(
- "lineEdit_audioFile=%s\n"
- "lineEdit_outputFile=%s\n"
- % (
- window.lineEdit_audioFile.text(),
- window.lineEdit_outputFile.text(),
- )
- )
- return True
- except Exception:
- return False
-
- def newVideoWorker(self, loader, audioFile, outputPath):
- """loader is MainWindow or Command object which must own the thread"""
- from . import video_thread
-
- self.videoThread = QtCore.QThread(loader)
- videoWorker = video_thread.Worker(
- loader, audioFile, outputPath, self.selectedComponents
- )
- videoWorker.moveToThread(self.videoThread)
- videoWorker.videoCreated.connect(self.stopVideoThread)
-
- self.videoThread.start()
- return videoWorker
-
- def stopVideoThread(self):
- self.videoThread.quit()
- self.videoThread.wait()
-
- def cancel(self):
- Core.canceled = True
-
- def reset(self):
- Core.canceled = False
-
- @classmethod
- def storeSettings(cls):
- """Store settings/paths to directories as class variables"""
- from .__init__ import wd
- from .toolkit.ffmpeg import findFfmpeg
-
- cls.wd = wd
- dataDir = QtCore.QStandardPaths.writableLocation(
- QtCore.QStandardPaths.StandardLocation.AppConfigLocation
- )
- # Windows: C:/Users//AppData/Local/audio-visualizer
- # macOS: ~/Library/Preferences/audio-visualizer
- # Linux: ~/.config/audio-visualizer
- with open(os.path.join(wd, "encoder-options.json")) as json_file:
- encoderOptions = json.load(json_file)
-
- # Locate FFmpeg
- ffmpegBin = findFfmpeg()
- if not ffmpegBin:
- print("Could not find FFmpeg")
-
- settings = {
- "canceled": False,
- "FFMPEG_BIN": ffmpegBin,
- "dataDir": dataDir,
- "settings": QtCore.QSettings(
- os.path.join(dataDir, "settings.ini"),
- QtCore.QSettings.Format.IniFormat,
- ),
- "presetDir": os.path.join(dataDir, "presets"),
- "componentsPath": os.path.join(wd, "components"),
- "junkStream": os.path.join(wd, "gui", "background.png"),
- "encoderOptions": encoderOptions,
- "resolutions": [
- "1920x1080",
- "1280x720",
- "854x480",
- ],
- "logDir": os.path.join(dataDir, "log"),
- "logEnabled": False,
- "previewEnabled": True,
- }
-
- settings["videoFormats"] = toolkit.appendUppercase(
- [
- "*.mp4",
- "*.mov",
- "*.mkv",
- "*.avi",
- "*.webm",
- "*.flv",
- ]
- )
- settings["audioFormats"] = toolkit.appendUppercase(
- [
- "*.mp3",
- "*.wav",
- "*.ogg",
- "*.fla",
- "*.flac",
- "*.aac",
- ]
- )
- settings["imageFormats"] = toolkit.appendUppercase(
- [
- "*.png",
- "*.jpg",
- "*.tif",
- "*.tiff",
- "*.gif",
- "*.bmp",
- "*.ico",
- "*.xbm",
- "*.xpm",
- ]
- )
-
- # Register all settings as class variables
- for classvar, val in settings.items():
- setattr(cls, classvar, val)
-
- cls.loadDefaultSettings()
- if not os.path.exists(cls.dataDir):
- os.makedirs(cls.dataDir)
- for neededDirectory in (
- cls.presetDir,
- cls.logDir,
- cls.settings.value("projectDir"),
- ):
- if not os.path.exists(neededDirectory):
- os.mkdir(neededDirectory)
- cls.makeLogger(deleteOldLogs=True)
-
- @classmethod
- def loadDefaultSettings(cls):
- # settings that get saved into the ini file
- cls.defaultSettings = {
- "outputWidth": 1280,
- "outputHeight": 720,
- "outputFrameRate": 30,
- "outputAudioCodec": "AAC",
- "outputAudioBitrate": "192",
- "outputVideoCodec": "H264",
- "outputVideoBitrate": "2500",
- "outputVideoFormat": "yuv420p",
- "outputPreset": "medium",
- "outputFormat": "mp4",
- "outputContainer": "MP4",
- "projectDir": os.path.join(cls.dataDir, "projects"),
- "pref_insertCompAtTop": True,
- "pref_genericPreview": True,
- "pref_undoLimit": 10,
- }
-
- for parm, value in cls.defaultSettings.items():
- if cls.settings.value(parm) is None:
- cls.settings.setValue(parm, value)
-
- # Allow manual editing of prefs. (Surprisingly necessary as Qt seems to
- # store True as 'true' but interprets a manually-added 'true' as str.)
- for key in cls.settings.allKeys():
- if not key.startswith("pref_"):
- continue
- val = cls.settings.value(key)
- try:
- val = int(val)
- except ValueError:
- if val == "true":
- val = True
- elif val == "false":
- val = False
- cls.settings.setValue(key, val)
-
- @staticmethod
- def makeLogger(deleteOldLogs=False):
- # send critical log messages to stdout
- logStream = logging.StreamHandler()
- logStream.setLevel(STDOUT_LOGLVL)
- streamFormatter = logging.Formatter("<%(name)s> %(levelname)s: %(message)s")
- logStream.setFormatter(streamFormatter)
- log = logging.getLogger("AVP")
- log.addHandler(logStream)
-
- if FILE_LOGLVL is not None:
- # write log files as well!
- Core.logEnabled = True
- logFilename = os.path.join(Core.logDir, "avp_debug.log")
- libLogFilename = os.path.join(Core.logDir, "global_debug.log")
-
- if deleteOldLogs:
- for log_ in (logFilename, libLogFilename):
- if os.path.exists(log_):
- os.remove(log_)
-
- logFile = logging.FileHandler(logFilename, delay=True)
- logFile.setLevel(FILE_LOGLVL)
- libLogFile = logging.FileHandler(libLogFilename, delay=True)
- libLogFile.setLevel(FILE_LIBLOGLVL)
- fileFormatter = logging.Formatter(
- "[%(asctime)s] %(threadName)-10.10s %(name)-23.23s %(levelname)s: "
- "%(message)s"
- )
- logFile.setFormatter(fileFormatter)
- libLogFile.setFormatter(fileFormatter)
-
- libLog = logging.getLogger()
- log.addHandler(logFile)
- libLog.addHandler(libLogFile)
- # lowest level must be explicitly set on the root Logger
- libLog.setLevel(0)
-
-
-# always store settings in class variables even if a Core object is not created
-Core.storeSettings()
diff --git a/src/encoder-options.json b/src/encoder-options.json
deleted file mode 100644
index 78bc940..0000000
--- a/src/encoder-options.json
+++ /dev/null
@@ -1,130 +0,0 @@
-{
- "containers":[
- {
- "name": "MP4",
- "container": "mp4",
- "default-vcodec": "H264",
- "default-acodec": "AAC",
- "video-codecs": [
- "H264",
- "H264 (nvenc)",
- "MPEG4"
- ],
- "audio-codecs": [
- "AAC",
- "AC3",
- "MP3"
- ]
- },
- {
- "name": "MOV",
- "container": "mov",
- "default-vcodec": "H264",
- "default-acodec": "AAC",
- "video-codecs": [
- "H264",
- "H264 (nvenc)",
- "MPEG4",
- "XVID"
- ],
- "audio-codecs": [
- "AAC",
- "AC3",
- "MP3",
- "PCM s16 LE"
- ]
- },
- {
- "name": "MKV",
- "container": "matroska",
- "default-vcodec": "H264",
- "default-acodec": "AAC",
- "video-codecs": [
- "H264",
- "H264 (nvenc)",
- "MPEG4",
- "MPEG2",
- "DV",
- "WMV"
- ],
- "audio-codecs": [
- "AAC",
- "AC3",
- "MP3",
- "PCM s16 LE",
- "WMA"
- ]
- },
- {
- "name": "AVI",
- "container": "avi",
- "default-vcodec": "H264",
- "default-acodec": "AAC",
- "video-codecs": [
- "H264",
- "H264 (nvenc)",
- "MPEG4",
- "MPEG2",
- "DV",
- "WMV"
- ],
- "audio-codecs": [
- "AAC",
- "AC3",
- "MP3",
- "PCM s16 LE",
- "WMA"
- ]
- },
- {
- "name": "WEBM",
- "container": "webm",
- "default-vcodec": "VP9",
- "default-acodec": "Vorbis",
- "video-codecs": [
- "VP9",
- "VP8"
- ],
- "audio-codecs": [
- "Vorbis"
- ]
- },
- {
- "name": "FLV",
- "container": "flv",
- "default-vcodec": "FLV",
- "default-acodec": "Vorbis",
- "video-codecs": [
- "Sorenson (flv)",
- "H264",
- "H264 (nvenc)",
- "MPEG4"
- ],
- "audio-codecs": [
- "MP3",
- "PCM s16 LE",
- "Vorbis"
- ]
- }
- ],
- "video-codecs":{
- "H264": ["libx264"],
- "H264 (nvenc)": ["h264_nvenc", "nvenc_h264"],
- "MPEG4": ["mpeg4"],
- "VP9": ["libvpx-vp9"],
- "VP8": ["libvpx"],
- "XVID": ["libxvid"],
- "Sorenson (flv)": ["flv"],
- "MPEG2": ["mp2video"],
- "DV": ["dvvideo"],
- "WMV": ["wmv2"]
- },
- "audio-codecs": {
- "AAC": ["libfdk_aac", "aac"],
- "AC3": ["ac3"],
- "MP3": ["libmp3lame"],
- "PCM s16 LE": ["pcm_s16le"],
- "WMA": ["wmav2"],
- "Vorbis": ["libvorbis"]
- }
-}
\ No newline at end of file
diff --git a/src/gui/__init__.py b/src/gui/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/gui/actions.py b/src/gui/actions.py
deleted file mode 100644
index 654b2a0..0000000
--- a/src/gui/actions.py
+++ /dev/null
@@ -1,196 +0,0 @@
-"""
-QCommand classes for every undoable user action performed in the MainWindow
-"""
-
-from PyQt6.QtGui import QUndoCommand
-import os
-import logging
-from copy import copy
-
-from ..core import Core
-
-
-log = logging.getLogger("AVP.Gui.Actions")
-
-
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-# COMPONENT ACTIONS
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-
-
-class AddComponent(QUndoCommand):
- def __init__(self, parent, compI, moduleI):
- super().__init__(
- "create new %s component" % parent.core.modules[moduleI].Component.name
- )
- self.parent = parent
- self.moduleI = moduleI
- self.compI = compI
- self.comp = None
- self.valid = True
-
- def redo(self):
- if self.comp is None:
- i = self.parent.core.insertComponent(self.compI, self.moduleI, self.parent)
- if i != self.compI:
- self.valid = False
- if i is not None:
- log.error(
- f"Expected new component index to be {self.compI} but received {i}"
- )
- else:
- # inserting previously-created component
- self.parent.core.insertComponent(self.compI, self.comp, self.parent)
-
- def undo(self):
- if not self.valid:
- return
- self.comp = self.parent.core.selectedComponents[self.compI]
- self.parent._removeComponent(self.compI)
-
-
-class RemoveComponent(QUndoCommand):
- def __init__(self, parent, selectedRows):
- super().__init__("remove component")
- self.parent = parent
- componentList = self.parent.listWidget_componentList
- self.selectedRows = [componentList.row(selected) for selected in selectedRows]
- self.components = [parent.core.selectedComponents[i] for i in self.selectedRows]
-
- def redo(self):
- self.parent._removeComponent(self.selectedRows[0])
-
- def undo(self):
- componentList = self.parent.listWidget_componentList
- for index, comp in zip(self.selectedRows, self.components):
- self.parent.core.insertComponent(index, comp, self.parent)
- self.parent.drawPreview()
-
-
-class MoveComponent(QUndoCommand):
- def __init__(self, parent, row, newRow, tag):
- super().__init__("move component %s" % tag)
- self.parent = parent
- self.row = row
- self.newRow = newRow
- self.id_ = ord(tag[0])
-
- def id(self):
- """If 2 consecutive updates have same id, Qt will call mergeWith()"""
- return self.id_
-
- def mergeWith(self, other):
- self.newRow = other.newRow
- return True
-
- def do(self, rowa, rowb):
- componentList = self.parent.listWidget_componentList
-
- page = self.parent.pages.pop(rowa)
- self.parent.pages.insert(rowb, page)
-
- item = componentList.takeItem(rowa)
- componentList.insertItem(rowb, item)
-
- stackedWidget = self.parent.stackedWidget
- widget = stackedWidget.removeWidget(page)
- stackedWidget.insertWidget(rowb, page)
- componentList.setCurrentRow(rowb)
- stackedWidget.setCurrentIndex(rowb)
- self.parent.core.moveComponent(rowa, rowb)
- self.parent.drawPreview(True)
-
- def redo(self):
- self.do(self.row, self.newRow)
-
- def undo(self):
- self.do(self.newRow, self.row)
-
-
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-# PRESET ACTIONS
-# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-
-
-class ClearPreset(QUndoCommand):
- def __init__(self, parent, compI):
- super().__init__("clear preset")
- self.parent = parent
- self.compI = compI
- self.component = self.parent.core.selectedComponents[compI]
- self.store = self.component.savePreset()
- self.store["preset"] = self.component.currentPreset
-
- def redo(self):
- self.parent.core.clearPreset(self.compI)
- self.parent.updateComponentTitle(self.compI, False)
-
- def undo(self):
- self.parent.core.selectedComponents[self.compI].loadPreset(self.store)
- self.parent.updateComponentTitle(self.compI, self.store)
-
-
-class OpenPreset(QUndoCommand):
- def __init__(self, parent, presetName, compI):
- super().__init__("open %s preset" % presetName)
- self.parent = parent
- self.presetName = presetName
- self.compI = compI
-
- comp = self.parent.core.selectedComponents[compI]
- self.store = comp.savePreset()
- self.store["preset"] = copy(comp.currentPreset)
-
- def redo(self):
- self.parent._openPreset(self.presetName, self.compI)
-
- def undo(self):
- self.parent.core.selectedComponents[self.compI].loadPreset(self.store)
- self.parent.parent.updateComponentTitle(self.compI, self.store)
-
-
-class RenamePreset(QUndoCommand):
- def __init__(self, parent, path, oldName, newName):
- super().__init__("rename preset")
- self.parent = parent
- self.path = path
- self.oldName = oldName
- self.newName = newName
-
- def redo(self):
- self.parent.renamePreset(self.path, self.oldName, self.newName)
-
- def undo(self):
- self.parent.renamePreset(self.path, self.newName, self.oldName)
-
-
-class DeletePreset(QUndoCommand):
- def __init__(self, parent, compName, vers, presetFile):
- self.parent = parent
- self.preset = (compName, vers, presetFile)
- self.path = os.path.join(Core.presetDir, compName, str(vers), presetFile)
- self.store = self.parent.core.getPreset(self.path)
- self.presetName = self.store["preset"]
- super().__init__("delete %s preset (%s)" % (self.presetName, compName))
- self.loadedPresets = [
- i
- for i, comp in enumerate(self.parent.core.selectedComponents)
- if self.presetName == str(comp.currentPreset)
- ]
-
- def redo(self):
- os.remove(self.path)
- for i in self.loadedPresets:
- self.parent.core.clearPreset(i)
- self.parent.parent.updateComponentTitle(i, False)
- self.parent.findPresets()
- self.parent.drawPresetList()
-
- def undo(self):
- self.parent.createNewPreset(*self.preset, self.store)
- selectedComponents = self.parent.core.selectedComponents
- for i in self.loadedPresets:
- selectedComponents[i].currentPreset = self.presetName
- self.parent.parent.updateComponentTitle(i)
- self.parent.findPresets()
- self.parent.drawPresetList()
diff --git a/src/gui/background.png b/src/gui/background.png
deleted file mode 100644
index fb58593..0000000
Binary files a/src/gui/background.png and /dev/null differ
diff --git a/src/gui/mainwindow.py b/src/gui/mainwindow.py
deleted file mode 100644
index b0a564b..0000000
--- a/src/gui/mainwindow.py
+++ /dev/null
@@ -1,1053 +0,0 @@
-"""
-When using GUI mode, this module's object (the main window) takes
-user input to construct a program state (stored in the Core object).
-This shows a preview of the video being created and allows for saving
-projects and exporting the video at a later time.
-"""
-
-from PyQt6 import QtCore, QtWidgets, uic
-import PyQt6.QtWidgets as QtWidgets
-from PyQt6.QtGui import QUndoStack, QShortcut
-from PIL import Image
-from queue import Queue
-import sys
-import os
-import signal
-import filecmp
-import time
-import logging
-
-from ..core import Core
-from . import preview_thread
-from .preview_win import PreviewWindow
-from .presetmanager import PresetManager
-from .actions import *
-from ..toolkit import (
- disableWhenEncoding,
- disableWhenOpeningProject,
- checkOutput,
- blockSignals,
-)
-
-
-appName = "Audio Visualizer"
-log = logging.getLogger("AVP.Gui.MainWindow")
-
-
-class MyQUndoStack(QUndoStack):
- # FIXME move this class
- @property
- def encoding(self):
- return self.parent().encoding
-
- @disableWhenEncoding
- def undo(self, *args, **kwargs):
- super().undo(*args, **kwargs)
-
- @disableWhenEncoding
- def redo(self, *args, **kwargs):
- super().redo(*args, **kwargs)
-
-
-class MainWindow(QtWidgets.QMainWindow):
- """
- The MainWindow wraps many Core methods in order to update the GUI
- accordingly. E.g., instead of self.core.openProject(), it will use
- self.openProject() and update the window titlebar within the wrapper.
-
- MainWindow manages the autosave feature, although Core has the
- primary functions for opening and creating project files.
- """
-
- createVideo = QtCore.pyqtSignal()
- newTask = QtCore.pyqtSignal(list) # for the preview window
- processTask = QtCore.pyqtSignal()
-
- def __init__(self, project, dpi):
- super().__init__()
- log.debug("Main thread id: {}".format(int(QtCore.QThread.currentThreadId())))
- uic.loadUi(os.path.join(Core.wd, "gui", "mainwindow.ui"), self)
-
- if dpi:
- self.resize(
- int(self.width() * (dpi / 144)),
- int(self.height() * (dpi / 144)),
- )
-
- self.core = Core()
- Core.mode = "GUI"
- # widgets of component settings
- self.pages = []
- self.lastAutosave = time.time()
- # list of previous five autosave times, used to reduce update spam
- self.autosaveTimes = []
- self.autosaveCooldown = 0.2
- self.encoding = False
-
- # Find settings created by Core object
- self.dataDir = Core.dataDir
- self.presetDir = Core.presetDir
- self.autosavePath = os.path.join(self.dataDir, "autosave.avp")
- self.settings = Core.settings
-
- # Create stack of undoable user actions
- self.undoStack = MyQUndoStack(self)
- undoLimit = self.settings.value("pref_undoLimit")
- self.undoStack.setUndoLimit(undoLimit)
-
- # Create Undo Dialog - A standard QUndoView on a standard QDialog
- self.undoDialog = QtWidgets.QDialog(self)
- self.undoDialog.setWindowTitle("Undo History")
- undoView = QtWidgets.QUndoView(self.undoStack)
- layout = QtWidgets.QVBoxLayout()
- layout.addWidget(undoView)
- self.undoDialog.setLayout(layout)
-
- # Create Preset Manager
- self.presetManager = PresetManager(self)
-
- # Create the preview window and its thread, queues, and timers
- log.debug("Creating preview window")
- self.previewWindow = PreviewWindow(
- self, os.path.join(Core.wd, "gui", "background.png")
- )
- self.verticalLayout_previewWrapper.addWidget(self.previewWindow)
-
- log.debug("Starting preview thread")
- self.previewQueue = Queue()
- self.previewThread = QtCore.QThread(self)
- self.previewWorker = preview_thread.Worker(
- self.core, self.settings, self.previewQueue
- )
- self.previewWorker.moveToThread(self.previewThread)
- self.newTask.connect(self.previewWorker.createPreviewImage)
- self.processTask.connect(self.previewWorker.process)
- self.previewWorker.error.connect(self.previewWindow.threadError)
- self.previewWorker.imageCreated.connect(self.showPreviewImage)
- self.previewThread.start()
- self.previewThread.finished.connect(
- lambda: log.info("Preview thread finished.")
- )
-
- timeout = 500
- log.debug("Preview timer set to trigger when idle for %sms" % str(timeout))
- self.timer = QtCore.QTimer(self)
- self.timer.timeout.connect(self.processTask.emit)
- self.timer.start(timeout)
-
- # Begin decorating the window and connecting events
- componentList = self.listWidget_componentList
-
- # Undo Feature
- def toggleUndoButtonEnabled(*_):
- """Enable/disable undo button depending on whether UndoStack contains Actions"""
- try:
- undoButton.setEnabled(self.undoStack.count())
- except RuntimeError:
- # program is probably in midst of exiting
- pass
-
- style = self.pushButton_undo.style()
- undoButton = self.pushButton_undo
- undoButton.setIcon(
- style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_FileDialogBack)
- )
- undoButton.clicked.connect(self.undoStack.undo)
- undoButton.setEnabled(False)
- self.undoStack.cleanChanged.connect(toggleUndoButtonEnabled)
- self.undoMenu = QtWidgets.QMenu()
- self.undoMenu.addAction(self.undoStack.createUndoAction(self))
- self.undoMenu.addAction(self.undoStack.createRedoAction(self))
- action = self.undoMenu.addAction("Show History...")
- action.triggered.connect(lambda _: self.showUndoStack())
- undoButton.setMenu(self.undoMenu)
- # end of Undo Feature
-
- style = self.pushButton_listMoveUp.style()
- self.pushButton_listMoveUp.setIcon(
- style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowUp)
- )
- style = self.pushButton_listMoveDown.style()
- self.pushButton_listMoveDown.setIcon(
- style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowDown)
- )
- style = self.pushButton_removeComponent.style()
- self.pushButton_removeComponent.setIcon(
- style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogDiscardButton)
- )
-
- if sys.platform == "darwin":
- log.debug("Darwin detected: showing progress label below progress bar")
- self.progressBar_createVideo.setTextVisible(False)
- else:
- self.progressLabel.setHidden(True)
-
- self.toolButton_selectAudioFile.clicked.connect(self.openInputFileDialog)
-
- self.toolButton_selectOutputFile.clicked.connect(self.openOutputFileDialog)
-
- def changedField():
- self.autosave()
- self.updateWindowTitle()
-
- self.lineEdit_audioFile.textChanged.connect(changedField)
- self.lineEdit_outputFile.textChanged.connect(changedField)
-
- self.progressBar_createVideo.setValue(0)
-
- self.pushButton_createVideo.clicked.connect(self.createAudioVisualization)
-
- self.pushButton_Cancel.clicked.connect(self.stopVideo)
-
- for i, container in enumerate(Core.encoderOptions["containers"]):
- self.comboBox_videoContainer.addItem(container["name"])
- if container["name"] == self.settings.value("outputContainer"):
- selectedContainer = i
-
- self.comboBox_videoContainer.setCurrentIndex(selectedContainer)
- self.comboBox_videoContainer.currentIndexChanged.connect(self.updateCodecs)
-
- self.updateCodecs()
-
- for i in range(self.comboBox_videoCodec.count()):
- codec = self.comboBox_videoCodec.itemText(i)
- if codec == self.settings.value("outputVideoCodec"):
- self.comboBox_videoCodec.setCurrentIndex(i)
-
- for i in range(self.comboBox_audioCodec.count()):
- codec = self.comboBox_audioCodec.itemText(i)
- if codec == self.settings.value("outputAudioCodec"):
- self.comboBox_audioCodec.setCurrentIndex(i)
-
- self.comboBox_videoCodec.currentIndexChanged.connect(self.updateCodecSettings)
-
- self.comboBox_audioCodec.currentIndexChanged.connect(self.updateCodecSettings)
-
- vBitrate = int(self.settings.value("outputVideoBitrate"))
- aBitrate = int(self.settings.value("outputAudioBitrate"))
-
- self.spinBox_vBitrate.setValue(vBitrate)
- self.spinBox_aBitrate.setValue(aBitrate)
- self.spinBox_vBitrate.valueChanged.connect(self.updateCodecSettings)
- self.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings)
-
- # Make component buttons
- self.compMenu = QtWidgets.QMenu()
- for i, comp in enumerate(self.core.modules):
- action = self.compMenu.addAction(comp.Component.name)
- action.triggered.connect(lambda _, item=i: self.addComponent(0, item))
-
- self.pushButton_addComponent.setMenu(self.compMenu)
-
- componentList.dropEvent = self.dragComponent
- componentList.itemSelectionChanged.connect(self.changeComponentWidget)
- componentList.itemSelectionChanged.connect(
- self.presetManager.clearPresetListSelection
- )
- self.pushButton_removeComponent.clicked.connect(lambda: self.removeComponent())
-
- componentList.setContextMenuPolicy(
- QtCore.Qt.ContextMenuPolicy.CustomContextMenu
- )
- componentList.customContextMenuRequested.connect(self.componentContextMenu)
-
- currentRes = (
- str(self.settings.value("outputWidth"))
- + "x"
- + str(self.settings.value("outputHeight"))
- )
- for i, res in enumerate(Core.resolutions):
- self.comboBox_resolution.addItem(res)
- if res == currentRes:
- currentRes = i
- self.comboBox_resolution.setCurrentIndex(currentRes)
- self.comboBox_resolution.currentIndexChanged.connect(
- self.updateResolution
- )
-
- self.pushButton_listMoveUp.clicked.connect(lambda: self.moveComponent(-1))
- self.pushButton_listMoveDown.clicked.connect(lambda: self.moveComponent(1))
-
- # Configure the Projects Menu
- self.projectMenu = QtWidgets.QMenu()
- self.menuButton_newProject = self.projectMenu.addAction("New Project")
- self.menuButton_newProject.triggered.connect(lambda: self.createNewProject())
- self.menuButton_openProject = self.projectMenu.addAction("Open Project")
- self.menuButton_openProject.triggered.connect(
- lambda: self.openOpenProjectDialog()
- )
-
- action = self.projectMenu.addAction("Save Project")
- action.triggered.connect(self.saveCurrentProject)
-
- action = self.projectMenu.addAction("Save Project As")
- action.triggered.connect(self.openSaveProjectDialog)
-
- self.pushButton_projects.setMenu(self.projectMenu)
-
- # Configure the Presets Button
- self.pushButton_presets.clicked.connect(self.openPresetManager)
-
- self.updateWindowTitle()
- log.debug("Showing main window")
- self.show()
-
- if project and project != self.autosavePath:
- if not project.endswith(".avp"):
- project += ".avp"
- # open a project from the commandline
- if not os.path.dirname(project):
- project = os.path.join(self.settings.value("projectDir"), project)
- self.currentProject = project
- self.settings.setValue("currentProject", project)
- if os.path.exists(self.autosavePath):
- os.remove(self.autosavePath)
- else:
- # open the last currentProject from settings
- self.currentProject = self.settings.value("currentProject")
-
- # delete autosave if it's identical to this project
- if self.autosaveExists(identical=True):
- os.remove(self.autosavePath)
-
- if self.currentProject and os.path.exists(self.autosavePath):
- ch = self.showMessage(
- msg="Restore unsaved changes in project '%s'?"
- % os.path.basename(self.currentProject)[:-4],
- showCancel=True,
- )
- if ch:
- self.saveProjectChanges()
- else:
- os.remove(self.autosavePath)
-
- self.openProject(self.currentProject, prompt=False)
- self.drawPreview(True)
-
- log.info("Pillow version %s", Image.__version__)
-
- # verify Ffmpeg version
- if not self.core.FFMPEG_BIN:
- self.showMessage(
- msg="FFmpeg could not be found. This is a critical error. "
- "Install FFmpeg, or download it and place the program executable "
- "in the same folder as this program.",
- icon="Critical",
- )
- else:
- if not self.settings.value("ffmpegMsgShown"):
- try:
- with open(os.devnull, "w") as f:
- ffmpegVers = checkOutput(
- [self.core.FFMPEG_BIN, "-version"], stderr=f
- )
- goodVersion = str(ffmpegVers).split()[2].startswith("4")
- 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)
-
- # Hotkeys for projects
-
- QShortcut("Ctrl+S", self, self.saveCurrentProject)
- QShortcut("Ctrl+A", self, self.openSaveProjectDialog)
- QShortcut("Ctrl+O", self, self.openOpenProjectDialog)
- QShortcut("Ctrl+N", self, self.createNewProject)
-
- # Hotkeys for undo/redo
- QShortcut("Ctrl+Z", self, self.undoStack.undo)
- QShortcut("Ctrl+Y", self, self.undoStack.redo)
- QShortcut("Ctrl+Shift+Z", self, self.undoStack.redo)
-
- # Hotkeys for component list
- for inskey in ("Ctrl+T", QtCore.Qt.Key.Key_Insert):
- QShortcut(
- inskey,
- self,
- activated=lambda: self.pushButton_addComponent.click(),
- )
- for delkey in ("Ctrl+R", QtCore.Qt.Key.Key_Delete):
- QShortcut(delkey, self.listWidget_componentList, self.removeComponent)
- QShortcut(
- "Ctrl+Space",
- self,
- activated=lambda: self.listWidget_componentList.setFocus(),
- )
- QShortcut("Ctrl+Shift+S", self, self.presetManager.openSavePresetDialog)
- QShortcut("Ctrl+Shift+C", self, self.presetManager.clearPreset)
-
- QShortcut(
- "Ctrl+Up",
- self.listWidget_componentList,
- activated=lambda: self.moveComponent(-1),
- )
- QShortcut(
- "Ctrl+Down",
- self.listWidget_componentList,
- activated=lambda: self.moveComponent(1),
- )
- QShortcut(
- "Ctrl+Home",
- self.listWidget_componentList,
- activated=lambda: self.moveComponent("top"),
- )
- QShortcut(
- "Ctrl+End",
- self.listWidget_componentList,
- activated=lambda: self.moveComponent("bottom"),
- )
-
- QShortcut("Ctrl+Shift+F", self, self.showFfmpegCommand)
- QShortcut("Ctrl+Shift+U", self, self.showUndoStack)
-
- if log.isEnabledFor(logging.DEBUG):
- QShortcut("Ctrl+Alt+Shift+R", self, self.drawPreview)
- QShortcut("Ctrl+Alt+Shift+A", self, lambda: log.debug(repr(self)))
-
- # Close MainWindow when receiving Ctrl+C from terminal
- signal.signal(signal.SIGINT, lambda *args: self.close())
-
- # Add initial components if none are in the list
- if not self.core.selectedComponents:
- self.core.insertComponent(0, 0, self)
- self.core.insertComponent(1, 1, self)
-
- def __repr__(self):
- return (
- "%s\n"
- "\n%s\n"
- "#####\n"
- "Preview thread is %s\n"
- % (
- super().__repr__(),
- (
- "core not initialized"
- if not hasattr(self, "core")
- else repr(self.core)
- ),
- (
- "live"
- if hasattr(self, "previewThread") and self.previewThread.isRunning()
- else "dead"
- ),
- )
- )
-
- def closeEvent(self, event):
- log.info("Ending the preview thread")
- self.timer.stop()
- self.previewThread.quit()
- self.previewThread.wait()
- return super().closeEvent(event)
-
- @disableWhenOpeningProject
- def updateWindowTitle(self):
- log.debug("Setting main window's title")
- windowTitle = appName
- try:
- if self.currentProject:
- windowTitle += (
- " - %s" % os.path.splitext(os.path.basename(self.currentProject))[0]
- )
- if self.autosaveExists(identical=False):
- windowTitle += "*"
- except AttributeError:
- pass
- log.verbose(f'Window title is "{windowTitle}"')
- self.setWindowTitle(windowTitle)
-
- @QtCore.pyqtSlot(int, dict)
- def updateComponentTitle(self, pos, presetStore=False):
- """
- Sets component title to modified or unmodified when given boolean.
- If given a preset dict, compares it against the component to
- determine if it is modified.
- A component with no preset is always unmodified.
- """
- if type(presetStore) is dict:
- name = presetStore["preset"]
- if name is None or name not in self.core.savedPresets:
- modified = False
- else:
- modified = presetStore != self.core.savedPresets[name]
-
- modified = bool(presetStore)
- if pos < 0:
- pos = len(self.core.selectedComponents) - 1
- name = self.core.selectedComponents[pos].name
- title = str(name)
- if self.core.selectedComponents[pos].currentPreset:
- title += " - %s" % self.core.selectedComponents[pos].currentPreset
- if modified:
- title += "*"
- if type(presetStore) is bool:
- log.debug(
- "Forcing %s #%s's modified status to %s: %s",
- name,
- pos,
- modified,
- title,
- )
- else:
- log.debug("Setting %s #%s's title: %s", name, pos, title)
- self.listWidget_componentList.item(pos).setText(title)
-
- def updateCodecs(self):
- containerWidget = self.comboBox_videoContainer
- vCodecWidget = self.comboBox_videoCodec
- aCodecWidget = self.comboBox_audioCodec
- index = containerWidget.currentIndex()
- name = containerWidget.itemText(index)
- self.settings.setValue("outputContainer", name)
-
- vCodecWidget.clear()
- aCodecWidget.clear()
-
- for container in Core.encoderOptions["containers"]:
- if container["name"] == name:
- for vCodec in container["video-codecs"]:
- vCodecWidget.addItem(vCodec)
- for aCodec in container["audio-codecs"]:
- aCodecWidget.addItem(aCodec)
-
- def updateCodecSettings(self):
- """Updates settings.ini to match encoder option widgets"""
- vCodecWidget = self.comboBox_videoCodec
- vBitrateWidget = self.spinBox_vBitrate
- aBitrateWidget = self.spinBox_aBitrate
- aCodecWidget = self.comboBox_audioCodec
- currentVideoCodec = vCodecWidget.currentIndex()
- currentVideoCodec = vCodecWidget.itemText(currentVideoCodec)
- currentVideoBitrate = vBitrateWidget.value()
- currentAudioCodec = aCodecWidget.currentIndex()
- currentAudioCodec = aCodecWidget.itemText(currentAudioCodec)
- currentAudioBitrate = aBitrateWidget.value()
- self.settings.setValue("outputVideoCodec", currentVideoCodec)
- self.settings.setValue("outputAudioCodec", currentAudioCodec)
- self.settings.setValue("outputVideoBitrate", currentVideoBitrate)
- self.settings.setValue("outputAudioBitrate", currentAudioBitrate)
-
- @disableWhenOpeningProject
- def autosave(self, force=False):
- if not self.currentProject:
- if os.path.exists(self.autosavePath):
- os.remove(self.autosavePath)
- elif force or time.time() - self.lastAutosave >= self.autosaveCooldown:
- self.core.createProjectFile(self.autosavePath, self)
- self.lastAutosave = time.time()
- if len(self.autosaveTimes) >= 5:
- # Do some math to reduce autosave spam. This gives a smooth
- # curve up to 5 seconds cooldown and maintains that for 30 secs
- # if a component is continuously updated
- timeDiff = self.lastAutosave - self.autosaveTimes.pop()
- if not force and timeDiff >= 1.0 and timeDiff <= 10.0:
- if self.autosaveCooldown / 4.0 < 0.5:
- self.autosaveCooldown += 1.0
- self.autosaveCooldown = (5.0 * (self.autosaveCooldown / 5.0)) + (
- self.autosaveCooldown / 5.0
- ) * 2
- elif force or timeDiff >= self.autosaveCooldown * 5:
- self.autosaveCooldown = 0.2
- self.autosaveTimes.insert(0, self.lastAutosave)
- else:
- log.debug("Autosave rejected by cooldown")
-
- def autosaveExists(self, identical=True):
- """Determines if creating the autosave should be blocked."""
- try:
- if (
- self.currentProject
- and os.path.exists(self.autosavePath)
- and filecmp.cmp(self.autosavePath, self.currentProject) == identical
- ):
- log.debug(
- "Autosave found %s to be identical" % "not" if not identical else ""
- )
- return True
- except FileNotFoundError:
- log.error("Project file couldn't be located: %s", self.currentProject)
- return identical
- return False
-
- def saveProjectChanges(self):
- """Overwrites project file with autosave file"""
- try:
- os.remove(self.currentProject)
- os.rename(self.autosavePath, self.currentProject)
- return True
- except (FileNotFoundError, IsADirectoryError) as e:
- self.showMessage(msg="Project file couldn't be saved.", detail=str(e))
- return False
-
- def openInputFileDialog(self):
- inputDir = self.settings.value("inputDir", os.path.expanduser("~"))
-
- fileName, _ = QtWidgets.QFileDialog.getOpenFileName(
- self,
- "Open Audio File",
- inputDir,
- "Audio Files (%s)" % " ".join(Core.audioFormats),
- )
-
- if fileName:
- self.settings.setValue("inputDir", os.path.dirname(fileName))
- self.lineEdit_audioFile.setText(fileName)
-
- def openOutputFileDialog(self):
- outputDir = self.settings.value("outputDir", os.path.expanduser("~"))
-
- fileName, _ = QtWidgets.QFileDialog.getSaveFileName(
- self,
- "Set Output Video File",
- outputDir,
- "Video Files (%s);; All Files (*)" % " ".join(Core.videoFormats),
- )
-
- if fileName:
- self.settings.setValue("outputDir", os.path.dirname(fileName))
- self.lineEdit_outputFile.setText(fileName)
-
- def stopVideo(self):
- log.info("Export cancelled")
- self.videoWorker.cancel()
- self.canceled = True
-
- def createAudioVisualization(self):
- # create output video if mandatory settings are filled in
- audioFile = self.lineEdit_audioFile.text()
- outputPath = self.lineEdit_outputFile.text()
-
- if audioFile and outputPath and self.core.selectedComponents:
- if not os.path.dirname(outputPath):
- outputPath = os.path.join(os.path.expanduser("~"), outputPath)
- if outputPath and os.path.isdir(outputPath):
- self.showMessage(
- msg="Chosen filename matches a directory, which "
- "cannot be overwritten. Please choose a different "
- "filename or move the directory.",
- icon="Warning",
- )
- return
- else:
- if not audioFile or not outputPath:
- self.showMessage(
- msg="You must select an audio file and output filename."
- )
- elif not self.core.selectedComponents:
- self.showMessage(msg="Not enough components.")
- return
-
- self.canceled = False
- self.progressBarUpdated(-1)
- self.videoWorker = self.core.newVideoWorker(self, audioFile, outputPath)
- self.videoWorker.progressBarUpdate.connect(self.progressBarUpdated)
- self.videoWorker.progressBarSetText.connect(self.progressBarSetText)
- self.videoWorker.imageCreated.connect(self.showPreviewImage)
- self.videoWorker.encoding.connect(self.changeEncodingStatus)
- self.createVideo.emit()
-
- @QtCore.pyqtSlot(str, str)
- def videoThreadError(self, msg, detail):
- try:
- self.stopVideo()
- except AttributeError as e:
- if "videoWorker" not in str(e):
- raise
- self.showMessage(
- msg=msg,
- detail=detail,
- icon="Critical",
- )
- log.info("%s", repr(self))
-
- def changeEncodingStatus(self, status):
- self.encoding = status
- if status:
- # Disable many widgets when starting to export
- self.pushButton_createVideo.setEnabled(False)
- self.pushButton_Cancel.setEnabled(True)
- self.comboBox_resolution.setEnabled(False)
- self.stackedWidget.setEnabled(False)
- self.tab_encoderSettings.setEnabled(False)
- self.label_audioFile.setEnabled(False)
- self.toolButton_selectAudioFile.setEnabled(False)
- self.label_outputFile.setEnabled(False)
- self.toolButton_selectOutputFile.setEnabled(False)
- self.lineEdit_audioFile.setEnabled(False)
- self.lineEdit_outputFile.setEnabled(False)
- self.listWidget_componentList.setEnabled(False)
- self.pushButton_addComponent.setEnabled(False)
- self.pushButton_removeComponent.setEnabled(False)
- self.pushButton_listMoveDown.setEnabled(False)
- self.pushButton_listMoveUp.setEnabled(False)
- self.pushButton_undo.setEnabled(False)
- self.menuButton_newProject.setEnabled(False)
- self.menuButton_openProject.setEnabled(False)
- # Close undo history dialog if open
- self.undoDialog.close()
- # Show label under progress bar on macOS
- if sys.platform == "darwin":
- self.progressLabel.setHidden(False)
- else:
- self.pushButton_createVideo.setEnabled(True)
- self.pushButton_Cancel.setEnabled(False)
- self.comboBox_resolution.setEnabled(True)
- self.stackedWidget.setEnabled(True)
- self.tab_encoderSettings.setEnabled(True)
- self.label_audioFile.setEnabled(True)
- self.toolButton_selectAudioFile.setEnabled(True)
- self.lineEdit_audioFile.setEnabled(True)
- self.label_outputFile.setEnabled(True)
- self.toolButton_selectOutputFile.setEnabled(True)
- self.lineEdit_outputFile.setEnabled(True)
- self.pushButton_addComponent.setEnabled(True)
- self.pushButton_removeComponent.setEnabled(True)
- self.pushButton_listMoveDown.setEnabled(True)
- self.pushButton_listMoveUp.setEnabled(True)
- self.pushButton_undo.setEnabled(True)
- self.menuButton_newProject.setEnabled(True)
- self.menuButton_openProject.setEnabled(True)
- self.listWidget_componentList.setEnabled(True)
- self.progressLabel.setHidden(True)
- self.drawPreview(True)
-
- @QtCore.pyqtSlot(int)
- def progressBarUpdated(self, value):
- self.progressBar_createVideo.setValue(value)
-
- @QtCore.pyqtSlot(str)
- def progressBarSetText(self, value):
- if sys.platform == "darwin":
- self.progressLabel.setText(value)
- else:
- self.progressBar_createVideo.setFormat(value)
-
- def updateResolution(self):
- resIndex = int(self.comboBox_resolution.currentIndex())
- res = Core.resolutions[resIndex].split("x")
- changed = res[0] != self.settings.value("outputWidth")
- self.settings.setValue("outputWidth", res[0])
- self.settings.setValue("outputHeight", res[1])
- if changed:
- for i in range(len(self.core.selectedComponents)):
- self.core.updateComponent(i)
-
- def drawPreview(self, force=False, **kwargs):
- """Use autosave keyword arg to force saving or not saving if needed"""
- self.newTask.emit(self.core.selectedComponents)
- # self.processTask.emit()
- if force or "autosave" in kwargs:
- if force or kwargs["autosave"]:
- self.autosave(True)
- else:
- self.autosave()
- self.updateWindowTitle()
-
- @QtCore.pyqtSlot("QImage")
- def showPreviewImage(self, image):
- self.previewWindow.changePixmap(image)
-
- @disableWhenEncoding
- def showUndoStack(self):
- self.undoDialog.show()
-
- def showFfmpegCommand(self):
- from textwrap import wrap
- from ..toolkit.ffmpeg import createFfmpegCommand
-
- command = createFfmpegCommand(
- self.lineEdit_audioFile.text(),
- self.lineEdit_outputFile.text(),
- self.core.selectedComponents,
- )
- command = " ".join(command)
- log.info(f"FFmpeg command: {command}")
- lines = wrap(command, 49)
- self.showMessage(msg=f"Current FFmpeg command:\n\n{' '.join(lines)}")
-
- def addComponent(self, compPos, moduleIndex):
- """Creates an undoable action that adds a new component."""
- action = AddComponent(self, compPos, moduleIndex)
- self.undoStack.push(action)
-
- def insertComponent(self, index):
- """Triggered by Core to finish initializing a new component."""
- if not hasattr(self.core.selectedComponents[index], "page"):
- log.error("Component failed to initialize")
- return
- componentList = self.listWidget_componentList
- stackedWidget = self.stackedWidget
-
- componentList.insertItem(index, self.core.selectedComponents[index].name)
- componentList.setCurrentRow(index)
-
- # connect to signal that adds an asterisk when modified
- self.core.selectedComponents[index].modified.connect(self.updateComponentTitle)
-
- self.pages.insert(index, self.core.selectedComponents[index].page)
- stackedWidget.insertWidget(index, self.pages[index])
- stackedWidget.setCurrentIndex(index)
-
- return index
-
- def removeComponent(self):
- componentList = self.listWidget_componentList
- selected = componentList.selectedItems()
- if selected:
- action = RemoveComponent(self, selected)
- self.undoStack.push(action)
-
- def _removeComponent(self, index):
- stackedWidget = self.stackedWidget
- componentList = self.listWidget_componentList
- stackedWidget.removeWidget(self.pages[index])
- componentList.takeItem(index)
- self.core.removeComponent(index)
- self.pages.pop(index)
- self.changeComponentWidget()
- self.drawPreview()
-
- @disableWhenEncoding
- def moveComponent(self, change):
- """Moves a component relatively from its current position"""
- componentList = self.listWidget_componentList
- tag = change
- if change == "top":
- change = -componentList.currentRow()
- elif change == "bottom":
- change = len(componentList) - componentList.currentRow() - 1
- else:
- tag = "down" if change == 1 else "up"
-
- row = componentList.currentRow()
- newRow = row + change
- if newRow > -1 and newRow < componentList.count():
- action = MoveComponent(self, row, newRow, tag)
- self.undoStack.push(action)
-
- def getComponentListMousePos(self, position):
- """
- Given a QPos, returns the component index under the mouse cursor
- or -1 if no component is there.
- """
- componentList = self.listWidget_componentList
-
- if hasattr(position, "toPointF"):
- position = position.toPointF()
- position = position.toPoint()
-
- modelIndexes = [
- componentList.model().index(i) for i in range(componentList.count())
- ]
- rects = [componentList.visualRect(modelIndex) for modelIndex in modelIndexes]
- mousePos = [rect.contains(position) for rect in rects]
- if not any(mousePos):
- # Not clicking a component
- mousePos = -1
- else:
- mousePos = mousePos.index(True)
- log.debug("Click component list row %s" % mousePos)
- return mousePos
-
- @disableWhenEncoding
- def dragComponent(self, event):
- """Used as Qt drop event for the component listwidget"""
- componentList = self.listWidget_componentList
- mousePos = self.getComponentListMousePos(event.position())
-
- if mousePos > -1:
- change = (componentList.currentRow() - mousePos) * -1
- else:
- change = componentList.count() - componentList.currentRow() - 1
- self.moveComponent(change)
-
- def changeComponentWidget(self):
- selected = self.listWidget_componentList.selectedItems()
- if selected:
- index = self.listWidget_componentList.row(selected[0])
- self.stackedWidget.setCurrentIndex(index)
-
- def openPresetManager(self):
- """Preset manager for importing, exporting, renaming, deleting"""
- self.presetManager.show_()
-
- def clear(self):
- """Get a blank slate"""
- self.core.clearComponents()
- self.listWidget_componentList.clear()
- for widget in self.pages:
- self.stackedWidget.removeWidget(widget)
- self.pages = []
- for field in (self.lineEdit_audioFile, self.lineEdit_outputFile):
- with blockSignals(field):
- field.setText("")
- self.progressBarUpdated(0)
- self.progressBarSetText("")
- self.undoStack.clear()
-
- @disableWhenEncoding
- def createNewProject(self, prompt=True):
- if prompt:
- self.openSaveChangesDialog("starting a new project")
-
- self.clear()
- self.currentProject = None
- self.settings.setValue("currentProject", None)
- self.drawPreview(True)
-
- def saveCurrentProject(self):
- if self.currentProject:
- self.core.createProjectFile(self.currentProject, self)
- try:
- os.remove(self.autosavePath)
- except FileNotFoundError:
- pass
- self.updateWindowTitle()
- else:
- self.openSaveProjectDialog()
-
- def openSaveChangesDialog(self, phrase):
- success = True
- if self.autosaveExists(identical=False):
- ch = self.showMessage(
- msg="You have unsaved changes in project '%s'. "
- "Save before %s?"
- % (os.path.basename(self.currentProject)[:-4], phrase),
- showCancel=True,
- )
- if ch:
- success = self.saveProjectChanges()
-
- if success and os.path.exists(self.autosavePath):
- os.remove(self.autosavePath)
-
- def openSaveProjectDialog(self):
- filename, _ = QtWidgets.QFileDialog.getSaveFileName(
- self,
- "Create Project File",
- self.settings.value("projectDir"),
- "Project Files (*.avp)",
- )
- if not filename:
- return
- if not filename.endswith(".avp"):
- filename += ".avp"
- self.settings.setValue("projectDir", os.path.dirname(filename))
- self.settings.setValue("currentProject", filename)
- self.currentProject = filename
- self.core.createProjectFile(filename, self)
- self.updateWindowTitle()
-
- @disableWhenEncoding
- def openOpenProjectDialog(self):
- filename, _ = QtWidgets.QFileDialog.getOpenFileName(
- self,
- "Open Project File",
- self.settings.value("projectDir"),
- "Project Files (*.avp)",
- )
- self.openProject(filename)
-
- def openProject(self, filepath, prompt=True):
- if (
- not filepath
- or not os.path.exists(filepath)
- or not filepath.endswith(".avp")
- ):
- return
-
- self.clear()
- # ask to save any changes that are about to get deleted
- if prompt:
- self.openSaveChangesDialog("opening another project")
-
- self.currentProject = filepath
- self.settings.setValue("currentProject", filepath)
- self.settings.setValue("projectDir", os.path.dirname(filepath))
- # actually load the project using core method
- self.core.openProject(self, filepath)
- self.drawPreview(autosave=False)
- self.updateWindowTitle()
-
- def showMessage(self, **kwargs):
- parent = kwargs["parent"] if "parent" in kwargs else self
- msg = QtWidgets.QMessageBox(parent)
- msg.setWindowTitle(appName)
- msg.setModal(True)
- msg.setText(kwargs["msg"])
- msg.setIcon(
- eval("QtWidgets.QMessageBox.Icon.%s" % kwargs["icon"])
- if "icon" in kwargs
- else QtWidgets.QMessageBox.Icon.Information
- )
- msg.setDetailedText(kwargs["detail"] if "detail" in kwargs else None)
- if "showCancel" in kwargs and kwargs["showCancel"]:
- msg.setStandardButtons(
- QtWidgets.QMessageBox.StandardButton.Ok
- | QtWidgets.QMessageBox.StandardButton.Cancel
- )
- else:
- msg.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok)
- ch = msg.exec()
- if ch == 1024:
- return True
- return False
-
- @disableWhenEncoding
- def componentContextMenu(self, QPos):
- """Appears when right-clicking the component list"""
- componentList = self.listWidget_componentList
- self.menu = QtWidgets.QMenu()
- parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0))
-
- index = self.getComponentListMousePos(QPos)
- if index > -1:
- # Show preset menu if clicking a component
- self.presetManager.findPresets()
- menuItem = self.menu.addAction("Save Preset")
- menuItem.triggered.connect(self.presetManager.openSavePresetDialog)
-
- # submenu for opening presets
- try:
- presets = self.presetManager.presets[
- str(self.core.selectedComponents[index])
- ]
- self.presetSubmenu = QtWidgets.QMenu("Open Preset")
- self.menu.addMenu(self.presetSubmenu)
-
- for version, presetName in presets:
- menuItem = self.presetSubmenu.addAction(presetName)
- menuItem.triggered.connect(
- lambda _, presetName=presetName: self.presetManager.openPreset(
- presetName
- )
- )
- except KeyError:
- pass
-
- if self.core.selectedComponents[index].currentPreset:
- menuItem = self.menu.addAction("Clear Preset")
- menuItem.triggered.connect(self.presetManager.clearPreset)
- self.menu.addSeparator()
-
- # "Add Component" submenu
- self.submenu = QtWidgets.QMenu("Add")
- self.menu.addMenu(self.submenu)
- insertCompAtTop = self.settings.value("pref_insertCompAtTop")
- for i, comp in enumerate(self.core.modules):
- menuItem = self.submenu.addAction(comp.Component.name)
- menuItem.triggered.connect(
- lambda _, item=i: self.addComponent(
- 0 if insertCompAtTop else index, item
- )
- )
-
- self.menu.move(parentPosition + QPos)
- self.menu.show()
diff --git a/src/gui/mainwindow.ui b/src/gui/mainwindow.ui
deleted file mode 100644
index cd8454d..0000000
--- a/src/gui/mainwindow.ui
+++ /dev/null
@@ -1,835 +0,0 @@
-
-
- MainWindow
-
-
-
- 0
- 0
- 1008
- 575
-
-
-
-
- 0
- 0
-
-
-
-
- 0
- 0
-
-
-
- Qt::StrongFocus
-
-
- MainWindow
-
-
-
-
- 0
- 0
-
-
-
- false
-
-
-
- 9
-
-
- 0
-
- -
-
-
-
-
-
- Qt::Vertical
-
-
- QSizePolicy::MinimumExpanding
-
-
-
- 0
- 360
-
-
-
-
- -
-
-
- QLayout::SetDefaultConstraint
-
-
- 0
-
-
-
-
-
- Qt::Horizontal
-
-
- QSizePolicy::MinimumExpanding
-
-
-
- 420
- 0
-
-
-
-
-
-
- -
-
-
- QLayout::SetMinimumSize
-
-
- 3
-
-
-
-
-
- QLayout::SetMinimumSize
-
-
- 3
-
-
-
-
-
- QLayout::SetMinimumSize
-
-
-
-
-
- Undo
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 140
- 20
-
-
-
-
- -
-
-
- Projects
-
-
-
- -
-
-
- Presets
-
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Minimum
-
-
-
- 20
- 2
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 0
- 0
-
-
-
-
- 16777215
- 16777215
-
-
-
- true
-
-
- QFrame::StyledPanel
-
-
- QFrame::Sunken
-
-
- 1
-
-
- true
-
-
- true
-
-
- false
-
-
- QAbstractItemView::InternalMove
-
-
- Qt::MoveAction
-
-
-
- -
-
-
-
-
-
- Add
-
-
-
- -
-
-
- Remove
-
-
-
- -
-
-
- Up
-
-
-
- -
-
-
- Down
-
-
-
-
-
-
-
- -
-
-
- 4
-
-
- 2
-
-
-
-
-
-
-
- -
-
-
- QLayout::SetFixedSize
-
-
- 4
-
-
- 0
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 500
- 0
-
-
-
-
- 16777215
- 180
-
-
-
- QTabWidget::North
-
-
- QTabWidget::Rounded
-
-
- 0
-
-
-
- Export Video
-
-
-
- 10
-
-
-
-
-
- 0
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 85
- 0
-
-
-
-
- 80
- 16777215
-
-
-
-
- 80
- 0
-
-
-
- Audio File
-
-
- Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 0
- 28
-
-
-
-
- 16777215
- 28
-
-
-
-
- 0
- 0
-
-
-
-
- -
-
-
-
- 0
- 28
-
-
-
-
- 16777215
- 28
-
-
-
- ...
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 85
- 0
-
-
-
-
- 0
- 0
-
-
-
- Output File
-
-
- Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 0
- 28
-
-
-
-
- 16777215
- 28
-
-
-
-
- -
-
-
-
- 0
- 28
-
-
-
-
- 16777215
- 28
-
-
-
- ...
-
-
-
-
-
-
-
- -
-
-
- 0
-
-
-
-
-
-
- 0
- 0
-
-
-
- 24
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Minimum
-
-
-
- 10
- 20
-
-
-
-
- -
-
-
- Create Video
-
-
-
- -
-
-
- false
-
-
- Cancel
-
-
-
-
-
- -
-
-
-
-
-
- true
-
-
- Qt::AlignCenter
-
-
- -1
-
-
-
-
-
-
- progressLabel
-
-
-
- Encoder Settings
-
-
-
- 10
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 85
- 0
-
-
-
- Container
-
-
-
- -
-
-
-
- 150
- 0
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Minimum
-
-
-
- 5
- 5
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Resolution
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 0
- 0
-
-
-
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 85
- 0
-
-
-
- Video Codec
-
-
-
- -
-
-
-
- 150
- 0
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 5
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Video Bitrate (Kbps)
-
-
-
- -
-
-
- 99999
-
-
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 85
- 0
-
-
-
- Audio Codec
-
-
-
- -
-
-
-
- 150
- 0
-
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QSizePolicy::Fixed
-
-
-
- 5
- 10
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Audio Bitrate (Kbps)
-
-
-
- -
-
-
- 9999
-
-
-
-
-
-
-
-
-
- -
-
-
- QLayout::SetDefaultConstraint
-
-
-
-
-
- Qt::Horizontal
-
-
- QSizePolicy::MinimumExpanding
-
-
-
- 500
- 0
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
-
- 0
- 180
-
-
-
-
- 16777215
- 180
-
-
-
- -1
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/gui/presetmanager.py b/src/gui/presetmanager.py
deleted file mode 100644
index 980a969..0000000
--- a/src/gui/presetmanager.py
+++ /dev/null
@@ -1,349 +0,0 @@
-"""
-Preset manager object handles all interactions with presets, including
-the context menu accessed from MainWindow.
-"""
-
-from PyQt6 import QtCore, QtWidgets, uic
-import string
-import os
-import logging
-
-from ..toolkit import badName
-from ..core import Core
-from .actions import *
-
-
-log = logging.getLogger("AVP.Gui.PresetManager")
-
-
-class PresetManager(QtWidgets.QDialog):
- def __init__(self, parent):
- super().__init__()
- uic.loadUi(os.path.join(Core.wd, "gui", "presetmanager.ui"), self)
- self.parent = parent
- self.core = parent.core
- 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.findPresets()
-
- # window
- self.lastFilter = "*"
- self.presetRows = [] # list of (comp, vers, name) tuples
-
- self.setWindowFlags(QtCore.Qt.WindowType.WindowStaysOnTopHint)
-
- # connect button signals
- self.pushButton_delete.clicked.connect(self.openDeletePresetDialog)
- self.pushButton_rename.clicked.connect(self.openRenamePresetDialog)
- self.pushButton_import.clicked.connect(self.openImportDialog)
- self.pushButton_export.clicked.connect(self.openExportDialog)
- self.pushButton_close.clicked.connect(self.close)
-
- # create filter box and preset list
- self.drawFilterList()
- self.comboBox_filter.currentIndexChanged.connect(
- lambda: self.drawPresetList(
- self.comboBox_filter.currentText(), self.lineEdit_search.text()
- )
- )
-
- # make auto-completion for search bar
- self.autocomplete = QtCore.QStringListModel()
- completer = QtWidgets.QCompleter()
- completer.setModel(self.autocomplete)
- self.lineEdit_search.setCompleter(completer)
- self.lineEdit_search.textChanged.connect(
- lambda: self.drawPresetList(
- self.comboBox_filter.currentText(), self.lineEdit_search.text()
- )
- )
- self.drawPresetList("*")
-
- def show_(self):
- """Open a new preset manager window from the mainwindow"""
- self.findPresets()
- self.drawFilterList()
- self.drawPresetList("*")
- self.show()
-
- def findPresets(self):
- log.debug("Searching %s for presets", self.presetDir)
- parseList = []
- for dirpath, dirnames, filenames in os.walk(self.presetDir):
- # anything without a subdirectory must be a preset folder
- if dirnames:
- continue
- for preset in filenames:
- compName = os.path.basename(os.path.dirname(dirpath))
- if compName not in self.core.compNames:
- continue
- compVers = os.path.basename(dirpath)
- try:
- parseList.append((compName, int(compVers), preset))
- except ValueError:
- continue
- self.presets = {
- compName: [
- (vers, preset) for name, vers, preset in parseList if name == compName
- ]
- for compName, _, __ in parseList
- }
-
- def drawPresetList(self, compFilter=None, presetFilter=""):
- self.listWidget_presets.clear()
- if compFilter:
- self.lastFilter = str(compFilter)
- else:
- compFilter = str(self.lastFilter)
- self.presetRows = []
- presetNames = []
- for component, presets in self.presets.items():
- if compFilter != "*" and component != compFilter:
- continue
- for vers, preset in presets:
- if not presetFilter or presetFilter in preset:
- self.listWidget_presets.addItem("%s: %s" % (component, preset))
- self.presetRows.append((component, vers, preset))
- if preset not in presetNames:
- presetNames.append(preset)
- self.autocomplete.setStringList(presetNames)
-
- def drawFilterList(self):
- self.comboBox_filter.clear()
- self.comboBox_filter.addItem("*")
- for component in self.presets:
- self.comboBox_filter.addItem(component)
-
- def clearPreset(self, compI=None):
- """Functions on mainwindow level from the context menu"""
- compI = self.parent.listWidget_componentList.currentRow()
- action = ClearPreset(self.parent, compI)
- self.parent.undoStack.push(action)
-
- def openSavePresetDialog(self):
- """Functions on mainwindow level from the context menu"""
- selectedComponents = self.core.selectedComponents
- componentList = self.parent.listWidget_componentList
-
- if componentList.currentRow() == -1:
- return
- while True:
- index = componentList.currentRow()
- currentPreset = selectedComponents[index].currentPreset
- newName, OK = QtWidgets.QInputDialog.getText(
- self.parent,
- "Audio Visualizer",
- "New Preset Name:",
- QtWidgets.QLineEdit.EchoMode.Normal,
- currentPreset,
- )
- if OK:
- if badName(newName):
- self.warnMessage(self.parent)
- continue
- if newName:
- if index != -1:
- selectedComponents[index].currentPreset = newName
- saveValueStore = selectedComponents[index].savePreset()
- saveValueStore["preset"] = newName
- componentName = str(selectedComponents[index]).strip()
- vers = selectedComponents[index].version
- self.createNewPreset(
- componentName,
- vers,
- newName,
- saveValueStore,
- window=self.parent,
- )
- self.findPresets()
- self.drawPresetList()
- self.openPreset(newName, index)
- break
-
- def createNewPreset(self, compName, vers, filename, saveValueStore, **kwargs):
- path = os.path.join(self.presetDir, compName, str(vers), filename)
- if self.presetExists(path, **kwargs):
- return
- self.core.createPresetFile(compName, vers, filename, saveValueStore)
-
- def presetExists(self, path, **kwargs):
- if os.path.exists(path):
- window = kwargs.get("window", self)
- ch = self.parent.showMessage(
- msg="%s already exists! Overwrite it?" % os.path.basename(path),
- showCancel=True,
- icon="Warning",
- parent=window,
- )
- if not ch:
- # user clicked cancel
- return True
-
- return False
-
- def openPreset(self, presetName, compPos=None):
- componentList = self.parent.listWidget_componentList
- index = compPos if compPos is not None else componentList.currentRow()
- if index == -1:
- return
- action = OpenPreset(self, presetName, index)
- self.parent.undoStack.push(action)
-
- def _openPreset(self, presetName, index):
- selectedComponents = self.core.selectedComponents
-
- componentName = selectedComponents[index].name.strip()
- version = selectedComponents[index].version
- dirname = os.path.join(self.presetDir, componentName, str(version))
- filepath = os.path.join(dirname, presetName)
- self.core.openPreset(filepath, index, presetName)
-
- self.parent.updateComponentTitle(index)
- self.parent.drawPreview()
-
- def openDeletePresetDialog(self):
- row = self.getPresetRow()
- if row == -1:
- return
- comp, vers, name = self.presetRows[row]
- ch = self.parent.showMessage(
- msg="Really delete %s?" % name,
- showCancel=True,
- icon="Warning",
- parent=self,
- )
- if not ch:
- return
- self.deletePreset(comp, vers, name)
-
- def deletePreset(self, comp, vers, name):
- action = DeletePreset(self, comp, vers, name)
- self.parent.undoStack.push(action)
-
- def warnMessage(self, window=None):
- self.parent.showMessage(
- msg="Preset names must contain only letters, " "numbers, and spaces.",
- parent=window if window else self,
- )
-
- def getPresetRow(self):
- row = self.listWidget_presets.currentRow()
- if row > -1:
- return row
-
- # check if component selected in MainWindow has preset loaded
- componentList = self.parent.listWidget_componentList
- compIndex = componentList.currentRow()
- if compIndex == -1:
- return compIndex
-
- preset = self.core.selectedComponents[compIndex].currentPreset
- if preset is None:
- return -1
- else:
- rowTuple = (
- self.core.selectedComponents[compIndex].name,
- self.core.selectedComponents[compIndex].version,
- preset,
- )
- for i, tup in enumerate(self.presetRows):
- if rowTuple == tup:
- index = i
- break
- else:
- return -1
- return index
-
- def openRenamePresetDialog(self):
- presetList = self.listWidget_presets
- index = self.getPresetRow()
- if index == -1:
- return
-
- while True:
- newName, OK = QtWidgets.QInputDialog.getText(
- self,
- "Preset Manager",
- "Rename Preset:",
- QtWidgets.QLineEdit.EchoMode.Normal,
- self.presetRows[index][2],
- )
- if OK:
- if badName(newName):
- self.warnMessage()
- continue
- if newName:
- comp, vers, oldName = self.presetRows[index]
- path = os.path.join(self.presetDir, comp, str(vers))
- newPath = os.path.join(path, newName)
- if self.presetExists(newPath):
- return
- action = RenamePreset(self, path, oldName, newName)
- self.parent.undoStack.push(action)
- break
-
- def renamePreset(self, path, oldName, newName):
- oldPath = os.path.join(path, oldName)
- newPath = os.path.join(path, newName)
- if os.path.exists(newPath):
- os.remove(newPath)
- os.rename(oldPath, newPath)
- self.findPresets()
- self.drawPresetList()
- path = os.path.dirname(newPath)
- for i, comp in enumerate(self.core.selectedComponents):
- if self.core.getPresetDir(comp) == path and comp.currentPreset == oldName:
- self.core.openPreset(newPath, i, newName)
- self.parent.updateComponentTitle(i, False)
- self.parent.drawPreview()
-
- def openImportDialog(self):
- filename, _ = QtWidgets.QFileDialog.getOpenFileName(
- self,
- "Import Preset File",
- self.settings.value("presetDir"),
- "Preset Files (*.avl)",
- )
- if filename:
- # get installed path & ask user to overwrite if needed
- path = ""
- while True:
- if path:
- if self.presetExists(path):
- break
- else:
- if os.path.exists(path):
- os.remove(path)
- success, path = self.core.importPreset(filename)
- if success:
- break
-
- self.findPresets()
- self.drawPresetList()
- self.settings.setValue("presetDir", os.path.dirname(filename))
-
- def openExportDialog(self):
- index = self.getPresetRow()
- if index == -1:
- return
- filename, _ = QtWidgets.QFileDialog.getSaveFileName(
- self,
- "Export Preset",
- self.settings.value("presetDir"),
- "Preset Files (*.avl)",
- )
- if filename:
- comp, vers, name = self.presetRows[index]
- if not self.core.exportPreset(filename, comp, vers, name):
- self.parent.showMessage(
- msg="Couldn't export %s." % filename, parent=self
- )
- self.settings.setValue("presetDir", os.path.dirname(filename))
-
- def clearPresetListSelection(self):
- self.listWidget_presets.setCurrentRow(-1)
diff --git a/src/gui/presetmanager.ui b/src/gui/presetmanager.ui
deleted file mode 100644
index 5257b1c..0000000
--- a/src/gui/presetmanager.ui
+++ /dev/null
@@ -1,150 +0,0 @@
-
-
- presetmanager
-
-
- Qt::NonModal
-
-
- true
-
-
-
- 0
- 0
- 497
- 377
-
-
-
- Preset Manager
-
-
- -
-
-
-
-
-
-
-
-
- Filter by name
-
-
-
- -
-
-
-
- 200
- 0
-
-
-
-
-
-
- -
-
-
-
-
-
-
- 0
- 0
-
-
-
- true
-
-
-
-
-
- -
-
-
- QLayout::SetMinimumSize
-
-
-
-
-
- Import
-
-
-
- -
-
-
- Export
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- true
-
-
- Rename
-
-
-
- -
-
-
- Delete
-
-
-
-
-
- -
-
-
-
-
-
- <html><head/><body><p><span style=" font-size:10pt; font-style:italic;">Right-click components in the main window to create presets</span></p></body></html>
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
- Close
-
-
-
-
-
-
-
-
-
-
diff --git a/src/gui/preview_thread.py b/src/gui/preview_thread.py
deleted file mode 100644
index 1d78516..0000000
--- a/src/gui/preview_thread.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""
-Thread that runs to create QImages for MainWindow's preview label.
-Processes a queue of component lists.
-"""
-
-from PyQt6 import QtCore, QtGui, uic
-from PyQt6.QtCore import pyqtSignal, pyqtSlot
-from PIL import Image
-from PIL.ImageQt import ImageQt
-from queue import Queue, Empty
-import os
-import logging
-
-from ..toolkit.frame import Checkerboard
-from ..toolkit import disableWhenOpeningProject
-
-
-log = logging.getLogger("AVP.Gui.PreviewThread")
-
-
-class Worker(QtCore.QObject):
-
- imageCreated = pyqtSignal(QtGui.QImage)
- error = pyqtSignal(str)
-
- def __init__(self, core, settings, queue):
- super().__init__()
- self.core = core
- self.settings = settings
- width = int(self.settings.value("outputWidth"))
- height = int(self.settings.value("outputHeight"))
- self.queue = queue
- self.background = Checkerboard(width, height)
-
- @disableWhenOpeningProject
- @pyqtSlot(list)
- def createPreviewImage(self, components):
- dic = {
- "components": components,
- }
- self.queue.put(dic)
- log.debug("Preview thread id: {}".format(int(QtCore.QThread.currentThreadId())))
-
- @pyqtSlot()
- def process(self):
- try:
- nextPreviewInformation = self.queue.get(block=False)
- while self.queue.qsize() >= 2:
- try:
- self.queue.get(block=False)
- except Empty:
- continue
- width = int(self.settings.value("outputWidth"))
- height = int(self.settings.value("outputHeight"))
- if self.background.width != width or self.background.height != height:
- self.background = Checkerboard(width, height)
-
- frame = self.background.copy()
- log.info("Creating new preview frame")
- components = nextPreviewInformation["components"]
- for component in reversed(components):
- try:
- component.lockSize(width, height)
- newFrame = component.previewRender()
- component.unlockSize()
- frame = Image.alpha_composite(frame, newFrame)
-
- except ValueError as e:
- errMsg = (
- "Bad frame returned by %s's preview renderer. "
- "%s. New frame size was %s*%s; should be %s*%s."
- % (
- str(component),
- str(e).capitalize(),
- newFrame.width,
- newFrame.height,
- width,
- height,
- )
- )
- log.critical(errMsg)
- self.error.emit(errMsg)
- break
- except RuntimeError as e:
- log.error(str(e))
- else:
- # We must store a reference to this QImage
- # or else Qt will garbage-collect it on the C++ side
- self.frame = ImageQt(frame)
- self.imageCreated.emit(QtGui.QImage(self.frame))
-
- except Empty:
- True
diff --git a/src/gui/preview_win.py b/src/gui/preview_win.py
deleted file mode 100644
index f52f8a3..0000000
--- a/src/gui/preview_win.py
+++ /dev/null
@@ -1,58 +0,0 @@
-from PyQt6 import QtCore, QtGui, QtWidgets
-import logging
-
-log = logging.getLogger("AVP.Gui.PreviewWindow")
-
-
-class PreviewWindow(QtWidgets.QLabel):
- """
- Paints the preview QLabel in MainWindow and maintains the aspect ratio
- when the window is resized.
- """
-
- def __init__(self, parent, img):
- super().__init__()
- self.parent = parent
- # FIXME
- # self.setFrameStyle(QtWidgets.QFrame.StyledPanel)
- self.pixmap = QtGui.QPixmap(img)
-
- def paintEvent(self, event):
- size = self.size()
- painter = QtGui.QPainter(self)
- point = QtCore.QPoint(0, 0)
- scaledPix = self.pixmap.scaled(
- size,
- QtCore.Qt.AspectRatioMode.KeepAspectRatio,
- transformMode=QtCore.Qt.TransformationMode.SmoothTransformation,
- )
-
- # start painting the label from left upper corner
- point.setX(int((size.width() - scaledPix.width()) / 2))
- point.setY(int((size.height() - scaledPix.height()) / 2))
- painter.drawPixmap(point, scaledPix)
-
- def changePixmap(self, img):
- self.pixmap = QtGui.QPixmap(img)
- self.repaint()
-
- def mousePressEvent(self, event):
- if self.parent.encoding:
- return
-
- i = self.parent.listWidget_componentList.currentRow()
- if i >= 0:
- component = self.parent.core.selectedComponents[i]
- if not hasattr(component, "previewClickEvent"):
- return
- qpoint = event.position().toPoint()
- pos = (qpoint.x(), qpoint.y())
- size = (self.width(), self.height())
- butt = event.button()
- log.info("Click event for #%s: %s button %s" % (i, pos, butt))
- component.previewClickEvent(pos, size, butt)
-
- @QtCore.pyqtSlot(str)
- def threadError(self, msg):
- self.parent.showMessage(msg=msg, icon="Critical", parent=self)
- log.info("%", repr(self.parent))
diff --git a/src/tests/__init__.py b/src/tests/__init__.py
deleted file mode 100644
index e2d83e7..0000000
--- a/src/tests/__init__.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import pytest
-import os
-import sys
-from ..core import Core
-
-
-def getTestDataPath(filename):
- return os.path.join(Core.wd, "tests", "data", filename)
-
-
-def run(logFile):
- """Run Pytest, which then imports and runs all tests in this module."""
- os.environ["PYTEST_QT_API"] = "PyQt6"
- with open(logFile, "w") as f:
- # temporarily redirect stdout to a text file so we capture pytest's output
- sys.stdout = f
- try:
- val = pytest.main(
- [
- os.path.dirname(__file__),
- "-s", # disable pytest's internal capturing of stdout etc.
- ]
- )
- finally:
- sys.stdout = sys.__stdout__
-
- return val
diff --git a/src/tests/data/test.jpg b/src/tests/data/test.jpg
deleted file mode 100644
index 86266d9..0000000
Binary files a/src/tests/data/test.jpg and /dev/null differ
diff --git a/src/tests/data/test.ogg b/src/tests/data/test.ogg
deleted file mode 100644
index 46af76c..0000000
Binary files a/src/tests/data/test.ogg and /dev/null differ
diff --git a/src/tests/data/test.png b/src/tests/data/test.png
deleted file mode 100644
index f1ffd4a..0000000
Binary files a/src/tests/data/test.png and /dev/null differ
diff --git a/src/tests/test_commandline_export.py b/src/tests/test_commandline_export.py
deleted file mode 100644
index 6126da7..0000000
--- a/src/tests/test_commandline_export.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import sys
-import os
-import tempfile
-from ..command import Command
-from . import getTestDataPath
-from pytestqt import qtbot
-
-
-def test_commandline_classic_export(qtbot):
- """Run Qt event loop and create a video in the system /tmp or /temp"""
- soundFile = getTestDataPath("test.ogg")
- outputDir = tempfile.mkdtemp(prefix="avp-test-")
- outputFilename = os.path.join(outputDir, "output.mp4")
- sys.argv = [
- "",
- "-c",
- "0",
- "classic",
- "-i",
- soundFile,
- "-o",
- outputFilename,
- ]
-
- command = Command()
- command.quit = lambda _: None
- command.parseArgs()
- # Command object now has a video_thread Worker which is exporting the video
-
- with qtbot.waitSignal(command.worker.videoCreated, timeout=10000):
- """
- Wait until videoCreated is emitted by the video_thread Worker
- or until 10 second timeout has passed
- """
- print(f"Test Video created at {outputFilename}")
-
- assert os.path.exists(outputFilename)
- # output video should be at least 200kb
- assert os.path.getsize(outputFilename) > 200000
diff --git a/src/tests/test_commandline_parser.py b/src/tests/test_commandline_parser.py
deleted file mode 100644
index 5d1232b..0000000
--- a/src/tests/test_commandline_parser.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import sys
-import pytest
-from ..command import Command
-
-
-def test_commandline_help():
- command = Command()
- sys.argv = ["", "--help"]
- with pytest.raises(SystemExit):
- command.parseArgs()
-
-
-def test_commandline_help_if_bad_args():
- command = Command()
- sys.argv = ["", "--junk"]
- with pytest.raises(SystemExit):
- command.parseArgs()
-
-
-def test_commandline_launches_gui_if_debug():
- command = Command()
- sys.argv = ["", "--debug"]
- mode = command.parseArgs()
- assert mode == "GUI"
-
-
-def test_commandline_launches_gui_if_debug_with_project():
- command = Command()
- sys.argv = ["", "test", "--debug"]
- mode = command.parseArgs()
- assert mode == "GUI"
-
-
-def test_commandline_tries_to_export():
- command = Command()
- didCallFunction = False
-
- def captureFunction(*args):
- nonlocal didCallFunction
- didCallFunction = True
-
- sys.argv = ["", "-c", "0", "classic", "-i", "_", "-o", "_"]
- command.createAudioVisualization = captureFunction
- command.parseArgs()
- assert didCallFunction
diff --git a/src/tests/test_core_init.py b/src/tests/test_core_init.py
deleted file mode 100644
index 950dc13..0000000
--- a/src/tests/test_core_init.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from ..core import Core
-
-
-def test_component_names():
- core = Core()
- assert core.compNames == [
- "Classic Visualizer",
- "Color",
- "Conway's Game of Life",
- "Image",
- "Sound",
- "Spectrum",
- "Title Text",
- "Video",
- "Waveform",
- ]
-
-
-def test_moduleindex():
- core = Core()
- assert core.moduleIndexFor("Classic Visualizer") == 0
diff --git a/src/toolkit/__init__.py b/src/toolkit/__init__.py
deleted file mode 100644
index 55e5f84..0000000
--- a/src/toolkit/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .common import *
diff --git a/src/toolkit/common.py b/src/toolkit/common.py
deleted file mode 100644
index e35aba2..0000000
--- a/src/toolkit/common.py
+++ /dev/null
@@ -1,192 +0,0 @@
-"""
-Common functions
-"""
-
-from PyQt6 import QtWidgets
-import string
-import os
-import sys
-import subprocess
-import logging
-from copy import copy
-from collections import OrderedDict
-
-
-log = logging.getLogger("AVP.Toolkit.Common")
-
-
-class blockSignals:
- """
- Context manager to temporarily block list of QtWidgets from updating,
- and guarantee restoring the previous state afterwards.
- """
-
- def __init__(self, widgets):
- if type(widgets) is dict:
- self.widgets = concatDictVals(widgets)
- else:
- self.widgets = widgets if hasattr(widgets, "__iter__") else [widgets]
-
- def __enter__(self):
- log.verbose(
- "Blocking signals for %s",
- ", ".join([str(w.__class__.__name__) for w in self.widgets]),
- )
- self.oldStates = [w.signalsBlocked() for w in self.widgets]
- for w in self.widgets:
- w.blockSignals(True)
-
- def __exit__(self, *args):
- log.verbose("Resetting blockSignals to %s", str(bool(sum(self.oldStates))))
- for w, state in zip(self.widgets, self.oldStates):
- w.blockSignals(state)
-
-
-def concatDictVals(d):
- """Concatenates all values in given dict into one list."""
- key, value = d.popitem()
- d[key] = value
- final = copy(value)
- if type(final) is not list:
- final = [final]
- final.extend([val for val in d.values()])
- else:
- value.extend([item for val in d.values() for item in val])
- return final
-
-
-def badName(name):
- """Returns whether a name contains non-alphanumeric chars"""
- return any([letter in string.punctuation for letter in name])
-
-
-def alphabetizeDict(dictionary):
- """Alphabetizes a dict into OrderedDict"""
- return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0]))
-
-
-def presetToString(dictionary):
- """Returns string repr of a preset"""
- return repr(alphabetizeDict(dictionary))
-
-
-def presetFromString(string):
- """Turns a string repr of OrderedDict into a regular dict"""
- return dict(eval(string))
-
-
-def appendUppercase(lst):
- for form, i in zip(lst, range(len(lst))):
- lst.append(form.upper())
- return lst
-
-
-def pipeWrapper(func):
- """A decorator to insert proper kwargs into Popen objects."""
-
- def pipeWrapper(commandList, **kwargs):
- if sys.platform == "win32":
- # Stop CMD window from appearing on Windows
- startupinfo = subprocess.STARTUPINFO()
- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
- kwargs["startupinfo"] = startupinfo
-
- if "bufsize" not in kwargs:
- kwargs["bufsize"] = 10**8
- if "stdin" not in kwargs:
- kwargs["stdin"] = subprocess.DEVNULL
- return func(commandList, **kwargs)
-
- return pipeWrapper
-
-
-@pipeWrapper
-def checkOutput(commandList, **kwargs):
- return subprocess.check_output(commandList, **kwargs)
-
-
-def disableWhenEncoding(func):
- def decorator(self, *args, **kwargs):
- if self.encoding:
- return
- else:
- return func(self, *args, **kwargs)
-
- return decorator
-
-
-def disableWhenOpeningProject(func):
- def decorator(self, *args, **kwargs):
- if self.core.openingProject:
- return
- else:
- return func(self, *args, **kwargs)
-
- return decorator
-
-
-def rgbFromString(string):
- """Turns an RGB string like "255, 255, 255" into a tuple"""
- try:
- tup = tuple([int(i) for i in string.split(",")])
- if len(tup) != 3:
- raise ValueError
- for i in tup:
- if i > 255 or i < 0:
- raise ValueError
- return tup
- except:
- return (255, 255, 255)
-
-
-def formatTraceback(tb=None):
- import traceback
-
- if tb is None:
- import sys
-
- tb = sys.exc_info()[2]
- return "Traceback:\n%s" % "\n".join(traceback.format_tb(tb))
-
-
-def connectWidget(widget, func):
- if type(widget) == QtWidgets.QLineEdit:
- widget.textChanged.connect(func)
- elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox:
- widget.valueChanged.connect(func)
- elif type(widget) == QtWidgets.QCheckBox:
- widget.stateChanged.connect(func)
- elif type(widget) == QtWidgets.QComboBox:
- widget.currentIndexChanged.connect(func)
- else:
- log.warning("Failed to connect %s ", str(widget.__class__.__name__))
- return False
- return True
-
-
-def setWidgetValue(widget, val):
- """Generic setValue method for use with any typical QtWidget"""
- log.verbose("Setting %s to %s" % (str(widget.__class__.__name__), val))
- if type(widget) == QtWidgets.QLineEdit:
- widget.setText(val)
- elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox:
- widget.setValue(val)
- elif type(widget) == QtWidgets.QCheckBox:
- widget.setChecked(val)
- elif type(widget) == QtWidgets.QComboBox:
- widget.setCurrentIndex(val)
- else:
- log.warning("Failed to set %s ", str(widget.__class__.__name__))
- return False
- return True
-
-
-def getWidgetValue(widget):
- if type(widget) == QtWidgets.QLineEdit:
- return widget.text()
- elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox:
- return widget.value()
- elif type(widget) == QtWidgets.QCheckBox:
- return widget.isChecked()
- elif type(widget) == QtWidgets.QComboBox:
- return widget.currentIndex()
diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py
deleted file mode 100644
index 5aedff3..0000000
--- a/src/toolkit/ffmpeg.py
+++ /dev/null
@@ -1,545 +0,0 @@
-"""
-Tools for using ffmpeg
-"""
-
-import numpy
-import sys
-import os
-import subprocess
-import threading
-import signal
-from queue import PriorityQueue
-import logging
-
-from .. import core
-from .common import checkOutput, pipeWrapper
-
-
-log = logging.getLogger("AVP.Toolkit.Ffmpeg")
-
-
-class FfmpegVideo:
- """Opens a pipe to ffmpeg and stores a buffer of raw video frames."""
-
- # error from the thread used to fill the buffer
- threadError = None
-
- def __init__(self, **kwargs):
- mandatoryArgs = [
- "inputPath",
- "filter_",
- "width",
- "height",
- "frameRate", # frames per second
- "chunkSize", # number of bytes in one frame
- "parent", # mainwindow object
- "component", # component object
- ]
- for arg in mandatoryArgs:
- setattr(self, arg, kwargs[arg])
-
- self.frameNo = -1
- self.currentFrame = "None"
- self.map_ = None
-
- if "loopVideo" in kwargs and kwargs["loopVideo"]:
- self.loopValue = "-1"
- else:
- self.loopValue = "0"
- if "filter_" in kwargs:
- if kwargs["filter_"][0] != "-filter_complex":
- kwargs["filter_"].insert(0, "-filter_complex")
- else:
- kwargs["filter_"] = None
-
- self.command = [
- core.Core.FFMPEG_BIN,
- "-thread_queue_size",
- "512",
- "-r",
- str(self.frameRate),
- "-stream_loop",
- str(self.loopValue),
- "-i",
- self.inputPath,
- "-f",
- "image2pipe",
- "-pix_fmt",
- "rgba",
- ]
- if type(kwargs["filter_"]) is list:
- self.command.extend(kwargs["filter_"])
- self.command.extend(
- [
- "-codec:v",
- "rawvideo",
- "-",
- ]
- )
-
- self.frameBuffer = PriorityQueue()
- self.frameBuffer.maxsize = self.frameRate
- self.finishedFrames = {}
-
- self.thread = threading.Thread(
- target=self.fillBuffer, name="FFmpeg Frame-Fetcher"
- )
- self.thread.daemon = True
- self.thread.start()
-
- def frame(self, num):
- while True:
- if num in self.finishedFrames:
- image = self.finishedFrames.pop(num)
- return image
-
- i, image = self.frameBuffer.get()
- self.finishedFrames[i] = image
- self.frameBuffer.task_done()
-
- def fillBuffer(self):
- from ..component import ComponentError
-
- if core.Core.logEnabled:
- logFilename = os.path.join(
- core.Core.logDir, "render_%s.log" % str(self.component.compPos)
- )
- log.debug("Creating ffmpeg process (log at %s)", logFilename)
- with open(logFilename, "w") as logf:
- logf.write(" ".join(self.command) + "\n\n")
- with open(logFilename, "a") as logf:
- self.pipe = openPipe(
- self.command,
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=logf,
- bufsize=10**8,
- )
- else:
- self.pipe = openPipe(
- self.command,
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- bufsize=10**8,
- )
-
- while True:
- if self.parent.canceled:
- break
- self.frameNo += 1
-
- # If we run out of frames, use the last good frame and loop.
- try:
- if len(self.currentFrame) == 0:
- self.frameBuffer.put((self.frameNo - 1, self.lastFrame))
- continue
- except AttributeError:
- FfmpegVideo.threadError = ComponentError(
- self.component,
- "video",
- "Video seemed playable but wasn't.",
- )
- break
-
- try:
- self.currentFrame = self.pipe.stdout.read(self.chunkSize)
- except ValueError as e:
- if str(e) == "PyMemoryView_FromBuffer(): info->buf must not be NULL":
- log.debug(
- "Ignored 'info->buf must not be NULL' error from FFmpeg pipe"
- )
- return
- else:
- FfmpegVideo.threadError = ComponentError(self.component, "video")
-
- if len(self.currentFrame) != 0:
- self.frameBuffer.put((self.frameNo, self.currentFrame))
- self.lastFrame = self.currentFrame
-
-
-@pipeWrapper
-def openPipe(commandList, **kwargs):
- return subprocess.Popen(commandList, **kwargs)
-
-
-def closePipe(pipe):
- pipe.stdout.close()
- pipe.send_signal(signal.SIGTERM)
-
-
-def findFfmpeg():
- if sys.platform == "win32":
- bin = "ffmpeg.exe"
- else:
- bin = "ffmpeg"
-
- if getattr(sys, "frozen", False):
- # The application is frozen
- bin = os.path.join(core.Core.wd, bin)
-
- with open(os.devnull, "w") as f:
- try:
- checkOutput([bin, "-version"], stderr=f)
- except (subprocess.CalledProcessError, FileNotFoundError):
- bin = ""
-
- return bin
-
-
-def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
- """
- Constructs the major ffmpeg command used to export the video
- """
- if 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)
- encoders = encoders.decode("utf-8")
-
- acodec = Core.settings.value("outputAudioCodec")
-
- options = Core.encoderOptions
- containerName = Core.settings.value("outputContainer")
- vcodec = Core.settings.value("outputVideoCodec")
- vbitrate = str(Core.settings.value("outputVideoBitrate")) + "k"
- acodec = Core.settings.value("outputAudioCodec")
- abitrate = str(Core.settings.value("outputAudioBitrate")) + "k"
-
- for cont in options["containers"]:
- if cont["name"] == containerName:
- container = cont["container"]
- break
-
- vencoders = options["video-codecs"][vcodec]
- aencoders = options["audio-codecs"][acodec]
-
- def error():
- nonlocal encoders, encoder
- log.critical(
- "Selected encoder (%s) is not supported by Ffmpeg. The supported encoders are: %s",
- encoder,
- encoders,
- )
- return []
-
- for encoder in vencoders:
- if encoder in encoders:
- vencoder = encoder
- break
- else:
- return error()
-
- for encoder in aencoders:
- if encoder in encoders:
- aencoder = encoder
- break
- else:
- return error()
-
- ffmpegCommand = [
- Core.FFMPEG_BIN,
- "-thread_queue_size",
- "512",
- "-y", # overwrite the output file if it already exists.
- # INPUT VIDEO
- "-f",
- "rawvideo",
- "-vcodec",
- "rawvideo",
- "-s",
- f'{Core.settings.value("outputWidth")}x{Core.settings.value("outputHeight")}',
- "-pix_fmt",
- "rgba",
- "-r",
- str(Core.settings.value("outputFrameRate")),
- "-t",
- duration,
- "-an", # the video input has no sound
- "-i",
- "-", # the video input comes from a pipe
- # INPUT SOUND
- "-t",
- duration,
- "-i",
- inputFile,
- ]
-
- extraAudio = [comp.audio for comp in components if "audio" in comp.properties()]
- segment = createAudioFilterCommand(extraAudio, safeDuration)
- ffmpegCommand.extend(segment)
- # Map audio from the filters or the single audio input, and map video from the pipe
- ffmpegCommand.extend(
- [
- "-map",
- "0:v",
- "-map",
- "[a]" if segment else "1:a",
- ]
- )
-
- ffmpegCommand.extend(
- [
- # OUTPUT
- "-vcodec",
- vencoder,
- "-acodec",
- aencoder,
- "-b:v",
- vbitrate,
- "-b:a",
- abitrate,
- "-pix_fmt",
- Core.settings.value("outputVideoFormat"),
- "-preset",
- Core.settings.value("outputPreset"),
- "-f",
- container,
- ]
- )
-
- if acodec == "aac":
- ffmpegCommand.append("-strict")
- ffmpegCommand.append("-2")
-
- ffmpegCommand.append(outputFile)
- return ffmpegCommand
-
-
-def createAudioFilterCommand(extraAudio, duration):
- """Add extra inputs and any needed filters to the main ffmpeg command."""
- # NOTE: Global filters are currently hard-coded here for debugging use
- globalFilters = 0 # increase to add global filters
-
- if not extraAudio and not globalFilters:
- return []
-
- ffmpegCommand = []
- # Add -i options for extra input files
- extraFilters = {}
- for streamNo, params in enumerate(reversed(extraAudio)):
- extraInputFile, params = params
- ffmpegCommand.extend(
- [
- "-t",
- duration,
- # Tell ffmpeg about shorter clips (seemingly not needed)
- # streamDuration = getAudioDuration(extraInputFile)
- # if streamDuration and streamDuration > float(safeDuration)
- # else "{0:.3f}".format(streamDuration),
- "-i",
- extraInputFile,
- ]
- )
- # Construct dataset of extra filters we'll need to add later
- for ffmpegFilter in params:
- if streamNo + 2 not in extraFilters:
- extraFilters[streamNo + 2] = []
- extraFilters[streamNo + 2].append((ffmpegFilter, params[ffmpegFilter]))
-
- # Start creating avfilters! Popen-style, so don't use semicolons;
- extraFilterCommand = []
-
- if globalFilters <= 0:
- # Dictionary of last-used tmp labels for a given stream number
- tmpInputs = {streamNo: -1 for streamNo in extraFilters}
- else:
- # Insert blank entries for global filters into extraFilters
- # so the per-stream filters know what input to source later
- for streamNo in range(len(extraAudio), 0, -1):
- if streamNo + 1 not in extraFilters:
- extraFilters[streamNo + 1] = []
- # Also filter the primary audio track
- extraFilters[1] = []
- tmpInputs = {streamNo: globalFilters - 1 for streamNo in extraFilters}
-
- # Add the global filters!
- # NOTE: list length must = globalFilters, currently hardcoded
- if tmpInputs:
- extraFilterCommand.extend(
- [
- "[%s:a] ashowinfo [%stmp0]" % (str(streamNo), str(streamNo))
- for streamNo in tmpInputs
- ]
- )
-
- # Now add the per-stream filters!
- for streamNo, paramList in extraFilters.items():
- for param in paramList:
- source = (
- "[%s:a]" % str(streamNo)
- if tmpInputs[streamNo] == -1
- else "[%stmp%s]" % (str(streamNo), str(tmpInputs[streamNo]))
- )
- tmpInputs[streamNo] = tmpInputs[streamNo] + 1
- extraFilterCommand.append(
- "%s %s%s [%stmp%s]"
- % (
- source,
- param[0],
- param[1],
- str(streamNo),
- str(tmpInputs[streamNo]),
- )
- )
-
- # Join all the filters together and combine into 1 stream
- extraFilterCommand = "; ".join(extraFilterCommand) + "; " if tmpInputs else ""
- ffmpegCommand.extend(
- [
- "-filter_complex",
- extraFilterCommand
- + "%s amix=inputs=%s:duration=first [a]"
- % (
- "".join(
- [
- (
- "[%stmp%s]" % (str(i), tmpInputs[i])
- if i in extraFilters
- else "[%s:a]" % str(i)
- )
- for i in range(1, len(extraAudio) + 2)
- ]
- ),
- str(len(extraAudio) + 1),
- ),
- ]
- )
- return ffmpegCommand
-
-
-def testAudioStream(filename):
- """Test if an audio stream definitely exists"""
- audioTestCommand = [
- core.Core.FFMPEG_BIN,
- "-i",
- filename,
- "-vn",
- "-f",
- "null",
- "-",
- ]
- try:
- checkOutput(audioTestCommand, stderr=subprocess.DEVNULL)
- except subprocess.CalledProcessError:
- return False
- else:
- return True
-
-
-def getAudioDuration(filename):
- """Try to get duration of audio file as float, or False if not possible"""
- command = [core.Core.FFMPEG_BIN, "-i", filename]
-
- try:
- fileInfo = checkOutput(command, stderr=subprocess.STDOUT)
- except subprocess.CalledProcessError as ex:
- fileInfo = ex.output
- except (FileNotFoundError, PermissionError):
- # ffmpeg is possibly not installed
- return False
-
- try:
- info = fileInfo.decode("utf-8").split("\n")
- except UnicodeDecodeError as e:
- log.error("Unicode error:", str(e))
- return False
-
- for line in info:
- if "Duration" in line:
- d = line.split(",")[0]
- d = d.split(" ")[3]
- d = d.split(":")
- duration = float(d[0]) * 3600 + float(d[1]) * 60 + float(d[2])
- break
- else:
- # String not found in output
- return False
- return duration
-
-
-def readAudioFile(filename, videoWorker):
- """
- Creates the completeAudioArray given to components
- and used to draw the classic visualizer.
- """
- duration = getAudioDuration(filename)
- if not duration:
- log.error(f"Audio file {filename} doesn't exist or unreadable.")
- return
-
- command = [
- core.Core.FFMPEG_BIN,
- "-i",
- filename,
- "-f",
- "s16le",
- "-acodec",
- "pcm_s16le",
- "-ar",
- "44100", # ouput will have 44100 Hz
- "-ac",
- "1", # mono (set to '2' for stereo)
- "-",
- ]
- in_pipe = openPipe(
- command,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- bufsize=10**8,
- )
-
- completeAudioArray = numpy.empty(0, dtype="int16")
-
- progress = 0
- lastPercent = None
- while True:
- if core.Core.canceled:
- return
- # read 2 seconds of audio
- progress += 4
- raw_audio = in_pipe.stdout.read(88200 * 4)
- if len(raw_audio) == 0:
- break
- audio_array = numpy.frombuffer(raw_audio, dtype="int16")
- completeAudioArray = numpy.append(completeAudioArray, audio_array)
-
- percent = int(100 * (progress / duration))
- if percent >= 100:
- percent = 100
-
- if lastPercent != percent:
- string = "Loading audio file: " + str(percent) + "%"
- videoWorker.progressBarSetText.emit(string)
- videoWorker.progressBarUpdate.emit(percent)
-
- lastPercent = percent
-
- in_pipe.kill()
- in_pipe.wait()
-
- # add 0s the end
- completeAudioArrayCopy = numpy.zeros(len(completeAudioArray) + 44100, dtype="int16")
- completeAudioArrayCopy[: len(completeAudioArray)] = completeAudioArray
- completeAudioArray = completeAudioArrayCopy
-
- return (completeAudioArray, duration)
-
-
-def exampleSound(style="white", extra="apulsator=offset_l=0.35:offset_r=0.67"):
- """Help generate an example sound for use in creating a preview"""
-
- if style == "white":
- src = "-2+random(0)"
- elif style == "freq":
- src = "sin(1000*t*PI*t)"
- elif style == "wave":
- src = "sin(random(0)*2*PI*t)*tan(random(0)*2*PI*t)"
- elif style == "stereo":
- 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 "")
diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py
deleted file mode 100644
index 94537a6..0000000
--- a/src/toolkit/frame.py
+++ /dev/null
@@ -1,117 +0,0 @@
-"""
-Common tools for drawing compatible frames in a Component's frameRender()
-"""
-
-from PyQt6 import QtGui
-from PIL import Image
-from PIL.ImageQt import ImageQt
-from PyQt6 import QtCore
-import sys
-import os
-import math
-import logging
-from .. import core
-
-
-log = logging.getLogger("AVP.Toolkit.Frame")
-
-
-class FramePainter(QtGui.QPainter):
- """
- A QPainter for a blank frame, which can be converted into a
- Pillow image with finalize()
- """
-
- def __init__(self, width, height):
- image = BlankFrame(width, height)
- log.debug("Creating QImage from PIL image object")
- self.image = ImageQt(image)
- super().__init__(self.image)
-
- def setPen(self, penStyle):
- if type(penStyle) is tuple:
- super().setPen(PaintColor(*penStyle))
- else:
- super().setPen(penStyle)
-
- def finalize(self):
- log.verbose("Finalizing FramePainter")
- buffer = QtCore.QBuffer()
- buffer.open(QtCore.QBuffer.OpenModeFlag.ReadWrite)
- self.image.save(buffer, "PNG")
- import io
-
- frame = Image.open(io.BytesIO(buffer.data()))
- buffer.close()
- self.end()
- return frame
- imBytes = self.image.bits().asstring(self.image.byteCount())
- frame = Image.frombytes(
- "RGBA", (self.image.width(), self.image.height()), imBytes
- )
- self.end()
- return frame
-
-
-class PaintColor(QtGui.QColor):
- """
- Subclass of QtGui.QColor with an added scale() method
- Previously this class reversed the painter colour to solve
- hardware issues related to endianness,
- but Qt appears to deal with this itself nowadays
- """
-
- def __init__(self, r, g, b, a=255):
- super().__init__(r, g, b, a)
-
-
-def scale(scalePercent, width, height, returntype=None):
- width = (float(width) / 100.0) * float(scalePercent)
- height = (float(height) / 100.0) * float(scalePercent)
- if returntype == str:
- return (str(math.ceil(width)), str(math.ceil(height)))
- elif returntype == int:
- return (math.ceil(width), math.ceil(height))
- else:
- return (width, height)
-
-
-def defaultSize(framefunc):
- """Makes width/height arguments optional"""
-
- def decorator(*args):
- if len(args) < 2:
- newArgs = list(args)
- if len(args) == 0 or len(args) == 1:
- height = int(core.Core.settings.value("outputHeight"))
- newArgs.append(height)
- if len(args) == 0:
- width = int(core.Core.settings.value("outputWidth"))
- newArgs.insert(0, width)
- args = tuple(newArgs)
- return framefunc(*args)
-
- return decorator
-
-
-def FloodFrame(width, height, RgbaTuple):
- return Image.new("RGBA", (width, height), RgbaTuple)
-
-
-@defaultSize
-def BlankFrame(width, height):
- """The base frame used by each component to start drawing."""
- return FloodFrame(width, height, (0, 0, 0, 0))
-
-
-@defaultSize
-def Checkerboard(width, height):
- """
- A checkerboard to represent transparency to the user.
- """
- # TODO: Would be cool to generate this image with numpy instead.
- log.debug("Creating new %s*%s checkerboard" % (width, height))
- image = FloodFrame(1920, 1080, (0, 0, 0, 0))
- image.paste(Image.open(os.path.join(core.Core.wd, "gui", "background.png")), (0, 0))
- image = image.resize((width, height))
- return image
diff --git a/src/video_thread.py b/src/video_thread.py
deleted file mode 100644
index 5d72409..0000000
--- a/src/video_thread.py
+++ /dev/null
@@ -1,417 +0,0 @@
-"""
-Worker thread created to export a video. It has a slot to begin export using
-an input file, output path, and component list.
-
-Signals are emitted to update MainWindow's progress bar, detail text, and preview.
-A Command object takes the place of MainWindow while in commandline mode.
-
-Export can be cancelled with cancel()
-"""
-
-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 .toolkit.ffmpeg import (
- openPipe,
- readAudioFile,
- getAudioDuration,
- createFfmpegCommand,
-)
-
-
-log = logging.getLogger("AVP.VideoThread")
-
-
-class Worker(QtCore.QObject):
-
- imageCreated = pyqtSignal("QImage")
- videoCreated = pyqtSignal()
- progressBarUpdate = pyqtSignal(int)
- progressBarSetText = pyqtSignal(str)
- encoding = pyqtSignal(bool)
-
- def __init__(self, parent, inputFile, outputFile, components):
- super().__init__()
- self.core = parent.core
- self.settings = parent.settings
- self.modules = parent.core.modules
- parent.createVideo.connect(self.createVideo)
- self.previewEnabled = type(parent.core).previewEnabled
-
- self.components = components
- self.outputFile = outputFile
- self.inputFile = inputFile
-
- self.hertz = 44100
- self.sampleSize = 1470 # 44100 / 30 = 1470
- self.canceled = False
- self.error = False
-
- def createFfmpegCommand(self, duration):
- try:
- ffmpegCommand = createFfmpegCommand(
- self.inputFile, self.outputFile, self.components, duration
- )
- except sp.CalledProcessError as e:
- # FIXME video_thread should own this error signal, not components
- self.components[0]._error.emit(
- "Ffmpeg could not be found. Is it installed?", str(e)
- )
- self.error = True
- return
-
- if not ffmpegCommand:
- # FIXME video_thread should own this error signal, not components
- self.components[0]._error.emit(
- "The FFmpeg command could not be generated.", ""
- )
- log.critical(
- "Cancelling render process due to failure while generating the ffmpeg command."
- )
- self.failExport()
- return
- return ffmpegCommand
-
- def determineAudioLength(self):
- """
- Returns audio length which determines length of final video, or False if failure occurs
- """
- if any(
- [True if "pcm" in comp.properties() else False for comp in self.components]
- ):
- self.progressBarSetText.emit("Loading audio file...")
- audioFileTraits = readAudioFile(self.inputFile, self)
- if audioFileTraits is None:
- self.cancelExport()
- return False
- self.completeAudioArray, duration = audioFileTraits
- self.audioArrayLen = len(self.completeAudioArray)
- else:
- duration = getAudioDuration(self.inputFile)
- self.completeAudioArray = []
- self.audioArrayLen = int(
- ((duration * self.hertz) + self.hertz) - self.sampleSize
- )
- return duration
-
- def preFrameRender(self):
- """
- Initializes components that need to pre-compute stuff.
- Also prerenders "static" components like text and merges them if possible
- """
- self.staticComponents = {}
-
- # Call preFrameRender on each component
- canceledByComponent = False
- initText = ", ".join(
- [
- "%s) %s" % (num, str(component))
- for num, component in enumerate(reversed(self.components))
- ]
- )
- print("Loaded Components:", initText)
- log.info("Calling preFrameRender for %s", initText)
- for compNo, comp in enumerate(reversed(self.components)):
- try:
- comp.preFrameRender(
- audioFile=self.inputFile,
- completeAudioArray=self.completeAudioArray,
- audioArrayLen=self.audioArrayLen,
- sampleSize=self.sampleSize,
- progressBarUpdate=self.progressBarUpdate,
- progressBarSetText=self.progressBarSetText,
- )
- except ComponentError:
- log.warning(
- "#%s %s encountered an error in its preFrameRender method",
- compNo,
- comp,
- )
-
- compProps = comp.properties()
- if "error" in compProps or comp._lockedError is not None:
- self.cancel()
- self.canceled = True
- canceledByComponent = True
- compError = (
- comp.error() if type(comp.error()) is tuple else (comp.error(), "")
- )
- errMsg = (
- "Component #%s (%s) encountered an error!"
- % (str(compNo), comp.name)
- if comp.error() is None
- else "Export cancelled by component #%s (%s): %s"
- % (str(compNo), comp.name, compError[0])
- )
- log.error(errMsg)
- comp._error.emit(errMsg, compError[1])
- break
- if "static" in compProps:
- log.info("Saving static frame from #%s %s", compNo, comp)
- self.staticComponents[compNo] = comp.frameRender(0).copy()
-
- # Check if any errors occured
- log.debug("Checking if a component wishes to cancel the export...")
- if self.canceled:
- if canceledByComponent:
- log.error(
- "Export cancelled by component #%s (%s): %s",
- compNo,
- comp.name,
- (
- "No message."
- if comp.error() is None
- else (
- comp.error()
- if type(comp.error()) is str
- else comp.error()[0]
- )
- ),
- )
- self.cancelExport()
-
- # Merge static frames that can be merged to reduce workload
- def mergeConsecutiveStaticComponentFrames(self):
- log.info("Merging consecutive static component frames")
- for compNo in range(len(self.components)):
- if (
- compNo not in self.staticComponents
- or compNo + 1 not in self.staticComponents
- ):
- continue
- self.staticComponents[compNo + 1] = Image.alpha_composite(
- self.staticComponents.pop(compNo),
- self.staticComponents[compNo + 1],
- )
- self.staticComponents[compNo] = None
-
- mergeConsecutiveStaticComponentFrames(self)
-
- def frameRender(self, audioI):
- """
- Renders a frame composited together from the frames returned by each component
- audioI is a multiple of self.sampleSize, which can be divided to determine frameNo
- """
-
- 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))
-
- bgI = int(audioI / self.sampleSize)
- frame = None
- for layerNo, comp in enumerate(reversed((self.components))):
- if self.canceled:
- break
- try:
- if layerNo in self.staticComponents:
- if self.staticComponents[layerNo] is None:
- # this layer was merged into a following layer
- continue
- # static component
- if frame is None: # bottom-most layer
- frame = self.staticComponents[layerNo]
- else:
- frame = Image.alpha_composite(
- frame, self.staticComponents[layerNo]
- )
-
- else:
- # animated component
- if frame is None: # bottom-most layer
- frame = comp.frameRender(bgI)
- else:
- frame = Image.alpha_composite(frame, comp.frameRender(bgI))
- except Exception as e:
- err()
- return frame
-
- def showPreview(self, frame):
- """
- Receives a final frame that will be piped to FFmpeg,
- adds it to the MainWindow for the live preview
- """
- # We must store a reference to this QImage
- # or else Qt will garbage-collect it on the C++ side
- self.latestPreview = ImageQt(frame)
- self.imageCreated.emit(QtGui.QImage(self.latestPreview))
-
- @pyqtSlot()
- def createVideo(self):
- """
- 1. Numpy is set to ignore division errors during this method
- 2. Determine length of final video
- 3. Call preFrameRender on each component
- 4. Create the main FFmpeg command
- 5. Open the out_pipe to FFmpeg process
- 6. Iterate over the audio data array and call frameRender on the components to get frames
- 7. Close the out_pipe
- 8. Call postFrameRender on each component
- """
- log.debug("Video worker received signal to createVideo")
- log.debug("Video thread id: {}".format(int(QtCore.QThread.currentThreadId())))
- numpy.seterr(divide="ignore")
- self.encoding.emit(True)
- self.extraAudio = []
- self.width = int(self.settings.value("outputWidth"))
- self.height = int(self.settings.value("outputHeight"))
-
- # Set core.Core.canceled to False and call .reset() on each component
- self.reset()
- # Initialize progress bar to 0
- progressBarValue = 0
- self.progressBarUpdate.emit(progressBarValue)
-
- # Determine longest length of audio which will be the final video's duration
- log.debug("Determining length of audio...")
- duration = self.determineAudioLength()
- if not duration:
- return
-
- # Call preFrameRender on each component to perform initialization
- self.progressBarUpdate.emit(0)
- self.progressBarSetText.emit("Starting components...")
- self.preFrameRender()
- if self.canceled:
- return
-
- # Create FFmpeg command
- ffmpegCommand = self.createFfmpegCommand(duration)
- if not ffmpegCommand:
- return
- cmd = " ".join(ffmpegCommand)
- print("###### FFMPEG COMMAND ######\n%s" % cmd)
- print("############################")
- log.info(cmd)
-
- # Open pipe to FFmpeg
- log.info("Opening pipe to FFmpeg")
- try:
- self.out_pipe = openPipe(
- ffmpegCommand,
- stdin=sp.PIPE,
- stdout=sys.stdout,
- stderr=sys.stdout,
- )
- except sp.CalledProcessError:
- 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!
- self.progressBarSetText.emit("Exporting video...")
- for audioI in range(0, self.audioArrayLen, self.sampleSize):
- if self.canceled:
- break
- # fetch the next frame & add to the FFmpeg pipe
- frame = self.frameRender(audioI)
-
- # Update live preview
- if self.previewEnabled:
- self.showPreview(frame)
-
- try:
- self.out_pipe.stdin.write(frame.tobytes())
- except Exception:
- break
-
- # increase progress bar value
- completion = (audioI / self.audioArrayLen) * 100
- if progressBarValue + 1 <= completion:
- progressBarValue = numpy.floor(completion).astype(int)
- self.progressBarUpdate.emit(progressBarValue)
- self.progressBarSetText.emit(
- "Exporting video: %s%%" % str(int(progressBarValue))
- )
-
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
- # Finished creating the video!
- # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
-
- numpy.seterr(all="print")
-
- self.closePipe()
-
- for comp in reversed(self.components):
- comp.postFrameRender()
-
- if self.canceled:
- print("Export Canceled")
- try:
- os.remove(self.outputFile)
- except Exception:
- pass
- self.progressBarUpdate.emit(0)
- self.progressBarSetText.emit("Export Canceled")
- else:
- if self.error:
- self.failExport()
- else:
- print("Export Complete")
- self.progressBarUpdate.emit(100)
- self.progressBarSetText.emit("Export Complete")
-
- self.error = False
- self.canceled = False
- self.encoding.emit(False)
- self.videoCreated.emit()
-
- def closePipe(self):
- try:
- self.out_pipe.stdin.close()
- except (BrokenPipeError, OSError):
- log.debug("Broken pipe to FFmpeg!")
- if self.out_pipe.stderr is not None:
- log.error(self.out_pipe.stderr.read())
- self.out_pipe.stderr.close()
- self.error = True
- self.out_pipe.wait()
-
- def cancelExport(self, message="Export Canceled"):
- self.progressBarUpdate.emit(0)
- self.progressBarSetText.emit(message)
- self.encoding.emit(False)
- self.videoCreated.emit()
-
- def failExport(self):
- self.cancelExport("Export Failed")
-
- def updateProgress(self, pStr, pVal):
- self.progressBarValue.emit(pVal)
- self.progressBarSetText.emit(pStr)
-
- def cancel(self):
- self.canceled = True
- self.core.cancel()
-
- for comp in self.components:
- comp.cancel()
-
- try:
- self.out_pipe.send_signal(signal.SIGTERM)
- except Exception:
- pass
-
- def reset(self):
- self.core.reset()
- self.canceled = False
- for comp in self.components:
- comp.reset()
--
cgit v1.2.3