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