summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortassaron2026-01-11 14:29:58 -0500
committertassaron2026-01-11 14:29:58 -0500
commit669756b391d26661cf2e2a97a304e73343ef6655 (patch)
tree9cf2d4858c209bdab9f44d5c7f95c2a30b37f7a6
parent9d45f7f1a986aaa5d3c084c7ae747442b94a61b1 (diff)
update to Qt 6 and Pillow 12
and yeah, I accidentally ran black on the codebase. I don't want to spend more free time fixing that. All of these changes are simple renames or removals, nothing too major.
-rw-r--r--README.md103
-rw-r--r--setup.py58
-rw-r--r--src/__init__.py27
-rw-r--r--src/__main__.py37
-rw-r--r--src/command.py152
-rw-r--r--src/component.py657
-rw-r--r--src/components/color.py136
-rw-r--r--src/components/image.py71
-rw-r--r--src/components/life.py276
-rw-r--r--src/components/life.ui2
-rw-r--r--src/components/original.py189
-rw-r--r--src/components/sound.py54
-rw-r--r--src/components/spectrum.py300
-rw-r--r--src/components/text.py148
-rw-r--r--src/components/video.py178
-rw-r--r--src/components/waveform.py170
-rw-r--r--src/core.py420
-rw-r--r--src/gui/actions.py69
-rw-r--r--src/gui/mainwindow.py648
-rw-r--r--src/gui/presetmanager.py157
-rw-r--r--src/gui/preview_thread.py50
-rw-r--r--src/gui/preview_win.py45
-rw-r--r--src/tests/__init__.py14
-rw-r--r--src/tests/test_commandline_export.py17
-rw-r--r--src/tests/test_commandline_parser.py11
-rw-r--r--src/tests/test_core_init.py16
-rw-r--r--src/toolkit/common.py87
-rw-r--r--src/toolkit/ffmpeg.py455
-rw-r--r--src/toolkit/frame.py69
-rw-r--r--src/video_thread.py182
30 files changed, 2558 insertions, 2240 deletions
diff --git a/README.md b/README.md
index ed9f928..791c4eb 100644
--- a/README.md
+++ b/README.md
@@ -1,89 +1,102 @@
1# Audio Visualizer Python 1# Audio Visualizer Python
2
2**We need a good name that is not as generic as "audio-visualizer-python"!** 3**We need a good name that is not as generic as "audio-visualizer-python"!**
3 4
4This is a little GUI tool which creates an audio visualization video from an input audio file. Different components can be added and layered to change the resulting video and add images, videos, gradients, text, etc. Encoding options can be changed with a variety of different output containers. 5This is a little GUI tool which creates an audio visualization video from an input audio file. Different components can be added and layered to change the resulting video and add images, videos, gradients, text, etc. Encoding options can be changed with a variety of different output containers.
5 6
6The program works on **Linux**, **macOS**, and **Windows**. If you encounter problems running it or have other bug reports or features that you wish to see implemented, please fork the project and submit a pull request and/or file an [issue](https://github.com/djfun/audio-visualizer-python/issues) on this project. 7The program works on **Linux**, **macOS**, and **Windows**. If you encounter problems running it or have other bug reports or features that you wish to see implemented, please fork the project and submit a pull request and/or file an [issue](https://github.com/djfun/audio-visualizer-python/issues) on this project.
7 8
8
9# Screenshots & Videos 9# Screenshots & Videos
10
10[<img title="AVP running on Windows" alt="Screenshot of program on Windows" src="screenshot.png" width="707">](/screenshot.png?raw=true) 11[<img title="AVP running on Windows" alt="Screenshot of program on Windows" src="screenshot.png" width="707">](/screenshot.png?raw=true)
11 12
12## A video created by this app 13## A video created by this app
13* **[YouTube: A day in spring](https://www.youtube.com/watch?v=-M3jR1NuJHM)** 🎥 14
15- **[YouTube: A day in spring](https://www.youtube.com/watch?v=-M3jR1NuJHM)** 🎥
14 16
15## Video demonstration of the app features 17## Video demonstration of the app features
16* [YouTube: Audio Visualizer Python v2.0.0 demonstration](https://www.youtube.com/watch?v=EVt2ckQs1Yg) 🎥
17 18
19- [YouTube: Audio Visualizer Python v2.0.0 demonstration](https://www.youtube.com/watch?v=EVt2ckQs1Yg) 🎥
18 20
19# Installation 21# Installation
22
20## Installation on Ubuntu 24.04 23## Installation on Ubuntu 24.04
21* Install dependencies: `sudo apt install ffmpeg pipx python3-pyqt5`
22* Download this repo and run `pipx install .` in this directory
23* Run the program with `avp` from terminal
24 24
25- Install system dependencies: `sudo apt install ffmpeg`
26- Make a virtual environment: `python -m venv env`
27- Activate it: `source env/bin/activate`
28- Install Python dependencies: `pip install pyqt6 pillow numpy`
29- Install this program: `pip install .` in this directory
30- Run the program with `avp` from terminal
25 31
26## Installation on Windows 32## Installation on Windows
27* Install Python from the Windows Store 33
28* Add Python to your system PATH (it should ask during the installation process) 34- Install Python from the Windows Store
29* Download this repo (extract from zip if needed) 35- Add Python to your system PATH (it should ask during the installation process)
30* Download and install [FFmpeg](https://www.ffmpeg.org/download.html). Use the GPL-licensed static builds. 36- Download this repo (extract from zip if needed)
31* Add FFmpeg to the system PATH as well (program will then work anywhere) 37- Download and install [FFmpeg](https://www.ffmpeg.org/download.html). Use the GPL-licensed static builds.
32 * Alternatively, copy ffmpeg.exe into the folder that you want to run the program within 38- Add FFmpeg to the system PATH as well (program will then work anywhere)
33* Open command prompt, `cd` into the repo directory, and run: `pip install .` 39 - Alternatively, copy ffmpeg.exe into the folder that you want to run the program within
34* Now run `avp` or `python -m avp` from a command prompt window to start the app 40- Open command prompt, `cd` into the repo directory, and run: `pip install pyqt6 pillow numpy .`
41- Now run `avp` or `python -m avp` from a command prompt window to start the app
35 42
36## Installation on macOS 43## Installation on macOS
37* We need help writing instructions for macOS, but the program should work in theory. 44
45- We need help writing instructions for macOS, but the program should work in theory.
38 46
39# [Keyboard Shortcuts](https://github.com/djfun/audio-visualizer-python/wiki/Keyboard-Shortcuts) 47# [Keyboard Shortcuts](https://github.com/djfun/audio-visualizer-python/wiki/Keyboard-Shortcuts)
40| Key Combo | Effect |
41| ------------------------- | -------------------------------------------------- |
42| Ctrl+S | Save Current Project |
43| Ctrl+A | Save Project As... |
44| Ctrl+O | Open Project |
45| Ctrl+N | New Project (prompts to save current project) |
46| Ctrl+Z | Undo |
47| Ctrl+Shift+Z _or_ Ctrl+Y | Redo |
48| Ctrl+T _or_ Insert | Add Component |
49| Ctrl+R _or_ Delete | Remove Component |
50| Ctrl+Space | Focus Component List |
51| Ctrl+Shift+S | Save Component Preset |
52| Ctrl+Shift+C | Remove Preset from Component |
53| Ctrl+Up | Move Selected Component Up |
54| Ctrl+Down | Move Selected Component Down |
55| Ctrl+Home | Move Selected Component to Top |
56| Ctrl+End | Move Selected Component to Bottom |
57| Ctrl+Shift+U | Open Undo History |
58| Ctrl+Shift+F | Show FFmpeg Command |
59 48
49| Key Combo | Effect |
50| ------------------------ | --------------------------------------------- |
51| Ctrl+S | Save Current Project |
52| Ctrl+A | Save Project As... |
53| Ctrl+O | Open Project |
54| Ctrl+N | New Project (prompts to save current project) |
55| Ctrl+Z | Undo |
56| Ctrl+Shift+Z _or_ Ctrl+Y | Redo |
57| Ctrl+T _or_ Insert | Add Component |
58| Ctrl+R _or_ Delete | Remove Component |
59| Ctrl+Space | Focus Component List |
60| Ctrl+Shift+S | Save Component Preset |
61| Ctrl+Shift+C | Remove Preset from Component |
62| Ctrl+Up | Move Selected Component Up |
63| Ctrl+Down | Move Selected Component Down |
64| Ctrl+Home | Move Selected Component to Top |
65| Ctrl+End | Move Selected Component to Bottom |
66| Ctrl+Shift+U | Open Undo History |
67| Ctrl+Shift+F | Show FFmpeg Command |
60 68
61# Commandline Mode 69# Commandline Mode
70
62Projects can be created with the GUI then loaded from the commandline for easy automation of video production. Some components have commandline options for extra customization, and you can save "presets" with settings to load if the commandline option doesn't exist. 71Projects can be created with the GUI then loaded from the commandline for easy automation of video production. Some components have commandline options for extra customization, and you can save "presets" with settings to load if the commandline option doesn't exist.
63 72
64## Example/test command 73## Example/test command
65* Create a video with a grey "classic visualizer", background image, and text:
66 * `avp -c 0 image path=src/tests/data/test.jpg -c 1 classic color=180,180,180 -c 2 text "title=Episode 371" -i src/tests/data/test.ogg -o output.mp4`
67* [See more about commandline mode in the wiki!](https://github.com/djfun/audio-visualizer-python/wiki/Commandline-Mode)
68 74
75- Create a video with a grey "classic visualizer", background image, and text:
76 - `avp -c 0 image path=src/tests/data/test.jpg -c 1 classic color=180,180,180 -c 2 text "title=Episode 371" -i src/tests/data/test.ogg -o output.mp4`
77- [See more about commandline mode in the wiki!](https://github.com/djfun/audio-visualizer-python/wiki/Commandline-Mode)
69 78
70# Developer Information 79# Developer Information
80
71## Known Working Versions of Dependencies 81## Known Working Versions of Dependencies
72* Python 3.10 82
73* FFmpeg 4.4.1 83- Python 3.13
74* PyQt5 (Qt v5.15.3) 84- FFmpeg 8.0.1
75* Pillow 9.1.0 85- PyQt6 v6.10.2 (Qt v6.10.1)
76* NumPy 1.22.3 86- Pillow 12.1.0
87- NumPy 2.4.1
77 88
78## Getting Faster Export Times 89## Getting Faster Export Times
79* [Pillow-SIMD](https://github.com/uploadcare/pillow-simd) may be used as a drop-in replacement for Pillow if you desire faster video export times, but it must be compiled from source. For help installing dependencies to compile Pillow-SIMD, see the [Pillow installation guide](https://pillow.readthedocs.io/en/stable/installation.html). Then add the `-SIMD` suffix into `setup.py` and install as usual. 90
91- [Pillow-SIMD](https://github.com/uploadcare/pillow-simd) may be used as a drop-in replacement for Pillow if you desire faster video export times, but it must be compiled from source. For help installing dependencies to compile Pillow-SIMD, see the [Pillow installation guide](https://pillow.readthedocs.io/en/stable/installation.html).
80 92
81## Developing a New Component 93## Developing a New Component
82* Information for developing a component is in our wiki: [How a Component Works](https://github.com/djfun/audio-visualizer-python/wiki/How-a-Component-Works)
83* File an issue on GitHub if you need help fitting your visualizer into our component system; we would be happy to collaborate
84 94
95- Information for developing a component is in our wiki: [How a Component Works](https://github.com/djfun/audio-visualizer-python/wiki/How-a-Component-Works)
96- File an issue on GitHub if you need help fitting your visualizer into our component system; we would be happy to collaborate
85 97
86# License 98# License
99
87Source code of audio-visualizer-python is licensed under the MIT license. 100Source code of audio-visualizer-python is licensed under the MIT license.
88 101
89Some dependencies of this application are under the GPL license. When packaged with these dependencies, audio-visualizer-python may also be under the terms of this GPL license. 102Some dependencies of this application are under the GPL license. When packaged with these dependencies, audio-visualizer-python may also be under the terms of this GPL license.
diff --git a/setup.py b/setup.py
index 89c51e2..b62dbad 100644
--- a/setup.py
+++ b/setup.py
@@ -15,49 +15,53 @@ def getTextFromFile(filename, fallback):
15 return output 15 return output
16 16
17 17
18PACKAGE_NAME = 'avp' 18PACKAGE_NAME = "avp"
19SOURCE_DIRECTORY = 'src' 19SOURCE_DIRECTORY = "src"
20SOURCE_PACKAGE_REGEX = re.compile(rf'^{SOURCE_DIRECTORY}') 20SOURCE_PACKAGE_REGEX = re.compile(rf"^{SOURCE_DIRECTORY}")
21PACKAGE_DESCRIPTION = 'Create audio visualization videos from a GUI or commandline' 21PACKAGE_DESCRIPTION = "Create audio visualization videos from a GUI or commandline"
22 22
23 23
24avp = import_module(SOURCE_DIRECTORY) 24avp = import_module(SOURCE_DIRECTORY)
25source_packages = find_packages(include=[SOURCE_DIRECTORY, f'{SOURCE_DIRECTORY}.*']) 25source_packages = find_packages(include=[SOURCE_DIRECTORY, f"{SOURCE_DIRECTORY}.*"])
26proj_packages = [SOURCE_PACKAGE_REGEX.sub(PACKAGE_NAME, name) for name in source_packages] 26proj_packages = [
27 SOURCE_PACKAGE_REGEX.sub(PACKAGE_NAME, name) for name in source_packages
28]
27 29
28 30
29setup( 31setup(
30 name='audio_visualizer_python', 32 name="audio_visualizer_python",
31 version=avp.__version__, 33 version=avp.__version__,
32 url='https://github.com/djfun/audio-visualizer-python', 34 url="https://github.com/djfun/audio-visualizer-python",
33 license='MIT', 35 license="MIT",
34 description=PACKAGE_DESCRIPTION, 36 description=PACKAGE_DESCRIPTION,
35 author=getTextFromFile('AUTHORS', 'djfun, tassaron'), 37 author=getTextFromFile("AUTHORS", "djfun, tassaron"),
36 long_description=getTextFromFile('README.md', PACKAGE_DESCRIPTION), 38 long_description=getTextFromFile("README.md", PACKAGE_DESCRIPTION),
37 classifiers=[ 39 classifiers=[
38 'Development Status :: 4 - Beta', 40 "Development Status :: 4 - Beta",
39 'License :: OSI Approved :: MIT License', 41 "License :: OSI Approved :: MIT License",
40 'Programming Language :: Python :: 3 :: Only', 42 "Programming Language :: Python :: 3 :: Only",
41 'Intended Audience :: End Users/Desktop', 43 "Intended Audience :: End Users/Desktop",
42 'Topic :: Multimedia :: Video :: Non-Linear Editor', 44 "Topic :: Multimedia :: Video :: Non-Linear Editor",
43 ], 45 ],
44 keywords=[ 46 keywords=[
45 'visualizer', 'visualization', 'commandline video', 47 "visualizer",
46 'video editor', 'ffmpeg', 'podcast' 48 "visualization",
49 "commandline video",
50 "video editor",
51 "ffmpeg",
52 "podcast",
47 ], 53 ],
48 packages=proj_packages, 54 packages=proj_packages,
49 package_dir={PACKAGE_NAME: SOURCE_DIRECTORY}, 55 package_dir={PACKAGE_NAME: SOURCE_DIRECTORY},
50 include_package_data=True, 56 include_package_data=True,
51 install_requires=[ 57 install_requires=[
52 'Pillow==9.1.1', 58 "Pillow",
53 'PyQt5', 59 "PyQt6",
54 'numpy', 60 "numpy",
55 'pytest', 61 "pytest",
56 'pytest-qt', 62 "pytest-qt",
57 ], 63 ],
58 entry_points={ 64 entry_points={
59 'console_scripts': [ 65 "console_scripts": [f"avp = {PACKAGE_NAME}.__main__:main"],
60 f'avp = {PACKAGE_NAME}.__main__:main' 66 },
61 ],
62 }
63) 67)
diff --git a/src/__init__.py b/src/__init__.py
index 2080de5..ee9bebb 100644
--- a/src/__init__.py
+++ b/src/__init__.py
@@ -3,20 +3,21 @@ import os
3import logging 3import logging
4 4
5 5
6__version__ = '2.0.0' 6__version__ = "2.1.0"
7 7
8 8
9class Logger(logging.getLoggerClass()): 9class Logger(logging.getLoggerClass()):
10 ''' 10 """
11 Custom Logger class to handle custom VERBOSE log level. 11 Custom Logger class to handle custom VERBOSE log level.
12 Levels used in this program are as follows: 12 Levels used in this program are as follows:
13 VERBOSE Annoyingly frequent debug messages (e.g, in loops) 13 VERBOSE Annoyingly frequent debug messages (e.g, in loops)
14 DEBUG Ordinary debug information 14 DEBUG Ordinary debug information
15 INFO Expected events that are expensive or irreversible 15 INFO Expected events that are expensive or irreversible
16 WARNING A non-fatal error or suspicious behaviour 16 WARNING A non-fatal error or suspicious behaviour
17 ERROR Any error that would interrupt the user 17 ERROR Any error that would interrupt the user
18 CRITICAL Things that really shouldn't happen at all 18 CRITICAL Things that really shouldn't happen at all
19 ''' 19 """
20
20 def __init__(self, name, level=logging.NOTSET): 21 def __init__(self, name, level=logging.NOTSET):
21 super().__init__(name, level) 22 super().__init__(name, level)
22 logging.addLevelName(5, "VERBOSE") 23 logging.addLevelName(5, "VERBOSE")
@@ -24,11 +25,13 @@ class Logger(logging.getLoggerClass()):
24 def verbose(self, msg, *args, **kwargs): 25 def verbose(self, msg, *args, **kwargs):
25 if self.isEnabledFor(5): 26 if self.isEnabledFor(5):
26 self._log(5, msg, args, **kwargs) 27 self._log(5, msg, args, **kwargs)
28
29
27logging.setLoggerClass(Logger) 30logging.setLoggerClass(Logger)
28logging.VERBOSE = 5 31logging.VERBOSE = 5
29 32
30 33
31if getattr(sys, 'frozen', False): 34if getattr(sys, "frozen", False):
32 # frozen 35 # frozen
33 wd = os.path.dirname(sys.executable) 36 wd = os.path.dirname(sys.executable)
34else: 37else:
diff --git a/src/__main__.py b/src/__main__.py
index 284fd2c..db48788 100644
--- a/src/__main__.py
+++ b/src/__main__.py
@@ -1,30 +1,30 @@
1from PyQt5.QtWidgets import QApplication 1from PyQt6.QtWidgets import QApplication
2import sys 2import sys
3import logging 3import logging
4import re 4import re
5import string 5import string
6 6
7 7
8log = logging.getLogger('AVP.Main') 8log = logging.getLogger("AVP.Main")
9 9
10 10
11def main() -> int: 11def main() -> int:
12 """Returns an exit code (0 for success)""" 12 """Returns an exit code (0 for success)"""
13 proj = None 13 proj = None
14 mode = 'GUI' 14 mode = "GUI"
15 15
16 # Determine whether we're in GUI or commandline mode 16 # Determine whether we're in GUI or commandline mode
17 if len(sys.argv) > 2: 17 if len(sys.argv) > 2:
18 mode = 'commandline' 18 mode = "commandline"
19 elif len(sys.argv) == 2: 19 elif len(sys.argv) == 2:
20 if sys.argv[1].startswith('-'): 20 if sys.argv[1].startswith("-"):
21 mode = 'commandline' 21 mode = "commandline"
22 else: 22 else:
23 # remove unsafe punctuation characters such as \/?*&^%$# 23 # remove unsafe punctuation characters such as \/?*&^%$#
24 if sys.argv[1].endswith('.avp'): 24 if sys.argv[1].endswith(".avp"):
25 # remove file extension 25 # remove file extension
26 sys.argv[1] = sys.argv[1][:-4] 26 sys.argv[1] = sys.argv[1][:-4]
27 sys.argv[1] = re.sub(f'[{re.escape(string.punctuation)}]', '', sys.argv[1]) 27 sys.argv[1] = re.sub(f"[{re.escape(string.punctuation)}]", "", sys.argv[1])
28 # opening a project file with gui 28 # opening a project file with gui
29 proj = sys.argv[1] 29 proj = sys.argv[1]
30 30
@@ -32,8 +32,16 @@ def main() -> int:
32 app = QApplication(sys.argv) 32 app = QApplication(sys.argv)
33 app.setApplicationName("audio-visualizer") 33 app.setApplicationName("audio-visualizer")
34 34
35 screen = app.primaryScreen()
36 if screen is None:
37 dpi = None
38 log.error("Could not detect DPI")
39 else:
40 dpi = screen.physicalDotsPerInchX()
41 log.info("Detected screen DPI: %s", dpi)
42
35 # Launch program 43 # Launch program
36 if mode == 'commandline': 44 if mode == "commandline":
37 from .command import Command 45 from .command import Command
38 46
39 main = Command() 47 main = Command()
@@ -42,14 +50,15 @@ def main() -> int:
42 50
43 # Both branches here may occur in one execution: 51 # Both branches here may occur in one execution:
44 # Commandline parsing could change mode back to GUI 52 # Commandline parsing could change mode back to GUI
45 if mode == 'GUI': 53 if mode == "GUI":
46 from .gui.mainwindow import MainWindow 54 from .gui.mainwindow import MainWindow
47 55
48 mainWindow = MainWindow(proj) 56 mainWindow = MainWindow(proj, dpi)
49 log.debug("Finished creating MainWindow") 57 log.debug("Finished creating MainWindow")
50 mainWindow.raise_() 58 mainWindow.raise_()
51 59
52 return app.exec_() 60 return app.exec()
61
53 62
54if __name__ == '__main__': 63if __name__ == "__main__":
55 sys.exit(main()) \ No newline at end of file 64 sys.exit(main())
diff --git a/src/command.py b/src/command.py
index 53801fa..783ac26 100644
--- a/src/command.py
+++ b/src/command.py
@@ -1,9 +1,10 @@
1''' 1"""
2 When using commandline mode, this module's object handles interpreting 2When using commandline mode, this module's object handles interpreting
3 the arguments and giving them to Core, which tracks the main program state. 3the arguments and giving them to Core, which tracks the main program state.
4 Then it immediately exports a video. 4Then it immediately exports a video.
5''' 5"""
6from PyQt5 import QtCore 6
7from PyQt6 import QtCore
7import argparse 8import argparse
8import os 9import os
9import sys 10import sys
@@ -16,12 +17,12 @@ import logging
16from . import core 17from . import core
17 18
18 19
19log = logging.getLogger('AVP.Commandline') 20log = logging.getLogger("AVP.Commandline")
20 21
21 22
22class Command(QtCore.QObject): 23class Command(QtCore.QObject):
23 """ 24 """
24 This replaces the GUI MainWindow when in commandline mode. 25 This replaces the GUI MainWindow when in commandline mode.
25 """ 26 """
26 27
27 createVideo = QtCore.pyqtSignal() 28 createVideo = QtCore.pyqtSignal()
@@ -29,7 +30,7 @@ class Command(QtCore.QObject):
29 def __init__(self): 30 def __init__(self):
30 super().__init__() 31 super().__init__()
31 self.core = core.Core() 32 self.core = core.Core()
32 core.Core.mode = 'commandline' 33 core.Core.mode = "commandline"
33 self.dataDir = self.core.dataDir 34 self.dataDir = self.core.dataDir
34 self.canceled = False 35 self.canceled = False
35 self.settings = core.Core.settings 36 self.settings = core.Core.settings
@@ -39,52 +40,58 @@ class Command(QtCore.QObject):
39 40
40 def parseArgs(self): 41 def parseArgs(self):
41 parser = argparse.ArgumentParser( 42 parser = argparse.ArgumentParser(
42 prog='avp' if os.path.basename(sys.argv[0]) == "__main__.py" else None, 43 prog="avp" if os.path.basename(sys.argv[0]) == "__main__.py" else None,
43 description='Create a visualization for an audio file', 44 description="Create a visualization for an audio file",
44 epilog='EXAMPLE COMMAND: avp myvideotemplate ' 45 epilog="EXAMPLE COMMAND: avp myvideotemplate "
45 '-i ~/Music/song.mp3 -o ~/video.mp4 ' 46 "-i ~/Music/song.mp3 -o ~/video.mp4 "
46 '-c 0 image path=~/Pictures/thisWeeksPicture.jpg ' 47 "-c 0 image path=~/Pictures/thisWeeksPicture.jpg "
47 '-c 1 video "preset=My Logo" -c 2 vis layout=classic' 48 '-c 1 video "preset=My Logo" -c 2 vis layout=classic',
48 ) 49 )
49 50
50
51 # input/output automatic-export commands 51 # input/output automatic-export commands
52 parser.add_argument("-i", "--input", metavar="SOUND", help="input audio file")
52 parser.add_argument( 53 parser.add_argument(
53 '-i', '--input', metavar='SOUND', 54 "-o", "--output", metavar="OUTPUT", help="output video file"
54 help='input audio file'
55 ) 55 )
56 parser.add_argument( 56 parser.add_argument(
57 '-o', '--output', metavar='OUTPUT', 57 "--export-project",
58 help='output video file' 58 action="store_true",
59 ) 59 help="use input and output files from project file if -i or -o is missing",
60 parser.add_argument(
61 '--export-project', action='store_true',
62 help='use input and output files from project file if -i or -o is missing'
63 ) 60 )
64 61
65 # mutually exclusive debug options 62 # mutually exclusive debug options
66 debugCommands = parser.add_mutually_exclusive_group() 63 debugCommands = parser.add_mutually_exclusive_group()
67 debugCommands.add_argument( 64 debugCommands.add_argument(
68 '--test', action='store_true', 65 "--test",
69 help='run tests and create a report full of debugging info' 66 action="store_true",
67 help="run tests and create a report full of debugging info",
70 ) 68 )
71 debugCommands.add_argument( 69 debugCommands.add_argument(
72 '--debug', action='store_true', 70 "--debug",
73 help='create bigger logfiles while program is running' 71 action="store_true",
72 help="create bigger logfiles while program is running",
74 ) 73 )
75 74
76 # project/GUI options 75 # project/GUI options
77 parser.add_argument( 76 parser.add_argument(
78 'projpath', metavar='path-to-project', 77 "projpath",
79 help='open a project file (.avp)', nargs='?') 78 metavar="path-to-project",
79 help="open a project file (.avp)",
80 nargs="?",
81 )
80 parser.add_argument( 82 parser.add_argument(
81 '-c', '--comp', metavar=('LAYER', 'ARG'), 83 "-c",
82 help='first arg must be component NAME to insert at LAYER.' 84 "--comp",
85 metavar=("LAYER", "ARG"),
86 help="first arg must be component NAME to insert at LAYER."
83 '"help" for information about possible args for a component.', 87 '"help" for information about possible args for a component.',
84 nargs='*', action='append') 88 nargs="*",
89 action="append",
90 )
85 parser.add_argument( 91 parser.add_argument(
86 '--no-preview', action='store_true', 92 "--no-preview",
87 help='disable live preview during export' 93 action="store_true",
94 help="disable live preview during export",
88 ) 95 )
89 96
90 args = parser.parse_args() 97 args = parser.parse_args()
@@ -101,15 +108,11 @@ class Command(QtCore.QObject):
101 if args.projpath: 108 if args.projpath:
102 projPath = args.projpath 109 projPath = args.projpath
103 if not os.path.dirname(projPath): 110 if not os.path.dirname(projPath):
104 projPath = os.path.join( 111 projPath = os.path.join(self.settings.value("projectDir"), projPath)
105 self.settings.value("projectDir"), 112 if not projPath.endswith(".avp"):
106 projPath 113 projPath += ".avp"
107 )
108 if not projPath.endswith('.avp'):
109 projPath += '.avp'
110 self.core.openProject(self, projPath) 114 self.core.openProject(self, projPath)
111 self.core.selectedComponents = list( 115 self.core.selectedComponents = list(reversed(self.core.selectedComponents))
112 reversed(self.core.selectedComponents))
113 self.core.componentListChanged() 116 self.core.componentListChanged()
114 117
115 if args.comp: 118 if args.comp:
@@ -120,14 +123,17 @@ class Command(QtCore.QObject):
120 try: 123 try:
121 pos = int(pos) 124 pos = int(pos)
122 except ValueError: 125 except ValueError:
123 print(pos, 'is not a layer number.') 126 print(pos, "is not a layer number.")
124 quit(1) 127 quit(1)
125 realName = self.parseCompName(name) 128 realName = self.parseCompName(name)
126 if not realName: 129 if not realName:
127 print(name, 'is not a valid component name.') 130 print(name, "is not a valid component name.")
128 quit(1) 131 quit(1)
129 modI = self.core.moduleIndexFor(realName) 132 modI = self.core.moduleIndexFor(realName)
130 i = self.core.insertComponent(pos, modI, self) 133 i = self.core.insertComponent(pos, modI, self)
134 if i is None:
135 print(name, "could not be initialized.")
136 quit(1)
131 for arg in compargs: 137 for arg in compargs:
132 self.core.selectedComponents[i].command(arg) 138 self.core.selectedComponents[i].command(arg)
133 139
@@ -135,15 +141,12 @@ class Command(QtCore.QObject):
135 errcode, data = self.core.parseAvFile(projPath) 141 errcode, data = self.core.parseAvFile(projPath)
136 input_ = None 142 input_ = None
137 output = None 143 output = None
138 for key, value in data['WindowFields']: 144 for key, value in data["WindowFields"]:
139 if 'outputFile' in key: 145 if "outputFile" in key:
140 output = value 146 output = value
141 if output and not os.path.dirname(value): 147 if output and not os.path.dirname(value):
142 output = os.path.join( 148 output = os.path.join(os.path.expanduser("~"), output)
143 os.path.expanduser('~'), 149 if "audioFile" in key:
144 output
145 )
146 if 'audioFile' in key:
147 input_ = value 150 input_ = value
148 151
149 # use input/output from project file, overwritten by -i and -o 152 # use input/output from project file, overwritten by -i and -o
@@ -153,7 +156,7 @@ class Command(QtCore.QObject):
153 156
154 self.createAudioVisualization( 157 self.createAudioVisualization(
155 input_ if not args.input else args.input, 158 input_ if not args.input else args.input,
156 output if not args.output else args.output 159 output if not args.output else args.output,
157 ) 160 )
158 return "commandline" 161 return "commandline"
159 162
@@ -165,11 +168,11 @@ class Command(QtCore.QObject):
165 core.Core.previewEnabled = False 168 core.Core.previewEnabled = False
166 169
167 elif ( 170 elif (
168 args.projpath is None and 171 args.projpath is None
169 'help' not in sys.argv and 172 and "help" not in sys.argv
170 '--debug' not in sys.argv and 173 and "--debug" not in sys.argv
171 '--test' not in sys.argv 174 and "--test" not in sys.argv
172 ): 175 ):
173 parser.print_help() 176 parser.print_help()
174 quit(1) 177 quit(1)
175 178
@@ -180,12 +183,9 @@ class Command(QtCore.QObject):
180 print("No components selected. Adding a default visualizer.") 183 print("No components selected. Adding a default visualizer.")
181 time.sleep(1) 184 time.sleep(1)
182 self.core.insertComponent(0, 0, self) 185 self.core.insertComponent(0, 0, self)
183 self.core.selectedComponents = list( 186 self.core.selectedComponents = list(reversed(self.core.selectedComponents))
184 reversed(self.core.selectedComponents))
185 self.core.componentListChanged() 187 self.core.componentListChanged()
186 self.worker = self.core.newVideoWorker( 188 self.worker = self.core.newVideoWorker(self, input, output)
187 self, input, output
188 )
189 # quit(0) after video is created 189 # quit(0) after video is created
190 self.worker.videoCreated.connect(self.videoCreated) 190 self.worker.videoCreated.connect(self.videoCreated)
191 self.lastProgressUpdate = time.time() 191 self.lastProgressUpdate = time.time()
@@ -199,16 +199,18 @@ class Command(QtCore.QObject):
199 199
200 @QtCore.pyqtSlot(str) 200 @QtCore.pyqtSlot(str)
201 def progressBarSetText(self, value): 201 def progressBarSetText(self, value):
202 if 'Export ' in value: 202 if "Export " in value:
203 # Don't duplicate completion/failure messages 203 # Don't duplicate completion/failure messages
204 return 204 return
205 if not value.startswith('Exporting') \ 205 if (
206 and time.time() - self.lastProgressUpdate >= 0.05: 206 not value.startswith("Exporting")
207 and time.time() - self.lastProgressUpdate >= 0.05
208 ):
207 # Show most messages very often 209 # Show most messages very often
208 print(value) 210 print(value)
209 elif time.time() - self.lastProgressUpdate >= 2.0: 211 elif time.time() - self.lastProgressUpdate >= 2.0:
210 # Give user time to read ffmpeg's output during the export 212 # Give user time to read ffmpeg's output during the export
211 print('##### %s' % value) 213 print("##### %s" % value)
212 else: 214 else:
213 return 215 return
214 self.lastProgressUpdate = time.time() 216 self.lastProgressUpdate = time.time()
@@ -221,9 +223,9 @@ class Command(QtCore.QObject):
221 quit(code) 223 quit(code)
222 224
223 def showMessage(self, **kwargs): 225 def showMessage(self, **kwargs):
224 print(kwargs['msg']) 226 print(kwargs["msg"])
225 if 'detail' in kwargs: 227 if "detail" in kwargs:
226 print(kwargs['detail']) 228 print(kwargs["detail"])
227 229
228 @QtCore.pyqtSlot(str, str) 230 @QtCore.pyqtSlot(str, str)
229 def videoThreadError(self, msg, detail): 231 def videoThreadError(self, msg, detail):
@@ -235,7 +237,7 @@ class Command(QtCore.QObject):
235 pass 237 pass
236 238
237 def parseCompName(self, name): 239 def parseCompName(self, name):
238 '''Deduces a proper component name out of a commandline arg''' 240 """Deduces a proper component name out of a commandline arg"""
239 241
240 if name.title() in self.core.compNames: 242 if name.title() in self.core.compNames:
241 return name.title() 243 return name.title()
@@ -244,9 +246,7 @@ class Command(QtCore.QObject):
244 return compName 246 return compName
245 247
246 compFileNames = [ 248 compFileNames = [
247 os.path.splitext( 249 os.path.splitext(os.path.basename(mod.__file__))[0]
248 os.path.basename(mod.__file__)
249 )[0]
250 for mod in self.core.modules 250 for mod in self.core.modules
251 ] 251 ]
252 for i, compFileName in enumerate(compFileNames): 252 for i, compFileName in enumerate(compFileNames):
@@ -258,15 +258,17 @@ class Command(QtCore.QObject):
258 258
259 def runTests(self): 259 def runTests(self):
260 from . import tests 260 from . import tests
261
261 test_report = os.path.join(core.Core.logDir, "test_report.log") 262 test_report = os.path.join(core.Core.logDir, "test_report.log")
262 tests.run(test_report) 263 tests.run(test_report)
263 264
264 # Choose a numbered location to put the output file 265 # Choose a numbered location to put the output file
265 logNumber = 0 266 logNumber = 0
267
266 def getFilename(): 268 def getFilename():
267 """Get a numbered filename for the final test report""" 269 """Get a numbered filename for the final test report"""
268 nonlocal logNumber 270 nonlocal logNumber
269 name = os.path.join(os.path.expanduser('~'), "avp_test_report") 271 name = os.path.join(os.path.expanduser("~"), "avp_test_report")
270 while True: 272 while True:
271 possibleName = f"{name}{logNumber:0>2}.txt" 273 possibleName = f"{name}{logNumber:0>2}.txt"
272 if os.path.exists(possibleName) and logNumber < 100: 274 if os.path.exists(possibleName) and logNumber < 100:
diff --git a/src/component.py b/src/component.py
index 1e10f66..01d4e44 100644
--- a/src/component.py
+++ b/src/component.py
@@ -1,9 +1,10 @@
1''' 1"""
2 Base classes for components to import. Read comments for some documentation 2Base classes for components to import. Read comments for some documentation
3 on making a valid component. 3on making a valid component.
4''' 4"""
5from PyQt5 import uic, QtCore, QtWidgets 5
6from PyQt5.QtGui import QColor 6from PyQt6 import uic, QtCore, QtWidgets
7from PyQt6.QtGui import QColor, QUndoCommand
7import os 8import os
8import sys 9import sys
9import math 10import math
@@ -13,18 +14,22 @@ from copy import copy
13 14
14from .toolkit.frame import BlankFrame 15from .toolkit.frame import BlankFrame
15from .toolkit import ( 16from .toolkit import (
16 getWidgetValue, setWidgetValue, connectWidget, rgbFromString, blockSignals 17 getWidgetValue,
18 setWidgetValue,
19 connectWidget,
20 rgbFromString,
21 blockSignals,
17) 22)
18 23
19 24
20log = logging.getLogger('AVP.ComponentHandler') 25log = logging.getLogger("AVP.ComponentHandler")
21 26
22 27
23class ComponentMetaclass(type(QtCore.QObject)): 28class ComponentMetaclass(type(QtCore.QObject)):
24 ''' 29 """
25 Checks the validity of each Component class and mutates some attrs. 30 Checks the validity of each Component class and mutates some attrs.
26 E.g., takes only major version from version string & decorates methods 31 E.g., takes only major version from version string & decorates methods
27 ''' 32 """
28 33
29 def initializationWrapper(func): 34 def initializationWrapper(func):
30 def initializationWrapper(self, *args, **kwargs): 35 def initializationWrapper(self, *args, **kwargs):
@@ -32,51 +37,55 @@ class ComponentMetaclass(type(QtCore.QObject)):
32 return func(self, *args, **kwargs) 37 return func(self, *args, **kwargs)
33 except Exception: 38 except Exception:
34 try: 39 try:
35 raise ComponentError(self, 'initialization process') 40 raise ComponentError(self, "initialization process")
36 except ComponentError: 41 except ComponentError:
37 return 42 return
43
38 return initializationWrapper 44 return initializationWrapper
39 45
40 def renderWrapper(func): 46 def renderWrapper(func):
41 def renderWrapper(self, *args, **kwargs): 47 def renderWrapper(self, *args, **kwargs):
42 try: 48 try:
43 log.verbose( 49 log.verbose(
44 '### %s #%s renders a preview frame ###', 50 "### %s #%s renders a preview frame ###",
45 self.__class__.name, str(self.compPos), 51 self.__class__.name,
52 str(self.compPos),
46 ) 53 )
47 return func(self, *args, **kwargs) 54 return func(self, *args, **kwargs)
48 except Exception as e: 55 except Exception as e:
49 try: 56 try:
50 if e.__class__.__name__.startswith('Component'): 57 if e.__class__.__name__.startswith("Component"):
51 raise 58 raise
52 else: 59 else:
53 raise ComponentError(self, 'renderer') 60 raise ComponentError(self, "renderer")
54 except ComponentError: 61 except ComponentError:
55 return BlankFrame() 62 return BlankFrame()
63
56 return renderWrapper 64 return renderWrapper
57 65
58 def commandWrapper(func): 66 def commandWrapper(func):
59 '''Intercepts the command() method to check for global args''' 67 """Intercepts the command() method to check for global args"""
68
60 def commandWrapper(self, arg): 69 def commandWrapper(self, arg):
61 if arg.startswith('preset='): 70 if arg.startswith("preset="):
62 _, preset = arg.split('=', 1) 71 _, preset = arg.split("=", 1)
63 path = os.path.join(self.core.getPresetDir(self), preset) 72 path = os.path.join(self.core.getPresetDir(self), preset)
64 if not os.path.exists(path): 73 if not os.path.exists(path):
65 print('Couldn\'t locate preset "%s"' % preset) 74 print('Couldn\'t locate preset "%s"' % preset)
66 quit(1) 75 quit(1)
67 else: 76 else:
68 print('Opening "%s" preset on layer %s' % ( 77 print('Opening "%s" preset on layer %s' % (preset, self.compPos))
69 preset, self.compPos)
70 )
71 self.core.openPreset(path, self.compPos, preset) 78 self.core.openPreset(path, self.compPos, preset)
72 # Don't call the component's command() method 79 # Don't call the component's command() method
73 return 80 return
74 else: 81 else:
75 return func(self, arg) 82 return func(self, arg)
83
76 return commandWrapper 84 return commandWrapper
77 85
78 def propertiesWrapper(func): 86 def propertiesWrapper(func):
79 '''Intercepts the usual properties if the properties are locked.''' 87 """Intercepts the usual properties if the properties are locked."""
88
80 def propertiesWrapper(self): 89 def propertiesWrapper(self):
81 if self._lockedProperties is not None: 90 if self._lockedProperties is not None:
82 return self._lockedProperties 91 return self._lockedProperties
@@ -85,22 +94,26 @@ class ComponentMetaclass(type(QtCore.QObject)):
85 return func(self) 94 return func(self)
86 except Exception: 95 except Exception:
87 try: 96 try:
88 raise ComponentError(self, 'properties') 97 raise ComponentError(self, "properties")
89 except ComponentError: 98 except ComponentError:
90 return [] 99 return []
100
91 return propertiesWrapper 101 return propertiesWrapper
92 102
93 def errorWrapper(func): 103 def errorWrapper(func):
94 '''Intercepts the usual error message if it is locked.''' 104 """Intercepts the usual error message if it is locked."""
105
95 def errorWrapper(self): 106 def errorWrapper(self):
96 if self._lockedError is not None: 107 if self._lockedError is not None:
97 return self._lockedError 108 return self._lockedError
98 else: 109 else:
99 return func(self) 110 return func(self)
111
100 return errorWrapper 112 return errorWrapper
101 113
102 def loadPresetWrapper(func): 114 def loadPresetWrapper(func):
103 '''Wraps loadPreset to handle the self.openingPreset boolean''' 115 """Wraps loadPreset to handle the self.openingPreset boolean"""
116
104 class openingPreset: 117 class openingPreset:
105 def __init__(self, comp): 118 def __init__(self, comp):
106 self.comp = comp 119 self.comp = comp
@@ -117,17 +130,19 @@ class ComponentMetaclass(type(QtCore.QObject)):
117 return func(self, *args) 130 return func(self, *args)
118 except Exception: 131 except Exception:
119 try: 132 try:
120 raise ComponentError(self, 'preset loader') 133 raise ComponentError(self, "preset loader")
121 except ComponentError: 134 except ComponentError:
122 return 135 return
136
123 return presetWrapper 137 return presetWrapper
124 138
125 def updateWrapper(func): 139 def updateWrapper(func):
126 ''' 140 """
127 Calls _preUpdate before every subclass update(). 141 Calls _preUpdate before every subclass update().
128 Afterwards, for non-user updates, calls _autoUpdate(). 142 Afterwards, for non-user updates, calls _autoUpdate().
129 For undoable updates triggered by the user, calls _userUpdate() 143 For undoable updates triggered by the user, calls _userUpdate()
130 ''' 144 """
145
131 class wrap: 146 class wrap:
132 def __init__(self, comp, auto): 147 def __init__(self, comp, auto):
133 self.comp = comp 148 self.comp = comp
@@ -137,28 +152,33 @@ class ComponentMetaclass(type(QtCore.QObject)):
137 self.comp._preUpdate() 152 self.comp._preUpdate()
138 153
139 def __exit__(self, *args): 154 def __exit__(self, *args):
140 if self.auto or self.comp.openingPreset \ 155 if (
141 or not hasattr(self.comp.parent, 'undoStack'): 156 self.auto
142 log.verbose('Automatic update') 157 or self.comp.openingPreset
158 or not hasattr(self.comp.parent, "undoStack")
159 ):
160 log.verbose("Automatic update")
143 self.comp._autoUpdate() 161 self.comp._autoUpdate()
144 else: 162 else:
145 log.verbose('User update') 163 log.verbose("User update")
146 self.comp._userUpdate() 164 self.comp._userUpdate()
147 165
148 def updateWrapper(self, **kwargs): 166 def updateWrapper(self, **kwargs):
149 auto = kwargs['auto'] if 'auto' in kwargs else False 167 auto = kwargs["auto"] if "auto" in kwargs else False
150 with wrap(self, auto): 168 with wrap(self, auto):
151 try: 169 try:
152 return func(self) 170 return func(self)
153 except Exception: 171 except Exception:
154 try: 172 try:
155 raise ComponentError(self, 'update method') 173 raise ComponentError(self, "update method")
156 except ComponentError: 174 except ComponentError:
157 return 175 return
176
158 return updateWrapper 177 return updateWrapper
159 178
160 def widgetWrapper(func): 179 def widgetWrapper(func):
161 '''Connects all widgets to update method after the subclass's method''' 180 """Connects all widgets to update method after the subclass's method"""
181
162 class wrap: 182 class wrap:
163 def __init__(self, comp): 183 def __init__(self, comp):
164 self.comp = comp 184 self.comp = comp
@@ -169,92 +189,99 @@ class ComponentMetaclass(type(QtCore.QObject)):
169 def __exit__(self, *args): 189 def __exit__(self, *args):
170 for widgetList in self.comp._allWidgets.values(): 190 for widgetList in self.comp._allWidgets.values():
171 for widget in widgetList: 191 for widget in widgetList:
172 log.verbose('Connecting %s', str( 192 log.verbose("Connecting %s", str(widget.__class__.__name__))
173 widget.__class__.__name__))
174 connectWidget(widget, self.comp.update) 193 connectWidget(widget, self.comp.update)
175 194
176 def widgetWrapper(self, *args, **kwargs): 195 def widgetWrapper(self, *args, **kwargs):
177 auto = kwargs['auto'] if 'auto' in kwargs else False 196 auto = kwargs["auto"] if "auto" in kwargs else False
178 with wrap(self): 197 with wrap(self):
179 try: 198 try:
180 return func(self, *args, **kwargs) 199 return func(self, *args, **kwargs)
181 except Exception: 200 except Exception:
182 try: 201 try:
183 raise ComponentError(self, 'widget creation') 202 raise ComponentError(self, "widget creation")
184 except ComponentError: 203 except ComponentError:
185 return 204 return
205
186 return widgetWrapper 206 return widgetWrapper
187 207
188 def __new__(cls, name, parents, attrs): 208 def __new__(cls, name, parents, attrs):
189 if 'ui' not in attrs: 209 if "ui" not in attrs:
190 # Use module name as ui filename by default 210 # Use module name as ui filename by default
191 attrs['ui'] = '%s.ui' % os.path.splitext( 211 attrs["ui"] = (
192 attrs['__module__'].split('.')[-1] 212 "%s.ui" % os.path.splitext(attrs["__module__"].split(".")[-1])[0]
193 )[0] 213 )
194 214
195 decorate = ( 215 decorate = (
196 'names', # Class methods 216 "names", # Class methods
197 'error', 'audio', 'properties', # Properties 217 "error",
198 'preFrameRender', 'previewRender', 218 "audio",
199 'loadPreset', 'command', 219 "properties", # Properties
200 'update', 'widget', 220 "preFrameRender",
221 "previewRender",
222 "loadPreset",
223 "command",
224 "update",
225 "widget",
201 ) 226 )
202 227
203 # Auto-decorate methods 228 # Auto-decorate methods
204 for key in decorate: 229 for key in decorate:
205 if key not in attrs: 230 if key not in attrs:
206 continue 231 continue
207 if key in ('names'): 232 if key in ("names"):
208 attrs[key] = classmethod(attrs[key]) 233 attrs[key] = classmethod(attrs[key])
209 elif key in ('audio'): 234 elif key in ("audio"):
210 attrs[key] = property(attrs[key]) 235 attrs[key] = property(attrs[key])
211 elif key == 'command': 236 elif key == "command":
212 attrs[key] = cls.commandWrapper(attrs[key]) 237 attrs[key] = cls.commandWrapper(attrs[key])
213 elif key == 'previewRender': 238 elif key == "previewRender":
214 attrs[key] = cls.renderWrapper(attrs[key]) 239 attrs[key] = cls.renderWrapper(attrs[key])
215 elif key == 'preFrameRender': 240 elif key == "preFrameRender":
216 attrs[key] = cls.initializationWrapper(attrs[key]) 241 attrs[key] = cls.initializationWrapper(attrs[key])
217 elif key == 'properties': 242 elif key == "properties":
218 attrs[key] = cls.propertiesWrapper(attrs[key]) 243 attrs[key] = cls.propertiesWrapper(attrs[key])
219 elif key == 'error': 244 elif key == "error":
220 attrs[key] = cls.errorWrapper(attrs[key]) 245 attrs[key] = cls.errorWrapper(attrs[key])
221 elif key == 'loadPreset': 246 elif key == "loadPreset":
222 attrs[key] = cls.loadPresetWrapper(attrs[key]) 247 attrs[key] = cls.loadPresetWrapper(attrs[key])
223 elif key == 'update': 248 elif key == "update":
224 attrs[key] = cls.updateWrapper(attrs[key]) 249 attrs[key] = cls.updateWrapper(attrs[key])
225 elif key == 'widget' and parents[0] != QtCore.QObject: 250 elif key == "widget" and parents[0] != QtCore.QObject:
226 attrs[key] = cls.widgetWrapper(attrs[key]) 251 attrs[key] = cls.widgetWrapper(attrs[key])
227 252
228 # Turn version string into a number 253 # Turn version string into a number
229 try: 254 try:
230 if 'version' not in attrs: 255 if "version" not in attrs:
231 log.error( 256 log.error(
232 'No version attribute in %s. Defaulting to 1', 257 "No version attribute in %s. Defaulting to 1",
233 attrs['name']) 258 attrs["name"],
234 attrs['version'] = 1 259 )
260 attrs["version"] = 1
235 else: 261 else:
236 attrs['version'] = int(attrs['version'].split('.')[0]) 262 attrs["version"] = int(attrs["version"].split(".")[0])
237 except ValueError: 263 except ValueError:
238 log.critical( 264 log.critical(
239 '%s component has an invalid version string:\n%s', 265 "%s component has an invalid version string:\n%s",
240 attrs['name'], str(attrs['version']) 266 attrs["name"],
267 str(attrs["version"]),
241 ) 268 )
242 except KeyError: 269 except KeyError:
243 log.critical('%s component has no version string.', attrs['name']) 270 log.critical("%s component has no version string.", attrs["name"])
244 else: 271 else:
245 return super().__new__(cls, name, parents, attrs) 272 return super().__new__(cls, name, parents, attrs)
246 quit(1) 273 quit(1)
247 274
248 275
249class Component(QtCore.QObject, metaclass=ComponentMetaclass): 276class Component(QtCore.QObject, metaclass=ComponentMetaclass):
250 ''' 277 """
251 The base class for components to inherit. 278 The base class for components to inherit.
252 ''' 279 """
253 280
254 name = 'Component' 281 name = "Component"
255 # ui = 'name_Of_Non_Default_Ui_File' 282 # ui = 'name_Of_Non_Default_Ui_File'
256 283
257 version = '1.0.0' 284 version = "1.0.0"
258 # The major version (before the first dot) is used to determine 285 # The major version (before the first dot) is used to determine
259 # preset compatibility; the rest is ignored so it can be non-numeric. 286 # preset compatibility; the rest is ignored so it can be non-numeric.
260 287
@@ -297,19 +324,19 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
297 324
298 def __repr__(self): 325 def __repr__(self):
299 import pprint 326 import pprint
327
300 try: 328 try:
301 preset = self.savePreset() 329 preset = self.savePreset()
302 except Exception as e: 330 except Exception as e:
303 preset = '%s occurred while saving preset' % str(e) 331 preset = "%s occurred while saving preset" % str(e)
304 332
305 return ( 333 return "Component(module %s, pos %s) (%s)\n" "Name: %s v%s\nPreset: %s" % (
306 'Component(module %s, pos %s) (%s)\n' 334 self.moduleIndex,
307 'Name: %s v%s\nPreset: %s' % ( 335 self.compPos,
308 self.moduleIndex, self.compPos, 336 object.__repr__(self),
309 object.__repr__(self), 337 self.__class__.name,
310 self.__class__.name, str(self.__class__.version), 338 str(self.__class__.version),
311 pprint.pformat(preset) 339 pprint.pformat(preset),
312 )
313 ) 340 )
314 341
315 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 342 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
@@ -321,17 +348,17 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
321 return image 348 return image
322 349
323 def preFrameRender(self, **kwargs): 350 def preFrameRender(self, **kwargs):
324 ''' 351 """
325 Must call super() when subclassing 352 Must call super() when subclassing
326 Triggered only before a video is exported (video_thread.py) 353 Triggered only before a video is exported (video_thread.py)
327 self.audioFile = filepath to the main input audio file 354 self.audioFile = filepath to the main input audio file
328 self.completeAudioArray = a list of audio samples 355 self.completeAudioArray = a list of audio samples
329 self.sampleSize = number of audio samples per video frame 356 self.sampleSize = number of audio samples per video frame
330 self.progressBarUpdate = signal to set progress bar number 357 self.progressBarUpdate = signal to set progress bar number
331 self.progressBarSetText = signal to set progress bar text 358 self.progressBarSetText = signal to set progress bar text
332 Use the latter two signals to update the MainWindow if needed 359 Use the latter two signals to update the MainWindow if needed
333 for a long initialization procedure (i.e., for a visualizer) 360 for a long initialization procedure (i.e., for a visualizer)
334 ''' 361 """
335 for key, value in kwargs.items(): 362 for key, value in kwargs.items():
336 setattr(self, key, value) 363 setattr(self, key, value)
337 364
@@ -348,92 +375,94 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
348 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 375 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
349 376
350 def properties(self): 377 def properties(self):
351 ''' 378 """
352 Return a list of properties to signify if your component is 379 Return a list of properties to signify if your component is
353 non-animated ('static'), returns sound ('audio'), or has 380 non-animated ('static'), returns sound ('audio'), or has
354 encountered an error in configuration ('error'). 381 encountered an error in configuration ('error').
355 ''' 382 """
356 return [] 383 return []
357 384
358 def error(self): 385 def error(self):
359 ''' 386 """
360 Return a string containing an error message, or None for a default. 387 Return a string containing an error message, or None for a default.
361 Or tuple of two strings for a message with details. 388 Or tuple of two strings for a message with details.
362 Alternatively use lockError(msgString) within properties() 389 Alternatively use lockError(msgString) within properties()
363 to skip this method entirely. 390 to skip this method entirely.
364 ''' 391 """
365 return 392 return
366 393
367 def audio(self): 394 def audio(self):
368 ''' 395 """
369 Return audio to mix into master as a tuple with two elements: 396 Return audio to mix into master as a tuple with two elements:
370 The first element can be: 397 The first element can be:
371 - A string (path to audio file), 398 - A string (path to audio file),
372 - Or an object that returns audio data through a pipe 399 - Or an object that returns audio data through a pipe
373 The second element must be a dictionary of ffmpeg filters/options 400 The second element must be a dictionary of ffmpeg filters/options
374 to apply to the input stream. See the filter docs for ideas: 401 to apply to the input stream. See the filter docs for ideas:
375 https://ffmpeg.org/ffmpeg-filters.html 402 https://ffmpeg.org/ffmpeg-filters.html
376 ''' 403 """
377 404
378 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 405 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
379 # Idle Methods 406 # Idle Methods
380 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 407 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
381 408
382 def widget(self, parent): 409 def widget(self, parent):
383 ''' 410 """
384 Call super().widget(*args) to create the component widget 411 Call super().widget(*args) to create the component widget
385 which also auto-connects any common widgets (e.g., checkBoxes) 412 which also auto-connects any common widgets (e.g., checkBoxes)
386 to self.update(). Then in a subclass connect special actions 413 to self.update(). Then in a subclass connect special actions
387 (e.g., pushButtons to select a file) and initialize 414 (e.g., pushButtons to select a file) and initialize
388 ''' 415 """
389 self.parent = parent 416 self.parent = parent
390 self.settings = parent.settings 417 self.settings = parent.settings
391 log.verbose( 418 log.verbose(
392 'Creating UI for %s #%s\'s widget', 419 "Creating UI for %s #%s's widget",
393 self.__class__.name, self.compPos 420 self.__class__.name,
421 self.compPos,
394 ) 422 )
395 self.page = self.loadUi(self.__class__.ui) 423 self.page = self.loadUi(self.__class__.ui)
396 424
397 # Find all normal widgets which will be connected after subclass method 425 # Find all normal widgets which will be connected after subclass method
398 self._allWidgets = { 426 self._allWidgets = {
399 'lineEdit': self.page.findChildren(QtWidgets.QLineEdit), 427 "lineEdit": self.page.findChildren(QtWidgets.QLineEdit),
400 'checkBox': self.page.findChildren(QtWidgets.QCheckBox), 428 "checkBox": self.page.findChildren(QtWidgets.QCheckBox),
401 'spinBox': self.page.findChildren(QtWidgets.QSpinBox), 429 "spinBox": self.page.findChildren(QtWidgets.QSpinBox),
402 'comboBox': self.page.findChildren(QtWidgets.QComboBox), 430 "comboBox": self.page.findChildren(QtWidgets.QComboBox),
403 } 431 }
404 self._allWidgets['spinBox'].extend( 432 self._allWidgets["spinBox"].extend(
405 self.page.findChildren(QtWidgets.QDoubleSpinBox) 433 self.page.findChildren(QtWidgets.QDoubleSpinBox)
406 ) 434 )
407 435
408 def update(self): 436 def update(self):
409 ''' 437 """
410 Starting point for a component update. A subclass should override 438 Starting point for a component update. A subclass should override
411 this method, and the base class will then magically insert a call 439 this method, and the base class will then magically insert a call
412 to either _autoUpdate() or _userUpdate() at the end. 440 to either _autoUpdate() or _userUpdate() at the end.
413 ''' 441 """
414 442
415 def loadPreset(self, presetDict, presetName=None): 443 def loadPreset(self, presetDict, presetName=None):
416 ''' 444 """
417 Subclasses should take (presetDict, *args) as args. 445 Subclasses should take (presetDict, *args) as args.
418 Must use super().loadPreset(presetDict, *args) first, 446 Must use super().loadPreset(presetDict, *args) first,
419 then update self.page widgets using the preset dict. 447 then update self.page widgets using the preset dict.
420 ''' 448 """
421 self.currentPreset = presetName \ 449 self.currentPreset = (
422 if presetName is not None else presetDict['preset'] 450 presetName if presetName is not None else presetDict["preset"]
451 )
423 for attr, widget in self._trackedWidgets.items(): 452 for attr, widget in self._trackedWidgets.items():
424 key = attr if attr not in self._presetNames \ 453 key = attr if attr not in self._presetNames else self._presetNames[attr]
425 else self._presetNames[attr]
426 try: 454 try:
427 val = presetDict[key] 455 val = presetDict[key]
428 except KeyError as e: 456 except KeyError as e:
429 log.info( 457 log.info(
430 '%s missing value %s. Outdated preset?', 458 "%s missing value %s. Outdated preset?",
431 self.currentPreset, str(e) 459 self.currentPreset,
460 str(e),
432 ) 461 )
433 val = getattr(self, key) 462 val = getattr(self, key)
434 463
435 if attr in self._colorWidgets: 464 if attr in self._colorWidgets:
436 widget.setText('%s,%s,%s' % val) 465 widget.setText("%s,%s,%s" % val)
437 btnStyle = ( 466 btnStyle = (
438 "QPushButton { background-color : %s; outline: none; }" 467 "QPushButton { background-color : %s; outline: none; }"
439 % QColor(*val).name() 468 % QColor(*val).name()
@@ -450,8 +479,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
450 saveValueStore = {} 479 saveValueStore = {}
451 for attr, widget in self._trackedWidgets.items(): 480 for attr, widget in self._trackedWidgets.items():
452 presetAttrName = ( 481 presetAttrName = (
453 attr if attr not in self._presetNames 482 attr if attr not in self._presetNames else self._presetNames[attr]
454 else self._presetNames[attr]
455 ) 483 )
456 if attr in self._relativeWidgets: 484 if attr in self._relativeWidgets:
457 try: 485 try:
@@ -465,19 +493,18 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
465 return saveValueStore 493 return saveValueStore
466 494
467 def commandHelp(self): 495 def commandHelp(self):
468 '''Help text as string for this component's commandline arguments''' 496 """Help text as string for this component's commandline arguments"""
469 497
470 def command(self, arg=''): 498 def command(self, arg=""):
471 ''' 499 """
472 Configure a component using an arg from the commandline. This is 500 Configure a component using an arg from the commandline. This is
473 never called if global args like 'preset=' are found in the arg. 501 never called if global args like 'preset=' are found in the arg.
474 So simply check for any non-global args in your component and 502 So simply check for any non-global args in your component and
475 call super().command() at the end to get a Help message. 503 call super().command() at the end to get a Help message.
476 ''' 504 """
477 print( 505 print(
478 self.__class__.name, 'Usage:\n' 506 self.__class__.name,
479 'Open a preset for this component:\n' 507 "Usage:\n" "Open a preset for this component:\n" ' "preset=Preset Name"',
480 ' "preset=Preset Name"'
481 ) 508 )
482 self.commandHelp() 509 self.commandHelp()
483 quit(0) 510 quit(0)
@@ -486,19 +513,21 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
486 # "Private" Methods 513 # "Private" Methods
487 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 514 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
488 def _preUpdate(self): 515 def _preUpdate(self):
489 '''Happens before subclass update()''' 516 """Happens before subclass update()"""
490 for attr in self._relativeWidgets: 517 for attr in self._relativeWidgets:
491 self.updateRelativeWidget(attr) 518 self.updateRelativeWidget(attr)
492 519
493 def _userUpdate(self): 520 def _userUpdate(self):
494 '''Happens after subclass update() for an undoable update by user.''' 521 """Happens after subclass update() for an undoable update by user."""
495 oldWidgetVals = { 522 oldWidgetVals = {
496 attr: copy(getattr(self, attr)) 523 attr: copy(getattr(self, attr)) for attr in self._trackedWidgets
497 for attr in self._trackedWidgets
498 } 524 }
499 newWidgetVals = { 525 newWidgetVals = {
500 attr: getWidgetValue(widget) 526 attr: (
501 if attr not in self._colorWidgets else rgbFromString(widget.text()) 527 getWidgetValue(widget)
528 if attr not in self._colorWidgets
529 else rgbFromString(widget.text())
530 )
502 for attr, widget in self._trackedWidgets.items() 531 for attr, widget in self._trackedWidgets.items()
503 } 532 }
504 modifiedWidgets = { 533 modifiedWidgets = {
@@ -511,7 +540,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
511 self.parent.undoStack.push(action) 540 self.parent.undoStack.push(action)
512 541
513 def _autoUpdate(self): 542 def _autoUpdate(self):
514 '''Happens after subclass update() for an internal component update.''' 543 """Happens after subclass update() for an internal component update."""
515 newWidgetVals = { 544 newWidgetVals = {
516 attr: getWidgetValue(widget) 545 attr: getWidgetValue(widget)
517 for attr, widget in self._trackedWidgets.items() 546 for attr, widget in self._trackedWidgets.items()
@@ -520,10 +549,10 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
520 self._sendUpdateSignal() 549 self._sendUpdateSignal()
521 550
522 def setAttrs(self, attrDict): 551 def setAttrs(self, attrDict):
523 ''' 552 """
524 Sets attrs (linked to trackedWidgets) in this component to 553 Sets attrs (linked to trackedWidgets) in this component to
525 the values in the attrDict. Mutates certain widget values if needed 554 the values in the attrDict. Mutates certain widget values if needed
526 ''' 555 """
527 for attr, val in attrDict.items(): 556 for attr, val in attrDict.items():
528 if attr in self._colorWidgets: 557 if attr in self._colorWidgets:
529 # Color Widgets must have a tuple & have a button to update 558 # Color Widgets must have a tuple & have a button to update
@@ -533,110 +562,111 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
533 rgbTuple = rgbFromString(val) 562 rgbTuple = rgbFromString(val)
534 btnStyle = ( 563 btnStyle = (
535 "QPushButton { background-color : %s; outline: none; }" 564 "QPushButton { background-color : %s; outline: none; }"
536 % QColor(*rgbTuple).name()) 565 % QColor(*rgbTuple).name()
566 )
537 self._colorWidgets[attr].setStyleSheet(btnStyle) 567 self._colorWidgets[attr].setStyleSheet(btnStyle)
538 setattr(self, attr, rgbTuple) 568 setattr(self, attr, rgbTuple)
539 569
540 else: 570 else:
541 # Normal tracked widget 571 # Normal tracked widget
542 setattr(self, attr, val) 572 setattr(self, attr, val)
543 log.verbose('Setting %s self.%s to %s' % ( 573 log.verbose("Setting %s self.%s to %s" % (self.__class__.name, attr, val))
544 self.__class__.name, attr, val))
545 574
546 def setWidgetValues(self, attrDict): 575 def setWidgetValues(self, attrDict):
547 ''' 576 """
548 Sets widgets defined by keys in trackedWidgets in this preset to 577 Sets widgets defined by keys in trackedWidgets in this preset to
549 the values in the attrDict. 578 the values in the attrDict.
550 ''' 579 """
551 affectedWidgets = [ 580 affectedWidgets = [self._trackedWidgets[attr] for attr in attrDict]
552 self._trackedWidgets[attr] for attr in attrDict
553 ]
554 with blockSignals(affectedWidgets): 581 with blockSignals(affectedWidgets):
555 for attr, val in attrDict.items(): 582 for attr, val in attrDict.items():
556 widget = self._trackedWidgets[attr] 583 widget = self._trackedWidgets[attr]
557 if attr in self._colorWidgets: 584 if attr in self._colorWidgets:
558 val = '%s,%s,%s' % val 585 val = "%s,%s,%s" % val
559 setWidgetValue(widget, val) 586 setWidgetValue(widget, val)
560 587
561 def _sendUpdateSignal(self): 588 def _sendUpdateSignal(self):
562 if not self.core.openingProject: 589 if not self.core.openingProject:
563 self.parent.drawPreview() 590 self.parent.drawPreview()
564 saveValueStore = self.savePreset() 591 saveValueStore = self.savePreset()
565 saveValueStore['preset'] = self.currentPreset 592 saveValueStore["preset"] = self.currentPreset
566 self.modified.emit(self.compPos, saveValueStore) 593 self.modified.emit(self.compPos, saveValueStore)
567 594
568 def trackWidgets(self, trackDict, **kwargs): 595 def trackWidgets(self, trackDict, **kwargs):
569 ''' 596 """
570 Name widgets to track in update(), savePreset(), loadPreset(), and 597 Name widgets to track in update(), savePreset(), loadPreset(), and
571 command(). Requires a dict of attr names as keys, widgets as values 598 command(). Requires a dict of attr names as keys, widgets as values
572 599
573 Optional args: 600 Optional args:
574 'presetNames': preset variable names to replace attr names 601 'presetNames': preset variable names to replace attr names
575 'commandArgs': arg keywords that differ from attr names 602 'commandArgs': arg keywords that differ from attr names
576 'colorWidgets': identify attr as RGB tuple & update button CSS 603 'colorWidgets': identify attr as RGB tuple & update button CSS
577 'relativeWidgets': change value proportionally to resolution 604 'relativeWidgets': change value proportionally to resolution
578 605
579 NOTE: Any kwarg key set to None will selectively disable tracking. 606 NOTE: Any kwarg key set to None will selectively disable tracking.
580 ''' 607 """
581 self._trackedWidgets = trackDict 608 self._trackedWidgets = trackDict
582 for kwarg in kwargs: 609 for kwarg in kwargs:
583 try: 610 try:
584 if kwarg in ( 611 if kwarg in (
585 'presetNames', 612 "presetNames",
586 'commandArgs', 613 "commandArgs",
587 'colorWidgets', 614 "colorWidgets",
588 'relativeWidgets', 615 "relativeWidgets",
589 ): 616 ):
590 setattr(self, '_{}'.format(kwarg), kwargs[kwarg]) 617 setattr(self, "_{}".format(kwarg), kwargs[kwarg])
591 else: 618 else:
592 raise ComponentError( 619 raise ComponentError(self, "Nonsensical keywords to trackWidgets.")
593 self, 'Nonsensical keywords to trackWidgets.')
594 except ComponentError: 620 except ComponentError:
595 continue 621 continue
596 622
597 if kwarg == 'colorWidgets': 623 if kwarg == "colorWidgets":
624
598 def makeColorFunc(attr): 625 def makeColorFunc(attr):
599 def pickColor_(): 626 def pickColor_():
600 self.mergeUndo = False 627 self.mergeUndo = False
601 self.pickColor( 628 self.pickColor(
602 self._trackedWidgets[attr], 629 self._trackedWidgets[attr],
603 self._colorWidgets[attr] 630 self._colorWidgets[attr],
604 ) 631 )
605 self.mergeUndo = True 632 self.mergeUndo = True
633
606 return pickColor_ 634 return pickColor_
607 self._colorFuncs = { 635
608 attr: makeColorFunc(attr) for attr in kwargs[kwarg] 636 self._colorFuncs = {attr: makeColorFunc(attr) for attr in kwargs[kwarg]}
609 }
610 for attr, func in self._colorFuncs.items(): 637 for attr, func in self._colorFuncs.items():
611 self._colorWidgets[attr].clicked.connect(func) 638 self._colorWidgets[attr].clicked.connect(func)
612 self._colorWidgets[attr].setStyleSheet( 639 self._colorWidgets[attr].setStyleSheet(
613 "QPushButton {" 640 "QPushButton {" "background-color : #FFFFFF; outline: none; }"
614 "background-color : #FFFFFF; outline: none; }"
615 ) 641 )
616 642
617 if kwarg == 'relativeWidgets': 643 if kwarg == "relativeWidgets":
618 # store maximum values of spinBoxes to be scaled appropriately 644 # store maximum values of spinBoxes to be scaled appropriately
619 for attr in kwargs[kwarg]: 645 for attr in kwargs[kwarg]:
620 self._relativeMaximums[attr] = \ 646 self._relativeMaximums[attr] = self._trackedWidgets[attr].maximum()
621 self._trackedWidgets[attr].maximum()
622 self.updateRelativeWidgetMaximum(attr) 647 self.updateRelativeWidgetMaximum(attr)
623 setattr( 648 setattr(self, attr, getWidgetValue(self._trackedWidgets[attr]))
624 self, attr, getWidgetValue(self._trackedWidgets[attr])
625 )
626 649
627 self._preUpdate() 650 self._preUpdate()
628 self._autoUpdate() 651 self._autoUpdate()
629 652
630 def pickColor(self, textWidget, button): 653 def pickColor(self, textWidget, button):
631 '''Use color picker to get color input from the user.''' 654 """Use color picker to get color input from the user."""
632 dialog = QtWidgets.QColorDialog() 655 dialog = QtWidgets.QColorDialog()
633 dialog.setOption(QtWidgets.QColorDialog.ShowAlphaChannel, True) 656 # TODO alpha channel is not actually shown in most color picker widgets?
657 dialog.setOption(
658 QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel, True
659 )
634 color = dialog.getColor() 660 color = dialog.getColor()
635 if color.isValid(): 661 if color.isValid():
636 RGBstring = '%s,%s,%s' % ( 662 RGBstring = "%s,%s,%s" % (
637 str(color.red()), str(color.green()), str(color.blue())) 663 str(color.red()),
638 btnStyle = "QPushButton{background-color: %s; outline: none;}" \ 664 str(color.green()),
639 % color.name() 665 str(color.blue()),
666 )
667 btnStyle = (
668 "QPushButton{background-color: %s; outline: none;}" % color.name()
669 )
640 textWidget.setText(RGBstring) 670 textWidget.setText(RGBstring)
641 button.setStyleSheet(btnStyle) 671 button.setStyleSheet(btnStyle)
642 672
@@ -659,25 +689,25 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
659 self._lockedSize = None 689 self._lockedSize = None
660 690
661 def loadUi(self, filename): 691 def loadUi(self, filename):
662 '''Load a Qt Designer ui file to use for this component's widget''' 692 """Load a Qt Designer ui file to use for this component's widget"""
663 return uic.loadUi(os.path.join(self.core.componentsPath, filename)) 693 return uic.loadUi(os.path.join(self.core.componentsPath, filename))
664 694
665 @property 695 @property
666 def width(self): 696 def width(self):
667 if self._lockedSize is None: 697 if self._lockedSize is None:
668 return int(self.settings.value('outputWidth')) 698 return int(self.settings.value("outputWidth"))
669 else: 699 else:
670 return self._lockedSize[0] 700 return self._lockedSize[0]
671 701
672 @property 702 @property
673 def height(self): 703 def height(self):
674 if self._lockedSize is None: 704 if self._lockedSize is None:
675 return int(self.settings.value('outputHeight')) 705 return int(self.settings.value("outputHeight"))
676 else: 706 else:
677 return self._lockedSize[1] 707 return self._lockedSize[1]
678 708
679 def cancel(self): 709 def cancel(self):
680 '''Stop any lengthy process in response to this variable.''' 710 """Stop any lengthy process in response to this variable."""
681 self.canceled = True 711 self.canceled = True
682 712
683 def reset(self): 713 def reset(self):
@@ -688,22 +718,22 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
688 def relativeWidgetAxis(func): 718 def relativeWidgetAxis(func):
689 def relativeWidgetAxis(self, attr, *args, **kwargs): 719 def relativeWidgetAxis(self, attr, *args, **kwargs):
690 hasVerticalWords = ( 720 hasVerticalWords = (
691 lambda attr: 721 lambda attr: "height" in attr.lower()
692 'height' in attr.lower() or 722 or "ypos" in attr.lower()
693 'ypos' in attr.lower() or 723 or attr == "y"
694 attr == 'y'
695 ) 724 )
696 if 'axis' not in kwargs: 725 if "axis" not in kwargs:
697 axis = self.width 726 axis = self.width
698 if hasVerticalWords(attr): 727 if hasVerticalWords(attr):
699 axis = self.height 728 axis = self.height
700 kwargs['axis'] = axis 729 kwargs["axis"] = axis
701 if 'axis' in kwargs and type(kwargs['axis']) is tuple: 730 if "axis" in kwargs and type(kwargs["axis"]) is tuple:
702 axis = kwargs['axis'][0] 731 axis = kwargs["axis"][0]
703 if hasVerticalWords(attr): 732 if hasVerticalWords(attr):
704 axis = kwargs['axis'][1] 733 axis = kwargs["axis"][1]
705 kwargs['axis'] = axis 734 kwargs["axis"] = axis
706 return func(self, attr, *args, **kwargs) 735 return func(self, attr, *args, **kwargs)
736
707 return relativeWidgetAxis 737 return relativeWidgetAxis
708 738
709 @relativeWidgetAxis 739 @relativeWidgetAxis
@@ -712,14 +742,20 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
712 val = self._relativeValues[attr] 742 val = self._relativeValues[attr]
713 if val > 50.0: 743 if val > 50.0:
714 log.warning( 744 log.warning(
715 '%s #%s attempted to set %s to dangerously high number %s', 745 "%s #%s attempted to set %s to dangerously high number %s",
716 self.__class__.name, self.compPos, attr, val 746 self.__class__.name,
747 self.compPos,
748 attr,
749 val,
717 ) 750 )
718 val = 50.0 751 val = 50.0
719 result = math.ceil(kwargs['axis'] * val) 752 result = math.ceil(kwargs["axis"] * val)
720 log.verbose( 753 log.verbose(
721 'Converting %s: f%s to px%s using axis %s', 754 "Converting %s: f%s to px%s using axis %s",
722 attr, val, result, kwargs['axis'] 755 attr,
756 val,
757 result,
758 kwargs["axis"],
723 ) 759 )
724 return result 760 return result
725 761
@@ -727,65 +763,63 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
727 def floatValForAttr(self, attr, val=None, **kwargs): 763 def floatValForAttr(self, attr, val=None, **kwargs):
728 if val is None: 764 if val is None:
729 val = self._trackedWidgets[attr].value() 765 val = self._trackedWidgets[attr].value()
730 return val / kwargs['axis'] 766 return val / kwargs["axis"]
731 767
732 def setRelativeWidget(self, attr, floatVal): 768 def setRelativeWidget(self, attr, floatVal):
733 '''Set a relative widget using a float''' 769 """Set a relative widget using a float"""
734 pixelVal = self.pixelValForAttr(attr, floatVal) 770 pixelVal = self.pixelValForAttr(attr, floatVal)
735 with blockSignals(self._trackedWidgets[attr]): 771 with blockSignals(self._trackedWidgets[attr]):
736 self._trackedWidgets[attr].setValue(pixelVal) 772 self._trackedWidgets[attr].setValue(pixelVal)
737 self.update(auto=True) 773 self.update(auto=True)
738 774
739 def getOldAttr(self, attr): 775 def getOldAttr(self, attr):
740 ''' 776 """
741 Returns previous state of this attr. Used to determine whether 777 Returns previous state of this attr. Used to determine whether
742 a relative widget must be updated. Required because undoing/redoing 778 a relative widget must be updated. Required because undoing/redoing
743 can make determining the 'previous' value tricky. 779 can make determining the 'previous' value tricky.
744 ''' 780 """
745 if self.oldAttrs is not None: 781 if self.oldAttrs is not None:
746 return self.oldAttrs[attr] 782 return self.oldAttrs[attr]
747 else: 783 else:
748 try: 784 try:
749 return getattr(self, attr) 785 return getattr(self, attr)
750 except AttributeError: 786 except AttributeError:
751 log.error('Using visible values instead of oldAttrs') 787 log.error("Using visible values instead of oldAttrs")
752 return self._trackedWidgets[attr].value() 788 return self._trackedWidgets[attr].value()
753 789
754 def updateRelativeWidget(self, attr): 790 def updateRelativeWidget(self, attr):
755 '''Called by _preUpdate() for each relativeWidget before each update''' 791 """Called by _preUpdate() for each relativeWidget before each update"""
756 oldUserValue = self.getOldAttr(attr) 792 oldUserValue = self.getOldAttr(attr)
757 newUserValue = self._trackedWidgets[attr].value() 793 newUserValue = self._trackedWidgets[attr].value()
758 newRelativeVal = self.floatValForAttr(attr, newUserValue) 794 newRelativeVal = self.floatValForAttr(attr, newUserValue)
759 795
760 if attr in self._relativeValues: 796 if attr in self._relativeValues:
761 oldRelativeVal = self._relativeValues[attr] 797 oldRelativeVal = self._relativeValues[attr]
762 if oldUserValue == newUserValue \ 798 if oldUserValue == newUserValue and oldRelativeVal != newRelativeVal:
763 and oldRelativeVal != newRelativeVal:
764 # Float changed without pixel value changing, which 799 # Float changed without pixel value changing, which
765 # means the pixel value needs to be updated 800 # means the pixel value needs to be updated
766 log.debug( 801 log.debug(
767 'Updating %s #%s\'s relative widget: %s', 802 "Updating %s #%s's relative widget: %s",
768 self.__class__.name, self.compPos, attr) 803 self.__class__.name,
804 self.compPos,
805 attr,
806 )
769 with blockSignals(self._trackedWidgets[attr]): 807 with blockSignals(self._trackedWidgets[attr]):
770 self.updateRelativeWidgetMaximum(attr) 808 self.updateRelativeWidgetMaximum(attr)
771 pixelVal = self.pixelValForAttr(attr, oldRelativeVal) 809 pixelVal = self.pixelValForAttr(attr, oldRelativeVal)
772 self._trackedWidgets[attr].setValue(pixelVal) 810 self._trackedWidgets[attr].setValue(pixelVal)
773 811
774 if attr not in self._relativeValues \ 812 if attr not in self._relativeValues or oldUserValue != newUserValue:
775 or oldUserValue != newUserValue:
776 self._relativeValues[attr] = newRelativeVal 813 self._relativeValues[attr] = newRelativeVal
777 814
778 def updateRelativeWidgetMaximum(self, attr): 815 def updateRelativeWidgetMaximum(self, attr):
779 maxRes = int(self.core.resolutions[0].split('x')[0]) 816 maxRes = int(self.core.resolutions[0].split("x")[0])
780 newMaximumValue = self.width * ( 817 newMaximumValue = self.width * (self._relativeMaximums[attr] / maxRes)
781 self._relativeMaximums[attr] /
782 maxRes
783 )
784 self._trackedWidgets[attr].setMaximum(int(newMaximumValue)) 818 self._trackedWidgets[attr].setMaximum(int(newMaximumValue))
785 819
786 820
787class ComponentError(RuntimeError): 821class ComponentError(RuntimeError):
788 '''Gives the MainWindow a traceback to display, and cancels the export.''' 822 """Gives the MainWindow a traceback to display, and cancels the export."""
789 823
790 prevErrors = [] 824 prevErrors = []
791 lastTime = time.time() 825 lastTime = time.time()
@@ -794,42 +828,46 @@ class ComponentError(RuntimeError):
794 if msg is None and sys.exc_info()[0] is not None: 828 if msg is None and sys.exc_info()[0] is not None:
795 msg = str(sys.exc_info()[1]) 829 msg = str(sys.exc_info()[1])
796 else: 830 else:
797 msg = 'Unknown error.' 831 msg = "Unknown error."
798 log.error("ComponentError by %s's %s: %s" % ( 832 log.error("ComponentError by %s's %s: %s" % (caller.name, name, msg))
799 caller.name, name, msg))
800 833
801 # Don't create multiple windows for quickly repeated messages 834 # Don't create multiple windows for quickly repeated messages
802 if len(ComponentError.prevErrors) > 1: 835 if len(ComponentError.prevErrors) > 1:
803 ComponentError.prevErrors.pop() 836 ComponentError.prevErrors.pop()
804 ComponentError.prevErrors.insert(0, name) 837 ComponentError.prevErrors.insert(0, name)
805 curTime = time.time() 838 curTime = time.time()
806 if name in ComponentError.prevErrors[1:] \ 839 if (
807 and curTime - ComponentError.lastTime < 1.0: 840 name in ComponentError.prevErrors[1:]
841 and curTime - ComponentError.lastTime < 1.0
842 ):
808 return 843 return
809 ComponentError.lastTime = time.time() 844 ComponentError.lastTime = time.time()
810 845
811 from .toolkit import formatTraceback 846 from .toolkit import formatTraceback
847
812 if sys.exc_info()[0] is not None: 848 if sys.exc_info()[0] is not None:
813 string = ( 849 string = "%s component (#%s): %s encountered %s %s: %s" % (
814 "%s component (#%s): %s encountered %s %s: %s" % ( 850 caller.__class__.name,
815 caller.__class__.name, 851 str(caller.compPos),
816 str(caller.compPos), 852 name,
817 name, 853 (
818 'an' if any([ 854 "an"
819 sys.exc_info()[0].__name__.startswith(vowel) 855 if any(
820 for vowel in ('A', 'I', 'U', 'O', 'E') 856 [
821 ]) else 'a', 857 sys.exc_info()[0].__name__.startswith(vowel)
822 sys.exc_info()[0].__name__, 858 for vowel in ("A", "I", "U", "O", "E")
823 str(sys.exc_info()[1]) 859 ]
824 ) 860 )
861 else "a"
862 ),
863 sys.exc_info()[0].__name__,
864 str(sys.exc_info()[1]),
825 ) 865 )
826 detail = formatTraceback(sys.exc_info()[2]) 866 detail = formatTraceback(sys.exc_info()[2])
827 else: 867 else:
828 string = name 868 string = name
829 detail = "Attributes:\n%s" % ( 869 detail = "Attributes:\n%s" % (
830 "\n".join( 870 "\n".join([m for m in dir(caller) if not m.startswith("_")])
831 [m for m in dir(caller) if not m.startswith('_')]
832 )
833 ) 871 )
834 872
835 super().__init__(string) 873 super().__init__(string)
@@ -837,28 +875,29 @@ class ComponentError(RuntimeError):
837 caller._error.emit(string, detail) 875 caller._error.emit(string, detail)
838 876
839 877
840class ComponentUpdate(QtWidgets.QUndoCommand): 878class ComponentUpdate(QUndoCommand):
841 '''Command object for making a component action undoable''' 879 """Command object for making a component action undoable"""
880
842 def __init__(self, parent, oldWidgetVals, modifiedVals): 881 def __init__(self, parent, oldWidgetVals, modifiedVals):
843 super().__init__( 882 super().__init__("change %s component #%s" % (parent.name, parent.compPos))
844 'change %s component #%s' % (
845 parent.name, parent.compPos
846 )
847 )
848 self.undone = False 883 self.undone = False
849 self.res = (int(parent.width), int(parent.height)) 884 self.res = (int(parent.width), int(parent.height))
850 self.parent = parent 885 self.parent = parent
851 self.oldWidgetVals = { 886 self.oldWidgetVals = {
852 attr: copy(val) 887 attr: (
853 if attr not in self.parent._relativeWidgets 888 copy(val)
854 else self.parent.floatValForAttr(attr, val, axis=self.res) 889 if attr not in self.parent._relativeWidgets
890 else self.parent.floatValForAttr(attr, val, axis=self.res)
891 )
855 for attr, val in oldWidgetVals.items() 892 for attr, val in oldWidgetVals.items()
856 if attr in modifiedVals 893 if attr in modifiedVals
857 } 894 }
858 self.modifiedVals = { 895 self.modifiedVals = {
859 attr: val 896 attr: (
860 if attr not in self.parent._relativeWidgets 897 val
861 else self.parent.floatValForAttr(attr, val, axis=self.res) 898 if attr not in self.parent._relativeWidgets
899 else self.parent.floatValForAttr(attr, val, axis=self.res)
900 )
862 for attr, val in modifiedVals.items() 901 for attr, val in modifiedVals.items()
863 } 902 }
864 903
@@ -877,12 +916,13 @@ class ComponentUpdate(QtWidgets.QUndoCommand):
877 self.modifiedVals[attr] = val 916 self.modifiedVals[attr] = val
878 else: 917 else:
879 log.warning( 918 log.warning(
880 '%s component settings changed at once. (%s)', 919 "%s component settings changed at once. (%s)",
881 len(self.modifiedVals), repr(self.modifiedVals) 920 len(self.modifiedVals),
921 repr(self.modifiedVals),
882 ) 922 )
883 923
884 def id(self): 924 def id(self):
885 '''If 2 consecutive updates have same id, Qt will call mergeWith()''' 925 """If 2 consecutive updates have same id, Qt will call mergeWith()"""
886 return self.id_ 926 return self.id_
887 927
888 def mergeWith(self, other): 928 def mergeWith(self, other):
@@ -890,20 +930,23 @@ class ComponentUpdate(QtWidgets.QUndoCommand):
890 return True 930 return True
891 931
892 def setWidgetValues(self, attrDict): 932 def setWidgetValues(self, attrDict):
893 ''' 933 """
894 Mask the component's usual method to handle our 934 Mask the component's usual method to handle our
895 relative widgets in case the resolution has changed. 935 relative widgets in case the resolution has changed.
896 ''' 936 """
897 newAttrDict = { 937 newAttrDict = {
898 attr: val if attr not in self.parent._relativeWidgets 938 attr: (
899 else self.parent.pixelValForAttr(attr, val) 939 val
940 if attr not in self.parent._relativeWidgets
941 else self.parent.pixelValForAttr(attr, val)
942 )
900 for attr, val in attrDict.items() 943 for attr, val in attrDict.items()
901 } 944 }
902 self.parent.setWidgetValues(newAttrDict) 945 self.parent.setWidgetValues(newAttrDict)
903 946
904 def redo(self): 947 def redo(self):
905 if self.undone: 948 if self.undone:
906 log.info('Redoing component update') 949 log.info("Redoing component update")
907 self.parent.oldAttrs = self.relativeWidgetValsAfterUndo 950 self.parent.oldAttrs = self.relativeWidgetValsAfterUndo
908 self.setWidgetValues(self.modifiedVals) 951 self.setWidgetValues(self.modifiedVals)
909 self.parent.update(auto=True) 952 self.parent.update(auto=True)
@@ -916,7 +959,7 @@ class ComponentUpdate(QtWidgets.QUndoCommand):
916 self.parent._sendUpdateSignal() 959 self.parent._sendUpdateSignal()
917 960
918 def undo(self): 961 def undo(self):
919 log.info('Undoing component update') 962 log.info("Undoing component update")
920 self.undone = True 963 self.undone = True
921 self.parent.oldAttrs = self.relativeWidgetValsAfterRedo 964 self.parent.oldAttrs = self.relativeWidgetValsAfterRedo
922 self.setWidgetValues(self.oldWidgetVals) 965 self.setWidgetValues(self.oldWidgetVals)
diff --git a/src/components/color.py b/src/components/color.py
index 8d0edd2..1f32c23 100644
--- a/src/components/color.py
+++ b/src/components/color.py
@@ -1,16 +1,16 @@
1from PyQt5 import QtGui 1from PyQt6 import QtGui
2import logging 2import logging
3 3
4from ..component import Component 4from ..component import Component
5from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor 5from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor
6 6
7 7
8log = logging.getLogger('AVP.Components.Color') 8log = logging.getLogger("AVP.Components.Color")
9 9
10 10
11class Component(Component): 11class Component(Component):
12 name = 'Color' 12 name = "Color"
13 version = '1.0.0' 13 version = "1.0.0"
14 14
15 def widget(self, *args): 15 def widget(self, *args):
16 self.x = 0 16 self.x = 0
@@ -20,48 +20,56 @@ class Component(Component):
20 # disable color #2 until non-default 'fill' option gets changed 20 # disable color #2 until non-default 'fill' option gets changed
21 self.page.lineEdit_color2.setDisabled(True) 21 self.page.lineEdit_color2.setDisabled(True)
22 self.page.pushButton_color2.setDisabled(True) 22 self.page.pushButton_color2.setDisabled(True)
23 self.page.spinBox_width.setValue( 23 self.page.spinBox_width.setValue(int(self.settings.value("outputWidth")))
24 int(self.settings.value("outputWidth"))) 24 self.page.spinBox_height.setValue(int(self.settings.value("outputHeight")))
25 self.page.spinBox_height.setValue(
26 int(self.settings.value("outputHeight")))
27 25
28 self.fillLabels = [ 26 self.fillLabels = [
29 'Solid', 27 "Solid",
30 'Linear Gradient', 28 "Linear Gradient",
31 'Radial Gradient', 29 "Radial Gradient",
32 ] 30 ]
33 for label in self.fillLabels: 31 for label in self.fillLabels:
34 self.page.comboBox_fill.addItem(label) 32 self.page.comboBox_fill.addItem(label)
35 self.page.comboBox_fill.setCurrentIndex(0) 33 self.page.comboBox_fill.setCurrentIndex(0)
36 34
37 self.trackWidgets({ 35 self.trackWidgets(
38 'x': self.page.spinBox_x, 36 {
39 'y': self.page.spinBox_y, 37 "x": self.page.spinBox_x,
40 'sizeWidth': self.page.spinBox_width, 38 "y": self.page.spinBox_y,
41 'sizeHeight': self.page.spinBox_height, 39 "sizeWidth": self.page.spinBox_width,
42 'trans': self.page.checkBox_trans, 40 "sizeHeight": self.page.spinBox_height,
43 'spread': self.page.comboBox_spread, 41 "trans": self.page.checkBox_trans,
44 'stretch': self.page.checkBox_stretch, 42 "spread": self.page.comboBox_spread,
45 'RG_start': self.page.spinBox_radialGradient_start, 43 "stretch": self.page.checkBox_stretch,
46 'LG_start': self.page.spinBox_linearGradient_start, 44 "RG_start": self.page.spinBox_radialGradient_start,
47 'RG_end': self.page.spinBox_radialGradient_end, 45 "LG_start": self.page.spinBox_linearGradient_start,
48 'LG_end': self.page.spinBox_linearGradient_end, 46 "RG_end": self.page.spinBox_radialGradient_end,
49 'RG_centre': self.page.spinBox_radialGradient_spread, 47 "LG_end": self.page.spinBox_linearGradient_end,
50 'fillType': self.page.comboBox_fill, 48 "RG_centre": self.page.spinBox_radialGradient_spread,
51 'color1': self.page.lineEdit_color1, 49 "fillType": self.page.comboBox_fill,
52 'color2': self.page.lineEdit_color2, 50 "color1": self.page.lineEdit_color1,
53 }, presetNames={ 51 "color2": self.page.lineEdit_color2,
54 'sizeWidth': 'width', 52 },
55 'sizeHeight': 'height', 53 presetNames={
56 }, colorWidgets={ 54 "sizeWidth": "width",
57 'color1': self.page.pushButton_color1, 55 "sizeHeight": "height",
58 'color2': self.page.pushButton_color2, 56 },
59 }, relativeWidgets=[ 57 colorWidgets={
60 'x', 'y', 58 "color1": self.page.pushButton_color1,
61 'sizeWidth', 'sizeHeight', 59 "color2": self.page.pushButton_color2,
62 'LG_start', 'LG_end', 60 },
63 'RG_start', 'RG_end', 'RG_centre', 61 relativeWidgets=[
64 ]) 62 "x",
63 "y",
64 "sizeWidth",
65 "sizeHeight",
66 "LG_start",
67 "LG_end",
68 "RG_start",
69 "RG_end",
70 "RG_centre",
71 ],
72 )
65 73
66 def update(self): 74 def update(self):
67 fillType = self.page.comboBox_fill.currentIndex() 75 fillType = self.page.comboBox_fill.currentIndex()
@@ -86,7 +94,7 @@ class Component(Component):
86 return self.drawFrame(self.width, self.height) 94 return self.drawFrame(self.width, self.height)
87 95
88 def properties(self): 96 def properties(self):
89 return ['static'] 97 return ["static"]
90 98
91 def frameRender(self, frameNo): 99 def frameRender(self, frameNo):
92 log.debug("Color component is drawing frame #%s", frameNo) 100 log.debug("Color component is drawing frame #%s", frameNo)
@@ -96,8 +104,12 @@ class Component(Component):
96 r, g, b = self.color1 104 r, g, b = self.color1
97 shapeSize = (self.sizeWidth, self.sizeHeight) 105 shapeSize = (self.sizeWidth, self.sizeHeight)
98 # in default state, skip all this logic and return a plain fill 106 # in default state, skip all this logic and return a plain fill
99 if self.fillType == 0 and shapeSize == (width, height) \ 107 if (
100 and self.x == 0 and self.y == 0: 108 self.fillType == 0
109 and shapeSize == (width, height)
110 and self.x == 0
111 and self.y == 0
112 ):
101 return FloodFrame(width, height, (r, g, b, 255)) 113 return FloodFrame(width, height, (r, g, b, 255))
102 114
103 # Return a solid image at x, y 115 # Return a solid image at x, y
@@ -120,19 +132,26 @@ class Component(Component):
120 132
121 if self.fillType == 1: # Linear Gradient 133 if self.fillType == 1: # Linear Gradient
122 brush = QtGui.QLinearGradient( 134 brush = QtGui.QLinearGradient(
123 self.LG_start, 135 float(self.LG_start),
124 self.LG_start, 136 float(self.LG_start),
125 self.LG_end+width/3, 137 float(self.LG_end + width / 3),
126 self.LG_end) 138 float(self.LG_end),
139 )
127 140
128 elif self.fillType == 2: # Radial Gradient 141 elif self.fillType == 2: # Radial Gradient
129 brush = QtGui.QRadialGradient( 142 brush = QtGui.QRadialGradient(
130 self.RG_start, 143 float(self.RG_start),
131 self.RG_end, 144 float(self.RG_end),
132 w, h, 145 float(w),
133 self.RG_centre) 146 float(h),
134 147 float(self.RG_centre),
135 brush.setSpread(self.spread) 148 )
149 spread = QtGui.QGradient.Spread.PadSpread
150 if self.spread == 1:
151 spread = QtGui.QGradient.Spread.ReflectSpread
152 elif self.spread == 2:
153 spread = QtGui.QGradient.Spread.RepeatSpread
154 brush.setSpread(spread)
136 brush.setColorAt(0.0, PaintColor(*self.color1)) 155 brush.setColorAt(0.0, PaintColor(*self.color1))
137 if self.trans: 156 if self.trans:
138 brush.setColorAt(1.0, PaintColor(0, 0, 0, 0)) 157 brush.setColorAt(1.0, PaintColor(0, 0, 0, 0))
@@ -141,20 +160,17 @@ class Component(Component):
141 else: 160 else:
142 brush.setColorAt(1.0, PaintColor(*self.color2)) 161 brush.setColorAt(1.0, PaintColor(*self.color2))
143 image.setBrush(brush) 162 image.setBrush(brush)
144 image.drawRect( 163 image.drawRect(self.x, self.y, self.sizeWidth, self.sizeHeight)
145 self.x, self.y,
146 self.sizeWidth, self.sizeHeight
147 )
148 164
149 return image.finalize() 165 return image.finalize()
150 166
151 def commandHelp(self): 167 def commandHelp(self):
152 print('Specify a color:\n color=255,255,255') 168 print("Specify a color:\n color=255,255,255")
153 169
154 def command(self, arg): 170 def command(self, arg):
155 if '=' in arg: 171 if "=" in arg:
156 key, arg = arg.split('=', 1) 172 key, arg = arg.split("=", 1)
157 if key == 'color': 173 if key == "color":
158 self.page.lineEdit_color1.setText(arg) 174 self.page.lineEdit_color1.setText(arg)
159 return 175 return
160 super().command(arg) 176 super().command(arg)
diff --git a/src/components/image.py b/src/components/image.py
index 42f9564..2393611 100644
--- a/src/components/image.py
+++ b/src/components/image.py
@@ -1,5 +1,5 @@
1from PIL import Image, ImageDraw, ImageEnhance 1from PIL import Image, ImageDraw, ImageEnhance
2from PyQt5 import QtGui, QtCore, QtWidgets 2from PyQt6 import QtGui, QtCore, QtWidgets
3import os 3import os
4 4
5from ..component import Component 5from ..component import Component
@@ -7,37 +7,39 @@ from ..toolkit.frame import BlankFrame
7 7
8 8
9class Component(Component): 9class Component(Component):
10 name = 'Image' 10 name = "Image"
11 version = '1.0.1' 11 version = "1.0.1"
12 12
13 def widget(self, *args): 13 def widget(self, *args):
14 super().widget(*args) 14 super().widget(*args)
15 self.page.pushButton_image.clicked.connect(self.pickImage) 15 self.page.pushButton_image.clicked.connect(self.pickImage)
16 self.trackWidgets({ 16 self.trackWidgets(
17 'imagePath': self.page.lineEdit_image, 17 {
18 'scale': self.page.spinBox_scale, 18 "imagePath": self.page.lineEdit_image,
19 'stretchScale': self.page.spinBox_scale_stretch, 19 "scale": self.page.spinBox_scale,
20 'rotate': self.page.spinBox_rotate, 20 "stretchScale": self.page.spinBox_scale_stretch,
21 'color': self.page.spinBox_color, 21 "rotate": self.page.spinBox_rotate,
22 'xPosition': self.page.spinBox_x, 22 "color": self.page.spinBox_color,
23 'yPosition': self.page.spinBox_y, 23 "xPosition": self.page.spinBox_x,
24 'stretched': self.page.checkBox_stretch, 24 "yPosition": self.page.spinBox_y,
25 'mirror': self.page.checkBox_mirror, 25 "stretched": self.page.checkBox_stretch,
26 }, presetNames={ 26 "mirror": self.page.checkBox_mirror,
27 'imagePath': 'image', 27 },
28 'xPosition': 'x', 28 presetNames={
29 'yPosition': 'y', 29 "imagePath": "image",
30 }, relativeWidgets=[ 30 "xPosition": "x",
31 'xPosition', 'yPosition', 'scale' 31 "yPosition": "y",
32 ]) 32 },
33 relativeWidgets=["xPosition", "yPosition", "scale"],
34 )
33 35
34 def previewRender(self): 36 def previewRender(self):
35 return self.drawFrame(self.width, self.height) 37 return self.drawFrame(self.width, self.height)
36 38
37 def properties(self): 39 def properties(self):
38 props = ['static'] 40 props = ["static"]
39 if not os.path.exists(self.imagePath): 41 if not os.path.exists(self.imagePath):
40 props.append('error') 42 props.append("error")
41 return props 43 return props
42 44
43 def error(self): 45 def error(self):
@@ -57,17 +59,15 @@ class Component(Component):
57 59
58 # Modify image's appearance 60 # Modify image's appearance
59 if self.color != 100: 61 if self.color != 100:
60 image = ImageEnhance.Color(image).enhance( 62 image = ImageEnhance.Color(image).enhance(float(self.color / 100))
61 float(self.color / 100)
62 )
63 if self.mirror: 63 if self.mirror:
64 image = image.transpose(Image.FLIP_LEFT_RIGHT) 64 image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
65 if self.stretched and image.size != (width, height): 65 if self.stretched and image.size != (width, height):
66 image = image.resize((width, height), Image.ANTIALIAS) 66 image = image.resize((width, height), Image.Resampling.LANCZOS)
67 if scale != 100: 67 if scale != 100:
68 newHeight = int((image.height / 100) * scale) 68 newHeight = int((image.height / 100) * scale)
69 newWidth = int((image.width / 100) * scale) 69 newWidth = int((image.width / 100) * scale)
70 image = image.resize((newWidth, newHeight), Image.ANTIALIAS) 70 image = image.resize((newWidth, newHeight), Image.Resampling.LANCZOS)
71 71
72 # Paste image at correct position 72 # Paste image at correct position
73 frame.paste(image, box=(self.xPosition, self.yPosition)) 73 frame.paste(image, box=(self.xPosition, self.yPosition))
@@ -79,8 +79,11 @@ class Component(Component):
79 def pickImage(self): 79 def pickImage(self):
80 imgDir = self.settings.value("componentDir", os.path.expanduser("~")) 80 imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
81 filename, _ = QtWidgets.QFileDialog.getOpenFileName( 81 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
82 self.page, "Choose Image", imgDir, 82 self.page,
83 "Image Files (%s)" % " ".join(self.core.imageFormats)) 83 "Choose Image",
84 imgDir,
85 "Image Files (%s)" % " ".join(self.core.imageFormats),
86 )
84 if filename: 87 if filename:
85 self.settings.setValue("componentDir", os.path.dirname(filename)) 88 self.settings.setValue("componentDir", os.path.dirname(filename))
86 self.mergeUndo = False 89 self.mergeUndo = False
@@ -88,9 +91,9 @@ class Component(Component):
88 self.mergeUndo = True 91 self.mergeUndo = True
89 92
90 def command(self, arg): 93 def command(self, arg):
91 if '=' in arg: 94 if "=" in arg:
92 key, arg = arg.split('=', 1) 95 key, arg = arg.split("=", 1)
93 if key == 'path' and os.path.exists(arg): 96 if key == "path" and os.path.exists(arg):
94 try: 97 try:
95 Image.open(arg) 98 Image.open(arg)
96 self.page.lineEdit_image.setText(arg) 99 self.page.lineEdit_image.setText(arg)
@@ -102,7 +105,7 @@ class Component(Component):
102 super().command(arg) 105 super().command(arg)
103 106
104 def commandHelp(self): 107 def commandHelp(self):
105 print('Load an image:\n path=/filepath/to/image.png') 108 print("Load an image:\n path=/filepath/to/image.png")
106 109
107 def savePreset(self): 110 def savePreset(self):
108 # Maintain the illusion that the scale spinbox is one widget 111 # Maintain the illusion that the scale spinbox is one widget
diff --git a/src/components/life.py b/src/components/life.py
index e19ed36..b3c2c58 100644
--- a/src/components/life.py
+++ b/src/components/life.py
@@ -1,16 +1,21 @@
1from PyQt5 import QtGui, QtCore, QtWidgets 1from PyQt6 import QtGui, QtCore, QtWidgets
2from PyQt5.QtWidgets import QUndoCommand 2from PyQt6.QtGui import QUndoCommand
3from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter 3from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter
4import os 4import os
5import math 5import math
6import logging
7
6 8
7from ..component import Component 9from ..component import Component
8from ..toolkit.frame import BlankFrame, scale 10from ..toolkit.frame import BlankFrame, scale
9 11
10 12
13log = logging.getLogger("AVP.Component.Life")
14
15
11class Component(Component): 16class Component(Component):
12 name = 'Conway\'s Game of Life' 17 name = "Conway's Game of Life"
13 version = '1.0.0' 18 version = "1.0.0"
14 19
15 def widget(self, *args): 20 def widget(self, *args):
16 super().widget(*args) 21 super().widget(*args)
@@ -18,34 +23,50 @@ class Component(Component):
18 self.updateGridSize() 23 self.updateGridSize()
19 # The initial grid: a "Queen Bee Shuttle" 24 # The initial grid: a "Queen Bee Shuttle"
20 # https://conwaylife.com/wiki/Queen_bee_shuttle 25 # https://conwaylife.com/wiki/Queen_bee_shuttle
21 self.startingGrid = set([ 26 self.startingGrid = set(
22 (3, 7), (3, 8), 27 [
23 (4, 7), (4, 8), 28 (3, 7),
24 (8, 7), 29 (3, 8),
25 (9, 6), (9, 8), 30 (4, 7),
26 (10, 5), (10, 9), 31 (4, 8),
27 (11, 6), (11, 7), (11, 8), 32 (8, 7),
28 (12, 4), (12, 5), (12, 9), (12, 10), 33 (9, 6),
29 (23, 6), (23, 7), 34 (9, 8),
30 (24, 6), (24, 7) 35 (10, 5),
31 ]) 36 (10, 9),
37 (11, 6),
38 (11, 7),
39 (11, 8),
40 (12, 4),
41 (12, 5),
42 (12, 9),
43 (12, 10),
44 (23, 6),
45 (23, 7),
46 (24, 6),
47 (24, 7),
48 ]
49 )
32 50
33 # Amount of 'bleed' (off-canvas coordinates) on each side of the grid 51 # Amount of 'bleed' (off-canvas coordinates) on each side of the grid
34 self.bleedSize = 40 52 self.bleedSize = 40
35 53
36 self.page.pushButton_pickImage.clicked.connect(self.pickImage) 54 self.page.pushButton_pickImage.clicked.connect(self.pickImage)
37 self.trackWidgets({ 55 self.trackWidgets(
38 'tickRate': self.page.spinBox_tickRate, 56 {
39 'scale': self.page.spinBox_scale, 57 "tickRate": self.page.spinBox_tickRate,
40 'color': self.page.lineEdit_color, 58 "scale": self.page.spinBox_scale,
41 'shapeType': self.page.comboBox_shapeType, 59 "color": self.page.lineEdit_color,
42 'shadow': self.page.checkBox_shadow, 60 "shapeType": self.page.comboBox_shapeType,
43 'customImg': self.page.checkBox_customImg, 61 "shadow": self.page.checkBox_shadow,
44 'showGrid': self.page.checkBox_showGrid, 62 "customImg": self.page.checkBox_customImg,
45 'image': self.page.lineEdit_image, 63 "showGrid": self.page.checkBox_showGrid,
46 }, colorWidgets={ 64 "image": self.page.lineEdit_image,
47 'color': self.page.pushButton_color, 65 },
48 }) 66 colorWidgets={
67 "color": self.page.pushButton_color,
68 },
69 )
49 self.shiftButtons = ( 70 self.shiftButtons = (
50 self.page.toolButton_up, 71 self.page.toolButton_up,
51 self.page.toolButton_down, 72 self.page.toolButton_down,
@@ -56,7 +77,9 @@ class Component(Component):
56 def shiftFunc(i): 77 def shiftFunc(i):
57 def shift(): 78 def shift():
58 self.shiftGrid(i) 79 self.shiftGrid(i)
80
59 return shift 81 return shift
82
60 shiftFuncs = [shiftFunc(i) for i in range(len(self.shiftButtons))] 83 shiftFuncs = [shiftFunc(i) for i in range(len(self.shiftButtons))]
61 for i, widget in enumerate(self.shiftButtons): 84 for i, widget in enumerate(self.shiftButtons):
62 widget.clicked.connect(shiftFuncs[i]) 85 widget.clicked.connect(shiftFuncs[i])
@@ -66,8 +89,11 @@ class Component(Component):
66 def pickImage(self): 89 def pickImage(self):
67 imgDir = self.settings.value("componentDir", os.path.expanduser("~")) 90 imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
68 filename, _ = QtWidgets.QFileDialog.getOpenFileName( 91 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
69 self.page, "Choose Image", imgDir, 92 self.page,
70 "Image Files (%s)" % " ".join(self.core.imageFormats)) 93 "Choose Image",
94 imgDir,
95 "Image Files (%s)" % " ".join(self.core.imageFormats),
96 )
71 if filename: 97 if filename:
72 self.settings.setValue("componentDir", os.path.dirname(filename)) 98 self.settings.setValue("componentDir", os.path.dirname(filename))
73 self.mergeUndo = False 99 self.mergeUndo = False
@@ -98,20 +124,20 @@ class Component(Component):
98 self.page.label_image.setVisible(False) 124 self.page.label_image.setVisible(False)
99 self.page.lineEdit_image.setVisible(False) 125 self.page.lineEdit_image.setVisible(False)
100 self.page.pushButton_pickImage.setVisible(False) 126 self.page.pushButton_pickImage.setVisible(False)
101 enabled = (len(self.startingGrid) > 0) 127 enabled = len(self.startingGrid) > 0
102 for widget in self.shiftButtons: 128 for widget in self.shiftButtons:
103 widget.setEnabled(enabled) 129 widget.setEnabled(enabled)
104 130
105 def previewClickEvent(self, pos, size, button): 131 def previewClickEvent(self, pos, size, button):
106 pos = ( 132 pos = (
107 math.ceil((pos[0] / size[0]) * self.gridWidth) - 1, 133 math.ceil((pos[0] / size[0]) * self.gridWidth) - 1,
108 math.ceil((pos[1] / size[1]) * self.gridHeight) - 1 134 math.ceil((pos[1] / size[1]) * self.gridHeight) - 1,
109 ) 135 )
110 action = ClickGrid(self, pos, button) 136 action = ClickGrid(self, pos, button)
111 self.parent.undoStack.push(action) 137 self.parent.undoStack.push(action)
112 138
113 def updateGridSize(self): 139 def updateGridSize(self):
114 w, h = self.core.resolutions[-1].split('x') 140 w, h = self.core.resolutions[-1].split("x")
115 self.gridWidth = int(int(w) / self.scale) 141 self.gridWidth = int(int(w) / self.scale)
116 self.gridHeight = int(int(h) / self.scale) 142 self.gridHeight = int(int(h) / self.scale)
117 self.pxWidth = math.ceil(self.width / self.gridWidth) 143 self.pxWidth = math.ceil(self.width / self.gridWidth)
@@ -125,10 +151,8 @@ class Component(Component):
125 self.tickGrids = {0: self.startingGrid} 151 self.tickGrids = {0: self.startingGrid}
126 152
127 def properties(self): 153 def properties(self):
128 if self.customImg and ( 154 if self.customImg and (not self.image or not os.path.exists(self.image)):
129 not self.image or not os.path.exists(self.image) 155 return ["error"]
130 ):
131 return ['error']
132 return [] 156 return []
133 157
134 def error(self): 158 def error(self):
@@ -162,42 +186,47 @@ class Component(Component):
162 drawer = ImageDraw.Draw(frame) 186 drawer = ImageDraw.Draw(frame)
163 rect = ( 187 rect = (
164 (drawPtX, drawPtY), 188 (drawPtX, drawPtY),
165 (drawPtX + self.pxWidth, drawPtY + self.pxHeight) 189 (drawPtX + self.pxWidth, drawPtY + self.pxHeight),
166 ) 190 )
167 shape = self.page.comboBox_shapeType.currentText().lower() 191 shape = self.page.comboBox_shapeType.currentText().lower()
168 192
169 # Rectangle 193 # Rectangle
170 if shape == 'rectangle': 194 if shape == "rectangle":
171 drawer.rectangle(rect, fill=self.color) 195 drawer.rectangle(rect, fill=self.color)
172 196
173 # Elliptical 197 # Elliptical
174 elif shape == 'elliptical': 198 elif shape == "elliptical":
175 drawer.ellipse(rect, fill=self.color) 199 drawer.ellipse(rect, fill=self.color)
176 200
177 tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int) 201 tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int)
178 smallerShape = ( 202 smallerShape = (
179 (drawPtX + tenthX + int(tenthX / 4), 203 (
180 drawPtY + tenthY + int(tenthY / 2)), 204 drawPtX + tenthX + int(tenthX / 4),
181 (drawPtX + self.pxWidth - tenthX - int(tenthX / 4), 205 drawPtY + tenthY + int(tenthY / 2),
182 drawPtY + self.pxHeight - (tenthY + int(tenthY / 2))) 206 ),
207 (
208 drawPtX + self.pxWidth - tenthX - int(tenthX / 4),
209 drawPtY + self.pxHeight - (tenthY + int(tenthY / 2)),
210 ),
183 ) 211 )
184 outlineShape = ( 212 outlineShape = (
185 (drawPtX + int(tenthX / 4), 213 (drawPtX + int(tenthX / 4), drawPtY + int(tenthY / 2)),
186 drawPtY + int(tenthY / 2)), 214 (
187 (drawPtX + self.pxWidth - int(tenthX / 4), 215 drawPtX + self.pxWidth - int(tenthX / 4),
188 drawPtY + self.pxHeight - int(tenthY / 2)) 216 drawPtY + self.pxHeight - int(tenthY / 2),
217 ),
189 ) 218 )
190 # Circle 219 # Circle
191 if shape == 'circle': 220 if shape == "circle":
192 drawer.ellipse(outlineShape, fill=self.color) 221 drawer.ellipse(outlineShape, fill=self.color)
193 drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) 222 drawer.ellipse(smallerShape, fill=(0, 0, 0, 0))
194 223
195 # Lilypad 224 # Lilypad
196 elif shape == 'lilypad': 225 elif shape == "lilypad":
197 drawer.pieslice(smallerShape, 290, 250, fill=self.color) 226 drawer.pieslice(smallerShape, 290, 250, fill=self.color)
198 227
199 # Pie 228 # Pie
200 elif shape == 'pie': 229 elif shape == "pie":
201 drawer.pieslice(outlineShape, 35, 320, fill=self.color) 230 drawer.pieslice(outlineShape, 35, 320, fill=self.color)
202 231
203 hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline 232 hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline
@@ -205,12 +234,15 @@ class Component(Component):
205 qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline 234 qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline
206 235
207 # Path 236 # Path
208 if shape == 'path': 237 if shape == "path":
209 drawer.ellipse(rect, fill=self.color) 238 drawer.ellipse(rect, fill=self.color)
210 rects = { 239 rects = {
211 direction: False 240 direction: False
212 for direction in ( 241 for direction in (
213 'up', 'down', 'left', 'right', 242 "up",
243 "down",
244 "left",
245 "right",
214 ) 246 )
215 } 247 }
216 for cell in self.nearbyCoords(x, y): 248 for cell in self.nearbyCoords(x, y):
@@ -218,60 +250,59 @@ class Component(Component):
218 continue 250 continue
219 if cell[0] == x: 251 if cell[0] == x:
220 if cell[1] < y: 252 if cell[1] < y:
221 rects['up'] = True 253 rects["up"] = True
222 if cell[1] > y: 254 if cell[1] > y:
223 rects['down'] = True 255 rects["down"] = True
224 if cell[1] == y: 256 if cell[1] == y:
225 if cell[0] < x: 257 if cell[0] < x:
226 rects['left'] = True 258 rects["left"] = True
227 if cell[0] > x: 259 if cell[0] > x:
228 rects['right'] = True 260 rects["right"] = True
229 261
230 for direction, rect in rects.items(): 262 for direction, rect in rects.items():
231 if rect: 263 if rect:
232 if direction == 'up': 264 if direction == "up":
233 sect = ( 265 sect = (
234 (drawPtX, drawPtY), 266 (drawPtX, drawPtY),
235 (drawPtX + self.pxWidth, drawPtY + hY) 267 (drawPtX + self.pxWidth, drawPtY + hY),
236 ) 268 )
237 elif direction == 'down': 269 elif direction == "down":
238 sect = ( 270 sect = (
239 (drawPtX, drawPtY + hY), 271 (drawPtX, drawPtY + hY),
240 (drawPtX + self.pxWidth, 272 (
241 drawPtY + self.pxHeight) 273 drawPtX + self.pxWidth,
274 drawPtY + self.pxHeight,
275 ),
242 ) 276 )
243 elif direction == 'left': 277 elif direction == "left":
244 sect = ( 278 sect = (
245 (drawPtX, drawPtY), 279 (drawPtX, drawPtY),
246 (drawPtX + hX, 280 (drawPtX + hX, drawPtY + self.pxHeight),
247 drawPtY + self.pxHeight)
248 ) 281 )
249 elif direction == 'right': 282 elif direction == "right":
250 sect = ( 283 sect = (
251 (drawPtX + hX, drawPtY), 284 (drawPtX + hX, drawPtY),
252 (drawPtX + self.pxWidth, 285 (
253 drawPtY + self.pxHeight) 286 drawPtX + self.pxWidth,
287 drawPtY + self.pxHeight,
288 ),
254 ) 289 )
255 drawer.rectangle(sect, fill=self.color) 290 drawer.rectangle(sect, fill=self.color)
256 291
257 # Duck 292 # Duck
258 elif shape == 'duck': 293 elif shape == "duck":
259 duckHead = ( 294 duckHead = (
260 (drawPtX + qX, drawPtY + qY), 295 (drawPtX + qX, drawPtY + qY),
261 (drawPtX + int(qX * 3), drawPtY + int(tY * 2)) 296 (drawPtX + int(qX * 3), drawPtY + int(tY * 2)),
262 ) 297 )
263 duckBeak = ( 298 duckBeak = (
264 (drawPtX + hX, drawPtY + qY), 299 (drawPtX + hX, drawPtY + qY),
265 (drawPtX + self.pxWidth + qX, 300 (drawPtX + self.pxWidth + qX, drawPtY + int(qY * 3)),
266 drawPtY + int(qY * 3))
267 )
268 duckWing = (
269 (drawPtX, drawPtY + hY),
270 rect[1]
271 ) 301 )
302 duckWing = ((drawPtX, drawPtY + hY), rect[1])
272 duckBody = ( 303 duckBody = (
273 (drawPtX + int(qX / 4), drawPtY + int(qY * 3)), 304 (drawPtX + int(qX / 4), drawPtY + int(qY * 3)),
274 (drawPtX + int(tX * 2), drawPtY + self.pxHeight) 305 (drawPtX + int(tX * 2), drawPtY + self.pxHeight),
275 ) 306 )
276 drawer.ellipse(duckBody, fill=self.color) 307 drawer.ellipse(duckBody, fill=self.color)
277 drawer.ellipse(duckHead, fill=self.color) 308 drawer.ellipse(duckHead, fill=self.color)
@@ -279,11 +310,16 @@ class Component(Component):
279 drawer.pieslice(duckBeak, 145, 200, fill=self.color) 310 drawer.pieslice(duckBeak, 145, 200, fill=self.color)
280 311
281 # Peace 312 # Peace
282 elif shape == 'peace': 313 elif shape == "peace":
283 line = (( 314 line = (
284 drawPtX + hX - int(tenthX / 2), drawPtY + int(tenthY / 2)), 315 (
285 (drawPtX + hX + int(tenthX / 2), 316 drawPtX + hX - int(tenthX / 2),
286 drawPtY + self.pxHeight - int(tenthY / 2)) 317 drawPtY + int(tenthY / 2),
318 ),
319 (
320 drawPtX + hX + int(tenthX / 2),
321 drawPtY + self.pxHeight - int(tenthY / 2),
322 ),
287 ) 323 )
288 drawer.ellipse(outlineShape, fill=self.color) 324 drawer.ellipse(outlineShape, fill=self.color)
289 drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) 325 drawer.ellipse(smallerShape, fill=(0, 0, 0, 0))
@@ -291,21 +327,15 @@ class Component(Component):
291 327
292 def slantLine(difference): 328 def slantLine(difference):
293 return ( 329 return (
294 (drawPtX + difference), (drawPtY + self.pxHeight - qY) 330 (drawPtX + difference),
331 (drawPtY + self.pxHeight - qY),
295 ), ( 332 ), (
296 (drawPtX + hX), (drawPtY + hY) 333 (drawPtX + hX),
334 (drawPtY + hY),
297 ) 335 )
298 336
299 drawer.line( 337 drawer.line(slantLine(qX), fill=self.color, width=tenthX)
300 slantLine(qX), 338 drawer.line(slantLine(self.pxWidth - qX), fill=self.color, width=tenthX)
301 fill=self.color,
302 width=tenthX
303 )
304 drawer.line(
305 slantLine(self.pxWidth - qX),
306 fill=self.color,
307 width=tenthX
308 )
309 339
310 for x, y in grid: 340 for x, y in grid:
311 drawPtX = x * self.pxWidth 341 drawPtX = x * self.pxWidth
@@ -331,44 +361,38 @@ class Component(Component):
331 w, h = scale(0.05, self.width, self.height, int) 361 w, h = scale(0.05, self.width, self.height, int)
332 for x in range(self.pxWidth, self.width, self.pxWidth): 362 for x in range(self.pxWidth, self.width, self.pxWidth):
333 drawer.rectangle( 363 drawer.rectangle(
334 ((x, 0), 364 ((x, 0), (x + w, self.height)),
335 (x + w, self.height)),
336 fill=self.color, 365 fill=self.color,
337 ) 366 )
338 for y in range(self.pxHeight, self.height, self.pxHeight): 367 for y in range(self.pxHeight, self.height, self.pxHeight):
339 drawer.rectangle( 368 drawer.rectangle(
340 ((0, y), 369 ((0, y), (self.width, y + h)),
341 (self.width, y + h)),
342 fill=self.color, 370 fill=self.color,
343 ) 371 )
344 372
345 return frame 373 return frame
346 374
347 def gridForTick(self, tick): 375 def gridForTick(self, tick):
348 ''' 376 """
349 Given a tick number over 0, returns a new grid (a set of tuples). 377 Given a tick number over 0, returns a new grid (a set of tuples).
350 This must compute the previous ticks' grids if not already computed 378 This must compute the previous ticks' grids if not already computed
351 ''' 379 """
352 if tick - 1 not in self.tickGrids: 380 if tick - 1 not in self.tickGrids:
353 self.tickGrids[tick - 1] = self.gridForTick(tick - 1) 381 self.tickGrids[tick - 1] = self.gridForTick(tick - 1)
354 382
355 lastGrid = self.tickGrids[tick - 1] 383 lastGrid = self.tickGrids[tick - 1]
356 384
357 def neighbours(x, y): 385 def neighbours(x, y):
358 return { 386 return {cell for cell in self.nearbyCoords(x, y) if cell in lastGrid}
359 cell for cell in self.nearbyCoords(x, y)
360 if cell in lastGrid
361 }
362 387
363 newGrid = set() 388 newGrid = set()
364 # Copy cells from the previous grid if they have 2 or 3 neighbouring cells 389 # Copy cells from the previous grid if they have 2 or 3 neighbouring cells
365 # and if they are within the grid or its bleed area (off-canvas area) 390 # and if they are within the grid or its bleed area (off-canvas area)
366 for x, y in lastGrid: 391 for x, y in lastGrid:
367 if ( 392 if (
368 -self.bleedSize > x > self.gridWidth + self.bleedSize 393 -self.bleedSize > x > self.gridWidth + self.bleedSize
369 or 394 or -self.bleedSize > y > self.gridHeight + self.bleedSize
370 -self.bleedSize > y > self.gridHeight + self.bleedSize 395 ):
371 ):
372 continue 396 continue
373 surrounding = len(neighbours(x, y)) 397 surrounding = len(neighbours(x, y))
374 if surrounding == 2 or surrounding == 3: 398 if surrounding == 2 or surrounding == 3:
@@ -376,7 +400,8 @@ class Component(Component):
376 400
377 # Find positions around living cells which must be checked for reproduction 401 # Find positions around living cells which must be checked for reproduction
378 potentialNewCells = { 402 potentialNewCells = {
379 coordTup for origin in lastGrid 403 coordTup
404 for origin in lastGrid
380 for coordTup in list(self.nearbyCoords(*origin)) 405 for coordTup in list(self.nearbyCoords(*origin))
381 } 406 }
382 # Check for reproduction 407 # Check for reproduction
@@ -392,11 +417,11 @@ class Component(Component):
392 417
393 def savePreset(self): 418 def savePreset(self):
394 pr = super().savePreset() 419 pr = super().savePreset()
395 pr['GRID'] = sorted(self.startingGrid) 420 pr["GRID"] = sorted(self.startingGrid)
396 return pr 421 return pr
397 422
398 def loadPreset(self, pr, *args): 423 def loadPreset(self, pr, *args):
399 self.startingGrid = set(pr['GRID']) 424 self.startingGrid = set(pr["GRID"])
400 if self.startingGrid: 425 if self.startingGrid:
401 for widget in self.shiftButtons: 426 for widget in self.shiftButtons:
402 widget.setEnabled(True) 427 widget.setEnabled(True)
@@ -414,15 +439,17 @@ class Component(Component):
414 439
415 440
416class ClickGrid(QUndoCommand): 441class ClickGrid(QUndoCommand):
417 def __init__(self, comp, pos, id_): 442 def __init__(self, comp, pos, button):
418 super().__init__( 443 super().__init__("click %s component #%s" % (comp.name, comp.compPos))
419 "click %s component #%s" % (comp.name, comp.compPos))
420 self.comp = comp 444 self.comp = comp
421 self.pos = [pos] 445 self.pos = [pos]
422 self.id_ = id_ 446 if button == QtCore.Qt.MouseButton.RightButton:
447 self.button = 2
448 else:
449 self.button = 1
423 450
424 def id(self): 451 def id(self):
425 return self.id_ 452 return self.button
426 453
427 def mergeWith(self, other): 454 def mergeWith(self, other):
428 self.pos.extend(other.pos) 455 self.pos.extend(other.pos)
@@ -439,21 +466,21 @@ class ClickGrid(QUndoCommand):
439 self.comp.update(auto=True) 466 self.comp.update(auto=True)
440 467
441 def redo(self): 468 def redo(self):
442 if self.id_ == 1: # Left-click 469 if self.button == 1: # Left-click
443 self.add() 470 self.add()
444 elif self.id_ == 2: # Right-click 471 elif self.button == 2: # Right-click
445 self.remove() 472 self.remove()
446 473
447 def undo(self): 474 def undo(self):
448 if self.id_ == 1: # Left-click 475 if self.button == 1: # Left-click
449 self.remove() 476 self.remove()
450 elif self.id_ == 2: # Right-click 477 elif self.button == 2: # Right-click
451 self.add() 478 self.add()
452 479
480
453class ShiftGrid(QUndoCommand): 481class ShiftGrid(QUndoCommand):
454 def __init__(self, comp, direction): 482 def __init__(self, comp, direction):
455 super().__init__( 483 super().__init__("change %s component #%s" % (comp.name, comp.compPos))
456 "change %s component #%s" % (comp.name, comp.compPos))
457 self.comp = comp 484 self.comp = comp
458 self.direction = direction 485 self.direction = direction
459 self.distance = 1 486 self.distance = 1
@@ -466,10 +493,7 @@ class ShiftGrid(QUndoCommand):
466 return True 493 return True
467 494
468 def newGrid(self, Xchange, Ychange): 495 def newGrid(self, Xchange, Ychange):
469 return { 496 return {(x + Xchange, y + Ychange) for x, y in self.comp.startingGrid}
470 (x + Xchange, y + Ychange)
471 for x, y in self.comp.startingGrid
472 }
473 497
474 def redo(self): 498 def redo(self):
475 if self.direction == 0: 499 if self.direction == 0:
diff --git a/src/components/life.ui b/src/components/life.ui
index 3d6ad5a..30cf9d0 100644
--- a/src/components/life.ui
+++ b/src/components/life.ui
@@ -372,7 +372,7 @@ p, li { white-space: pre-wrap; }
372&lt;p style=&quot; margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;- A cell with more than 3 neighbours will die from overpopulation.&lt;/p&gt; 372&lt;p style=&quot; margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;- A cell with more than 3 neighbours will die from overpopulation.&lt;/p&gt;
373&lt;p style=&quot; margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;- An empty space surrounded by 3 live cells will cause reproduction.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string> 373&lt;p style=&quot; margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;- An empty space surrounded by 3 live cells will cause reproduction.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
374 </property> 374 </property>
375 <property name="tabStopWidth"> 375 <property name="tabStopDistance">
376 <number>80</number> 376 <number>80</number>
377 </property> 377 </property>
378 <property name="textInteractionFlags"> 378 <property name="textInteractionFlags">
diff --git a/src/components/original.py b/src/components/original.py
index 289e982..fad797b 100644
--- a/src/components/original.py
+++ b/src/components/original.py
@@ -1,9 +1,5 @@
1import numpy 1import numpy
2from PIL import Image, ImageDraw 2from PIL import Image, ImageDraw
3from PyQt5 import QtGui, QtCore, QtWidgets
4from PyQt5.QtGui import QColor
5import os
6import time
7from copy import copy 3from copy import copy
8 4
9from ..component import Component 5from ..component import Component
@@ -11,14 +7,14 @@ from ..toolkit.frame import BlankFrame
11 7
12 8
13class Component(Component): 9class Component(Component):
14 name = 'Classic Visualizer' 10 name = "Classic Visualizer"
15 version = '1.0.0' 11 version = "1.0.0"
16 12
17 def names(*args): 13 def names(*args):
18 return ['Original Audio Visualization'] 14 return ["Original Audio Visualization"]
19 15
20 def properties(self): 16 def properties(self):
21 return ['pcm'] 17 return ["pcm"]
22 18
23 def widget(self, *args): 19 def widget(self, *args):
24 self.scale = 20 20 self.scale = 20
@@ -31,23 +27,30 @@ class Component(Component):
31 self.page.comboBox_visLayout.addItem("Top") 27 self.page.comboBox_visLayout.addItem("Top")
32 self.page.comboBox_visLayout.setCurrentIndex(0) 28 self.page.comboBox_visLayout.setCurrentIndex(0)
33 29
34 self.page.lineEdit_visColor.setText('255,255,255') 30 self.page.lineEdit_visColor.setText("255,255,255")
35 31
36 self.trackWidgets({ 32 self.trackWidgets(
37 'visColor': self.page.lineEdit_visColor, 33 {
38 'layout': self.page.comboBox_visLayout, 34 "visColor": self.page.lineEdit_visColor,
39 'scale': self.page.spinBox_scale, 35 "layout": self.page.comboBox_visLayout,
40 'y': self.page.spinBox_y, 36 "scale": self.page.spinBox_scale,
41 'smooth': self.page.spinBox_smooth, 37 "y": self.page.spinBox_y,
42 }, colorWidgets={ 38 "smooth": self.page.spinBox_smooth,
43 'visColor': self.page.pushButton_visColor, 39 },
44 }, relativeWidgets=[ 40 colorWidgets={
45 'y', 41 "visColor": self.page.pushButton_visColor,
46 ]) 42 },
43 relativeWidgets=[
44 "y",
45 ],
46 )
47 47
48 def previewRender(self): 48 def previewRender(self):
49 spectrum = numpy.fromfunction( 49 spectrum = numpy.fromfunction(
50 lambda x: float(self.scale)/2500*(x-128)**2, (255,), dtype="int16") 50 lambda x: float(self.scale) / 2500 * (x - 128) ** 2,
51 (255,),
52 dtype="int16",
53 )
51 return self.drawBars( 54 return self.drawBars(
52 self.width, self.height, spectrum, self.visColor, self.layout 55 self.width, self.height, spectrum, self.visColor, self.layout
53 ) 56 )
@@ -63,41 +66,53 @@ class Component(Component):
63 if self.canceled: 66 if self.canceled:
64 break 67 break
65 self.lastSpectrum = self.transformData( 68 self.lastSpectrum = self.transformData(
66 i, self.completeAudioArray, self.sampleSize, 69 i,
67 self.smoothConstantDown, self.smoothConstantUp, 70 self.completeAudioArray,
68 self.lastSpectrum) 71 self.sampleSize,
72 self.smoothConstantDown,
73 self.smoothConstantUp,
74 self.lastSpectrum,
75 )
69 self.spectrumArray[i] = copy(self.lastSpectrum) 76 self.spectrumArray[i] = copy(self.lastSpectrum)
70 77
71 progress = int(100*(i/len(self.completeAudioArray))) 78 progress = int(100 * (i / len(self.completeAudioArray)))
72 if progress >= 100: 79 if progress >= 100:
73 progress = 100 80 progress = 100
74 pStr = "Analyzing audio: "+str(progress)+'%' 81 pStr = "Analyzing audio: " + str(progress) + "%"
75 self.progressBarSetText.emit(pStr) 82 self.progressBarSetText.emit(pStr)
76 self.progressBarUpdate.emit(int(progress)) 83 self.progressBarUpdate.emit(int(progress))
77 84
78 def frameRender(self, frameNo): 85 def frameRender(self, frameNo):
79 arrayNo = frameNo * self.sampleSize 86 arrayNo = frameNo * self.sampleSize
80 return self.drawBars( 87 return self.drawBars(
81 self.width, self.height, 88 self.width,
89 self.height,
82 self.spectrumArray[arrayNo], 90 self.spectrumArray[arrayNo],
83 self.visColor, self.layout) 91 self.visColor,
92 self.layout,
93 )
84 94
85 def transformData( 95 def transformData(
86 self, i, completeAudioArray, sampleSize, 96 self,
87 smoothConstantDown, smoothConstantUp, lastSpectrum): 97 i,
98 completeAudioArray,
99 sampleSize,
100 smoothConstantDown,
101 smoothConstantUp,
102 lastSpectrum,
103 ):
88 if len(completeAudioArray) < (i + sampleSize): 104 if len(completeAudioArray) < (i + sampleSize):
89 sampleSize = len(completeAudioArray) - i 105 sampleSize = len(completeAudioArray) - i
90 106
91 window = numpy.hanning(sampleSize) 107 window = numpy.hanning(sampleSize)
92 data = completeAudioArray[i:i+sampleSize][::1] * window 108 data = completeAudioArray[i : i + sampleSize][::1] * window
93 paddedSampleSize = 2048 109 paddedSampleSize = 2048
94 paddedData = numpy.pad( 110 paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), "constant")
95 data, (0, paddedSampleSize - sampleSize), 'constant')
96 spectrum = numpy.fft.fft(paddedData) 111 spectrum = numpy.fft.fft(paddedData)
97 sample_rate = 44100 112 sample_rate = 44100
98 frequencies = numpy.fft.fftfreq(len(spectrum), 1./sample_rate) 113 frequencies = numpy.fft.fftfreq(len(spectrum), 1.0 / sample_rate)
99 114
100 y = abs(spectrum[0:int(paddedSampleSize/2) - 1]) 115 y = abs(spectrum[0 : int(paddedSampleSize / 2) - 1])
101 116
102 # filter the noise away 117 # filter the noise away
103 # y[y<80] = 0 118 # y[y<80] = 0
@@ -106,22 +121,26 @@ class Component(Component):
106 y[numpy.isinf(y)] = 0 121 y[numpy.isinf(y)] = 0
107 122
108 if lastSpectrum is not None: 123 if lastSpectrum is not None:
109 lastSpectrum[y < lastSpectrum] = \ 124 lastSpectrum[y < lastSpectrum] = y[
110 y[y < lastSpectrum] * smoothConstantDown + \ 125 y < lastSpectrum
111 lastSpectrum[y < lastSpectrum] * (1 - smoothConstantDown) 126 ] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * (
112 127 1 - smoothConstantDown
113 lastSpectrum[y >= lastSpectrum] = \ 128 )
114 y[y >= lastSpectrum] * smoothConstantUp + \ 129
115 lastSpectrum[y >= lastSpectrum] * (1 - smoothConstantUp) 130 lastSpectrum[y >= lastSpectrum] = y[
131 y >= lastSpectrum
132 ] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * (
133 1 - smoothConstantUp
134 )
116 else: 135 else:
117 lastSpectrum = y 136 lastSpectrum = y
118 137
119 x = frequencies[0:int(paddedSampleSize/2) - 1] 138 x = frequencies[0 : int(paddedSampleSize / 2) - 1]
120 139
121 return lastSpectrum 140 return lastSpectrum
122 141
123 def drawBars(self, width, height, spectrum, color, layout): 142 def drawBars(self, width, height, spectrum, color, layout):
124 vH = height-height/8 143 vH = height - height / 8
125 bF = width / 64 144 bF = width / 64
126 bH = bF / 2 145 bH = bF / 2
127 bQ = bF / 4 146 bQ = bF / 4
@@ -133,72 +152,92 @@ class Component(Component):
133 bP = height / 1200 152 bP = height / 1200
134 153
135 for j in range(0, 63): 154 for j in range(0, 63):
136 draw.rectangle(( 155 x0 = bH + j * bF
137 bH + j * bF, vH+bQ, bH + j * bF + bF, vH + bQ - 156 y0 = vH + bQ
138 spectrum[j * 4] * bP - bH), fill=color2) 157 y1 = vH + bQ - spectrum[j * 4] * bP - bH
139 158 x1 = bH + j * bF + bF
140 draw.rectangle(( 159 draw.rectangle(
141 bH + bQ + j * bF, vH, bH + bQ + j * bF + bH, vH - 160 (
142 spectrum[j * 4] * bP), fill=color) 161 x0,
143 162 y0 if y0 < y1 else y1,
144 imBottom = imTop.transpose(Image.FLIP_TOP_BOTTOM) 163 x1 if x1 > x0 else x0,
164 y1 if y0 < y1 else y0,
165 ),
166 fill=color2,
167 )
168
169 x0 = bH + bQ + j * bF
170 y0 = vH
171 x1 = bH + bQ + j * bF + bH
172 y1 = vH - spectrum[j * 4] * bP
173 draw.rectangle(
174 (
175 x0,
176 y0 if y0 < y1 else y1,
177 x1 if x1 > x0 else x0,
178 y1 if y0 < y1 else y0,
179 ),
180 fill=color,
181 )
182
183 imBottom = imTop.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
145 184
146 im = BlankFrame(width, height) 185 im = BlankFrame(width, height)
147 186
148 if layout == 0: # Classic 187 if layout == 0: # Classic
149 y = self.y - int(height/100*43) 188 y = self.y - int(height / 100 * 43)
150 im.paste(imTop, (0, y), mask=imTop) 189 im.paste(imTop, (0, y), mask=imTop)
151 y = self.y + int(height/100*43) 190 y = self.y + int(height / 100 * 43)
152 im.paste(imBottom, (0, y), mask=imBottom) 191 im.paste(imBottom, (0, y), mask=imBottom)
153 192
154 if layout == 1: # Split 193 if layout == 1: # Split
155 y = self.y + int(height/100*10) 194 y = self.y + int(height / 100 * 10)
156 im.paste(imTop, (0, y), mask=imTop) 195 im.paste(imTop, (0, y), mask=imTop)
157 y = self.y - int(height/100*10) 196 y = self.y - int(height / 100 * 10)
158 im.paste(imBottom, (0, y), mask=imBottom) 197 im.paste(imBottom, (0, y), mask=imBottom)
159 198
160 if layout == 2: # Bottom 199 if layout == 2: # Bottom
161 y = self.y + int(height/100*10) 200 y = self.y + int(height / 100 * 10)
162 im.paste(imTop, (0, y), mask=imTop) 201 im.paste(imTop, (0, y), mask=imTop)
163 202
164 if layout == 3: # Top 203 if layout == 3: # Top
165 y = self.y - int(height/100*10) 204 y = self.y - int(height / 100 * 10)
166 im.paste(imBottom, (0, y), mask=imBottom) 205 im.paste(imBottom, (0, y), mask=imBottom)
167 206
168 return im 207 return im
169 208
170 def command(self, arg): 209 def command(self, arg):
171 if '=' in arg: 210 if "=" in arg:
172 key, arg = arg.split('=', 1) 211 key, arg = arg.split("=", 1)
173 try: 212 try:
174 if key == 'color': 213 if key == "color":
175 self.page.lineEdit_visColor.setText(arg) 214 self.page.lineEdit_visColor.setText(arg)
176 return 215 return
177 elif key == 'layout': 216 elif key == "layout":
178 if arg == 'classic': 217 if arg == "classic":
179 self.page.comboBox_visLayout.setCurrentIndex(0) 218 self.page.comboBox_visLayout.setCurrentIndex(0)
180 elif arg == 'split': 219 elif arg == "split":
181 self.page.comboBox_visLayout.setCurrentIndex(1) 220 self.page.comboBox_visLayout.setCurrentIndex(1)
182 elif arg == 'bottom': 221 elif arg == "bottom":
183 self.page.comboBox_visLayout.setCurrentIndex(2) 222 self.page.comboBox_visLayout.setCurrentIndex(2)
184 elif arg == 'top': 223 elif arg == "top":
185 self.page.comboBox_visLayout.setCurrentIndex(3) 224 self.page.comboBox_visLayout.setCurrentIndex(3)
186 return 225 return
187 elif key == 'scale': 226 elif key == "scale":
188 arg = int(arg) 227 arg = int(arg)
189 self.page.spinBox_scale.setValue(arg) 228 self.page.spinBox_scale.setValue(arg)
190 return 229 return
191 elif key == 'y': 230 elif key == "y":
192 arg = int(arg) 231 arg = int(arg)
193 self.page.spinBox_y.setValue(arg) 232 self.page.spinBox_y.setValue(arg)
194 return 233 return
195 except ValueError: 234 except ValueError:
196 print('You must enter a number.') 235 print("You must enter a number.")
197 quit(1) 236 quit(1)
198 super().command(arg) 237 super().command(arg)
199 238
200 def commandHelp(self): 239 def commandHelp(self):
201 print('Give a layout name:\n layout=[classic/split/bottom/top]') 240 print("Give a layout name:\n layout=[classic/split/bottom/top]")
202 print('Specify a color:\n color=255,255,255') 241 print("Specify a color:\n color=255,255,255")
203 print('Visualizer scale (20 is default):\n scale=number') 242 print("Visualizer scale (20 is default):\n scale=number")
204 print('Y position:\n y=number') 243 print("Y position:\n y=number")
diff --git a/src/components/sound.py b/src/components/sound.py
index 118ea23..2df8e38 100644
--- a/src/components/sound.py
+++ b/src/components/sound.py
@@ -1,4 +1,4 @@
1from PyQt5 import QtGui, QtCore, QtWidgets 1from PyQt6 import QtGui, QtCore, QtWidgets
2import os 2import os
3 3
4from ..component import Component 4from ..component import Component
@@ -6,25 +6,28 @@ from ..toolkit.frame import BlankFrame
6 6
7 7
8class Component(Component): 8class Component(Component):
9 name = 'Sound' 9 name = "Sound"
10 version = '1.0.0' 10 version = "1.0.0"
11 11
12 def widget(self, *args): 12 def widget(self, *args):
13 super().widget(*args) 13 super().widget(*args)
14 self.page.pushButton_sound.clicked.connect(self.pickSound) 14 self.page.pushButton_sound.clicked.connect(self.pickSound)
15 self.trackWidgets({ 15 self.trackWidgets(
16 'sound': self.page.lineEdit_sound, 16 {
17 'chorus': self.page.checkBox_chorus, 17 "sound": self.page.lineEdit_sound,
18 'delay': self.page.spinBox_delay, 18 "chorus": self.page.checkBox_chorus,
19 'volume': self.page.spinBox_volume, 19 "delay": self.page.spinBox_delay,
20 }, commandArgs={ 20 "volume": self.page.spinBox_volume,
21 'sound': None, 21 },
22 }) 22 commandArgs={
23 "sound": None,
24 },
25 )
23 26
24 def properties(self): 27 def properties(self):
25 props = ['static', 'audio'] 28 props = ["static", "audio"]
26 if not os.path.exists(self.sound): 29 if not os.path.exists(self.sound):
27 props.append('error') 30 props.append("error")
28 return props 31 return props
29 32
30 def error(self): 33 def error(self):
@@ -36,20 +39,22 @@ class Component(Component):
36 def audio(self): 39 def audio(self):
37 params = {} 40 params = {}
38 if self.delay != 0.0: 41 if self.delay != 0.0:
39 params['adelay'] = '=%s' % str(int(self.delay * 1000.00)) 42 params["adelay"] = "=%s" % str(int(self.delay * 1000.00))
40 if self.chorus: 43 if self.chorus:
41 params['chorus'] = \ 44 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"
42 '=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3'
43 if self.volume != 1.0: 45 if self.volume != 1.0:
44 params['volume'] = '=%s:replaygain_noclip=0' % str(self.volume) 46 params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume)
45 47
46 return (self.sound, params) 48 return (self.sound, params)
47 49
48 def pickSound(self): 50 def pickSound(self):
49 sndDir = self.settings.value("componentDir", os.path.expanduser("~")) 51 sndDir = self.settings.value("componentDir", os.path.expanduser("~"))
50 filename, _ = QtWidgets.QFileDialog.getOpenFileName( 52 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
51 self.page, "Choose Sound", sndDir, 53 self.page,
52 "Audio Files (%s)" % " ".join(self.core.audioFormats)) 54 "Choose Sound",
55 sndDir,
56 "Audio Files (%s)" % " ".join(self.core.audioFormats),
57 )
53 if filename: 58 if filename:
54 self.settings.setValue("componentDir", os.path.dirname(filename)) 59 self.settings.setValue("componentDir", os.path.dirname(filename))
55 self.mergeUndo = False 60 self.mergeUndo = False
@@ -57,14 +62,13 @@ class Component(Component):
57 self.mergeUndo = True 62 self.mergeUndo = True
58 63
59 def commandHelp(self): 64 def commandHelp(self):
60 print('Path to audio file:\n path=/filepath/to/sound.ogg') 65 print("Path to audio file:\n path=/filepath/to/sound.ogg")
61 66
62 def command(self, arg): 67 def command(self, arg):
63 if '=' in arg: 68 if "=" in arg:
64 key, arg = arg.split('=', 1) 69 key, arg = arg.split("=", 1)
65 if key == 'path': 70 if key == "path":
66 if '*%s' % os.path.splitext(arg)[1] \ 71 if "*%s" % os.path.splitext(arg)[1] not in self.core.audioFormats:
67 not in self.core.audioFormats:
68 print("Not a supported audio format") 72 print("Not a supported audio format")
69 quit(1) 73 quit(1)
70 self.page.lineEdit_sound.setText(arg) 74 self.page.lineEdit_sound.setText(arg)
diff --git a/src/components/spectrum.py b/src/components/spectrum.py
index 30d5426..062ebc7 100644
--- a/src/components/spectrum.py
+++ b/src/components/spectrum.py
@@ -1,5 +1,5 @@
1from PIL import Image 1from PIL import Image
2from PyQt5 import QtGui, QtCore, QtWidgets 2from PyQt6 import QtGui, QtCore, QtWidgets
3import os 3import os
4import math 4import math
5import subprocess 5import subprocess
@@ -10,16 +10,20 @@ from ..component import Component
10from ..toolkit.frame import BlankFrame, scale 10from ..toolkit.frame import BlankFrame, scale
11from ..toolkit import checkOutput, connectWidget 11from ..toolkit import checkOutput, connectWidget
12from ..toolkit.ffmpeg import ( 12from ..toolkit.ffmpeg import (
13 openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound 13 openPipe,
14 closePipe,
15 getAudioDuration,
16 FfmpegVideo,
17 exampleSound,
14) 18)
15 19
16 20
17log = logging.getLogger('AVP.Components.Spectrum') 21log = logging.getLogger("AVP.Components.Spectrum")
18 22
19 23
20class Component(Component): 24class Component(Component):
21 name = 'Spectrum' 25 name = "Spectrum"
22 version = '1.0.1' 26 version = "1.0.1"
23 27
24 def widget(self, *args): 28 def widget(self, *args):
25 self.previewFrame = None 29 self.previewFrame = None
@@ -30,34 +34,36 @@ class Component(Component):
30 self.previewSize = (214, 120) 34 self.previewSize = (214, 120)
31 self.previewPipe = None 35 self.previewPipe = None
32 36
33 if hasattr(self.parent, 'lineEdit_audioFile'): 37 if hasattr(self.parent, "lineEdit_audioFile"):
34 # update preview when audio file changes (if genericPreview is off) 38 # update preview when audio file changes (if genericPreview is off)
35 self.parent.lineEdit_audioFile.textChanged.connect( 39 self.parent.lineEdit_audioFile.textChanged.connect(self.update)
36 self.update
37 )
38 40
39 self.trackWidgets({ 41 self.trackWidgets(
40 'filterType': self.page.comboBox_filterType, 42 {
41 'window': self.page.comboBox_window, 43 "filterType": self.page.comboBox_filterType,
42 'mode': self.page.comboBox_mode, 44 "window": self.page.comboBox_window,
43 'amplitude': self.page.comboBox_amplitude0, 45 "mode": self.page.comboBox_mode,
44 'amplitude1': self.page.comboBox_amplitude1, 46 "amplitude": self.page.comboBox_amplitude0,
45 'amplitude2': self.page.comboBox_amplitude2, 47 "amplitude1": self.page.comboBox_amplitude1,
46 'display': self.page.comboBox_display, 48 "amplitude2": self.page.comboBox_amplitude2,
47 'zoom': self.page.spinBox_zoom, 49 "display": self.page.comboBox_display,
48 'tc': self.page.spinBox_tc, 50 "zoom": self.page.spinBox_zoom,
49 'x': self.page.spinBox_x, 51 "tc": self.page.spinBox_tc,
50 'y': self.page.spinBox_y, 52 "x": self.page.spinBox_x,
51 'mirror': self.page.checkBox_mirror, 53 "y": self.page.spinBox_y,
52 'draw': self.page.checkBox_draw, 54 "mirror": self.page.checkBox_mirror,
53 'scale': self.page.spinBox_scale, 55 "draw": self.page.checkBox_draw,
54 'color': self.page.comboBox_color, 56 "scale": self.page.spinBox_scale,
55 'compress': self.page.checkBox_compress, 57 "color": self.page.comboBox_color,
56 'mono': self.page.checkBox_mono, 58 "compress": self.page.checkBox_compress,
57 'hue': self.page.spinBox_hue, 59 "mono": self.page.checkBox_mono,
58 }, relativeWidgets=[ 60 "hue": self.page.spinBox_hue,
59 'x', 'y', 61 },
60 ]) 62 relativeWidgets=[
63 "x",
64 "y",
65 ],
66 )
61 for widget in self._trackedWidgets.values(): 67 for widget in self._trackedWidgets.values():
62 connectWidget(widget, lambda: self.changed()) 68 connectWidget(widget, lambda: self.changed())
63 69
@@ -78,18 +84,18 @@ class Component(Component):
78 84
79 def previewRender(self): 85 def previewRender(self):
80 changedSize = self.updateChunksize() 86 changedSize = self.updateChunksize()
81 if not changedSize \ 87 if (
82 and not self.changedOptions \ 88 not changedSize
83 and self.previewFrame is not None: 89 and not self.changedOptions
84 log.debug( 90 and self.previewFrame is not None
85 'Spectrum #%s is reusing old preview frame' % self.compPos) 91 ):
92 log.debug("Spectrum #%s is reusing old preview frame" % self.compPos)
86 return self.previewFrame 93 return self.previewFrame
87 94
88 frame = self.getPreviewFrame() 95 frame = self.getPreviewFrame()
89 self.changedOptions = False 96 self.changedOptions = False
90 if not frame: 97 if not frame:
91 log.warning( 98 log.warning("Spectrum #%s failed to create a preview frame" % self.compPos)
92 'Spectrum #%s failed to create a preview frame' % self.compPos)
93 self.previewFrame = None 99 self.previewFrame = None
94 return BlankFrame(self.width, self.height) 100 return BlankFrame(self.width, self.height)
95 else: 101 else:
@@ -105,10 +111,12 @@ class Component(Component):
105 self.video = FfmpegVideo( 111 self.video = FfmpegVideo(
106 inputPath=self.audioFile, 112 inputPath=self.audioFile,
107 filter_=self.makeFfmpegFilter(), 113 filter_=self.makeFfmpegFilter(),
108 width=w, height=h, 114 width=w,
115 height=h,
109 chunkSize=self.chunkSize, 116 chunkSize=self.chunkSize,
110 frameRate=int(self.settings.value("outputFrameRate")), 117 frameRate=int(self.settings.value("outputFrameRate")),
111 parent=self.parent, component=self, 118 parent=self.parent,
119 component=self,
112 ) 120 )
113 121
114 def frameRender(self, frameNo): 122 def frameRender(self, frameNo):
@@ -133,38 +141,55 @@ class Component(Component):
133 141
134 command = [ 142 command = [
135 self.core.FFMPEG_BIN, 143 self.core.FFMPEG_BIN,
136 '-thread_queue_size', '512', 144 "-thread_queue_size",
137 '-r', str(self.settings.value("outputFrameRate")), 145 "512",
138 '-ss', "{0:.3f}".format(startPt), 146 "-r",
139 '-i', 147 str(self.settings.value("outputFrameRate")),
140 self.core.junkStream 148 "-ss",
141 if genericPreview else inputFile, 149 "{0:.3f}".format(startPt),
142 '-f', 'image2pipe', 150 "-i",
143 '-pix_fmt', 'rgba', 151 self.core.junkStream if genericPreview else inputFile,
152 "-f",
153 "image2pipe",
154 "-pix_fmt",
155 "rgba",
144 ] 156 ]
145 command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) 157 command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt))
146 command.extend([ 158 command.extend(
147 '-an', 159 [
148 '-s:v', '%sx%s' % scale(self.scale, self.width, self.height, str), 160 "-an",
149 '-codec:v', 'rawvideo', '-', 161 "-s:v",
150 '-frames:v', '1', 162 "%sx%s" % scale(self.scale, self.width, self.height, str),
151 ]) 163 "-codec:v",
164 "rawvideo",
165 "-",
166 "-frames:v",
167 "1",
168 ]
169 )
152 170
153 if self.core.logEnabled: 171 if self.core.logEnabled:
154 logFilename = os.path.join( 172 logFilename = os.path.join(
155 self.core.logDir, 'preview_%s.log' % str(self.compPos)) 173 self.core.logDir, "preview_%s.log" % str(self.compPos)
156 log.debug('Creating FFmpeg process (log at %s)' % logFilename) 174 )
157 with open(logFilename, 'w') as logf: 175 log.debug("Creating FFmpeg process (log at %s)" % logFilename)
158 logf.write(" ".join(command) + '\n\n') 176 with open(logFilename, "w") as logf:
159 with open(logFilename, 'a') as logf: 177 logf.write(" ".join(command) + "\n\n")
178 with open(logFilename, "a") as logf:
160 self.previewPipe = openPipe( 179 self.previewPipe = openPipe(
161 command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, 180 command,
162 stderr=logf, bufsize=10**8 181 stdin=subprocess.DEVNULL,
182 stdout=subprocess.PIPE,
183 stderr=logf,
184 bufsize=10**8,
163 ) 185 )
164 else: 186 else:
165 self.previewPipe = openPipe( 187 self.previewPipe = openPipe(
166 command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, 188 command,
167 stderr=subprocess.DEVNULL, bufsize=10**8 189 stdin=subprocess.DEVNULL,
190 stdout=subprocess.PIPE,
191 stderr=subprocess.DEVNULL,
192 bufsize=10**8,
168 ) 193 )
169 byteFrame = self.previewPipe.stdout.read(self.chunkSize) 194 byteFrame = self.previewPipe.stdout.read(self.chunkSize)
170 closePipe(self.previewPipe) 195 closePipe(self.previewPipe)
@@ -173,132 +198,151 @@ class Component(Component):
173 return frame 198 return frame
174 199
175 def makeFfmpegFilter(self, preview=False, startPt=0): 200 def makeFfmpegFilter(self, preview=False, startPt=0):
176 '''Makes final FFmpeg filter command''' 201 """Makes final FFmpeg filter command"""
177 202
178 def getFilterComplexCommand(): 203 def getFilterComplexCommand():
179 '''Inner function that creates the final, complex part of the filter command''' 204 """Inner function that creates the final, complex part of the filter command"""
180 nonlocal self 205 nonlocal self
181 genericPreview = self.settings.value("pref_genericPreview") 206 genericPreview = self.settings.value("pref_genericPreview")
182 207
183 def getFilterComplexCommandForType(): 208 def getFilterComplexCommandForType():
184 '''Determine portion of filter command that changes depending on selected type''' 209 """Determine portion of filter command that changes depending on selected type"""
185 nonlocal self 210 nonlocal self
186 if preview: 211 if preview:
187 w, h = self.previewSize 212 w, h = self.previewSize
188 else: 213 else:
189 w, h = (self.width, self.height) 214 w, h = (self.width, self.height)
190 color = self.page.comboBox_color.currentText().lower() 215 color = self.page.comboBox_color.currentText().lower()
191 216
192 if self.filterType == 0: # Spectrum 217 if self.filterType == 0: # Spectrum
193 if self.amplitude == 0: 218 if self.amplitude == 0:
194 amplitude = 'sqrt' 219 amplitude = "sqrt"
195 elif self.amplitude == 1: 220 elif self.amplitude == 1:
196 amplitude = 'cbrt' 221 amplitude = "cbrt"
197 elif self.amplitude == 2: 222 elif self.amplitude == 2:
198 amplitude = '4thrt' 223 amplitude = "4thrt"
199 elif self.amplitude == 3: 224 elif self.amplitude == 3:
200 amplitude = '5thrt' 225 amplitude = "5thrt"
201 elif self.amplitude == 4: 226 elif self.amplitude == 4:
202 amplitude = 'lin' 227 amplitude = "lin"
203 elif self.amplitude == 5: 228 elif self.amplitude == 5:
204 amplitude = 'log' 229 amplitude = "log"
205 filter_ = ( 230 filter_ = (
206 f'showspectrum=s={w}x{h}:' 231 f"showspectrum=s={w}x{h}:"
207 'slide=scroll:' 232 "slide=scroll:"
208 f'win_func={self.page.comboBox_window.currentText()}:' 233 f"win_func={self.page.comboBox_window.currentText()}:"
209 f'color={color}:' 234 f"color={color}:"
210 f'scale={amplitude},' 235 f"scale={amplitude},"
211 'colorkey=color=black:' 236 "colorkey=color=black:"
212 'similarity=0.1:blend=0.5' 237 "similarity=0.1:blend=0.5"
213 ) 238 )
214 elif self.filterType == 1: # Histogram 239 elif self.filterType == 1: # Histogram
215 if self.amplitude1 == 0: 240 if self.amplitude1 == 0:
216 amplitude = 'log' 241 amplitude = "log"
217 elif self.amplitude1 == 1: 242 elif self.amplitude1 == 1:
218 amplitude = 'lin' 243 amplitude = "lin"
219 if self.display == 0: 244 if self.display == 0:
220 display = 'log' 245 display = "log"
221 elif self.display == 1: 246 elif self.display == 1:
222 display = 'sqrt' 247 display = "sqrt"
223 elif self.display == 2: 248 elif self.display == 2:
224 display = 'cbrt' 249 display = "cbrt"
225 elif self.display == 3: 250 elif self.display == 3:
226 display = 'lin' 251 display = "lin"
227 elif self.display == 4: 252 elif self.display == 4:
228 display = 'rlog' 253 display = "rlog"
229 filter_ = ( 254 filter_ = (
230 f'ahistogram=r={str(self.settings.value("outputFrameRate"))}:' 255 f'ahistogram=r={str(self.settings.value("outputFrameRate"))}:'
231 f's={w}x{h}:' 256 f"s={w}x{h}:"
232 'dmode=separate:' 257 "dmode=separate:"
233 f'ascale={amplitude}:' 258 f"ascale={amplitude}:"
234 f'scale={display}' 259 f"scale={display}"
235 ) 260 )
236 elif self.filterType == 2: # Vector Scope 261 elif self.filterType == 2: # Vector Scope
237 if self.amplitude2 == 0: 262 if self.amplitude2 == 0:
238 amplitude = 'log' 263 amplitude = "log"
239 elif self.amplitude2 == 1: 264 elif self.amplitude2 == 1:
240 amplitude = 'sqrt' 265 amplitude = "sqrt"
241 elif self.amplitude2 == 2: 266 elif self.amplitude2 == 2:
242 amplitude = 'cbrt' 267 amplitude = "cbrt"
243 elif self.amplitude2 == 3: 268 elif self.amplitude2 == 3:
244 amplitude = 'lin' 269 amplitude = "lin"
245 m = self.page.comboBox_mode.currentText() 270 m = self.page.comboBox_mode.currentText()
246 filter_ = ( 271 filter_ = (
247 f'avectorscope=s={w}x{h}:' 272 f"avectorscope=s={w}x{h}:"
248 f'draw={"line" if self.draw else "dot"}:' 273 f'draw={"line" if self.draw else "dot"}:'
249 f'm={m}:' 274 f"m={m}:"
250 f'scale={amplitude}:' 275 f"scale={amplitude}:"
251 f'zoom={str(self.zoom)}' 276 f"zoom={str(self.zoom)}"
252 ) 277 )
253 elif self.filterType == 3: # Musical Scale 278 elif self.filterType == 3: # Musical Scale
254 filter_ = ( 279 filter_ = (
255 f'showcqt=r={str(self.settings.value("outputFrameRate"))}:' 280 f'showcqt=r={str(self.settings.value("outputFrameRate"))}:'
256 f's={w}x{h}:' 281 f"s={w}x{h}:"
257 'count=30:' 282 "count=30:"
258 'text=0:' 283 "text=0:"
259 f'tc={str(self.tc)},' 284 f"tc={str(self.tc)},"
260 'colorkey=color=black:' 285 "colorkey=color=black:"
261 'similarity=0.1:blend=0.5' 286 "similarity=0.1:blend=0.5"
262 ) 287 )
263 elif self.filterType == 4: # Phase 288 elif self.filterType == 4: # Phase
264 filter_ = ( 289 filter_ = (
265 f'aphasemeter=r={str(self.settings.value("outputFrameRate"))}:' 290 f'aphasemeter=r={str(self.settings.value("outputFrameRate"))}:'
266 f's={w}x{h}:' 291 f"s={w}x{h}:"
267 'video=1 [atrash][vtmp1]; ' 292 "video=1 [atrash][vtmp1]; "
268 '[atrash] anullsink; ' 293 "[atrash] anullsink; "
269 '[vtmp1] colorkey=color=black:' 294 "[vtmp1] colorkey=color=black:"
270 'similarity=0.1:blend=0.5, ' 295 "similarity=0.1:blend=0.5, "
271 'crop=in_w/8:in_h:(in_w/8)*7:0 ' 296 "crop=in_w/8:in_h:(in_w/8)*7:0 "
272 ) 297 )
273 return filter_ 298 return filter_
274 299
275
276 if self.filterType < 2: 300 if self.filterType < 2:
277 exampleSnd = exampleSound('freq') 301 exampleSnd = exampleSound("freq")
278 elif self.filterType == 2 or self.filterType == 4: 302 elif self.filterType == 2 or self.filterType == 4:
279 exampleSnd = exampleSound('stereo') 303 exampleSnd = exampleSound("stereo")
280 elif self.filterType == 3: 304 elif self.filterType == 3:
281 exampleSnd = exampleSound('white') 305 exampleSnd = exampleSound("white")
282 compression = 'compand=gain=4,' if self.compress else '' 306 compression = "compand=gain=4," if self.compress else ""
283 aformat = 'aformat=channel_layouts=mono,' if self.mono and self.filterType not in (2, 4) else '' 307 aformat = (
308 "aformat=channel_layouts=mono,"
309 if self.mono and self.filterType not in (2, 4)
310 else ""
311 )
284 filter_ = getFilterComplexCommandForType() 312 filter_ = getFilterComplexCommandForType()
285 hflip = 'hflip, ' if self.mirror else '' 313 hflip = "hflip, " if self.mirror else ""
286 trim = 'trim=start=%s:end=%s, ' % ("{0:.3f}".format(startPt + 12), "{0:.3f}".format(startPt + 12.5)) if preview else '' 314 trim = (
287 scale_ = 'scale=%sx%s' % scale(self.scale, self.width, self.height, str) 315 "trim=start=%s:end=%s, "
288 hue = ', hue=h=%s:s=10' % str(self.hue) if self.hue > 0 and self.filterType != 3 else '' 316 % (
289 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 '' 317 "{0:.3f}".format(startPt + 12),
290 318 "{0:.3f}".format(startPt + 12.5),
319 )
320 if preview
321 else ""
322 )
323 scale_ = "scale=%sx%s" % scale(self.scale, self.width, self.height, str)
324 hue = (
325 ", hue=h=%s:s=10" % str(self.hue)
326 if self.hue > 0 and self.filterType != 3
327 else ""
328 )
329 convolution = (
330 ", 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"
331 if self.filterType == 3
332 else ""
333 )
334
291 return ( 335 return (
292 f"{exampleSnd if preview and genericPreview else '[0:a] '}" 336 f"{exampleSnd if preview and genericPreview else '[0:a] '}"
293 f"{compression}{aformat}{filter_} [v1]; " 337 f"{compression}{aformat}{filter_} [v1]; "
294 f"[v1] {hflip}{trim}{scale_}{hue}{convolution} [v]" 338 f"[v1] {hflip}{trim}{scale_}{hue}{convolution} [v]"
295 ) 339 )
296 340
297
298 return [ 341 return [
299 '-filter_complex', 342 "-filter_complex",
300 getFilterComplexCommand(), 343 getFilterComplexCommand(),
301 '-map', '[v]', 344 "-map",
345 "[v]",
302 ] 346 ]
303 347
304 def updateChunksize(self): 348 def updateChunksize(self):
@@ -311,9 +355,9 @@ class Component(Component):
311 def finalizeFrame(self, imageData): 355 def finalizeFrame(self, imageData):
312 try: 356 try:
313 image = Image.frombytes( 357 image = Image.frombytes(
314 'RGBA', 358 "RGBA",
315 scale(self.scale, self.width, self.height, int), 359 scale(self.scale, self.width, self.height, int),
316 imageData 360 imageData,
317 ) 361 )
318 self._image = image 362 self._image = image
319 except ValueError: 363 except ValueError:
diff --git a/src/components/text.py b/src/components/text.py
index 3238d2a..40c981a 100644
--- a/src/components/text.py
+++ b/src/components/text.py
@@ -1,22 +1,22 @@
1from PIL import ImageEnhance, ImageFilter, ImageChops 1from PIL import ImageEnhance, ImageFilter, ImageChops
2from PyQt5.QtGui import QColor, QFont 2from PyQt6.QtGui import QColor, QFont
3from PyQt5 import QtGui, QtCore, QtWidgets 3from PyQt6 import QtGui, QtCore, QtWidgets
4import os 4import os
5import logging 5import logging
6 6
7from ..component import Component 7from ..component import Component
8from ..toolkit.frame import FramePainter, PaintColor 8from ..toolkit.frame import FramePainter, PaintColor
9 9
10log = logging.getLogger('AVP.Components.Text') 10log = logging.getLogger("AVP.Components.Text")
11 11
12 12
13class Component(Component): 13class Component(Component):
14 name = 'Title Text' 14 name = "Title Text"
15 version = '1.0.1' 15 version = "1.0.1"
16 16
17 def widget(self, *args): 17 def widget(self, *args):
18 super().widget(*args) 18 super().widget(*args)
19 self.title = 'Text' 19 self.title = "Text"
20 self.alignment = 1 20 self.alignment = 1
21 self.titleFont = QFont() 21 self.titleFont = QFont()
22 self.fontSize = self.height / 13.5 22 self.fontSize = self.height / 13.5
@@ -29,33 +29,44 @@ class Component(Component):
29 self.page.lineEdit_title.setText(self.title) 29 self.page.lineEdit_title.setText(self.title)
30 self.page.pushButton_center.clicked.connect(self.centerXY) 30 self.page.pushButton_center.clicked.connect(self.centerXY)
31 31
32 self.page.fontComboBox_titleFont.currentFontChanged.connect(self._sendUpdateSignal) 32 self.page.fontComboBox_titleFont.currentFontChanged.connect(
33 self._sendUpdateSignal
34 )
33 # The QFontComboBox must be connected directly to the Qt Signal 35 # The QFontComboBox must be connected directly to the Qt Signal
34 # which triggers the preview to update. 36 # which triggers the preview to update.
35 # This unfortunately makes changing the font into a non-undoable action. 37 # This unfortunately makes changing the font into a non-undoable action.
36 # Must be something broken in the conversion to a ComponentAction 38 # Must be something broken in the conversion to a ComponentAction
37 39
38 self.trackWidgets({ 40 self.trackWidgets(
39 'textColor': self.page.lineEdit_textColor, 41 {
40 'title': self.page.lineEdit_title, 42 "textColor": self.page.lineEdit_textColor,
41 'alignment': self.page.comboBox_textAlign, 43 "title": self.page.lineEdit_title,
42 'fontSize': self.page.spinBox_fontSize, 44 "alignment": self.page.comboBox_textAlign,
43 'xPosition': self.page.spinBox_xTextAlign, 45 "fontSize": self.page.spinBox_fontSize,
44 'yPosition': self.page.spinBox_yTextAlign, 46 "xPosition": self.page.spinBox_xTextAlign,
45 'fontStyle': self.page.comboBox_fontStyle, 47 "yPosition": self.page.spinBox_yTextAlign,
46 'stroke': self.page.spinBox_stroke, 48 "fontStyle": self.page.comboBox_fontStyle,
47 'strokeColor': self.page.lineEdit_strokeColor, 49 "stroke": self.page.spinBox_stroke,
48 'shadow': self.page.checkBox_shadow, 50 "strokeColor": self.page.lineEdit_strokeColor,
49 'shadX': self.page.spinBox_shadX, 51 "shadow": self.page.checkBox_shadow,
50 'shadY': self.page.spinBox_shadY, 52 "shadX": self.page.spinBox_shadX,
51 'shadBlur': self.page.spinBox_shadBlur, 53 "shadY": self.page.spinBox_shadY,
52 }, colorWidgets={ 54 "shadBlur": self.page.spinBox_shadBlur,
53 'textColor': self.page.pushButton_textColor, 55 },
54 'strokeColor': self.page.pushButton_strokeColor, 56 colorWidgets={
55 }, relativeWidgets=[ 57 "textColor": self.page.pushButton_textColor,
56 'xPosition', 'yPosition', 'fontSize', 58 "strokeColor": self.page.pushButton_strokeColor,
57 'stroke', 'shadX', 'shadY', 'shadBlur' 59 },
58 ]) 60 relativeWidgets=[
61 "xPosition",
62 "yPosition",
63 "fontSize",
64 "stroke",
65 "shadX",
66 "shadY",
67 "shadBlur",
68 ],
69 )
59 self.centerXY() 70 self.centerXY()
60 71
61 def update(self): 72 def update(self):
@@ -74,20 +85,23 @@ class Component(Component):
74 self.page.spinBox_shadBlur.setHidden(True) 85 self.page.spinBox_shadBlur.setHidden(True)
75 86
76 def centerXY(self): 87 def centerXY(self):
77 self.setRelativeWidget('xPosition', 0.5) 88 self.setRelativeWidget("xPosition", 0.5)
78 self.setRelativeWidget('yPosition', 0.521) 89 self.setRelativeWidget("yPosition", 0.521)
79 90
80 def getXY(self): 91 def getXY(self):
81 '''Returns true x, y after considering alignment settings''' 92 """Returns true x, y after considering alignment settings"""
82 fm = QtGui.QFontMetrics(self.titleFont) 93 fm = QtGui.QFontMetrics(self.titleFont)
83 x = self.pixelValForAttr('xPosition') 94 text_width = fm.boundingRect(self.title).width()
95 x = self.pixelValForAttr("xPosition")
84 96
85 if self.alignment == 1: # Middle 97 if self.alignment == 1: # Middle
86 offset = int(fm.width(self.title)/2) 98 offset = int(text_width / 2)
87 x -= offset 99 elif self.alignment == 2: # Right
88 if self.alignment == 2: # Right 100 offset = text_width
89 offset = fm.width(self.title) 101 else:
90 x -= offset 102 raise ValueError(f"Alignment value {self.alignment} unknown")
103
104 x -= offset
91 105
92 return x, self.yPosition 106 return x, self.yPosition
93 107
@@ -95,21 +109,21 @@ class Component(Component):
95 super().loadPreset(pr, *args) 109 super().loadPreset(pr, *args)
96 110
97 font = QFont() 111 font = QFont()
98 font.fromString(pr['titleFont']) 112 font.fromString(pr["titleFont"])
99 self.page.fontComboBox_titleFont.setCurrentFont(font) 113 self.page.fontComboBox_titleFont.setCurrentFont(font)
100 114
101 def savePreset(self): 115 def savePreset(self):
102 saveValueStore = super().savePreset() 116 saveValueStore = super().savePreset()
103 saveValueStore['titleFont'] = self.titleFont.toString() 117 saveValueStore["titleFont"] = self.titleFont.toString()
104 return saveValueStore 118 return saveValueStore
105 119
106 def previewRender(self): 120 def previewRender(self):
107 return self.addText(self.width, self.height) 121 return self.addText(self.width, self.height)
108 122
109 def properties(self): 123 def properties(self):
110 props = ['static'] 124 props = ["static"]
111 if not self.title: 125 if not self.title:
112 props.append('error') 126 props.append("error")
113 return props 127 return props
114 128
115 def error(self): 129 def error(self):
@@ -121,26 +135,26 @@ class Component(Component):
121 def addText(self, width, height): 135 def addText(self, width, height):
122 font = self.titleFont 136 font = self.titleFont
123 font.setPixelSize(self.fontSize) 137 font.setPixelSize(self.fontSize)
124 font.setStyle(QFont.StyleNormal) 138 font.setStyle(QFont.Style.StyleNormal)
125 font.setWeight(QFont.Normal) 139 font.setWeight(QFont.Weight.Normal)
126 font.setCapitalization(QFont.MixedCase) 140 font.setCapitalization(QFont.Capitalization.MixedCase)
127 if self.fontStyle == 1: 141 if self.fontStyle == 1:
128 font.setWeight(QFont.DemiBold) 142 font.setWeight(QFont.Weight.DemiBold)
129 if self.fontStyle == 2: 143 if self.fontStyle == 2:
130 font.setWeight(QFont.Bold) 144 font.setWeight(QFont.Weight.Bold)
131 elif self.fontStyle == 3: 145 elif self.fontStyle == 3:
132 font.setStyle(QFont.StyleItalic) 146 font.setStyle(QFont.Style.StyleItalic)
133 elif self.fontStyle == 4: 147 elif self.fontStyle == 4:
134 font.setWeight(QFont.Bold) 148 font.setWeight(QFont.Weight.Bold)
135 font.setStyle(QFont.StyleItalic) 149 font.setStyle(QFont.Style.StyleItalic)
136 elif self.fontStyle == 5: 150 elif self.fontStyle == 5:
137 font.setStyle(QFont.StyleOblique) 151 font.setStyle(QFont.Style.StyleOblique)
138 elif self.fontStyle == 6: 152 elif self.fontStyle == 6:
139 font.setCapitalization(QFont.SmallCaps) 153 font.setCapitalization(QFont.Capitalization.SmallCaps)
140 154
141 image = FramePainter(width, height) 155 image = FramePainter(width, height)
142 x, y = self.getXY() 156 x, y = self.getXY()
143 log.debug('Text position translates to %s, %s', x, y) 157 log.debug("Text position translates to %s, %s", x, y)
144 if self.stroke > 0: 158 if self.stroke > 0:
145 outliner = QtGui.QPainterPathStroker() 159 outliner = QtGui.QPainterPathStroker()
146 outliner.setWidth(self.stroke) 160 outliner.setWidth(self.stroke)
@@ -149,16 +163,16 @@ class Component(Component):
149 # PathStroker ignores smallcaps so we need this weird hack 163 # PathStroker ignores smallcaps so we need this weird hack
150 path.addText(x, y, font, self.title[0]) 164 path.addText(x, y, font, self.title[0])
151 fm = QtGui.QFontMetrics(font) 165 fm = QtGui.QFontMetrics(font)
152 newX = x + fm.width(self.title[0]) 166 newX = x + fm.boundingRect(self.title[0]).width()
153 strokeFont = self.page.fontComboBox_titleFont.currentFont() 167 strokeFont = self.page.fontComboBox_titleFont.currentFont()
154 strokeFont.setCapitalization(QFont.SmallCaps) 168 strokeFont.setCapitalization(QFont.Capitalization.SmallCaps)
155 strokeFont.setPixelSize(int((self.fontSize / 7) * 5)) 169 strokeFont.setPixelSize(int((self.fontSize / 7) * 5))
156 strokeFont.setLetterSpacing(QFont.PercentageSpacing, 139) 170 strokeFont.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 139)
157 path.addText(newX, y, strokeFont, self.title[1:]) 171 path.addText(newX, y, strokeFont, self.title[1:])
158 else: 172 else:
159 path.addText(x, y, font, self.title) 173 path.addText(x, y, font, self.title)
160 path = outliner.createStroke(path) 174 path = outliner.createStroke(path)
161 image.setPen(QtCore.Qt.NoPen) 175 image.setPen(QtCore.Qt.PenStyle.NoPen)
162 image.setBrush(PaintColor(*self.strokeColor)) 176 image.setBrush(PaintColor(*self.strokeColor))
163 image.drawPath(path) 177 image.drawPath(path)
164 178
@@ -178,27 +192,27 @@ class Component(Component):
178 return frame 192 return frame
179 193
180 def commandHelp(self): 194 def commandHelp(self):
181 print('Enter a string to use as centred white text:') 195 print("Enter a string to use as centred white text:")
182 print(' "title=User Error"') 196 print(' "title=User Error"')
183 print('Specify a text color:\n color=255,255,255') 197 print("Specify a text color:\n color=255,255,255")
184 print('Set custom x, y position:\n x=500 y=500') 198 print("Set custom x, y position:\n x=500 y=500")
185 199
186 def command(self, arg): 200 def command(self, arg):
187 if '=' in arg: 201 if "=" in arg:
188 key, arg = arg.split('=', 1) 202 key, arg = arg.split("=", 1)
189 if key == 'color': 203 if key == "color":
190 self.page.lineEdit_textColor.setText(arg) 204 self.page.lineEdit_textColor.setText(arg)
191 return 205 return
192 elif key == 'size': 206 elif key == "size":
193 self.page.spinBox_fontSize.setValue(int(arg)) 207 self.page.spinBox_fontSize.setValue(int(arg))
194 return 208 return
195 elif key == 'x': 209 elif key == "x":
196 self.page.spinBox_xTextAlign.setValue(int(arg)) 210 self.page.spinBox_xTextAlign.setValue(int(arg))
197 return 211 return
198 elif key == 'y': 212 elif key == "y":
199 self.page.spinBox_yTextAlign.setValue(int(arg)) 213 self.page.spinBox_yTextAlign.setValue(int(arg))
200 return 214 return
201 elif key == 'title': 215 elif key == "title":
202 self.page.lineEdit_title.setText(arg) 216 self.page.lineEdit_title.setText(arg)
203 return 217 return
204 super().command(arg) 218 super().command(arg)
diff --git a/src/components/video.py b/src/components/video.py
index 60ca800..65a05af 100644
--- a/src/components/video.py
+++ b/src/components/video.py
@@ -1,5 +1,5 @@
1from PIL import Image 1from PIL import Image
2from PyQt5 import QtGui, QtCore, QtWidgets 2from PyQt6 import QtGui, QtCore, QtWidgets
3import os 3import os
4import math 4import math
5import subprocess 5import subprocess
@@ -11,15 +11,15 @@ from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo
11from ..toolkit import checkOutput 11from ..toolkit import checkOutput
12 12
13 13
14log = logging.getLogger('AVP.Components.Video') 14log = logging.getLogger("AVP.Components.Video")
15 15
16 16
17class Component(Component): 17class Component(Component):
18 name = 'Video' 18 name = "Video"
19 version = '1.0.0' 19 version = "1.0.0"
20 20
21 def widget(self, *args): 21 def widget(self, *args):
22 self.videoPath = '' 22 self.videoPath = ""
23 self.badAudio = False 23 self.badAudio = False
24 self.x = 0 24 self.x = 0
25 self.y = 0 25 self.y = 0
@@ -27,23 +27,28 @@ class Component(Component):
27 super().widget(*args) 27 super().widget(*args)
28 self._image = BlankFrame(self.width, self.height) 28 self._image = BlankFrame(self.width, self.height)
29 self.page.pushButton_video.clicked.connect(self.pickVideo) 29 self.page.pushButton_video.clicked.connect(self.pickVideo)
30 self.trackWidgets({ 30 self.trackWidgets(
31 'videoPath': self.page.lineEdit_video, 31 {
32 'loopVideo': self.page.checkBox_loop, 32 "videoPath": self.page.lineEdit_video,
33 'useAudio': self.page.checkBox_useAudio, 33 "loopVideo": self.page.checkBox_loop,
34 'distort': self.page.checkBox_distort, 34 "useAudio": self.page.checkBox_useAudio,
35 'scale': self.page.spinBox_scale, 35 "distort": self.page.checkBox_distort,
36 'volume': self.page.spinBox_volume, 36 "scale": self.page.spinBox_scale,
37 'xPosition': self.page.spinBox_x, 37 "volume": self.page.spinBox_volume,
38 'yPosition': self.page.spinBox_y, 38 "xPosition": self.page.spinBox_x,
39 }, presetNames={ 39 "yPosition": self.page.spinBox_y,
40 'videoPath': 'video', 40 },
41 'loopVideo': 'loop', 41 presetNames={
42 'xPosition': 'x', 42 "videoPath": "video",
43 'yPosition': 'y', 43 "loopVideo": "loop",
44 }, relativeWidgets=[ 44 "xPosition": "x",
45 'xPosition', 'yPosition', 45 "yPosition": "y",
46 ]) 46 },
47 relativeWidgets=[
48 "xPosition",
49 "yPosition",
50 ],
51 )
47 52
48 def update(self): 53 def update(self):
49 if self.page.checkBox_useAudio.isChecked(): 54 if self.page.checkBox_useAudio.isChecked():
@@ -64,7 +69,7 @@ class Component(Component):
64 def properties(self): 69 def properties(self):
65 props = [] 70 props = []
66 outputFile = None 71 outputFile = None
67 if hasattr(self.parent, 'lineEdit_outputFile'): 72 if hasattr(self.parent, "lineEdit_outputFile"):
68 # check only happens in GUI mode 73 # check only happens in GUI mode
69 outputFile = self.parent.lineEdit_outputFile.text() 74 outputFile = self.parent.lineEdit_outputFile.text()
70 75
@@ -72,34 +77,42 @@ class Component(Component):
72 self.lockError("There is no video selected.") 77 self.lockError("There is no video selected.")
73 elif not os.path.exists(self.videoPath): 78 elif not os.path.exists(self.videoPath):
74 self.lockError("The video selected does not exist!") 79 self.lockError("The video selected does not exist!")
75 elif outputFile and os.path.realpath(self.videoPath) == os.path.realpath(outputFile): 80 elif outputFile and os.path.realpath(self.videoPath) == os.path.realpath(
81 outputFile
82 ):
76 self.lockError("Input and output paths match.") 83 self.lockError("Input and output paths match.")
77 84
78 if self.useAudio: 85 if self.useAudio:
79 props.append('audio') 86 props.append("audio")
80 if not testAudioStream(self.videoPath) \ 87 if not testAudioStream(self.videoPath) and self.error() is None:
81 and self.error() is None: 88 self.lockError("Could not identify an audio stream in this video.")
82 self.lockError(
83 "Could not identify an audio stream in this video.")
84 89
85 return props 90 return props
86 91
87 def audio(self): 92 def audio(self):
88 params = {} 93 params = {}
89 if self.volume != 1.0: 94 if self.volume != 1.0:
90 params['volume'] = '=%s:replaygain_noclip=0' % str(self.volume) 95 params["volume"] = "=%s:replaygain_noclip=0" % str(self.volume)
91 return (self.videoPath, params) 96 return (self.videoPath, params)
92 97
93 def preFrameRender(self, **kwargs): 98 def preFrameRender(self, **kwargs):
94 super().preFrameRender(**kwargs) 99 super().preFrameRender(**kwargs)
95 self.updateChunksize() 100 self.updateChunksize()
96 self.video = FfmpegVideo( 101 self.video = (
97 inputPath=self.videoPath, filter_=self.makeFfmpegFilter(), 102 FfmpegVideo(
98 width=self.width, height=self.height, chunkSize=self.chunkSize, 103 inputPath=self.videoPath,
99 frameRate=int(self.settings.value("outputFrameRate")), 104 filter_=self.makeFfmpegFilter(),
100 parent=self.parent, loopVideo=self.loopVideo, 105 width=self.width,
101 component=self 106 height=self.height,
102 ) if os.path.exists(self.videoPath) else None 107 chunkSize=self.chunkSize,
108 frameRate=int(self.settings.value("outputFrameRate")),
109 parent=self.parent,
110 loopVideo=self.loopVideo,
111 component=self,
112 )
113 if os.path.exists(self.videoPath)
114 else None
115 )
103 116
104 def frameRender(self, frameNo): 117 def frameRender(self, frameNo):
105 if FfmpegVideo.threadError is not None: 118 if FfmpegVideo.threadError is not None:
@@ -112,8 +125,10 @@ class Component(Component):
112 def pickVideo(self): 125 def pickVideo(self):
113 imgDir = self.settings.value("componentDir", os.path.expanduser("~")) 126 imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
114 filename, _ = QtWidgets.QFileDialog.getOpenFileName( 127 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
115 self.page, "Choose Video", 128 self.page,
116 imgDir, "Video Files (%s)" % " ".join(self.core.videoFormats) 129 "Choose Video",
130 imgDir,
131 "Video Files (%s)" % " ".join(self.core.videoFormats),
117 ) 132 )
118 if filename: 133 if filename:
119 self.settings.setValue("componentDir", os.path.dirname(filename)) 134 self.settings.setValue("componentDir", os.path.dirname(filename))
@@ -127,33 +142,50 @@ class Component(Component):
127 142
128 command = [ 143 command = [
129 self.core.FFMPEG_BIN, 144 self.core.FFMPEG_BIN,
130 '-thread_queue_size', '512', 145 "-thread_queue_size",
131 '-i', self.videoPath, 146 "512",
132 '-f', 'image2pipe', 147 "-i",
133 '-pix_fmt', 'rgba', 148 self.videoPath,
149 "-f",
150 "image2pipe",
151 "-pix_fmt",
152 "rgba",
134 ] 153 ]
135 command.extend(self.makeFfmpegFilter()) 154 command.extend(self.makeFfmpegFilter())
136 command.extend([ 155 command.extend(
137 '-codec:v', 'rawvideo', '-', 156 [
138 '-ss', '90', 157 "-codec:v",
139 '-frames:v', '1', 158 "rawvideo",
140 ]) 159 "-",
160 "-ss",
161 "90",
162 "-frames:v",
163 "1",
164 ]
165 )
141 166
142 if self.core.logEnabled: 167 if self.core.logEnabled:
143 logFilename = os.path.join( 168 logFilename = os.path.join(
144 self.core.logDir, 'preview_%s.log' % str(self.compPos)) 169 self.core.logDir, "preview_%s.log" % str(self.compPos)
145 log.debug('Creating ffmpeg process (log at %s)' % logFilename) 170 )
146 with open(logFilename, 'w') as logf: 171 log.debug("Creating ffmpeg process (log at %s)" % logFilename)
147 logf.write(" ".join(command) + '\n\n') 172 with open(logFilename, "w") as logf:
148 with open(logFilename, 'a') as logf: 173 logf.write(" ".join(command) + "\n\n")
174 with open(logFilename, "a") as logf:
149 pipe = openPipe( 175 pipe = openPipe(
150 command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, 176 command,
151 stderr=logf, bufsize=10**8 177 stdin=subprocess.DEVNULL,
178 stdout=subprocess.PIPE,
179 stderr=logf,
180 bufsize=10**8,
152 ) 181 )
153 else: 182 else:
154 pipe = openPipe( 183 pipe = openPipe(
155 command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, 184 command,
156 stderr=subprocess.DEVNULL, bufsize=10**8 185 stdin=subprocess.DEVNULL,
186 stdout=subprocess.PIPE,
187 stderr=subprocess.DEVNULL,
188 bufsize=10**8,
157 ) 189 )
158 190
159 byteFrame = pipe.stdout.read(self.chunkSize) 191 byteFrame = pipe.stdout.read(self.chunkSize)
@@ -164,9 +196,8 @@ class Component(Component):
164 196
165 def makeFfmpegFilter(self): 197 def makeFfmpegFilter(self):
166 return [ 198 return [
167 '-filter_complex', 199 "-filter_complex",
168 '[0:v] scale=%s:%s' % scale( 200 "[0:v] scale=%s:%s" % scale(self.scale, self.width, self.height, str),
169 self.scale, self.width, self.height, str),
170 ] 201 ]
171 202
172 def updateChunksize(self): 203 def updateChunksize(self):
@@ -177,10 +208,10 @@ class Component(Component):
177 self.chunkSize = 4 * width * height 208 self.chunkSize = 4 * width * height
178 209
179 def command(self, arg): 210 def command(self, arg):
180 if '=' in arg: 211 if "=" in arg:
181 key, arg = arg.split('=', 1) 212 key, arg = arg.split("=", 1)
182 if key == 'path' and os.path.exists(arg): 213 if key == "path" and os.path.exists(arg):
183 if '*%s' % os.path.splitext(arg)[1] in self.core.videoFormats: 214 if "*%s" % os.path.splitext(arg)[1] in self.core.videoFormats:
184 self.page.lineEdit_video.setText(arg) 215 self.page.lineEdit_video.setText(arg)
185 self.page.spinBox_scale.setValue(100) 216 self.page.spinBox_scale.setValue(100)
186 self.page.checkBox_loop.setChecked(True) 217 self.page.checkBox_loop.setChecked(True)
@@ -188,7 +219,7 @@ class Component(Component):
188 else: 219 else:
189 print("Not a supported video format") 220 print("Not a supported video format")
190 quit(1) 221 quit(1)
191 elif arg == 'audio': 222 elif arg == "audio":
192 if not self.page.lineEdit_video.text(): 223 if not self.page.lineEdit_video.text():
193 print("'audio' option must follow a video selection") 224 print("'audio' option must follow a video selection")
194 quit(1) 225 quit(1)
@@ -197,28 +228,25 @@ class Component(Component):
197 super().command(arg) 228 super().command(arg)
198 229
199 def commandHelp(self): 230 def commandHelp(self):
200 print('Load a video:\n path=/filepath/to/video.mp4') 231 print("Load a video:\n path=/filepath/to/video.mp4")
201 print('Using audio:\n path=/filepath/to/video.mp4 audio') 232 print("Using audio:\n path=/filepath/to/video.mp4 audio")
202 233
203 def finalizeFrame(self, imageData): 234 def finalizeFrame(self, imageData):
204 try: 235 try:
205 if self.distort: 236 if self.distort:
206 image = Image.frombytes( 237 image = Image.frombytes("RGBA", (self.width, self.height), imageData)
207 'RGBA',
208 (self.width, self.height),
209 imageData)
210 else: 238 else:
211 image = Image.frombytes( 239 image = Image.frombytes(
212 'RGBA', 240 "RGBA",
213 scale(self.scale, self.width, self.height, int), 241 scale(self.scale, self.width, self.height, int),
214 imageData) 242 imageData,
243 )
215 self._image = image 244 self._image = image
216 except ValueError: 245 except ValueError:
217 # use last good frame 246 # use last good frame
218 image = self._image 247 image = self._image
219 248
220 if self.scale != 100 \ 249 if self.scale != 100 or self.xPosition != 0 or self.yPosition != 0:
221 or self.xPosition != 0 or self.yPosition != 0:
222 frame = BlankFrame(self.width, self.height) 250 frame = BlankFrame(self.width, self.height)
223 frame.paste(image, box=(self.xPosition, self.yPosition)) 251 frame.paste(image, box=(self.xPosition, self.yPosition))
224 else: 252 else:
diff --git a/src/components/waveform.py b/src/components/waveform.py
index eef6de0..7dc0b99 100644
--- a/src/components/waveform.py
+++ b/src/components/waveform.py
@@ -1,6 +1,6 @@
1from PIL import Image 1from PIL import Image
2from PyQt5 import QtGui, QtCore, QtWidgets 2from PyQt6 import QtGui, QtCore, QtWidgets
3from PyQt5.QtGui import QColor 3from PyQt6.QtGui import QColor
4import os 4import os
5import math 5import math
6import subprocess 6import subprocess
@@ -10,44 +10,51 @@ from ..component import Component
10from ..toolkit.frame import BlankFrame, scale 10from ..toolkit.frame import BlankFrame, scale
11from ..toolkit import checkOutput 11from ..toolkit import checkOutput
12from ..toolkit.ffmpeg import ( 12from ..toolkit.ffmpeg import (
13 openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound 13 openPipe,
14 closePipe,
15 getAudioDuration,
16 FfmpegVideo,
17 exampleSound,
14) 18)
15 19
16 20
17log = logging.getLogger('AVP.Components.Waveform') 21log = logging.getLogger("AVP.Components.Waveform")
18 22
19 23
20class Component(Component): 24class Component(Component):
21 name = 'Waveform' 25 name = "Waveform"
22 version = '1.0.0' 26 version = "1.0.0"
23 27
24 def widget(self, *args): 28 def widget(self, *args):
25 super().widget(*args) 29 super().widget(*args)
26 self._image = BlankFrame(self.width, self.height) 30 self._image = BlankFrame(self.width, self.height)
27 31
28 self.page.lineEdit_color.setText('255,255,255') 32 self.page.lineEdit_color.setText("255,255,255")
29 33
30 if hasattr(self.parent, 'lineEdit_audioFile'): 34 if hasattr(self.parent, "lineEdit_audioFile"):
31 self.parent.lineEdit_audioFile.textChanged.connect( 35 self.parent.lineEdit_audioFile.textChanged.connect(self.update)
32 self.update 36
33 ) 37 self.trackWidgets(
34 38 {
35 self.trackWidgets({ 39 "color": self.page.lineEdit_color,
36 'color': self.page.lineEdit_color, 40 "mode": self.page.comboBox_mode,
37 'mode': self.page.comboBox_mode, 41 "amplitude": self.page.comboBox_amplitude,
38 'amplitude': self.page.comboBox_amplitude, 42 "x": self.page.spinBox_x,
39 'x': self.page.spinBox_x, 43 "y": self.page.spinBox_y,
40 'y': self.page.spinBox_y, 44 "mirror": self.page.checkBox_mirror,
41 'mirror': self.page.checkBox_mirror, 45 "scale": self.page.spinBox_scale,
42 'scale': self.page.spinBox_scale, 46 "opacity": self.page.spinBox_opacity,
43 'opacity': self.page.spinBox_opacity, 47 "compress": self.page.checkBox_compress,
44 'compress': self.page.checkBox_compress, 48 "mono": self.page.checkBox_mono,
45 'mono': self.page.checkBox_mono, 49 },
46 }, colorWidgets={ 50 colorWidgets={
47 'color': self.page.pushButton_color, 51 "color": self.page.pushButton_color,
48 }, relativeWidgets=[ 52 },
49 'x', 'y', 53 relativeWidgets=[
50 ]) 54 "x",
55 "y",
56 ],
57 )
51 58
52 def previewRender(self): 59 def previewRender(self):
53 self.updateChunksize() 60 self.updateChunksize()
@@ -64,10 +71,13 @@ class Component(Component):
64 self.video = FfmpegVideo( 71 self.video = FfmpegVideo(
65 inputPath=self.audioFile, 72 inputPath=self.audioFile,
66 filter_=self.makeFfmpegFilter(), 73 filter_=self.makeFfmpegFilter(),
67 width=w, height=h, 74 width=w,
75 height=h,
68 chunkSize=self.chunkSize, 76 chunkSize=self.chunkSize,
69 frameRate=int(self.settings.value("outputFrameRate")), 77 frameRate=int(self.settings.value("outputFrameRate")),
70 parent=self.parent, component=self, debug=True, 78 parent=self.parent,
79 component=self,
80 debug=True,
71 ) 81 )
72 82
73 def frameRender(self, frameNo): 83 def frameRender(self, frameNo):
@@ -94,37 +104,54 @@ class Component(Component):
94 104
95 command = [ 105 command = [
96 self.core.FFMPEG_BIN, 106 self.core.FFMPEG_BIN,
97 '-thread_queue_size', '512', 107 "-thread_queue_size",
98 '-r', str(self.settings.value("outputFrameRate")), 108 "512",
99 '-ss', "{0:.3f}".format(startPt), 109 "-r",
100 '-i', 110 str(self.settings.value("outputFrameRate")),
101 self.core.junkStream 111 "-ss",
102 if genericPreview else inputFile, 112 "{0:.3f}".format(startPt),
103 '-f', 'image2pipe', 113 "-i",
104 '-pix_fmt', 'rgba', 114 self.core.junkStream if genericPreview else inputFile,
115 "-f",
116 "image2pipe",
117 "-pix_fmt",
118 "rgba",
105 ] 119 ]
106 command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) 120 command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt))
107 command.extend([ 121 command.extend(
108 '-an', 122 [
109 '-s:v', '%sx%s' % scale(self.scale, self.width, self.height, str), 123 "-an",
110 '-codec:v', 'rawvideo', '-', 124 "-s:v",
111 '-frames:v', '1', 125 "%sx%s" % scale(self.scale, self.width, self.height, str),
112 ]) 126 "-codec:v",
127 "rawvideo",
128 "-",
129 "-frames:v",
130 "1",
131 ]
132 )
113 if self.core.logEnabled: 133 if self.core.logEnabled:
114 logFilename = os.path.join( 134 logFilename = os.path.join(
115 self.core.logDir, 'preview_%s.log' % str(self.compPos)) 135 self.core.logDir, "preview_%s.log" % str(self.compPos)
116 log.debug('Creating ffmpeg log at %s', logFilename) 136 )
117 with open(logFilename, 'w') as logf: 137 log.debug("Creating ffmpeg log at %s", logFilename)
118 logf.write(" ".join(command) + '\n\n') 138 with open(logFilename, "w") as logf:
119 with open(logFilename, 'a') as logf: 139 logf.write(" ".join(command) + "\n\n")
140 with open(logFilename, "a") as logf:
120 pipe = openPipe( 141 pipe = openPipe(
121 command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, 142 command,
122 stderr=logf, bufsize=10**8 143 stdin=subprocess.DEVNULL,
144 stdout=subprocess.PIPE,
145 stderr=logf,
146 bufsize=10**8,
123 ) 147 )
124 else: 148 else:
125 pipe = openPipe( 149 pipe = openPipe(
126 command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, 150 command,
127 stderr=subprocess.DEVNULL, bufsize=10**8 151 stdin=subprocess.DEVNULL,
152 stdout=subprocess.PIPE,
153 stderr=subprocess.DEVNULL,
154 bufsize=10**8,
128 ) 155 )
129 byteFrame = pipe.stdout.read(self.chunkSize) 156 byteFrame = pipe.stdout.read(self.chunkSize)
130 closePipe(pipe) 157 closePipe(pipe)
@@ -135,35 +162,35 @@ class Component(Component):
135 def makeFfmpegFilter(self, preview=False, startPt=0): 162 def makeFfmpegFilter(self, preview=False, startPt=0):
136 w, h = scale(self.scale, self.width, self.height, str) 163 w, h = scale(self.scale, self.width, self.height, str)
137 if self.amplitude == 0: 164 if self.amplitude == 0:
138 amplitude = 'lin' 165 amplitude = "lin"
139 elif self.amplitude == 1: 166 elif self.amplitude == 1:
140 amplitude = 'log' 167 amplitude = "log"
141 elif self.amplitude == 2: 168 elif self.amplitude == 2:
142 amplitude = 'sqrt' 169 amplitude = "sqrt"
143 elif self.amplitude == 3: 170 elif self.amplitude == 3:
144 amplitude = 'cbrt' 171 amplitude = "cbrt"
145 hexcolor = QColor(*self.color).name() 172 hexcolor = QColor(*self.color).name()
146 opacity = "{0:.1f}".format(self.opacity / 100) 173 opacity = "{0:.1f}".format(self.opacity / 100)
147 genericPreview = self.settings.value("pref_genericPreview") 174 genericPreview = self.settings.value("pref_genericPreview")
148 if self.mode < 3: 175 if self.mode < 3:
149 filter_ = ( 176 filter_ = (
150 'showwaves=' 177 "showwaves="
151 f'r={str(self.settings.value("outputFrameRate"))}:' 178 f'r={str(self.settings.value("outputFrameRate"))}:'
152 f's={self.settings.value("outputWidth")}x{self.settings.value("outputHeight")}:' 179 f's={self.settings.value("outputWidth")}x{self.settings.value("outputHeight")}:'
153 f'mode={self.page.comboBox_mode.currentText().lower() if self.mode != 3 else "p2p"}:' 180 f'mode={self.page.comboBox_mode.currentText().lower() if self.mode != 3 else "p2p"}:'
154 f'colors={hexcolor}@{opacity}:scale={amplitude}' 181 f"colors={hexcolor}@{opacity}:scale={amplitude}"
155 ) 182 )
156 elif self.mode > 2: 183 elif self.mode > 2:
157 filter_ = ( 184 filter_ = (
158 f'showfreqs=s={str(self.settings.value("outputWidth"))}x{str(self.settings.value("outputHeight"))}:' 185 f'showfreqs=s={str(self.settings.value("outputWidth"))}x{str(self.settings.value("outputHeight"))}:'
159 f'mode={"line" if self.mode == 4 else "bar"}:' 186 f'mode={"line" if self.mode == 4 else "bar"}:'
160 f'colors={hexcolor}@{opacity}' 187 f"colors={hexcolor}@{opacity}"
161 f":ascale={amplitude}:fscale={'log' if self.mono else 'lin'}" 188 f":ascale={amplitude}:fscale={'log' if self.mono else 'lin'}"
162 ) 189 )
163 190
164 baselineHeight = int(self.height * (4 / 1080)) 191 baselineHeight = int(self.height * (4 / 1080))
165 return [ 192 return [
166 '-filter_complex', 193 "-filter_complex",
167 f"{exampleSound('wave', extra='') if preview and genericPreview else '[0:a] '}" 194 f"{exampleSound('wave', extra='') if preview and genericPreview else '[0:a] '}"
168 f"{'compand=gain=4,' if self.compress else ''}" 195 f"{'compand=gain=4,' if self.compress else ''}"
169 f"{'aformat=channel_layouts=mono,' if self.mono and self.mode < 3 else ''}" 196 f"{'aformat=channel_layouts=mono,' if self.mono and self.mode < 3 else ''}"
@@ -171,12 +198,14 @@ class Component(Component):
171 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 ''}" 198 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 ''}"
172 f"{', hflip' if self.mirror else''}" 199 f"{', hflip' if self.mirror else''}"
173 " [v1]; " 200 " [v1]; "
174 '[v1] scale=%s:%s%s [v]' % ( 201 "[v1] scale=%s:%s%s [v]"
175 w, h, 202 % (
176 ', trim=duration=%s' % "{0:.3f}".format(startPt + 3) 203 w,
177 if preview else '', 204 h,
205 ", trim=duration=%s" % "{0:.3f}".format(startPt + 3) if preview else "",
178 ), 206 ),
179 '-map', '[v]', 207 "-map",
208 "[v]",
180 ] 209 ]
181 210
182 def updateChunksize(self): 211 def updateChunksize(self):
@@ -186,15 +215,14 @@ class Component(Component):
186 def finalizeFrame(self, imageData): 215 def finalizeFrame(self, imageData):
187 try: 216 try:
188 image = Image.frombytes( 217 image = Image.frombytes(
189 'RGBA', 218 "RGBA",
190 scale(self.scale, self.width, self.height, int), 219 scale(self.scale, self.width, self.height, int),
191 imageData 220 imageData,
192 ) 221 )
193 self._image = image 222 self._image = image
194 except ValueError: 223 except ValueError:
195 image = self._image 224 image = self._image
196 if self.scale != 100 \ 225 if self.scale != 100 or self.x != 0 or self.y != 0:
197 or self.x != 0 or self.y != 0:
198 frame = BlankFrame(self.width, self.height) 226 frame = BlankFrame(self.width, self.height)
199 frame.paste(image, box=(self.x, self.y)) 227 frame.paste(image, box=(self.x, self.y))
200 else: 228 else:
diff --git a/src/core.py b/src/core.py
index 1ad4a67..df6ff63 100644
--- a/src/core.py
+++ b/src/core.py
@@ -1,8 +1,9 @@
1''' 1"""
2 Home to the Core class which tracks program state. Used by GUI & commandline 2Home to the Core class which tracks program state. Used by GUI & commandline
3 to create a list of components and create a video thread to export. 3to create a list of components and create a video thread to export.
4''' 4"""
5from PyQt5 import QtCore, QtGui, uic 5
6from PyQt6 import QtCore, QtGui, uic
6import sys 7import sys
7import os 8import os
8import json 9import json
@@ -12,20 +13,20 @@ import logging
12from . import toolkit 13from . import toolkit
13 14
14 15
15log = logging.getLogger('AVP.Core') 16log = logging.getLogger("AVP.Core")
16STDOUT_LOGLVL = logging.WARNING 17STDOUT_LOGLVL = logging.WARNING
17FILE_LIBLOGLVL = logging.WARNING 18FILE_LIBLOGLVL = logging.WARNING
18FILE_LOGLVL = logging.INFO 19FILE_LOGLVL = logging.INFO
19 20
20 21
21class Core: 22class Core:
22 ''' 23 """
23 MainWindow and Command module both use an instance of this class 24 MainWindow and Command module both use an instance of this class
24 to store the core program state. This object tracks the components, 25 to store the core program state. This object tracks the components,
25 talks to the components, handles opening/creating project files 26 talks to the components, handles opening/creating project files
26 and presets, and creates the video thread to export. 27 and presets, and creates the video thread to export.
27 This class also stores constants as class variables. 28 This class also stores constants as class variables.
28 ''' 29 """
29 30
30 def __init__(self): 31 def __init__(self):
31 self.importComponents() 32 self.importComponents()
@@ -34,9 +35,7 @@ class Core:
34 self.openingProject = False 35 self.openingProject = False
35 36
36 def __repr__(self): 37 def __repr__(self):
37 return "\n=~=~=~=\n".join( 38 return "\n=~=~=~=\n".join([repr(comp) for comp in self.selectedComponents])
38 [repr(comp) for comp in self.selectedComponents]
39 )
40 39
41 def importComponents(self): 40 def importComponents(self):
42 def findComponents(): 41 def findComponents():
@@ -44,11 +43,12 @@ class Core:
44 name, ext = os.path.splitext(f) 43 name, ext = os.path.splitext(f)
45 if name.startswith("__"): 44 if name.startswith("__"):
46 continue 45 continue
47 elif ext == '.py': 46 elif ext == ".py":
48 yield name 47 yield name
49 log.debug('Importing component modules') 48
49 log.debug("Importing component modules")
50 self.modules = [ 50 self.modules = [
51 import_module('.components.%s' % name, __package__) 51 import_module(".components.%s" % name, __package__)
52 for name in findComponents() 52 for name in findComponents()
53 ] 53 ]
54 # store canonical module names and indexes 54 # store canonical module names and indexes
@@ -62,7 +62,7 @@ class Core:
62 # store alternative names for modules 62 # store alternative names for modules
63 self.altCompNames = [] 63 self.altCompNames = []
64 for i, mod in enumerate(self.modules): 64 for i, mod in enumerate(self.modules):
65 if hasattr(mod.Component, 'names'): 65 if hasattr(mod.Component, "names"):
66 for name in mod.Component.names(): 66 for name in mod.Component.names():
67 self.altCompNames.append((name, i)) 67 self.altCompNames.append((name, i))
68 68
@@ -71,10 +71,10 @@ class Core:
71 component.compPos = i 71 component.compPos = i
72 72
73 def insertComponent(self, compPos, component, loader): 73 def insertComponent(self, compPos, component, loader):
74 ''' 74 """
75 Creates a new component using these args: 75 Creates a new component using these args:
76 (compPos, component obj or moduleIndex, MWindow/Command/Core obj) 76 (compPos, component obj or moduleIndex, MWindow/Command/Core obj)
77 ''' 77 """
78 if compPos < 0 or compPos > len(self.selectedComponents): 78 if compPos < 0 or compPos > len(self.selectedComponents):
79 compPos = len(self.selectedComponents) 79 compPos = len(self.selectedComponents)
80 if len(self.selectedComponents) > 50: 80 if len(self.selectedComponents) > 50:
@@ -82,25 +82,16 @@ class Core:
82 if type(component) is int: 82 if type(component) is int:
83 # create component using module index in self.modules 83 # create component using module index in self.modules
84 moduleIndex = int(component) 84 moduleIndex = int(component)
85 log.debug( 85 log.debug("Creating new component from module #%s", str(moduleIndex))
86 'Creating new component from module #%s', str(moduleIndex)) 86 component = self.modules[moduleIndex].Component(moduleIndex, compPos, self)
87 component = self.modules[moduleIndex].Component(
88 moduleIndex, compPos, self
89 )
90 component.widget(loader) 87 component.widget(loader)
91 else: 88 else:
92 moduleIndex = -1 89 moduleIndex = -1
93 log.debug( 90 log.debug("Inserting previously-created %s component", component.name)
94 'Inserting previously-created %s component', component.name)
95 91
96 component._error.connect( 92 component._error.connect(loader.videoThreadError)
97 loader.videoThreadError 93 self.selectedComponents.insert(compPos, component)
98 ) 94 if hasattr(loader, "insertComponent"):
99 self.selectedComponents.insert(
100 compPos,
101 component
102 )
103 if hasattr(loader, 'insertComponent'):
104 loader.insertComponent(compPos) 95 loader.insertComponent(compPos)
105 96
106 self.componentListChanged() 97 self.componentListChanged()
@@ -123,9 +114,7 @@ class Core:
123 self.componentListChanged() 114 self.componentListChanged()
124 115
125 def updateComponent(self, i): 116 def updateComponent(self, i):
126 log.debug( 117 log.debug("Auto-updating %s #%s", self.selectedComponents[i], str(i))
127 'Auto-updating %s #%s',
128 self.selectedComponents[i], str(i))
129 self.selectedComponents[i].update(auto=True) 118 self.selectedComponents[i].update(auto=True)
130 119
131 def moduleIndexFor(self, compName): 120 def moduleIndexFor(self, compName):
@@ -141,63 +130,59 @@ class Core:
141 self.selectedComponents[compIndex].currentPreset = None 130 self.selectedComponents[compIndex].currentPreset = None
142 131
143 def openPreset(self, filepath, compIndex, presetName): 132 def openPreset(self, filepath, compIndex, presetName):
144 '''Applies a preset to a specific component''' 133 """Applies a preset to a specific component"""
145 saveValueStore = self.getPreset(filepath) 134 saveValueStore = self.getPreset(filepath)
146 if not saveValueStore: 135 if not saveValueStore:
147 return False 136 return False
148 comp = self.selectedComponents[compIndex] 137 comp = self.selectedComponents[compIndex]
149 comp.loadPreset( 138 comp.loadPreset(saveValueStore, presetName)
150 saveValueStore,
151 presetName
152 )
153 139
154 self.savedPresets[presetName] = dict(saveValueStore) 140 self.savedPresets[presetName] = dict(saveValueStore)
155 return True 141 return True
156 142
157 def getPreset(self, filepath): 143 def getPreset(self, filepath):
158 '''Returns the preset dict stored at this filepath''' 144 """Returns the preset dict stored at this filepath"""
159 if not os.path.exists(filepath): 145 if not os.path.exists(filepath):
160 return False 146 return False
161 with open(filepath, 'r') as f: 147 with open(filepath, "r") as f:
162 for line in f: 148 for line in f:
163 saveValueStore = toolkit.presetFromString(line.strip()) 149 saveValueStore = toolkit.presetFromString(line.strip())
164 break 150 break
165 return saveValueStore 151 return saveValueStore
166 152
167 def getPresetDir(self, comp): 153 def getPresetDir(self, comp):
168 '''Get the preset subdir for a particular version of a component''' 154 """Get the preset subdir for a particular version of a component"""
169 return os.path.join(Core.presetDir, comp.name, str(comp.version)) 155 return os.path.join(Core.presetDir, comp.name, str(comp.version))
170 156
171 def openProject(self, loader, filepath): 157 def openProject(self, loader, filepath):
172 ''' loader is the object calling this method which must have 158 """loader is the object calling this method which must have
173 its own showMessage(**kwargs) method for displaying errors. 159 its own showMessage(**kwargs) method for displaying errors.
174 ''' 160 """
175 if not os.path.exists(filepath): 161 if not os.path.exists(filepath):
176 loader.showMessage(msg='Project file not found.') 162 loader.showMessage(msg="Project file not found.")
177 return 163 return
178 164
179 errcode, data = self.parseAvFile(filepath) 165 errcode, data = self.parseAvFile(filepath)
180 if errcode == 0: 166 if errcode == 0:
181 self.openingProject = True 167 self.openingProject = True
182 try: 168 try:
183 if hasattr(loader, 'window'): 169 if hasattr(loader, "window"):
184 for widget, value in data['WindowFields']: 170 for widget, value in data["WindowFields"]:
185 widget = eval('loader.%s' % widget) 171 widget = eval("loader.%s" % widget)
186 with toolkit.blockSignals(widget): 172 with toolkit.blockSignals(widget):
187 toolkit.setWidgetValue(widget, value) 173 toolkit.setWidgetValue(widget, value)
188 174
189 for key, value in data['Settings']: 175 for key, value in data["Settings"]:
190 Core.settings.setValue(key, value) 176 Core.settings.setValue(key, value)
191 for tup in data['Components']: 177 for tup in data["Components"]:
192 name, vers, preset = tup 178 name, vers, preset = tup
193 clearThis = False 179 clearThis = False
194 modified = False 180 modified = False
195 181
196 # add loaded named presets to savedPresets dict 182 # add loaded named presets to savedPresets dict
197 if 'preset' in preset and preset['preset'] is not None: 183 if "preset" in preset and preset["preset"] is not None:
198 nam = preset['preset'] 184 nam = preset["preset"]
199 filepath2 = os.path.join( 185 filepath2 = os.path.join(Core.presetDir, name, str(vers), nam)
200 Core.presetDir, name, str(vers), nam)
201 origSaveValueStore = self.getPreset(filepath2) 186 origSaveValueStore = self.getPreset(filepath2)
202 if origSaveValueStore: 187 if origSaveValueStore:
203 self.savedPresets[nam] = dict(origSaveValueStore) 188 self.savedPresets[nam] = dict(origSaveValueStore)
@@ -207,33 +192,31 @@ class Core:
207 clearThis = True 192 clearThis = True
208 193
209 # create the actual component object & get its index 194 # create the actual component object & get its index
210 i = self.insertComponent( 195 i = self.insertComponent(-1, self.moduleIndexFor(name), loader)
211 -1, 196 if i is None:
212 self.moduleIndexFor(name), 197 loader.showMessage(
213 loader 198 msg=f"Component '{name}' didn't initialize correctly and had to be removed."
214 ) 199 )
200 continue
215 if i == -1: 201 if i == -1:
216 loader.showMessage(msg="Too many components!") 202 loader.showMessage(msg="Too many components!")
217 break 203 break
218 204
219 try: 205 try:
220 if 'preset' in preset and preset['preset'] is not None: 206 if "preset" in preset and preset["preset"] is not None:
221 self.selectedComponents[i].loadPreset( 207 self.selectedComponents[i].loadPreset(preset)
222 preset
223 )
224 else: 208 else:
225 self.selectedComponents[i].loadPreset( 209 self.selectedComponents[i].loadPreset(
226 preset, 210 preset, preset["preset"]
227 preset['preset']
228 ) 211 )
229 except KeyError as e: 212 except KeyError as e:
230 log.warning('%s missing value: %s' % ( 213 log.warning(
231 self.selectedComponents[i], e) 214 "%s missing value: %s" % (self.selectedComponents[i], e)
232 ) 215 )
233 216
234 if clearThis: 217 if clearThis:
235 self.clearPreset(i) 218 self.clearPreset(i)
236 if hasattr(loader, 'updateComponentTitle'): 219 if hasattr(loader, "updateComponentTitle"):
237 loader.updateComponentTitle(i, modified) 220 loader.updateComponentTitle(i, modified)
238 self.openingProject = False 221 self.openingProject = False
239 return True 222 return True
@@ -243,56 +226,57 @@ class Core:
243 226
244 if errcode == 1: 227 if errcode == 1:
245 typ, value, tb = data 228 typ, value, tb = data
246 if typ.__name__ == 'KeyError': 229 if typ.__name__ == "KeyError":
247 # probably just an old version, still loadable 230 # probably just an old version, still loadable
248 log.warning('Project file missing value: %s' % value) 231 log.warning("Project file missing value: %s" % value)
249 return 232 return
250 if hasattr(loader, 'createNewProject'): 233 if hasattr(loader, "createNewProject"):
251 loader.createNewProject(prompt=False) 234 loader.createNewProject(prompt=False)
252 msg = '%s: %s\n\n' % (typ.__name__, value) 235 msg = "%s: %s\n\n" % (typ.__name__, value)
253 msg += toolkit.formatTraceback(tb) 236 msg += toolkit.formatTraceback(tb)
254 loader.showMessage( 237 loader.showMessage(
255 msg="Project file '%s' is corrupted." % filepath, 238 msg="Project file '%s' is corrupted." % filepath,
256 showCancel=False, 239 showCancel=False,
257 icon='Warning', 240 icon="Warning",
258 detail=msg) 241 detail=msg,
242 )
259 self.openingProject = False 243 self.openingProject = False
260 return False 244 return False
261 245
262 def parseAvFile(self, filepath): 246 def parseAvFile(self, filepath):
263 ''' 247 """
264 Parses an avp (project) or avl (preset package) file. 248 Parses an avp (project) or avl (preset package) file.
265 Returns dictionary with section names as the keys, each one 249 Returns dictionary with section names as the keys, each one
266 contains a list of tuples: (compName, version, compPresetDict) 250 contains a list of tuples: (compName, version, compPresetDict)
267 ''' 251 """
268 log.debug('Parsing av file: %s', filepath) 252 log.debug("Parsing av file: %s", filepath)
269 validSections = ( 253 validSections = ("Components", "Settings", "WindowFields")
270 'Components',
271 'Settings',
272 'WindowFields'
273 )
274 data = {sect: [] for sect in validSections} 254 data = {sect: [] for sect in validSections}
275 try: 255 try:
276 with open(filepath, 'r') as f: 256 with open(filepath, "r") as f:
257
277 def parseLine(line): 258 def parseLine(line):
278 '''Decides if a file line is a section header''' 259 """Decides if a file line is a section header"""
279 line = line.strip() 260 line = line.strip()
280 newSection = '' 261 newSection = ""
281 262
282 if line.startswith('[') and line.endswith(']') \ 263 if (
283 and line[1:-1] in validSections: 264 line.startswith("[")
265 and line.endswith("]")
266 and line[1:-1] in validSections
267 ):
284 newSection = line[1:-1] 268 newSection = line[1:-1]
285 269
286 return line, newSection 270 return line, newSection
287 271
288 section = '' 272 section = ""
289 i = 0 273 i = 0
290 for line in f: 274 for line in f:
291 line, newSection = parseLine(line) 275 line, newSection = parseLine(line)
292 if newSection: 276 if newSection:
293 section = str(newSection) 277 section = str(newSection)
294 continue 278 continue
295 if line and section == 'Components': 279 if line and section == "Components":
296 if i == 0: 280 if i == 0:
297 lastCompName = str(line) 281 lastCompName = str(line)
298 i += 1 282 i += 1
@@ -301,14 +285,12 @@ class Core:
301 i += 1 285 i += 1
302 elif i == 2: 286 elif i == 2:
303 lastCompPreset = toolkit.presetFromString(line) 287 lastCompPreset = toolkit.presetFromString(line)
304 data[section].append(( 288 data[section].append(
305 lastCompName, 289 (lastCompName, lastCompVers, lastCompPreset)
306 lastCompVers, 290 )
307 lastCompPreset
308 ))
309 i = 0 291 i = 0
310 elif line and section: 292 elif line and section:
311 key, value = line.split('=', 1) 293 key, value = line.split("=", 1)
312 data[section].append((key, value.strip())) 294 data[section].append((key, value.strip()))
313 295
314 return 0, data 296 return 0, data
@@ -319,51 +301,40 @@ class Core:
319 errcode, data = self.parseAvFile(filepath) 301 errcode, data = self.parseAvFile(filepath)
320 returnList = [] 302 returnList = []
321 if errcode == 0: 303 if errcode == 0:
322 name, vers, preset = data['Components'][0] 304 name, vers, preset = data["Components"][0]
323 presetName = preset['preset'] \ 305 presetName = (
324 if preset['preset'] else os.path.basename(filepath)[:-4] 306 preset["preset"]
325 newPath = os.path.join( 307 if preset["preset"]
326 Core.presetDir, 308 else os.path.basename(filepath)[:-4]
327 name,
328 vers,
329 presetName
330 ) 309 )
310 newPath = os.path.join(Core.presetDir, name, vers, presetName)
331 if os.path.exists(newPath): 311 if os.path.exists(newPath):
332 return False, newPath 312 return False, newPath
333 preset['preset'] = presetName 313 preset["preset"] = presetName
334 self.createPresetFile( 314 self.createPresetFile(name, vers, presetName, preset)
335 name, vers, presetName, preset
336 )
337 return True, presetName 315 return True, presetName
338 elif errcode == 1: 316 elif errcode == 1:
339 # TODO: an error message 317 # TODO: an error message
340 return False, '' 318 return False, ""
341 319
342 def exportPreset(self, exportPath, compName, vers, origName): 320 def exportPreset(self, exportPath, compName, vers, origName):
343 internalPath = os.path.join( 321 internalPath = os.path.join(Core.presetDir, compName, str(vers), origName)
344 Core.presetDir, compName, str(vers), origName
345 )
346 if not os.path.exists(internalPath): 322 if not os.path.exists(internalPath):
347 return 323 return
348 if os.path.exists(exportPath): 324 if os.path.exists(exportPath):
349 os.remove(exportPath) 325 os.remove(exportPath)
350 with open(internalPath, 'r') as f: 326 with open(internalPath, "r") as f:
351 internalData = [line for line in f] 327 internalData = [line for line in f]
352 try: 328 try:
353 saveValueStore = toolkit.presetFromString(internalData[0].strip()) 329 saveValueStore = toolkit.presetFromString(internalData[0].strip())
354 self.createPresetFile( 330 self.createPresetFile(compName, vers, origName, saveValueStore, exportPath)
355 compName, vers,
356 origName, saveValueStore,
357 exportPath
358 )
359 return True 331 return True
360 except Exception: 332 except Exception:
361 return False 333 return False
362 334
363 def createPresetFile( 335 def createPresetFile(self, compName, vers, presetName, saveValueStore, filepath=""):
364 self, compName, vers, presetName, saveValueStore, filepath=''): 336 """Create a preset file (.avl) at filepath using args.
365 '''Create a preset file (.avl) at filepath using args. 337 Or if filepath is empty, create an internal preset using args"""
366 Or if filepath is empty, create an internal preset using args'''
367 if not filepath: 338 if not filepath:
368 dirname = os.path.join(Core.presetDir, compName, str(vers)) 339 dirname = os.path.join(Core.presetDir, compName, str(vers))
369 if not os.path.exists(dirname): 340 if not os.path.exists(dirname):
@@ -371,54 +342,55 @@ class Core:
371 filepath = os.path.join(dirname, presetName) 342 filepath = os.path.join(dirname, presetName)
372 internal = True 343 internal = True
373 else: 344 else:
374 if not filepath.endswith('.avl'): 345 if not filepath.endswith(".avl"):
375 filepath += '.avl' 346 filepath += ".avl"
376 internal = False 347 internal = False
377 348
378 with open(filepath, 'w') as f: 349 with open(filepath, "w") as f:
379 if not internal: 350 if not internal:
380 f.write('[Components]\n') 351 f.write("[Components]\n")
381 f.write('%s\n' % compName) 352 f.write("%s\n" % compName)
382 f.write('%s\n' % str(vers)) 353 f.write("%s\n" % str(vers))
383 f.write(toolkit.presetToString(saveValueStore)) 354 f.write(toolkit.presetToString(saveValueStore))
384 355
385 def createProjectFile(self, filepath, window=None): 356 def createProjectFile(self, filepath, window=None):
386 '''Create a project file (.avp) using the current program state''' 357 """Create a project file (.avp) using the current program state"""
387 log.info('Creating %s', filepath) 358 log.info("Creating %s", filepath)
388 settingsKeys = [ 359 settingsKeys = [
389 'componentDir', 360 "componentDir",
390 'inputDir', 361 "inputDir",
391 'outputDir', 362 "outputDir",
392 'presetDir', 363 "presetDir",
393 'projectDir', 364 "projectDir",
394 ] 365 ]
395 try: 366 try:
396 if not filepath.endswith(".avp"): 367 if not filepath.endswith(".avp"):
397 filepath += '.avp' 368 filepath += ".avp"
398 if os.path.exists(filepath): 369 if os.path.exists(filepath):
399 os.remove(filepath) 370 os.remove(filepath)
400 371
401 with open(filepath, 'w') as f: 372 with open(filepath, "w") as f:
402 f.write('[Components]\n') 373 f.write("[Components]\n")
403 for comp in self.selectedComponents: 374 for comp in self.selectedComponents:
404 saveValueStore = comp.savePreset() 375 saveValueStore = comp.savePreset()
405 saveValueStore['preset'] = comp.currentPreset 376 saveValueStore["preset"] = comp.currentPreset
406 f.write('%s\n' % str(comp)) 377 f.write("%s\n" % str(comp))
407 f.write('%s\n' % str(comp.version)) 378 f.write("%s\n" % str(comp.version))
408 f.write('%s\n' % toolkit.presetToString(saveValueStore)) 379 f.write("%s\n" % toolkit.presetToString(saveValueStore))
409 380
410 f.write('\n[Settings]\n') 381 f.write("\n[Settings]\n")
411 for key in Core.settings.allKeys(): 382 for key in Core.settings.allKeys():
412 if key in settingsKeys: 383 if key in settingsKeys:
413 f.write('%s=%s\n' % (key, Core.settings.value(key))) 384 f.write("%s=%s\n" % (key, Core.settings.value(key)))
414 385
415 if window: 386 if window:
416 f.write('\n[WindowFields]\n') 387 f.write("\n[WindowFields]\n")
417 f.write( 388 f.write(
418 'lineEdit_audioFile=%s\n' 389 "lineEdit_audioFile=%s\n"
419 'lineEdit_outputFile=%s\n' % ( 390 "lineEdit_outputFile=%s\n"
391 % (
420 window.lineEdit_audioFile.text(), 392 window.lineEdit_audioFile.text(),
421 window.lineEdit_outputFile.text() 393 window.lineEdit_outputFile.text(),
422 ) 394 )
423 ) 395 )
424 return True 396 return True
@@ -426,8 +398,9 @@ class Core:
426 return False 398 return False
427 399
428 def newVideoWorker(self, loader, audioFile, outputPath): 400 def newVideoWorker(self, loader, audioFile, outputPath):
429 '''loader is MainWindow or Command object which must own the thread''' 401 """loader is MainWindow or Command object which must own the thread"""
430 from . import video_thread 402 from . import video_thread
403
431 self.videoThread = QtCore.QThread(loader) 404 self.videoThread = QtCore.QThread(loader)
432 videoWorker = video_thread.Worker( 405 videoWorker = video_thread.Worker(
433 loader, audioFile, outputPath, self.selectedComponents 406 loader, audioFile, outputPath, self.selectedComponents
@@ -450,18 +423,18 @@ class Core:
450 423
451 @classmethod 424 @classmethod
452 def storeSettings(cls): 425 def storeSettings(cls):
453 '''Store settings/paths to directories as class variables''' 426 """Store settings/paths to directories as class variables"""
454 from .__init__ import wd 427 from .__init__ import wd
455 from .toolkit.ffmpeg import findFfmpeg 428 from .toolkit.ffmpeg import findFfmpeg
456 429
457 cls.wd = wd 430 cls.wd = wd
458 dataDir = QtCore.QStandardPaths.writableLocation( 431 dataDir = QtCore.QStandardPaths.writableLocation(
459 QtCore.QStandardPaths.AppConfigLocation 432 QtCore.QStandardPaths.StandardLocation.AppConfigLocation
460 ) 433 )
461 # Windows: C:/Users/<USER>/AppData/Local/audio-visualizer 434 # Windows: C:/Users/<USER>/AppData/Local/audio-visualizer
462 # macOS: ~/Library/Preferences/audio-visualizer 435 # macOS: ~/Library/Preferences/audio-visualizer
463 # Linux: ~/.config/audio-visualizer 436 # Linux: ~/.config/audio-visualizer
464 with open(os.path.join(wd, 'encoder-options.json')) as json_file: 437 with open(os.path.join(wd, "encoder-options.json")) as json_file:
465 encoderOptions = json.load(json_file) 438 encoderOptions = json.load(json_file)
466 439
467 # Locate FFmpeg 440 # Locate FFmpeg
@@ -470,53 +443,60 @@ class Core:
470 print("Could not find FFmpeg") 443 print("Could not find FFmpeg")
471 444
472 settings = { 445 settings = {
473 'canceled': False, 446 "canceled": False,
474 'FFMPEG_BIN': ffmpegBin, 447 "FFMPEG_BIN": ffmpegBin,
475 'dataDir': dataDir, 448 "dataDir": dataDir,
476 'settings': QtCore.QSettings( 449 "settings": QtCore.QSettings(
477 os.path.join(dataDir, 'settings.ini'), 450 os.path.join(dataDir, "settings.ini"),
478 QtCore.QSettings.IniFormat), 451 QtCore.QSettings.Format.IniFormat,
479 'presetDir': os.path.join(dataDir, 'presets'), 452 ),
480 'componentsPath': os.path.join(wd, 'components'), 453 "presetDir": os.path.join(dataDir, "presets"),
481 'junkStream': os.path.join(wd, 'gui', 'background.png'), 454 "componentsPath": os.path.join(wd, "components"),
482 'encoderOptions': encoderOptions, 455 "junkStream": os.path.join(wd, "gui", "background.png"),
483 'resolutions': [ 456 "encoderOptions": encoderOptions,
484 '1920x1080', 457 "resolutions": [
485 '1280x720', 458 "1920x1080",
486 '854x480', 459 "1280x720",
460 "854x480",
487 ], 461 ],
488 'logDir': os.path.join(dataDir, 'log'), 462 "logDir": os.path.join(dataDir, "log"),
489 'logEnabled': False, 463 "logEnabled": False,
490 'previewEnabled': True, 464 "previewEnabled": True,
491 } 465 }
492 466
493 settings['videoFormats'] = toolkit.appendUppercase([ 467 settings["videoFormats"] = toolkit.appendUppercase(
494 '*.mp4', 468 [
495 '*.mov', 469 "*.mp4",
496 '*.mkv', 470 "*.mov",
497 '*.avi', 471 "*.mkv",
498 '*.webm', 472 "*.avi",
499 '*.flv', 473 "*.webm",
500 ]) 474 "*.flv",
501 settings['audioFormats'] = toolkit.appendUppercase([ 475 ]
502 '*.mp3', 476 )
503 '*.wav', 477 settings["audioFormats"] = toolkit.appendUppercase(
504 '*.ogg', 478 [
505 '*.fla', 479 "*.mp3",
506 '*.flac', 480 "*.wav",
507 '*.aac', 481 "*.ogg",
508 ]) 482 "*.fla",
509 settings['imageFormats'] = toolkit.appendUppercase([ 483 "*.flac",
510 '*.png', 484 "*.aac",
511 '*.jpg', 485 ]
512 '*.tif', 486 )
513 '*.tiff', 487 settings["imageFormats"] = toolkit.appendUppercase(
514 '*.gif', 488 [
515 '*.bmp', 489 "*.png",
516 '*.ico', 490 "*.jpg",
517 '*.xbm', 491 "*.tif",
518 '*.xpm', 492 "*.tiff",
519 ]) 493 "*.gif",
494 "*.bmp",
495 "*.ico",
496 "*.xbm",
497 "*.xpm",
498 ]
499 )
520 500
521 # Register all settings as class variables 501 # Register all settings as class variables
522 for classvar, val in settings.items(): 502 for classvar, val in settings.items():
@@ -526,7 +506,10 @@ class Core:
526 if not os.path.exists(cls.dataDir): 506 if not os.path.exists(cls.dataDir):
527 os.makedirs(cls.dataDir) 507 os.makedirs(cls.dataDir)
528 for neededDirectory in ( 508 for neededDirectory in (
529 cls.presetDir, cls.logDir, cls.settings.value("projectDir")): 509 cls.presetDir,
510 cls.logDir,
511 cls.settings.value("projectDir"),
512 ):
530 if not os.path.exists(neededDirectory): 513 if not os.path.exists(neededDirectory):
531 os.mkdir(neededDirectory) 514 os.mkdir(neededDirectory)
532 cls.makeLogger(deleteOldLogs=True) 515 cls.makeLogger(deleteOldLogs=True)
@@ -546,7 +529,7 @@ class Core:
546 "outputPreset": "medium", 529 "outputPreset": "medium",
547 "outputFormat": "mp4", 530 "outputFormat": "mp4",
548 "outputContainer": "MP4", 531 "outputContainer": "MP4",
549 "projectDir": os.path.join(cls.dataDir, 'projects'), 532 "projectDir": os.path.join(cls.dataDir, "projects"),
550 "pref_insertCompAtTop": True, 533 "pref_insertCompAtTop": True,
551 "pref_genericPreview": True, 534 "pref_genericPreview": True,
552 "pref_undoLimit": 10, 535 "pref_undoLimit": 10,
@@ -559,15 +542,15 @@ class Core:
559 # Allow manual editing of prefs. (Surprisingly necessary as Qt seems to 542 # Allow manual editing of prefs. (Surprisingly necessary as Qt seems to
560 # store True as 'true' but interprets a manually-added 'true' as str.) 543 # store True as 'true' but interprets a manually-added 'true' as str.)
561 for key in cls.settings.allKeys(): 544 for key in cls.settings.allKeys():
562 if not key.startswith('pref_'): 545 if not key.startswith("pref_"):
563 continue 546 continue
564 val = cls.settings.value(key) 547 val = cls.settings.value(key)
565 try: 548 try:
566 val = int(val) 549 val = int(val)
567 except ValueError: 550 except ValueError:
568 if val == 'true': 551 if val == "true":
569 val = True 552 val = True
570 elif val == 'false': 553 elif val == "false":
571 val = False 554 val = False
572 cls.settings.setValue(key, val) 555 cls.settings.setValue(key, val)
573 556
@@ -576,18 +559,16 @@ class Core:
576 # send critical log messages to stdout 559 # send critical log messages to stdout
577 logStream = logging.StreamHandler() 560 logStream = logging.StreamHandler()
578 logStream.setLevel(STDOUT_LOGLVL) 561 logStream.setLevel(STDOUT_LOGLVL)
579 streamFormatter = logging.Formatter( 562 streamFormatter = logging.Formatter("<%(name)s> %(levelname)s: %(message)s")
580 '<%(name)s> %(levelname)s: %(message)s'
581 )
582 logStream.setFormatter(streamFormatter) 563 logStream.setFormatter(streamFormatter)
583 log = logging.getLogger('AVP') 564 log = logging.getLogger("AVP")
584 log.addHandler(logStream) 565 log.addHandler(logStream)
585 566
586 if FILE_LOGLVL is not None: 567 if FILE_LOGLVL is not None:
587 # write log files as well! 568 # write log files as well!
588 Core.logEnabled = True 569 Core.logEnabled = True
589 logFilename = os.path.join(Core.logDir, 'avp_debug.log') 570 logFilename = os.path.join(Core.logDir, "avp_debug.log")
590 libLogFilename = os.path.join(Core.logDir, 'global_debug.log') 571 libLogFilename = os.path.join(Core.logDir, "global_debug.log")
591 572
592 if deleteOldLogs: 573 if deleteOldLogs:
593 for log_ in (logFilename, libLogFilename): 574 for log_ in (logFilename, libLogFilename):
@@ -599,8 +580,8 @@ class Core:
599 libLogFile = logging.FileHandler(libLogFilename, delay=True) 580 libLogFile = logging.FileHandler(libLogFilename, delay=True)
600 libLogFile.setLevel(FILE_LIBLOGLVL) 581 libLogFile.setLevel(FILE_LIBLOGLVL)
601 fileFormatter = logging.Formatter( 582 fileFormatter = logging.Formatter(
602 '[%(asctime)s] %(threadName)-10.10s %(name)-23.23s %(levelname)s: ' 583 "[%(asctime)s] %(threadName)-10.10s %(name)-23.23s %(levelname)s: "
603 '%(message)s' 584 "%(message)s"
604 ) 585 )
605 logFile.setFormatter(fileFormatter) 586 logFile.setFormatter(fileFormatter)
606 libLogFile.setFormatter(fileFormatter) 587 libLogFile.setFormatter(fileFormatter)
@@ -611,5 +592,6 @@ class Core:
611 # lowest level must be explicitly set on the root Logger 592 # lowest level must be explicitly set on the root Logger
612 libLog.setLevel(0) 593 libLog.setLevel(0)
613 594
595
614# always store settings in class variables even if a Core object is not created 596# always store settings in class variables even if a Core object is not created
615Core.storeSettings() 597Core.storeSettings()
diff --git a/src/gui/actions.py b/src/gui/actions.py
index afb980a..654b2a0 100644
--- a/src/gui/actions.py
+++ b/src/gui/actions.py
@@ -1,53 +1,61 @@
1''' 1"""
2 QCommand classes for every undoable user action performed in the MainWindow 2QCommand classes for every undoable user action performed in the MainWindow
3''' 3"""
4from PyQt5.QtWidgets import QUndoCommand 4
5from PyQt6.QtGui import QUndoCommand
5import os 6import os
7import logging
6from copy import copy 8from copy import copy
7 9
8from ..core import Core 10from ..core import Core
9 11
10 12
13log = logging.getLogger("AVP.Gui.Actions")
14
15
11# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 16# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
12# COMPONENT ACTIONS 17# COMPONENT ACTIONS
13# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 18# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
14 19
20
15class AddComponent(QUndoCommand): 21class AddComponent(QUndoCommand):
16 def __init__(self, parent, compI, moduleI): 22 def __init__(self, parent, compI, moduleI):
17 super().__init__( 23 super().__init__(
18 "create new %s component" % 24 "create new %s component" % parent.core.modules[moduleI].Component.name
19 parent.core.modules[moduleI].Component.name
20 ) 25 )
21 self.parent = parent 26 self.parent = parent
22 self.moduleI = moduleI 27 self.moduleI = moduleI
23 self.compI = compI 28 self.compI = compI
24 self.comp = None 29 self.comp = None
30 self.valid = True
25 31
26 def redo(self): 32 def redo(self):
27 if self.comp is None: 33 if self.comp is None:
28 self.parent.core.insertComponent( 34 i = self.parent.core.insertComponent(self.compI, self.moduleI, self.parent)
29 self.compI, self.moduleI, self.parent) 35 if i != self.compI:
36 self.valid = False
37 if i is not None:
38 log.error(
39 f"Expected new component index to be {self.compI} but received {i}"
40 )
30 else: 41 else:
31 # inserting previously-created component 42 # inserting previously-created component
32 self.parent.core.insertComponent( 43 self.parent.core.insertComponent(self.compI, self.comp, self.parent)
33 self.compI, self.comp, self.parent)
34 44
35 def undo(self): 45 def undo(self):
46 if not self.valid:
47 return
36 self.comp = self.parent.core.selectedComponents[self.compI] 48 self.comp = self.parent.core.selectedComponents[self.compI]
37 self.parent._removeComponent(self.compI) 49 self.parent._removeComponent(self.compI)
38 50
39 51
40class RemoveComponent(QUndoCommand): 52class RemoveComponent(QUndoCommand):
41 def __init__(self, parent, selectedRows): 53 def __init__(self, parent, selectedRows):
42 super().__init__('remove component') 54 super().__init__("remove component")
43 self.parent = parent 55 self.parent = parent
44 componentList = self.parent.listWidget_componentList 56 componentList = self.parent.listWidget_componentList
45 self.selectedRows = [ 57 self.selectedRows = [componentList.row(selected) for selected in selectedRows]
46 componentList.row(selected) for selected in selectedRows 58 self.components = [parent.core.selectedComponents[i] for i in self.selectedRows]
47 ]
48 self.components = [
49 parent.core.selectedComponents[i] for i in self.selectedRows
50 ]
51 59
52 def redo(self): 60 def redo(self):
53 self.parent._removeComponent(self.selectedRows[0]) 61 self.parent._removeComponent(self.selectedRows[0])
@@ -55,9 +63,7 @@ class RemoveComponent(QUndoCommand):
55 def undo(self): 63 def undo(self):
56 componentList = self.parent.listWidget_componentList 64 componentList = self.parent.listWidget_componentList
57 for index, comp in zip(self.selectedRows, self.components): 65 for index, comp in zip(self.selectedRows, self.components):
58 self.parent.core.insertComponent( 66 self.parent.core.insertComponent(index, comp, self.parent)
59 index, comp, self.parent
60 )
61 self.parent.drawPreview() 67 self.parent.drawPreview()
62 68
63 69
@@ -70,7 +76,7 @@ class MoveComponent(QUndoCommand):
70 self.id_ = ord(tag[0]) 76 self.id_ = ord(tag[0])
71 77
72 def id(self): 78 def id(self):
73 '''If 2 consecutive updates have same id, Qt will call mergeWith()''' 79 """If 2 consecutive updates have same id, Qt will call mergeWith()"""
74 return self.id_ 80 return self.id_
75 81
76 def mergeWith(self, other): 82 def mergeWith(self, other):
@@ -105,6 +111,7 @@ class MoveComponent(QUndoCommand):
105# PRESET ACTIONS 111# PRESET ACTIONS
106# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 112# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
107 113
114
108class ClearPreset(QUndoCommand): 115class ClearPreset(QUndoCommand):
109 def __init__(self, parent, compI): 116 def __init__(self, parent, compI):
110 super().__init__("clear preset") 117 super().__init__("clear preset")
@@ -112,7 +119,7 @@ class ClearPreset(QUndoCommand):
112 self.compI = compI 119 self.compI = compI
113 self.component = self.parent.core.selectedComponents[compI] 120 self.component = self.parent.core.selectedComponents[compI]
114 self.store = self.component.savePreset() 121 self.store = self.component.savePreset()
115 self.store['preset'] = self.component.currentPreset 122 self.store["preset"] = self.component.currentPreset
116 123
117 def redo(self): 124 def redo(self):
118 self.parent.core.clearPreset(self.compI) 125 self.parent.core.clearPreset(self.compI)
@@ -132,20 +139,19 @@ class OpenPreset(QUndoCommand):
132 139
133 comp = self.parent.core.selectedComponents[compI] 140 comp = self.parent.core.selectedComponents[compI]
134 self.store = comp.savePreset() 141 self.store = comp.savePreset()
135 self.store['preset'] = copy(comp.currentPreset) 142 self.store["preset"] = copy(comp.currentPreset)
136 143
137 def redo(self): 144 def redo(self):
138 self.parent._openPreset(self.presetName, self.compI) 145 self.parent._openPreset(self.presetName, self.compI)
139 146
140 def undo(self): 147 def undo(self):
141 self.parent.core.selectedComponents[self.compI].loadPreset( 148 self.parent.core.selectedComponents[self.compI].loadPreset(self.store)
142 self.store)
143 self.parent.parent.updateComponentTitle(self.compI, self.store) 149 self.parent.parent.updateComponentTitle(self.compI, self.store)
144 150
145 151
146class RenamePreset(QUndoCommand): 152class RenamePreset(QUndoCommand):
147 def __init__(self, parent, path, oldName, newName): 153 def __init__(self, parent, path, oldName, newName):
148 super().__init__('rename preset') 154 super().__init__("rename preset")
149 self.parent = parent 155 self.parent = parent
150 self.path = path 156 self.path = path
151 self.oldName = oldName 157 self.oldName = oldName
@@ -162,14 +168,13 @@ class DeletePreset(QUndoCommand):
162 def __init__(self, parent, compName, vers, presetFile): 168 def __init__(self, parent, compName, vers, presetFile):
163 self.parent = parent 169 self.parent = parent
164 self.preset = (compName, vers, presetFile) 170 self.preset = (compName, vers, presetFile)
165 self.path = os.path.join( 171 self.path = os.path.join(Core.presetDir, compName, str(vers), presetFile)
166 Core.presetDir, compName, str(vers), presetFile
167 )
168 self.store = self.parent.core.getPreset(self.path) 172 self.store = self.parent.core.getPreset(self.path)
169 self.presetName = self.store['preset'] 173 self.presetName = self.store["preset"]
170 super().__init__('delete %s preset (%s)' % (self.presetName, compName)) 174 super().__init__("delete %s preset (%s)" % (self.presetName, compName))
171 self.loadedPresets = [ 175 self.loadedPresets = [
172 i for i, comp in enumerate(self.parent.core.selectedComponents) 176 i
177 for i, comp in enumerate(self.parent.core.selectedComponents)
173 if self.presetName == str(comp.currentPreset) 178 if self.presetName == str(comp.currentPreset)
174 ] 179 ]
175 180
diff --git a/src/gui/mainwindow.py b/src/gui/mainwindow.py
index 159dc02..b0a564b 100644
--- a/src/gui/mainwindow.py
+++ b/src/gui/mainwindow.py
@@ -1,11 +1,13 @@
1''' 1"""
2 When using GUI mode, this module's object (the main window) takes 2When using GUI mode, this module's object (the main window) takes
3 user input to construct a program state (stored in the Core object). 3user input to construct a program state (stored in the Core object).
4 This shows a preview of the video being created and allows for saving 4This shows a preview of the video being created and allows for saving
5 projects and exporting the video at a later time. 5projects and exporting the video at a later time.
6''' 6"""
7from PyQt5 import QtCore, QtWidgets, uic 7
8import PyQt5.QtWidgets as QtWidgets 8from PyQt6 import QtCore, QtWidgets, uic
9import PyQt6.QtWidgets as QtWidgets
10from PyQt6.QtGui import QUndoStack, QShortcut
9from PIL import Image 11from PIL import Image
10from queue import Queue 12from queue import Queue
11import sys 13import sys
@@ -21,46 +23,59 @@ from .preview_win import PreviewWindow
21from .presetmanager import PresetManager 23from .presetmanager import PresetManager
22from .actions import * 24from .actions import *
23from ..toolkit import ( 25from ..toolkit import (
24 disableWhenEncoding, disableWhenOpeningProject, checkOutput, blockSignals 26 disableWhenEncoding,
27 disableWhenOpeningProject,
28 checkOutput,
29 blockSignals,
25) 30)
26 31
27 32
28appName = 'Audio Visualizer' 33appName = "Audio Visualizer"
29log = logging.getLogger('AVP.Gui.MainWindow') 34log = logging.getLogger("AVP.Gui.MainWindow")
35
36
37class MyQUndoStack(QUndoStack):
38 # FIXME move this class
39 @property
40 def encoding(self):
41 return self.parent().encoding
42
43 @disableWhenEncoding
44 def undo(self, *args, **kwargs):
45 super().undo(*args, **kwargs)
46
47 @disableWhenEncoding
48 def redo(self, *args, **kwargs):
49 super().redo(*args, **kwargs)
30 50
31 51
32class MainWindow(QtWidgets.QMainWindow): 52class MainWindow(QtWidgets.QMainWindow):
33 ''' 53 """
34 The MainWindow wraps many Core methods in order to update the GUI 54 The MainWindow wraps many Core methods in order to update the GUI
35 accordingly. E.g., instead of self.core.openProject(), it will use 55 accordingly. E.g., instead of self.core.openProject(), it will use
36 self.openProject() and update the window titlebar within the wrapper. 56 self.openProject() and update the window titlebar within the wrapper.
37 57
38 MainWindow manages the autosave feature, although Core has the 58 MainWindow manages the autosave feature, although Core has the
39 primary functions for opening and creating project files. 59 primary functions for opening and creating project files.
40 ''' 60 """
41 61
42 createVideo = QtCore.pyqtSignal() 62 createVideo = QtCore.pyqtSignal()
43 newTask = QtCore.pyqtSignal(list) # for the preview window 63 newTask = QtCore.pyqtSignal(list) # for the preview window
44 processTask = QtCore.pyqtSignal() 64 processTask = QtCore.pyqtSignal()
45 65
46 def __init__(self, project): 66 def __init__(self, project, dpi):
47 super().__init__() 67 super().__init__()
48 log.debug( 68 log.debug("Main thread id: {}".format(int(QtCore.QThread.currentThreadId())))
49 'Main thread id: {}'.format(int(QtCore.QThread.currentThreadId())))
50 uic.loadUi(os.path.join(Core.wd, "gui", "mainwindow.ui"), self) 69 uic.loadUi(os.path.join(Core.wd, "gui", "mainwindow.ui"), self)
51 desk = QtWidgets.QDesktopWidget() 70
52 dpi = desk.physicalDpiX() 71 if dpi:
53 log.info("Detected screen DPI: %s", dpi) 72 self.resize(
54 73 int(self.width() * (dpi / 144)),
55 self.resize( 74 int(self.height() * (dpi / 144)),
56 int(self.width() * 75 )
57 (dpi / 144)),
58 int(self.height() *
59 (dpi / 144))
60 )
61 76
62 self.core = Core() 77 self.core = Core()
63 Core.mode = 'GUI' 78 Core.mode = "GUI"
64 # widgets of component settings 79 # widgets of component settings
65 self.pages = [] 80 self.pages = []
66 self.lastAutosave = time.time() 81 self.lastAutosave = time.time()
@@ -72,15 +87,13 @@ class MainWindow(QtWidgets.QMainWindow):
72 # Find settings created by Core object 87 # Find settings created by Core object
73 self.dataDir = Core.dataDir 88 self.dataDir = Core.dataDir
74 self.presetDir = Core.presetDir 89 self.presetDir = Core.presetDir
75 self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') 90 self.autosavePath = os.path.join(self.dataDir, "autosave.avp")
76 self.settings = Core.settings 91 self.settings = Core.settings
77 92
78 # Create stack of undoable user actions 93 # Create stack of undoable user actions
79 self.undoStack = QtWidgets.QUndoStack(self) 94 self.undoStack = MyQUndoStack(self)
80 undoLimit = self.settings.value("pref_undoLimit") 95 undoLimit = self.settings.value("pref_undoLimit")
81 self.undoStack.setUndoLimit(undoLimit) 96 self.undoStack.setUndoLimit(undoLimit)
82 self.undoStack.undo = disableWhenEncoding(self.undoStack.undo)
83 self.undoStack.redo = disableWhenEncoding(self.undoStack.redo)
84 97
85 # Create Undo Dialog - A standard QUndoView on a standard QDialog 98 # Create Undo Dialog - A standard QUndoView on a standard QDialog
86 self.undoDialog = QtWidgets.QDialog(self) 99 self.undoDialog = QtWidgets.QDialog(self)
@@ -94,18 +107,17 @@ class MainWindow(QtWidgets.QMainWindow):
94 self.presetManager = PresetManager(self) 107 self.presetManager = PresetManager(self)
95 108
96 # Create the preview window and its thread, queues, and timers 109 # Create the preview window and its thread, queues, and timers
97 log.debug('Creating preview window') 110 log.debug("Creating preview window")
98 self.previewWindow = PreviewWindow(self, os.path.join( 111 self.previewWindow = PreviewWindow(
99 Core.wd, 'gui', "background.png")) 112 self, os.path.join(Core.wd, "gui", "background.png")
113 )
100 self.verticalLayout_previewWrapper.addWidget(self.previewWindow) 114 self.verticalLayout_previewWrapper.addWidget(self.previewWindow)
101 115
102 log.debug('Starting preview thread') 116 log.debug("Starting preview thread")
103 self.previewQueue = Queue() 117 self.previewQueue = Queue()
104 self.previewThread = QtCore.QThread(self) 118 self.previewThread = QtCore.QThread(self)
105 self.previewWorker = preview_thread.Worker( 119 self.previewWorker = preview_thread.Worker(
106 self.core, 120 self.core, self.settings, self.previewQueue
107 self.settings,
108 self.previewQueue
109 ) 121 )
110 self.previewWorker.moveToThread(self.previewThread) 122 self.previewWorker.moveToThread(self.previewThread)
111 self.newTask.connect(self.previewWorker.createPreviewImage) 123 self.newTask.connect(self.previewWorker.createPreviewImage)
@@ -113,12 +125,12 @@ class MainWindow(QtWidgets.QMainWindow):
113 self.previewWorker.error.connect(self.previewWindow.threadError) 125 self.previewWorker.error.connect(self.previewWindow.threadError)
114 self.previewWorker.imageCreated.connect(self.showPreviewImage) 126 self.previewWorker.imageCreated.connect(self.showPreviewImage)
115 self.previewThread.start() 127 self.previewThread.start()
116 self.previewThread.finished.connect(lambda: log.info('Preview thread finished.')) 128 self.previewThread.finished.connect(
129 lambda: log.info("Preview thread finished.")
130 )
117 131
118 timeout = 500 132 timeout = 500
119 log.debug( 133 log.debug("Preview timer set to trigger when idle for %sms" % str(timeout))
120 'Preview timer set to trigger when idle for %sms' % str(timeout)
121 )
122 self.timer = QtCore.QTimer(self) 134 self.timer = QtCore.QTimer(self)
123 self.timer.timeout.connect(self.processTask.emit) 135 self.timer.timeout.connect(self.processTask.emit)
124 self.timer.start(timeout) 136 self.timer.start(timeout)
@@ -128,7 +140,7 @@ class MainWindow(QtWidgets.QMainWindow):
128 140
129 # Undo Feature 141 # Undo Feature
130 def toggleUndoButtonEnabled(*_): 142 def toggleUndoButtonEnabled(*_):
131 """ Enable/disable undo button depending on whether UndoStack contains Actions """ 143 """Enable/disable undo button depending on whether UndoStack contains Actions"""
132 try: 144 try:
133 undoButton.setEnabled(self.undoStack.count()) 145 undoButton.setEnabled(self.undoStack.count())
134 except RuntimeError: 146 except RuntimeError:
@@ -138,50 +150,41 @@ class MainWindow(QtWidgets.QMainWindow):
138 style = self.pushButton_undo.style() 150 style = self.pushButton_undo.style()
139 undoButton = self.pushButton_undo 151 undoButton = self.pushButton_undo
140 undoButton.setIcon( 152 undoButton.setIcon(
141 style.standardIcon(QtWidgets.QStyle.SP_FileDialogBack) 153 style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_FileDialogBack)
142 ) 154 )
143 undoButton.clicked.connect(self.undoStack.undo) 155 undoButton.clicked.connect(self.undoStack.undo)
144 undoButton.setEnabled(False) 156 undoButton.setEnabled(False)
145 self.undoStack.cleanChanged.connect(toggleUndoButtonEnabled) 157 self.undoStack.cleanChanged.connect(toggleUndoButtonEnabled)
146 self.undoMenu = QtWidgets.QMenu() 158 self.undoMenu = QtWidgets.QMenu()
147 self.undoMenu.addAction( 159 self.undoMenu.addAction(self.undoStack.createUndoAction(self))
148 self.undoStack.createUndoAction(self) 160 self.undoMenu.addAction(self.undoStack.createRedoAction(self))
149 ) 161 action = self.undoMenu.addAction("Show History...")
150 self.undoMenu.addAction( 162 action.triggered.connect(lambda _: self.showUndoStack())
151 self.undoStack.createRedoAction(self)
152 )
153 action = self.undoMenu.addAction('Show History...')
154 action.triggered.connect(
155 lambda _: self.showUndoStack()
156 )
157 undoButton.setMenu(self.undoMenu) 163 undoButton.setMenu(self.undoMenu)
158 # end of Undo Feature 164 # end of Undo Feature
159 165
160 style = self.pushButton_listMoveUp.style() 166 style = self.pushButton_listMoveUp.style()
161 self.pushButton_listMoveUp.setIcon( 167 self.pushButton_listMoveUp.setIcon(
162 style.standardIcon(QtWidgets.QStyle.SP_ArrowUp) 168 style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowUp)
163 ) 169 )
164 style = self.pushButton_listMoveDown.style() 170 style = self.pushButton_listMoveDown.style()
165 self.pushButton_listMoveDown.setIcon( 171 self.pushButton_listMoveDown.setIcon(
166 style.standardIcon(QtWidgets.QStyle.SP_ArrowDown) 172 style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowDown)
167 ) 173 )
168 style = self.pushButton_removeComponent.style() 174 style = self.pushButton_removeComponent.style()
169 self.pushButton_removeComponent.setIcon( 175 self.pushButton_removeComponent.setIcon(
170 style.standardIcon(QtWidgets.QStyle.SP_DialogDiscardButton) 176 style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogDiscardButton)
171 ) 177 )
172 178
173 if sys.platform == 'darwin': 179 if sys.platform == "darwin":
174 log.debug( 180 log.debug("Darwin detected: showing progress label below progress bar")
175 'Darwin detected: showing progress label below progress bar')
176 self.progressBar_createVideo.setTextVisible(False) 181 self.progressBar_createVideo.setTextVisible(False)
177 else: 182 else:
178 self.progressLabel.setHidden(True) 183 self.progressLabel.setHidden(True)
179 184
180 self.toolButton_selectAudioFile.clicked.connect( 185 self.toolButton_selectAudioFile.clicked.connect(self.openInputFileDialog)
181 self.openInputFileDialog)
182 186
183 self.toolButton_selectOutputFile.clicked.connect( 187 self.toolButton_selectOutputFile.clicked.connect(self.openOutputFileDialog)
184 self.openOutputFileDialog)
185 188
186 def changedField(): 189 def changedField():
187 self.autosave() 190 self.autosave()
@@ -192,43 +195,36 @@ class MainWindow(QtWidgets.QMainWindow):
192 195
193 self.progressBar_createVideo.setValue(0) 196 self.progressBar_createVideo.setValue(0)
194 197
195 self.pushButton_createVideo.clicked.connect( 198 self.pushButton_createVideo.clicked.connect(self.createAudioVisualization)
196 self.createAudioVisualization)
197 199
198 self.pushButton_Cancel.clicked.connect(self.stopVideo) 200 self.pushButton_Cancel.clicked.connect(self.stopVideo)
199 201
200 for i, container in enumerate(Core.encoderOptions['containers']): 202 for i, container in enumerate(Core.encoderOptions["containers"]):
201 self.comboBox_videoContainer.addItem(container['name']) 203 self.comboBox_videoContainer.addItem(container["name"])
202 if container['name'] == self.settings.value('outputContainer'): 204 if container["name"] == self.settings.value("outputContainer"):
203 selectedContainer = i 205 selectedContainer = i
204 206
205 self.comboBox_videoContainer.setCurrentIndex(selectedContainer) 207 self.comboBox_videoContainer.setCurrentIndex(selectedContainer)
206 self.comboBox_videoContainer.currentIndexChanged.connect( 208 self.comboBox_videoContainer.currentIndexChanged.connect(self.updateCodecs)
207 self.updateCodecs
208 )
209 209
210 self.updateCodecs() 210 self.updateCodecs()
211 211
212 for i in range(self.comboBox_videoCodec.count()): 212 for i in range(self.comboBox_videoCodec.count()):
213 codec = self.comboBox_videoCodec.itemText(i) 213 codec = self.comboBox_videoCodec.itemText(i)
214 if codec == self.settings.value('outputVideoCodec'): 214 if codec == self.settings.value("outputVideoCodec"):
215 self.comboBox_videoCodec.setCurrentIndex(i) 215 self.comboBox_videoCodec.setCurrentIndex(i)
216 216
217 for i in range(self.comboBox_audioCodec.count()): 217 for i in range(self.comboBox_audioCodec.count()):
218 codec = self.comboBox_audioCodec.itemText(i) 218 codec = self.comboBox_audioCodec.itemText(i)
219 if codec == self.settings.value('outputAudioCodec'): 219 if codec == self.settings.value("outputAudioCodec"):
220 self.comboBox_audioCodec.setCurrentIndex(i) 220 self.comboBox_audioCodec.setCurrentIndex(i)
221 221
222 self.comboBox_videoCodec.currentIndexChanged.connect( 222 self.comboBox_videoCodec.currentIndexChanged.connect(self.updateCodecSettings)
223 self.updateCodecSettings
224 )
225 223
226 self.comboBox_audioCodec.currentIndexChanged.connect( 224 self.comboBox_audioCodec.currentIndexChanged.connect(self.updateCodecSettings)
227 self.updateCodecSettings
228 )
229 225
230 vBitrate = int(self.settings.value('outputVideoBitrate')) 226 vBitrate = int(self.settings.value("outputVideoBitrate"))
231 aBitrate = int(self.settings.value('outputAudioBitrate')) 227 aBitrate = int(self.settings.value("outputAudioBitrate"))
232 228
233 self.spinBox_vBitrate.setValue(vBitrate) 229 self.spinBox_vBitrate.setValue(vBitrate)
234 self.spinBox_aBitrate.setValue(aBitrate) 230 self.spinBox_aBitrate.setValue(aBitrate)
@@ -239,30 +235,27 @@ class MainWindow(QtWidgets.QMainWindow):
239 self.compMenu = QtWidgets.QMenu() 235 self.compMenu = QtWidgets.QMenu()
240 for i, comp in enumerate(self.core.modules): 236 for i, comp in enumerate(self.core.modules):
241 action = self.compMenu.addAction(comp.Component.name) 237 action = self.compMenu.addAction(comp.Component.name)
242 action.triggered.connect( 238 action.triggered.connect(lambda _, item=i: self.addComponent(0, item))
243 lambda _, item=i: self.addComponent(0, item)
244 )
245 239
246 self.pushButton_addComponent.setMenu(self.compMenu) 240 self.pushButton_addComponent.setMenu(self.compMenu)
247 241
248 componentList.dropEvent = self.dragComponent 242 componentList.dropEvent = self.dragComponent
249 componentList.itemSelectionChanged.connect( 243 componentList.itemSelectionChanged.connect(self.changeComponentWidget)
250 self.changeComponentWidget
251 )
252 componentList.itemSelectionChanged.connect( 244 componentList.itemSelectionChanged.connect(
253 self.presetManager.clearPresetListSelection 245 self.presetManager.clearPresetListSelection
254 ) 246 )
255 self.pushButton_removeComponent.clicked.connect( 247 self.pushButton_removeComponent.clicked.connect(lambda: self.removeComponent())
256 lambda: self.removeComponent()
257 )
258 248
259 componentList.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) 249 componentList.setContextMenuPolicy(
260 componentList.customContextMenuRequested.connect( 250 QtCore.Qt.ContextMenuPolicy.CustomContextMenu
261 self.componentContextMenu
262 ) 251 )
252 componentList.customContextMenuRequested.connect(self.componentContextMenu)
263 253
264 currentRes = str(self.settings.value('outputWidth'))+'x' + \ 254 currentRes = (
265 str(self.settings.value('outputHeight')) 255 str(self.settings.value("outputWidth"))
256 + "x"
257 + str(self.settings.value("outputHeight"))
258 )
266 for i, res in enumerate(Core.resolutions): 259 for i, res in enumerate(Core.resolutions):
267 self.comboBox_resolution.addItem(res) 260 self.comboBox_resolution.addItem(res)
268 if res == currentRes: 261 if res == currentRes:
@@ -272,24 +265,14 @@ class MainWindow(QtWidgets.QMainWindow):
272 self.updateResolution 265 self.updateResolution
273 ) 266 )
274 267
275 self.pushButton_listMoveUp.clicked.connect( 268 self.pushButton_listMoveUp.clicked.connect(lambda: self.moveComponent(-1))
276 lambda: self.moveComponent(-1) 269 self.pushButton_listMoveDown.clicked.connect(lambda: self.moveComponent(1))
277 )
278 self.pushButton_listMoveDown.clicked.connect(
279 lambda: self.moveComponent(1)
280 )
281 270
282 # Configure the Projects Menu 271 # Configure the Projects Menu
283 self.projectMenu = QtWidgets.QMenu() 272 self.projectMenu = QtWidgets.QMenu()
284 self.menuButton_newProject = self.projectMenu.addAction( 273 self.menuButton_newProject = self.projectMenu.addAction("New Project")
285 "New Project" 274 self.menuButton_newProject.triggered.connect(lambda: self.createNewProject())
286 ) 275 self.menuButton_openProject = self.projectMenu.addAction("Open Project")
287 self.menuButton_newProject.triggered.connect(
288 lambda: self.createNewProject()
289 )
290 self.menuButton_openProject = self.projectMenu.addAction(
291 "Open Project"
292 )
293 self.menuButton_openProject.triggered.connect( 276 self.menuButton_openProject.triggered.connect(
294 lambda: self.openOpenProjectDialog() 277 lambda: self.openOpenProjectDialog()
295 ) 278 )
@@ -303,22 +286,18 @@ class MainWindow(QtWidgets.QMainWindow):
303 self.pushButton_projects.setMenu(self.projectMenu) 286 self.pushButton_projects.setMenu(self.projectMenu)
304 287
305 # Configure the Presets Button 288 # Configure the Presets Button
306 self.pushButton_presets.clicked.connect( 289 self.pushButton_presets.clicked.connect(self.openPresetManager)
307 self.openPresetManager
308 )
309 290
310 self.updateWindowTitle() 291 self.updateWindowTitle()
311 log.debug('Showing main window') 292 log.debug("Showing main window")
312 self.show() 293 self.show()
313 294
314 if project and project != self.autosavePath: 295 if project and project != self.autosavePath:
315 if not project.endswith('.avp'): 296 if not project.endswith(".avp"):
316 project += '.avp' 297 project += ".avp"
317 # open a project from the commandline 298 # open a project from the commandline
318 if not os.path.dirname(project): 299 if not os.path.dirname(project):
319 project = os.path.join( 300 project = os.path.join(self.settings.value("projectDir"), project)
320 self.settings.value("projectDir"), project
321 )
322 self.currentProject = project 301 self.currentProject = project
323 self.settings.setValue("currentProject", project) 302 self.settings.setValue("currentProject", project)
324 if os.path.exists(self.autosavePath): 303 if os.path.exists(self.autosavePath):
@@ -335,7 +314,8 @@ class MainWindow(QtWidgets.QMainWindow):
335 ch = self.showMessage( 314 ch = self.showMessage(
336 msg="Restore unsaved changes in project '%s'?" 315 msg="Restore unsaved changes in project '%s'?"
337 % os.path.basename(self.currentProject)[:-4], 316 % os.path.basename(self.currentProject)[:-4],
338 showCancel=True) 317 showCancel=True,
318 )
339 if ch: 319 if ch:
340 self.saveProjectChanges() 320 self.saveProjectChanges()
341 else: 321 else:
@@ -352,16 +332,16 @@ class MainWindow(QtWidgets.QMainWindow):
352 msg="FFmpeg could not be found. This is a critical error. " 332 msg="FFmpeg could not be found. This is a critical error. "
353 "Install FFmpeg, or download it and place the program executable " 333 "Install FFmpeg, or download it and place the program executable "
354 "in the same folder as this program.", 334 "in the same folder as this program.",
355 icon='Critical' 335 icon="Critical",
356 ) 336 )
357 else: 337 else:
358 if not self.settings.value("ffmpegMsgShown"): 338 if not self.settings.value("ffmpegMsgShown"):
359 try: 339 try:
360 with open(os.devnull, "w") as f: 340 with open(os.devnull, "w") as f:
361 ffmpegVers = checkOutput( 341 ffmpegVers = checkOutput(
362 [self.core.FFMPEG_BIN, '-version'], stderr=f 342 [self.core.FFMPEG_BIN, "-version"], stderr=f
363 ) 343 )
364 goodVersion = str(ffmpegVers).split()[2].startswith('4') 344 goodVersion = str(ffmpegVers).split()[2].startswith("4")
365 except Exception: 345 except Exception:
366 goodVersion = False 346 goodVersion = False
367 else: 347 else:
@@ -375,70 +355,61 @@ class MainWindow(QtWidgets.QMainWindow):
375 self.settings.setValue("ffmpegMsgShown", True) 355 self.settings.setValue("ffmpegMsgShown", True)
376 356
377 # Hotkeys for projects 357 # Hotkeys for projects
378 QtWidgets.QShortcut("Ctrl+S", self, self.saveCurrentProject) 358
379 QtWidgets.QShortcut("Ctrl+A", self, self.openSaveProjectDialog) 359 QShortcut("Ctrl+S", self, self.saveCurrentProject)
380 QtWidgets.QShortcut("Ctrl+O", self, self.openOpenProjectDialog) 360 QShortcut("Ctrl+A", self, self.openSaveProjectDialog)
381 QtWidgets.QShortcut("Ctrl+N", self, self.createNewProject) 361 QShortcut("Ctrl+O", self, self.openOpenProjectDialog)
362 QShortcut("Ctrl+N", self, self.createNewProject)
382 363
383 # Hotkeys for undo/redo 364 # Hotkeys for undo/redo
384 QtWidgets.QShortcut("Ctrl+Z", self, self.undoStack.undo) 365 QShortcut("Ctrl+Z", self, self.undoStack.undo)
385 QtWidgets.QShortcut("Ctrl+Y", self, self.undoStack.redo) 366 QShortcut("Ctrl+Y", self, self.undoStack.redo)
386 QtWidgets.QShortcut("Ctrl+Shift+Z", self, self.undoStack.redo) 367 QShortcut("Ctrl+Shift+Z", self, self.undoStack.redo)
387 368
388 # Hotkeys for component list 369 # Hotkeys for component list
389 for inskey in ("Ctrl+T", QtCore.Qt.Key_Insert): 370 for inskey in ("Ctrl+T", QtCore.Qt.Key.Key_Insert):
390 QtWidgets.QShortcut( 371 QShortcut(
391 inskey, self, 372 inskey,
392 activated=lambda: self.pushButton_addComponent.click() 373 self,
393 ) 374 activated=lambda: self.pushButton_addComponent.click(),
394 for delkey in ("Ctrl+R", QtCore.Qt.Key_Delete):
395 QtWidgets.QShortcut(
396 delkey, self.listWidget_componentList,
397 self.removeComponent
398 ) 375 )
399 QtWidgets.QShortcut( 376 for delkey in ("Ctrl+R", QtCore.Qt.Key.Key_Delete):
400 "Ctrl+Space", self, 377 QShortcut(delkey, self.listWidget_componentList, self.removeComponent)
401 activated=lambda: self.listWidget_componentList.setFocus() 378 QShortcut(
402 ) 379 "Ctrl+Space",
403 QtWidgets.QShortcut( 380 self,
404 "Ctrl+Shift+S", self, 381 activated=lambda: self.listWidget_componentList.setFocus(),
405 self.presetManager.openSavePresetDialog
406 )
407 QtWidgets.QShortcut(
408 "Ctrl+Shift+C", self, self.presetManager.clearPreset
409 ) 382 )
383 QShortcut("Ctrl+Shift+S", self, self.presetManager.openSavePresetDialog)
384 QShortcut("Ctrl+Shift+C", self, self.presetManager.clearPreset)
410 385
411 QtWidgets.QShortcut( 386 QShortcut(
412 "Ctrl+Up", self.listWidget_componentList, 387 "Ctrl+Up",
413 activated=lambda: self.moveComponent(-1) 388 self.listWidget_componentList,
389 activated=lambda: self.moveComponent(-1),
414 ) 390 )
415 QtWidgets.QShortcut( 391 QShortcut(
416 "Ctrl+Down", self.listWidget_componentList, 392 "Ctrl+Down",
417 activated=lambda: self.moveComponent(1) 393 self.listWidget_componentList,
394 activated=lambda: self.moveComponent(1),
418 ) 395 )
419 QtWidgets.QShortcut( 396 QShortcut(
420 "Ctrl+Home", self.listWidget_componentList, 397 "Ctrl+Home",
421 activated=lambda: self.moveComponent('top') 398 self.listWidget_componentList,
399 activated=lambda: self.moveComponent("top"),
422 ) 400 )
423 QtWidgets.QShortcut( 401 QShortcut(
424 "Ctrl+End", self.listWidget_componentList, 402 "Ctrl+End",
425 activated=lambda: self.moveComponent('bottom') 403 self.listWidget_componentList,
404 activated=lambda: self.moveComponent("bottom"),
426 ) 405 )
427 406
428 QtWidgets.QShortcut( 407 QShortcut("Ctrl+Shift+F", self, self.showFfmpegCommand)
429 "Ctrl+Shift+F", self, self.showFfmpegCommand 408 QShortcut("Ctrl+Shift+U", self, self.showUndoStack)
430 )
431 QtWidgets.QShortcut(
432 "Ctrl+Shift+U", self, self.showUndoStack
433 )
434 409
435 if log.isEnabledFor(logging.DEBUG): 410 if log.isEnabledFor(logging.DEBUG):
436 QtWidgets.QShortcut( 411 QShortcut("Ctrl+Alt+Shift+R", self, self.drawPreview)
437 "Ctrl+Alt+Shift+R", self, self.drawPreview 412 QShortcut("Ctrl+Alt+Shift+A", self, lambda: log.debug(repr(self)))
438 )
439 QtWidgets.QShortcut(
440 "Ctrl+Alt+Shift+A", self, lambda: log.debug(repr(self))
441 )
442 413
443 # Close MainWindow when receiving Ctrl+C from terminal 414 # Close MainWindow when receiving Ctrl+C from terminal
444 signal.signal(signal.SIGINT, lambda *args: self.close()) 415 signal.signal(signal.SIGINT, lambda *args: self.close())
@@ -450,18 +421,27 @@ class MainWindow(QtWidgets.QMainWindow):
450 421
451 def __repr__(self): 422 def __repr__(self):
452 return ( 423 return (
453 '%s\n' 424 "%s\n"
454 '\n%s\n' 425 "\n%s\n"
455 '#####\n' 426 "#####\n"
456 'Preview thread is %s\n' % ( 427 "Preview thread is %s\n"
428 % (
457 super().__repr__(), 429 super().__repr__(),
458 "core not initialized" if not hasattr(self, "core") else repr(self.core), 430 (
459 'live' if hasattr(self, "previewThread") and self.previewThread.isRunning() else 'dead', 431 "core not initialized"
432 if not hasattr(self, "core")
433 else repr(self.core)
434 ),
435 (
436 "live"
437 if hasattr(self, "previewThread") and self.previewThread.isRunning()
438 else "dead"
439 ),
460 ) 440 )
461 ) 441 )
462 442
463 def closeEvent(self, event): 443 def closeEvent(self, event):
464 log.info('Ending the preview thread') 444 log.info("Ending the preview thread")
465 self.timer.stop() 445 self.timer.stop()
466 self.previewThread.quit() 446 self.previewThread.quit()
467 self.previewThread.wait() 447 self.previewThread.wait()
@@ -473,11 +453,11 @@ class MainWindow(QtWidgets.QMainWindow):
473 windowTitle = appName 453 windowTitle = appName
474 try: 454 try:
475 if self.currentProject: 455 if self.currentProject:
476 windowTitle += ' - %s' % \ 456 windowTitle += (
477 os.path.splitext( 457 " - %s" % os.path.splitext(os.path.basename(self.currentProject))[0]
478 os.path.basename(self.currentProject))[0] 458 )
479 if self.autosaveExists(identical=False): 459 if self.autosaveExists(identical=False):
480 windowTitle += '*' 460 windowTitle += "*"
481 except AttributeError: 461 except AttributeError:
482 pass 462 pass
483 log.verbose(f'Window title is "{windowTitle}"') 463 log.verbose(f'Window title is "{windowTitle}"')
@@ -485,38 +465,38 @@ class MainWindow(QtWidgets.QMainWindow):
485 465
486 @QtCore.pyqtSlot(int, dict) 466 @QtCore.pyqtSlot(int, dict)
487 def updateComponentTitle(self, pos, presetStore=False): 467 def updateComponentTitle(self, pos, presetStore=False):
488 ''' 468 """
489 Sets component title to modified or unmodified when given boolean. 469 Sets component title to modified or unmodified when given boolean.
490 If given a preset dict, compares it against the component to 470 If given a preset dict, compares it against the component to
491 determine if it is modified. 471 determine if it is modified.
492 A component with no preset is always unmodified. 472 A component with no preset is always unmodified.
493 ''' 473 """
494 if type(presetStore) is dict: 474 if type(presetStore) is dict:
495 name = presetStore['preset'] 475 name = presetStore["preset"]
496 if name is None or name not in self.core.savedPresets: 476 if name is None or name not in self.core.savedPresets:
497 modified = False 477 modified = False
498 else: 478 else:
499 modified = (presetStore != self.core.savedPresets[name]) 479 modified = presetStore != self.core.savedPresets[name]
500 480
501 modified = bool(presetStore) 481 modified = bool(presetStore)
502 if pos < 0: 482 if pos < 0:
503 pos = len(self.core.selectedComponents)-1 483 pos = len(self.core.selectedComponents) - 1
504 name = self.core.selectedComponents[pos].name 484 name = self.core.selectedComponents[pos].name
505 title = str(name) 485 title = str(name)
506 if self.core.selectedComponents[pos].currentPreset: 486 if self.core.selectedComponents[pos].currentPreset:
507 title += ' - %s' % self.core.selectedComponents[pos].currentPreset 487 title += " - %s" % self.core.selectedComponents[pos].currentPreset
508 if modified: 488 if modified:
509 title += '*' 489 title += "*"
510 if type(presetStore) is bool: 490 if type(presetStore) is bool:
511 log.debug( 491 log.debug(
512 'Forcing %s #%s\'s modified status to %s: %s', 492 "Forcing %s #%s's modified status to %s: %s",
513 name, pos, modified, title 493 name,
494 pos,
495 modified,
496 title,
514 ) 497 )
515 else: 498 else:
516 log.debug( 499 log.debug("Setting %s #%s's title: %s", name, pos, title)
517 'Setting %s #%s\'s title: %s',
518 name, pos, title
519 )
520 self.listWidget_componentList.item(pos).setText(title) 500 self.listWidget_componentList.item(pos).setText(title)
521 501
522 def updateCodecs(self): 502 def updateCodecs(self):
@@ -525,20 +505,20 @@ class MainWindow(QtWidgets.QMainWindow):
525 aCodecWidget = self.comboBox_audioCodec 505 aCodecWidget = self.comboBox_audioCodec
526 index = containerWidget.currentIndex() 506 index = containerWidget.currentIndex()
527 name = containerWidget.itemText(index) 507 name = containerWidget.itemText(index)
528 self.settings.setValue('outputContainer', name) 508 self.settings.setValue("outputContainer", name)
529 509
530 vCodecWidget.clear() 510 vCodecWidget.clear()
531 aCodecWidget.clear() 511 aCodecWidget.clear()
532 512
533 for container in Core.encoderOptions['containers']: 513 for container in Core.encoderOptions["containers"]:
534 if container['name'] == name: 514 if container["name"] == name:
535 for vCodec in container['video-codecs']: 515 for vCodec in container["video-codecs"]:
536 vCodecWidget.addItem(vCodec) 516 vCodecWidget.addItem(vCodec)
537 for aCodec in container['audio-codecs']: 517 for aCodec in container["audio-codecs"]:
538 aCodecWidget.addItem(aCodec) 518 aCodecWidget.addItem(aCodec)
539 519
540 def updateCodecSettings(self): 520 def updateCodecSettings(self):
541 '''Updates settings.ini to match encoder option widgets''' 521 """Updates settings.ini to match encoder option widgets"""
542 vCodecWidget = self.comboBox_videoCodec 522 vCodecWidget = self.comboBox_videoCodec
543 vBitrateWidget = self.spinBox_vBitrate 523 vBitrateWidget = self.spinBox_vBitrate
544 aBitrateWidget = self.spinBox_aBitrate 524 aBitrateWidget = self.spinBox_aBitrate
@@ -549,10 +529,10 @@ class MainWindow(QtWidgets.QMainWindow):
549 currentAudioCodec = aCodecWidget.currentIndex() 529 currentAudioCodec = aCodecWidget.currentIndex()
550 currentAudioCodec = aCodecWidget.itemText(currentAudioCodec) 530 currentAudioCodec = aCodecWidget.itemText(currentAudioCodec)
551 currentAudioBitrate = aBitrateWidget.value() 531 currentAudioBitrate = aBitrateWidget.value()
552 self.settings.setValue('outputVideoCodec', currentVideoCodec) 532 self.settings.setValue("outputVideoCodec", currentVideoCodec)
553 self.settings.setValue('outputAudioCodec', currentAudioCodec) 533 self.settings.setValue("outputAudioCodec", currentAudioCodec)
554 self.settings.setValue('outputVideoBitrate', currentVideoBitrate) 534 self.settings.setValue("outputVideoBitrate", currentVideoBitrate)
555 self.settings.setValue('outputAudioBitrate', currentAudioBitrate) 535 self.settings.setValue("outputAudioBitrate", currentAudioBitrate)
556 536
557 @disableWhenOpeningProject 537 @disableWhenOpeningProject
558 def autosave(self, force=False): 538 def autosave(self, force=False):
@@ -567,54 +547,54 @@ class MainWindow(QtWidgets.QMainWindow):
567 # curve up to 5 seconds cooldown and maintains that for 30 secs 547 # curve up to 5 seconds cooldown and maintains that for 30 secs
568 # if a component is continuously updated 548 # if a component is continuously updated
569 timeDiff = self.lastAutosave - self.autosaveTimes.pop() 549 timeDiff = self.lastAutosave - self.autosaveTimes.pop()
570 if not force and timeDiff >= 1.0 \ 550 if not force and timeDiff >= 1.0 and timeDiff <= 10.0:
571 and timeDiff <= 10.0:
572 if self.autosaveCooldown / 4.0 < 0.5: 551 if self.autosaveCooldown / 4.0 < 0.5:
573 self.autosaveCooldown += 1.0 552 self.autosaveCooldown += 1.0
574 self.autosaveCooldown = ( 553 self.autosaveCooldown = (5.0 * (self.autosaveCooldown / 5.0)) + (
575 5.0 * (self.autosaveCooldown / 5.0) 554 self.autosaveCooldown / 5.0
576 ) + (self.autosaveCooldown / 5.0) * 2 555 ) * 2
577 elif force or timeDiff >= self.autosaveCooldown * 5: 556 elif force or timeDiff >= self.autosaveCooldown * 5:
578 self.autosaveCooldown = 0.2 557 self.autosaveCooldown = 0.2
579 self.autosaveTimes.insert(0, self.lastAutosave) 558 self.autosaveTimes.insert(0, self.lastAutosave)
580 else: 559 else:
581 log.debug('Autosave rejected by cooldown') 560 log.debug("Autosave rejected by cooldown")
582 561
583 def autosaveExists(self, identical=True): 562 def autosaveExists(self, identical=True):
584 '''Determines if creating the autosave should be blocked.''' 563 """Determines if creating the autosave should be blocked."""
585 try: 564 try:
586 if self.currentProject and os.path.exists(self.autosavePath) \ 565 if (
587 and filecmp.cmp( 566 self.currentProject
588 self.autosavePath, self.currentProject) == identical: 567 and os.path.exists(self.autosavePath)
568 and filecmp.cmp(self.autosavePath, self.currentProject) == identical
569 ):
589 log.debug( 570 log.debug(
590 'Autosave found %s to be identical' 571 "Autosave found %s to be identical" % "not" if not identical else ""
591 % 'not' if not identical else ''
592 ) 572 )
593 return True 573 return True
594 except FileNotFoundError: 574 except FileNotFoundError:
595 log.error( 575 log.error("Project file couldn't be located: %s", self.currentProject)
596 'Project file couldn\'t be located: %s', self.currentProject)
597 return identical 576 return identical
598 return False 577 return False
599 578
600 def saveProjectChanges(self): 579 def saveProjectChanges(self):
601 '''Overwrites project file with autosave file''' 580 """Overwrites project file with autosave file"""
602 try: 581 try:
603 os.remove(self.currentProject) 582 os.remove(self.currentProject)
604 os.rename(self.autosavePath, self.currentProject) 583 os.rename(self.autosavePath, self.currentProject)
605 return True 584 return True
606 except (FileNotFoundError, IsADirectoryError) as e: 585 except (FileNotFoundError, IsADirectoryError) as e:
607 self.showMessage( 586 self.showMessage(msg="Project file couldn't be saved.", detail=str(e))
608 msg='Project file couldn\'t be saved.',
609 detail=str(e))
610 return False 587 return False
611 588
612 def openInputFileDialog(self): 589 def openInputFileDialog(self):
613 inputDir = self.settings.value("inputDir", os.path.expanduser("~")) 590 inputDir = self.settings.value("inputDir", os.path.expanduser("~"))
614 591
615 fileName, _ = QtWidgets.QFileDialog.getOpenFileName( 592 fileName, _ = QtWidgets.QFileDialog.getOpenFileName(
616 self, "Open Audio File", 593 self,
617 inputDir, "Audio Files (%s)" % " ".join(Core.audioFormats)) 594 "Open Audio File",
595 inputDir,
596 "Audio Files (%s)" % " ".join(Core.audioFormats),
597 )
618 598
619 if fileName: 599 if fileName:
620 self.settings.setValue("inputDir", os.path.dirname(fileName)) 600 self.settings.setValue("inputDir", os.path.dirname(fileName))
@@ -624,17 +604,18 @@ class MainWindow(QtWidgets.QMainWindow):
624 outputDir = self.settings.value("outputDir", os.path.expanduser("~")) 604 outputDir = self.settings.value("outputDir", os.path.expanduser("~"))
625 605
626 fileName, _ = QtWidgets.QFileDialog.getSaveFileName( 606 fileName, _ = QtWidgets.QFileDialog.getSaveFileName(
627 self, "Set Output Video File", 607 self,
608 "Set Output Video File",
628 outputDir, 609 outputDir,
629 "Video Files (%s);; All Files (*)" % " ".join( 610 "Video Files (%s);; All Files (*)" % " ".join(Core.videoFormats),
630 Core.videoFormats)) 611 )
631 612
632 if fileName: 613 if fileName:
633 self.settings.setValue("outputDir", os.path.dirname(fileName)) 614 self.settings.setValue("outputDir", os.path.dirname(fileName))
634 self.lineEdit_outputFile.setText(fileName) 615 self.lineEdit_outputFile.setText(fileName)
635 616
636 def stopVideo(self): 617 def stopVideo(self):
637 log.info('Export cancelled') 618 log.info("Export cancelled")
638 self.videoWorker.cancel() 619 self.videoWorker.cancel()
639 self.canceled = True 620 self.canceled = True
640 621
@@ -645,14 +626,13 @@ class MainWindow(QtWidgets.QMainWindow):
645 626
646 if audioFile and outputPath and self.core.selectedComponents: 627 if audioFile and outputPath and self.core.selectedComponents:
647 if not os.path.dirname(outputPath): 628 if not os.path.dirname(outputPath):
648 outputPath = os.path.join( 629 outputPath = os.path.join(os.path.expanduser("~"), outputPath)
649 os.path.expanduser("~"), outputPath)
650 if outputPath and os.path.isdir(outputPath): 630 if outputPath and os.path.isdir(outputPath):
651 self.showMessage( 631 self.showMessage(
652 msg='Chosen filename matches a directory, which ' 632 msg="Chosen filename matches a directory, which "
653 'cannot be overwritten. Please choose a different ' 633 "cannot be overwritten. Please choose a different "
654 'filename or move the directory.', 634 "filename or move the directory.",
655 icon='Warning', 635 icon="Warning",
656 ) 636 )
657 return 637 return
658 else: 638 else:
@@ -661,19 +641,14 @@ class MainWindow(QtWidgets.QMainWindow):
661 msg="You must select an audio file and output filename." 641 msg="You must select an audio file and output filename."
662 ) 642 )
663 elif not self.core.selectedComponents: 643 elif not self.core.selectedComponents:
664 self.showMessage( 644 self.showMessage(msg="Not enough components.")
665 msg="Not enough components."
666 )
667 return 645 return
668 646
669 self.canceled = False 647 self.canceled = False
670 self.progressBarUpdated(-1) 648 self.progressBarUpdated(-1)
671 self.videoWorker = self.core.newVideoWorker( 649 self.videoWorker = self.core.newVideoWorker(self, audioFile, outputPath)
672 self, audioFile, outputPath
673 )
674 self.videoWorker.progressBarUpdate.connect(self.progressBarUpdated) 650 self.videoWorker.progressBarUpdate.connect(self.progressBarUpdated)
675 self.videoWorker.progressBarSetText.connect( 651 self.videoWorker.progressBarSetText.connect(self.progressBarSetText)
676 self.progressBarSetText)
677 self.videoWorker.imageCreated.connect(self.showPreviewImage) 652 self.videoWorker.imageCreated.connect(self.showPreviewImage)
678 self.videoWorker.encoding.connect(self.changeEncodingStatus) 653 self.videoWorker.encoding.connect(self.changeEncodingStatus)
679 self.createVideo.emit() 654 self.createVideo.emit()
@@ -683,14 +658,14 @@ class MainWindow(QtWidgets.QMainWindow):
683 try: 658 try:
684 self.stopVideo() 659 self.stopVideo()
685 except AttributeError as e: 660 except AttributeError as e:
686 if 'videoWorker' not in str(e): 661 if "videoWorker" not in str(e):
687 raise 662 raise
688 self.showMessage( 663 self.showMessage(
689 msg=msg, 664 msg=msg,
690 detail=detail, 665 detail=detail,
691 icon='Critical', 666 icon="Critical",
692 ) 667 )
693 log.info('%s', repr(self)) 668 log.info("%s", repr(self))
694 669
695 def changeEncodingStatus(self, status): 670 def changeEncodingStatus(self, status):
696 self.encoding = status 671 self.encoding = status
@@ -718,7 +693,7 @@ class MainWindow(QtWidgets.QMainWindow):
718 # Close undo history dialog if open 693 # Close undo history dialog if open
719 self.undoDialog.close() 694 self.undoDialog.close()
720 # Show label under progress bar on macOS 695 # Show label under progress bar on macOS
721 if sys.platform == 'darwin': 696 if sys.platform == "darwin":
722 self.progressLabel.setHidden(False) 697 self.progressLabel.setHidden(False)
723 else: 698 else:
724 self.pushButton_createVideo.setEnabled(True) 699 self.pushButton_createVideo.setEnabled(True)
@@ -749,33 +724,33 @@ class MainWindow(QtWidgets.QMainWindow):
749 724
750 @QtCore.pyqtSlot(str) 725 @QtCore.pyqtSlot(str)
751 def progressBarSetText(self, value): 726 def progressBarSetText(self, value):
752 if sys.platform == 'darwin': 727 if sys.platform == "darwin":
753 self.progressLabel.setText(value) 728 self.progressLabel.setText(value)
754 else: 729 else:
755 self.progressBar_createVideo.setFormat(value) 730 self.progressBar_createVideo.setFormat(value)
756 731
757 def updateResolution(self): 732 def updateResolution(self):
758 resIndex = int(self.comboBox_resolution.currentIndex()) 733 resIndex = int(self.comboBox_resolution.currentIndex())
759 res = Core.resolutions[resIndex].split('x') 734 res = Core.resolutions[resIndex].split("x")
760 changed = res[0] != self.settings.value("outputWidth") 735 changed = res[0] != self.settings.value("outputWidth")
761 self.settings.setValue('outputWidth', res[0]) 736 self.settings.setValue("outputWidth", res[0])
762 self.settings.setValue('outputHeight', res[1]) 737 self.settings.setValue("outputHeight", res[1])
763 if changed: 738 if changed:
764 for i in range(len(self.core.selectedComponents)): 739 for i in range(len(self.core.selectedComponents)):
765 self.core.updateComponent(i) 740 self.core.updateComponent(i)
766 741
767 def drawPreview(self, force=False, **kwargs): 742 def drawPreview(self, force=False, **kwargs):
768 '''Use autosave keyword arg to force saving or not saving if needed''' 743 """Use autosave keyword arg to force saving or not saving if needed"""
769 self.newTask.emit(self.core.selectedComponents) 744 self.newTask.emit(self.core.selectedComponents)
770 # self.processTask.emit() 745 # self.processTask.emit()
771 if force or 'autosave' in kwargs: 746 if force or "autosave" in kwargs:
772 if force or kwargs['autosave']: 747 if force or kwargs["autosave"]:
773 self.autosave(True) 748 self.autosave(True)
774 else: 749 else:
775 self.autosave() 750 self.autosave()
776 self.updateWindowTitle() 751 self.updateWindowTitle()
777 752
778 @QtCore.pyqtSlot('QImage') 753 @QtCore.pyqtSlot("QImage")
779 def showPreviewImage(self, image): 754 def showPreviewImage(self, image):
780 self.previewWindow.changePixmap(image) 755 self.previewWindow.changePixmap(image)
781 756
@@ -786,36 +761,35 @@ class MainWindow(QtWidgets.QMainWindow):
786 def showFfmpegCommand(self): 761 def showFfmpegCommand(self):
787 from textwrap import wrap 762 from textwrap import wrap
788 from ..toolkit.ffmpeg import createFfmpegCommand 763 from ..toolkit.ffmpeg import createFfmpegCommand
764
789 command = createFfmpegCommand( 765 command = createFfmpegCommand(
790 self.lineEdit_audioFile.text(), 766 self.lineEdit_audioFile.text(),
791 self.lineEdit_outputFile.text(), 767 self.lineEdit_outputFile.text(),
792 self.core.selectedComponents 768 self.core.selectedComponents,
793 ) 769 )
794 command = " ".join(command) 770 command = " ".join(command)
795 log.info(f"FFmpeg command: {command}") 771 log.info(f"FFmpeg command: {command}")
796 lines = wrap(command, 49) 772 lines = wrap(command, 49)
797 self.showMessage( 773 self.showMessage(msg=f"Current FFmpeg command:\n\n{' '.join(lines)}")
798 msg=f"Current FFmpeg command:\n\n{' '.join(lines)}"
799 )
800 774
801 def addComponent(self, compPos, moduleIndex): 775 def addComponent(self, compPos, moduleIndex):
802 '''Creates an undoable action that adds a new component.''' 776 """Creates an undoable action that adds a new component."""
803 action = AddComponent(self, compPos, moduleIndex) 777 action = AddComponent(self, compPos, moduleIndex)
804 self.undoStack.push(action) 778 self.undoStack.push(action)
805 779
806 def insertComponent(self, index): 780 def insertComponent(self, index):
807 '''Triggered by Core to finish initializing a new component.''' 781 """Triggered by Core to finish initializing a new component."""
782 if not hasattr(self.core.selectedComponents[index], "page"):
783 log.error("Component failed to initialize")
784 return
808 componentList = self.listWidget_componentList 785 componentList = self.listWidget_componentList
809 stackedWidget = self.stackedWidget 786 stackedWidget = self.stackedWidget
810 787
811 componentList.insertItem( 788 componentList.insertItem(index, self.core.selectedComponents[index].name)
812 index,
813 self.core.selectedComponents[index].name)
814 componentList.setCurrentRow(index) 789 componentList.setCurrentRow(index)
815 790
816 # connect to signal that adds an asterisk when modified 791 # connect to signal that adds an asterisk when modified
817 self.core.selectedComponents[index].modified.connect( 792 self.core.selectedComponents[index].modified.connect(self.updateComponentTitle)
818 self.updateComponentTitle)
819 793
820 self.pages.insert(index, self.core.selectedComponents[index].page) 794 self.pages.insert(index, self.core.selectedComponents[index].page)
821 stackedWidget.insertWidget(index, self.pages[index]) 795 stackedWidget.insertWidget(index, self.pages[index])
@@ -842,15 +816,15 @@ class MainWindow(QtWidgets.QMainWindow):
842 816
843 @disableWhenEncoding 817 @disableWhenEncoding
844 def moveComponent(self, change): 818 def moveComponent(self, change):
845 '''Moves a component relatively from its current position''' 819 """Moves a component relatively from its current position"""
846 componentList = self.listWidget_componentList 820 componentList = self.listWidget_componentList
847 tag = change 821 tag = change
848 if change == 'top': 822 if change == "top":
849 change = -componentList.currentRow() 823 change = -componentList.currentRow()
850 elif change == 'bottom': 824 elif change == "bottom":
851 change = len(componentList)-componentList.currentRow()-1 825 change = len(componentList) - componentList.currentRow() - 1
852 else: 826 else:
853 tag = 'down' if change == 1 else 'up' 827 tag = "down" if change == 1 else "up"
854 828
855 row = componentList.currentRow() 829 row = componentList.currentRow()
856 newRow = row + change 830 newRow = row + change
@@ -859,38 +833,39 @@ class MainWindow(QtWidgets.QMainWindow):
859 self.undoStack.push(action) 833 self.undoStack.push(action)
860 834
861 def getComponentListMousePos(self, position): 835 def getComponentListMousePos(self, position):
862 ''' 836 """
863 Given a QPos, returns the component index under the mouse cursor 837 Given a QPos, returns the component index under the mouse cursor
864 or -1 if no component is there. 838 or -1 if no component is there.
865 ''' 839 """
866 componentList = self.listWidget_componentList 840 componentList = self.listWidget_componentList
867 841
842 if hasattr(position, "toPointF"):
843 position = position.toPointF()
844 position = position.toPoint()
845
868 modelIndexes = [ 846 modelIndexes = [
869 componentList.model().index(i) 847 componentList.model().index(i) for i in range(componentList.count())
870 for i in range(componentList.count())
871 ]
872 rects = [
873 componentList.visualRect(modelIndex)
874 for modelIndex in modelIndexes
875 ] 848 ]
849 rects = [componentList.visualRect(modelIndex) for modelIndex in modelIndexes]
876 mousePos = [rect.contains(position) for rect in rects] 850 mousePos = [rect.contains(position) for rect in rects]
877 if not any(mousePos): 851 if not any(mousePos):
878 # Not clicking a component 852 # Not clicking a component
879 mousePos = -1 853 mousePos = -1
880 else: 854 else:
881 mousePos = mousePos.index(True) 855 mousePos = mousePos.index(True)
882 log.debug('Click component list row %s' % mousePos) 856 log.debug("Click component list row %s" % mousePos)
883 return mousePos 857 return mousePos
884 858
885 @disableWhenEncoding 859 @disableWhenEncoding
886 def dragComponent(self, event): 860 def dragComponent(self, event):
887 '''Used as Qt drop event for the component listwidget''' 861 """Used as Qt drop event for the component listwidget"""
888 componentList = self.listWidget_componentList 862 componentList = self.listWidget_componentList
889 mousePos = self.getComponentListMousePos(event.pos()) 863 mousePos = self.getComponentListMousePos(event.position())
864
890 if mousePos > -1: 865 if mousePos > -1:
891 change = (componentList.currentRow() - mousePos) * -1 866 change = (componentList.currentRow() - mousePos) * -1
892 else: 867 else:
893 change = (componentList.count() - componentList.currentRow() - 1) 868 change = componentList.count() - componentList.currentRow() - 1
894 self.moveComponent(change) 869 self.moveComponent(change)
895 870
896 def changeComponentWidget(self): 871 def changeComponentWidget(self):
@@ -900,30 +875,27 @@ class MainWindow(QtWidgets.QMainWindow):
900 self.stackedWidget.setCurrentIndex(index) 875 self.stackedWidget.setCurrentIndex(index)
901 876
902 def openPresetManager(self): 877 def openPresetManager(self):
903 '''Preset manager for importing, exporting, renaming, deleting''' 878 """Preset manager for importing, exporting, renaming, deleting"""
904 self.presetManager.show_() 879 self.presetManager.show_()
905 880
906 def clear(self): 881 def clear(self):
907 '''Get a blank slate''' 882 """Get a blank slate"""
908 self.core.clearComponents() 883 self.core.clearComponents()
909 self.listWidget_componentList.clear() 884 self.listWidget_componentList.clear()
910 for widget in self.pages: 885 for widget in self.pages:
911 self.stackedWidget.removeWidget(widget) 886 self.stackedWidget.removeWidget(widget)
912 self.pages = [] 887 self.pages = []
913 for field in ( 888 for field in (self.lineEdit_audioFile, self.lineEdit_outputFile):
914 self.lineEdit_audioFile,
915 self.lineEdit_outputFile
916 ):
917 with blockSignals(field): 889 with blockSignals(field):
918 field.setText('') 890 field.setText("")
919 self.progressBarUpdated(0) 891 self.progressBarUpdated(0)
920 self.progressBarSetText('') 892 self.progressBarSetText("")
921 self.undoStack.clear() 893 self.undoStack.clear()
922 894
923 @disableWhenEncoding 895 @disableWhenEncoding
924 def createNewProject(self, prompt=True): 896 def createNewProject(self, prompt=True):
925 if prompt: 897 if prompt:
926 self.openSaveChangesDialog('starting a new project') 898 self.openSaveChangesDialog("starting a new project")
927 899
928 self.clear() 900 self.clear()
929 self.currentProject = None 901 self.currentProject = None
@@ -946,11 +918,10 @@ class MainWindow(QtWidgets.QMainWindow):
946 if self.autosaveExists(identical=False): 918 if self.autosaveExists(identical=False):
947 ch = self.showMessage( 919 ch = self.showMessage(
948 msg="You have unsaved changes in project '%s'. " 920 msg="You have unsaved changes in project '%s'. "
949 "Save before %s?" % ( 921 "Save before %s?"
950 os.path.basename(self.currentProject)[:-4], 922 % (os.path.basename(self.currentProject)[:-4], phrase),
951 phrase 923 showCancel=True,
952 ), 924 )
953 showCancel=True)
954 if ch: 925 if ch:
955 success = self.saveProjectChanges() 926 success = self.saveProjectChanges()
956 927
@@ -959,13 +930,15 @@ class MainWindow(QtWidgets.QMainWindow):
959 930
960 def openSaveProjectDialog(self): 931 def openSaveProjectDialog(self):
961 filename, _ = QtWidgets.QFileDialog.getSaveFileName( 932 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
962 self, "Create Project File", 933 self,
934 "Create Project File",
963 self.settings.value("projectDir"), 935 self.settings.value("projectDir"),
964 "Project Files (*.avp)") 936 "Project Files (*.avp)",
937 )
965 if not filename: 938 if not filename:
966 return 939 return
967 if not filename.endswith(".avp"): 940 if not filename.endswith(".avp"):
968 filename += '.avp' 941 filename += ".avp"
969 self.settings.setValue("projectDir", os.path.dirname(filename)) 942 self.settings.setValue("projectDir", os.path.dirname(filename))
970 self.settings.setValue("currentProject", filename) 943 self.settings.setValue("currentProject", filename)
971 self.currentProject = filename 944 self.currentProject = filename
@@ -975,20 +948,25 @@ class MainWindow(QtWidgets.QMainWindow):
975 @disableWhenEncoding 948 @disableWhenEncoding
976 def openOpenProjectDialog(self): 949 def openOpenProjectDialog(self):
977 filename, _ = QtWidgets.QFileDialog.getOpenFileName( 950 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
978 self, "Open Project File", 951 self,
952 "Open Project File",
979 self.settings.value("projectDir"), 953 self.settings.value("projectDir"),
980 "Project Files (*.avp)") 954 "Project Files (*.avp)",
955 )
981 self.openProject(filename) 956 self.openProject(filename)
982 957
983 def openProject(self, filepath, prompt=True): 958 def openProject(self, filepath, prompt=True):
984 if not filepath or not os.path.exists(filepath) \ 959 if (
985 or not filepath.endswith('.avp'): 960 not filepath
961 or not os.path.exists(filepath)
962 or not filepath.endswith(".avp")
963 ):
986 return 964 return
987 965
988 self.clear() 966 self.clear()
989 # ask to save any changes that are about to get deleted 967 # ask to save any changes that are about to get deleted
990 if prompt: 968 if prompt:
991 self.openSaveChangesDialog('opening another project') 969 self.openSaveChangesDialog("opening another project")
992 970
993 self.currentProject = filepath 971 self.currentProject = filepath
994 self.settings.setValue("currentProject", filepath) 972 self.settings.setValue("currentProject", filepath)
@@ -999,29 +977,32 @@ class MainWindow(QtWidgets.QMainWindow):
999 self.updateWindowTitle() 977 self.updateWindowTitle()
1000 978
1001 def showMessage(self, **kwargs): 979 def showMessage(self, **kwargs):
1002 parent = kwargs['parent'] if 'parent' in kwargs else self 980 parent = kwargs["parent"] if "parent" in kwargs else self
1003 msg = QtWidgets.QMessageBox(parent) 981 msg = QtWidgets.QMessageBox(parent)
1004 msg.setWindowTitle(appName) 982 msg.setWindowTitle(appName)
1005 msg.setModal(True) 983 msg.setModal(True)
1006 msg.setText(kwargs['msg']) 984 msg.setText(kwargs["msg"])
1007 msg.setIcon( 985 msg.setIcon(
1008 eval('QtWidgets.QMessageBox.%s' % kwargs['icon']) 986 eval("QtWidgets.QMessageBox.Icon.%s" % kwargs["icon"])
1009 if 'icon' in kwargs else QtWidgets.QMessageBox.Information 987 if "icon" in kwargs
988 else QtWidgets.QMessageBox.Icon.Information
1010 ) 989 )
1011 msg.setDetailedText(kwargs['detail'] if 'detail' in kwargs else None) 990 msg.setDetailedText(kwargs["detail"] if "detail" in kwargs else None)
1012 if 'showCancel'in kwargs and kwargs['showCancel']: 991 if "showCancel" in kwargs and kwargs["showCancel"]:
1013 msg.setStandardButtons( 992 msg.setStandardButtons(
1014 QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) 993 QtWidgets.QMessageBox.StandardButton.Ok
994 | QtWidgets.QMessageBox.StandardButton.Cancel
995 )
1015 else: 996 else:
1016 msg.setStandardButtons(QtWidgets.QMessageBox.Ok) 997 msg.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok)
1017 ch = msg.exec_() 998 ch = msg.exec()
1018 if ch == 1024: 999 if ch == 1024:
1019 return True 1000 return True
1020 return False 1001 return False
1021 1002
1022 @disableWhenEncoding 1003 @disableWhenEncoding
1023 def componentContextMenu(self, QPos): 1004 def componentContextMenu(self, QPos):
1024 '''Appears when right-clicking the component list''' 1005 """Appears when right-clicking the component list"""
1025 componentList = self.listWidget_componentList 1006 componentList = self.listWidget_componentList
1026 self.menu = QtWidgets.QMenu() 1007 self.menu = QtWidgets.QMenu()
1027 parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) 1008 parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0))
@@ -1031,9 +1012,7 @@ class MainWindow(QtWidgets.QMainWindow):
1031 # Show preset menu if clicking a component 1012 # Show preset menu if clicking a component
1032 self.presetManager.findPresets() 1013 self.presetManager.findPresets()
1033 menuItem = self.menu.addAction("Save Preset") 1014 menuItem = self.menu.addAction("Save Preset")
1034 menuItem.triggered.connect( 1015 menuItem.triggered.connect(self.presetManager.openSavePresetDialog)
1035 self.presetManager.openSavePresetDialog
1036 )
1037 1016
1038 # submenu for opening presets 1017 # submenu for opening presets
1039 try: 1018 try:
@@ -1046,17 +1025,16 @@ class MainWindow(QtWidgets.QMainWindow):
1046 for version, presetName in presets: 1025 for version, presetName in presets:
1047 menuItem = self.presetSubmenu.addAction(presetName) 1026 menuItem = self.presetSubmenu.addAction(presetName)
1048 menuItem.triggered.connect( 1027 menuItem.triggered.connect(
1049 lambda _, presetName=presetName: 1028 lambda _, presetName=presetName: self.presetManager.openPreset(
1050 self.presetManager.openPreset(presetName) 1029 presetName
1030 )
1051 ) 1031 )
1052 except KeyError: 1032 except KeyError:
1053 pass 1033 pass
1054 1034
1055 if self.core.selectedComponents[index].currentPreset: 1035 if self.core.selectedComponents[index].currentPreset:
1056 menuItem = self.menu.addAction("Clear Preset") 1036 menuItem = self.menu.addAction("Clear Preset")
1057 menuItem.triggered.connect( 1037 menuItem.triggered.connect(self.presetManager.clearPreset)
1058 self.presetManager.clearPreset
1059 )
1060 self.menu.addSeparator() 1038 self.menu.addSeparator()
1061 1039
1062 # "Add Component" submenu 1040 # "Add Component" submenu
diff --git a/src/gui/presetmanager.py b/src/gui/presetmanager.py
index 9cf95b4..11a9d9b 100644
--- a/src/gui/presetmanager.py
+++ b/src/gui/presetmanager.py
@@ -1,8 +1,9 @@
1''' 1"""
2 Preset manager object handles all interactions with presets, including 2Preset manager object handles all interactions with presets, including
3 the context menu accessed from MainWindow. 3the context menu accessed from MainWindow.
4''' 4"""
5from PyQt5 import QtCore, QtWidgets, uic 5
6from PyQt6 import QtCore, QtWidgets, uic
6import string 7import string
7import os 8import os
8import logging 9import logging
@@ -12,53 +13,43 @@ from ..core import Core
12from .actions import * 13from .actions import *
13 14
14 15
15log = logging.getLogger('AVP.Gui.PresetManager') 16log = logging.getLogger("AVP.Gui.PresetManager")
16 17
17 18
18class PresetManager(QtWidgets.QDialog): 19class PresetManager(QtWidgets.QDialog):
19 def __init__(self, parent): 20 def __init__(self, parent):
20 super().__init__() 21 super().__init__()
21 uic.loadUi( 22 uic.loadUi(os.path.join(Core.wd, "gui", "presetmanager.ui"), self)
22 os.path.join(Core.wd, 'gui', 'presetmanager.ui'), self)
23 self.parent = parent 23 self.parent = parent
24 self.core = parent.core 24 self.core = parent.core
25 self.settings = parent.settings 25 self.settings = parent.settings
26 self.presetDir = parent.presetDir 26 self.presetDir = parent.presetDir
27 if not self.settings.value('presetDir'): 27 if not self.settings.value("presetDir"):
28 self.settings.setValue( 28 self.settings.setValue(
29 "presetDir", 29 "presetDir", os.path.join(parent.dataDir, "projects")
30 os.path.join(parent.dataDir, 'projects')) 30 )
31 31
32 self.findPresets() 32 self.findPresets()
33 33
34 # window 34 # window
35 self.lastFilter = '*' 35 self.lastFilter = "*"
36 self.presetRows = [] # list of (comp, vers, name) tuples 36 self.presetRows = [] # list of (comp, vers, name) tuples
37 self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) 37
38 # FIXME
39 # self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
38 40
39 # connect button signals 41 # connect button signals
40 self.pushButton_delete.clicked.connect( 42 self.pushButton_delete.clicked.connect(self.openDeletePresetDialog)
41 self.openDeletePresetDialog 43 self.pushButton_rename.clicked.connect(self.openRenamePresetDialog)
42 ) 44 self.pushButton_import.clicked.connect(self.openImportDialog)
43 self.pushButton_rename.clicked.connect( 45 self.pushButton_export.clicked.connect(self.openExportDialog)
44 self.openRenamePresetDialog 46 self.pushButton_close.clicked.connect(self.close)
45 )
46 self.pushButton_import.clicked.connect(
47 self.openImportDialog
48 )
49 self.pushButton_export.clicked.connect(
50 self.openExportDialog
51 )
52 self.pushButton_close.clicked.connect(
53 self.close
54 )
55 47
56 # create filter box and preset list 48 # create filter box and preset list
57 self.drawFilterList() 49 self.drawFilterList()
58 self.comboBox_filter.currentIndexChanged.connect( 50 self.comboBox_filter.currentIndexChanged.connect(
59 lambda: self.drawPresetList( 51 lambda: self.drawPresetList(
60 self.comboBox_filter.currentText(), 52 self.comboBox_filter.currentText(), self.lineEdit_search.text()
61 self.lineEdit_search.text()
62 ) 53 )
63 ) 54 )
64 55
@@ -69,17 +60,16 @@ class PresetManager(QtWidgets.QDialog):
69 self.lineEdit_search.setCompleter(completer) 60 self.lineEdit_search.setCompleter(completer)
70 self.lineEdit_search.textChanged.connect( 61 self.lineEdit_search.textChanged.connect(
71 lambda: self.drawPresetList( 62 lambda: self.drawPresetList(
72 self.comboBox_filter.currentText(), 63 self.comboBox_filter.currentText(), self.lineEdit_search.text()
73 self.lineEdit_search.text()
74 ) 64 )
75 ) 65 )
76 self.drawPresetList('*') 66 self.drawPresetList("*")
77 67
78 def show_(self): 68 def show_(self):
79 '''Open a new preset manager window from the mainwindow''' 69 """Open a new preset manager window from the mainwindow"""
80 self.findPresets() 70 self.findPresets()
81 self.drawFilterList() 71 self.drawFilterList()
82 self.drawPresetList('*') 72 self.drawPresetList("*")
83 self.show() 73 self.show()
84 74
85 def findPresets(self): 75 def findPresets(self):
@@ -100,14 +90,12 @@ class PresetManager(QtWidgets.QDialog):
100 continue 90 continue
101 self.presets = { 91 self.presets = {
102 compName: [ 92 compName: [
103 (vers, preset) 93 (vers, preset) for name, vers, preset in parseList if name == compName
104 for name, vers, preset in parseList
105 if name == compName
106 ] 94 ]
107 for compName, _, __ in parseList 95 for compName, _, __ in parseList
108 } 96 }
109 97
110 def drawPresetList(self, compFilter=None, presetFilter=''): 98 def drawPresetList(self, compFilter=None, presetFilter=""):
111 self.listWidget_presets.clear() 99 self.listWidget_presets.clear()
112 if compFilter: 100 if compFilter:
113 self.lastFilter = str(compFilter) 101 self.lastFilter = str(compFilter)
@@ -116,13 +104,11 @@ class PresetManager(QtWidgets.QDialog):
116 self.presetRows = [] 104 self.presetRows = []
117 presetNames = [] 105 presetNames = []
118 for component, presets in self.presets.items(): 106 for component, presets in self.presets.items():
119 if compFilter != '*' and component != compFilter: 107 if compFilter != "*" and component != compFilter:
120 continue 108 continue
121 for vers, preset in presets: 109 for vers, preset in presets:
122 if not presetFilter or presetFilter in preset: 110 if not presetFilter or presetFilter in preset:
123 self.listWidget_presets.addItem( 111 self.listWidget_presets.addItem("%s: %s" % (component, preset))
124 '%s: %s' % (component, preset)
125 )
126 self.presetRows.append((component, vers, preset)) 112 self.presetRows.append((component, vers, preset))
127 if preset not in presetNames: 113 if preset not in presetNames:
128 presetNames.append(preset) 114 presetNames.append(preset)
@@ -130,18 +116,18 @@ class PresetManager(QtWidgets.QDialog):
130 116
131 def drawFilterList(self): 117 def drawFilterList(self):
132 self.comboBox_filter.clear() 118 self.comboBox_filter.clear()
133 self.comboBox_filter.addItem('*') 119 self.comboBox_filter.addItem("*")
134 for component in self.presets: 120 for component in self.presets:
135 self.comboBox_filter.addItem(component) 121 self.comboBox_filter.addItem(component)
136 122
137 def clearPreset(self, compI=None): 123 def clearPreset(self, compI=None):
138 '''Functions on mainwindow level from the context menu''' 124 """Functions on mainwindow level from the context menu"""
139 compI = self.parent.listWidget_componentList.currentRow() 125 compI = self.parent.listWidget_componentList.currentRow()
140 action = ClearPreset(self.parent, compI) 126 action = ClearPreset(self.parent, compI)
141 self.parent.undoStack.push(action) 127 self.parent.undoStack.push(action)
142 128
143 def openSavePresetDialog(self): 129 def openSavePresetDialog(self):
144 '''Functions on mainwindow level from the context menu''' 130 """Functions on mainwindow level from the context menu"""
145 selectedComponents = self.core.selectedComponents 131 selectedComponents = self.core.selectedComponents
146 componentList = self.parent.listWidget_componentList 132 componentList = self.parent.listWidget_componentList
147 133
@@ -152,10 +138,10 @@ class PresetManager(QtWidgets.QDialog):
152 currentPreset = selectedComponents[index].currentPreset 138 currentPreset = selectedComponents[index].currentPreset
153 newName, OK = QtWidgets.QInputDialog.getText( 139 newName, OK = QtWidgets.QInputDialog.getText(
154 self.parent, 140 self.parent,
155 'Audio Visualizer', 141 "Audio Visualizer",
156 'New Preset Name:', 142 "New Preset Name:",
157 QtWidgets.QLineEdit.Normal, 143 QtWidgets.QLineEdit.EchoMode.Normal,
158 currentPreset 144 currentPreset,
159 ) 145 )
160 if OK: 146 if OK:
161 if badName(newName): 147 if badName(newName):
@@ -164,21 +150,23 @@ class PresetManager(QtWidgets.QDialog):
164 if newName: 150 if newName:
165 if index != -1: 151 if index != -1:
166 selectedComponents[index].currentPreset = newName 152 selectedComponents[index].currentPreset = newName
167 saveValueStore = \ 153 saveValueStore = selectedComponents[index].savePreset()
168 selectedComponents[index].savePreset() 154 saveValueStore["preset"] = newName
169 saveValueStore['preset'] = newName
170 componentName = str(selectedComponents[index]).strip() 155 componentName = str(selectedComponents[index]).strip()
171 vers = selectedComponents[index].version 156 vers = selectedComponents[index].version
172 self.createNewPreset( 157 self.createNewPreset(
173 componentName, vers, newName, 158 componentName,
174 saveValueStore, window=self.parent) 159 vers,
160 newName,
161 saveValueStore,
162 window=self.parent,
163 )
175 self.findPresets() 164 self.findPresets()
176 self.drawPresetList() 165 self.drawPresetList()
177 self.openPreset(newName, index) 166 self.openPreset(newName, index)
178 break 167 break
179 168
180 def createNewPreset( 169 def createNewPreset(self, compName, vers, filename, saveValueStore, **kwargs):
181 self, compName, vers, filename, saveValueStore, **kwargs):
182 path = os.path.join(self.presetDir, compName, str(vers), filename) 170 path = os.path.join(self.presetDir, compName, str(vers), filename)
183 if self.presetExists(path, **kwargs): 171 if self.presetExists(path, **kwargs):
184 return 172 return
@@ -188,11 +176,11 @@ class PresetManager(QtWidgets.QDialog):
188 if os.path.exists(path): 176 if os.path.exists(path):
189 window = kwargs.get("window", self) 177 window = kwargs.get("window", self)
190 ch = self.parent.showMessage( 178 ch = self.parent.showMessage(
191 msg="%s already exists! Overwrite it?" % 179 msg="%s already exists! Overwrite it?" % os.path.basename(path),
192 os.path.basename(path),
193 showCancel=True, 180 showCancel=True,
194 icon='Warning', 181 icon="Warning",
195 parent=window) 182 parent=window,
183 )
196 if not ch: 184 if not ch:
197 # user clicked cancel 185 # user clicked cancel
198 return True 186 return True
@@ -225,10 +213,10 @@ class PresetManager(QtWidgets.QDialog):
225 return 213 return
226 comp, vers, name = self.presetRows[row] 214 comp, vers, name = self.presetRows[row]
227 ch = self.parent.showMessage( 215 ch = self.parent.showMessage(
228 msg='Really delete %s?' % name, 216 msg="Really delete %s?" % name,
229 showCancel=True, 217 showCancel=True,
230 icon='Warning', 218 icon="Warning",
231 parent=self 219 parent=self,
232 ) 220 )
233 if not ch: 221 if not ch:
234 return 222 return
@@ -240,9 +228,9 @@ class PresetManager(QtWidgets.QDialog):
240 228
241 def warnMessage(self, window=None): 229 def warnMessage(self, window=None):
242 self.parent.showMessage( 230 self.parent.showMessage(
243 msg='Preset names must contain only letters, ' 231 msg="Preset names must contain only letters, " "numbers, and spaces.",
244 'numbers, and spaces.', 232 parent=window if window else self,
245 parent=window if window else self) 233 )
246 234
247 def getPresetRow(self): 235 def getPresetRow(self):
248 row = self.listWidget_presets.currentRow() 236 row = self.listWidget_presets.currentRow()
@@ -262,14 +250,14 @@ class PresetManager(QtWidgets.QDialog):
262 rowTuple = ( 250 rowTuple = (
263 self.core.selectedComponents[compIndex].name, 251 self.core.selectedComponents[compIndex].name,
264 self.core.selectedComponents[compIndex].version, 252 self.core.selectedComponents[compIndex].version,
265 preset 253 preset,
266 ) 254 )
267 for i, tup in enumerate(self.presetRows): 255 for i, tup in enumerate(self.presetRows):
268 if rowTuple == tup: 256 if rowTuple == tup:
269 index = i 257 index = i
270 break 258 break
271 else: 259 else:
272 return -1 260 return -1
273 return index 261 return index
274 262
275 def openRenamePresetDialog(self): 263 def openRenamePresetDialog(self):
@@ -281,10 +269,10 @@ class PresetManager(QtWidgets.QDialog):
281 while True: 269 while True:
282 newName, OK = QtWidgets.QInputDialog.getText( 270 newName, OK = QtWidgets.QInputDialog.getText(
283 self, 271 self,
284 'Preset Manager', 272 "Preset Manager",
285 'Rename Preset:', 273 "Rename Preset:",
286 QtWidgets.QLineEdit.Normal, 274 QtWidgets.QLineEdit.EchoMode.Normal,
287 self.presetRows[index][2] 275 self.presetRows[index][2],
288 ) 276 )
289 if OK: 277 if OK:
290 if badName(newName): 278 if badName(newName):
@@ -292,8 +280,7 @@ class PresetManager(QtWidgets.QDialog):
292 continue 280 continue
293 if newName: 281 if newName:
294 comp, vers, oldName = self.presetRows[index] 282 comp, vers, oldName = self.presetRows[index]
295 path = os.path.join( 283 path = os.path.join(self.presetDir, comp, str(vers))
296 self.presetDir, comp, str(vers))
297 newPath = os.path.join(path, newName) 284 newPath = os.path.join(path, newName)
298 if self.presetExists(newPath): 285 if self.presetExists(newPath):
299 return 286 return
@@ -311,20 +298,21 @@ class PresetManager(QtWidgets.QDialog):
311 self.drawPresetList() 298 self.drawPresetList()
312 path = os.path.dirname(newPath) 299 path = os.path.dirname(newPath)
313 for i, comp in enumerate(self.core.selectedComponents): 300 for i, comp in enumerate(self.core.selectedComponents):
314 if self.core.getPresetDir(comp) == path \ 301 if self.core.getPresetDir(comp) == path and comp.currentPreset == oldName:
315 and comp.currentPreset == oldName:
316 self.core.openPreset(newPath, i, newName) 302 self.core.openPreset(newPath, i, newName)
317 self.parent.updateComponentTitle(i, False) 303 self.parent.updateComponentTitle(i, False)
318 self.parent.drawPreview() 304 self.parent.drawPreview()
319 305
320 def openImportDialog(self): 306 def openImportDialog(self):
321 filename, _ = QtWidgets.QFileDialog.getOpenFileName( 307 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
322 self, "Import Preset File", 308 self,
309 "Import Preset File",
323 self.settings.value("presetDir"), 310 self.settings.value("presetDir"),
324 "Preset Files (*.avl)") 311 "Preset Files (*.avl)",
312 )
325 if filename: 313 if filename:
326 # get installed path & ask user to overwrite if needed 314 # get installed path & ask user to overwrite if needed
327 path = '' 315 path = ""
328 while True: 316 while True:
329 if path: 317 if path:
330 if self.presetExists(path): 318 if self.presetExists(path):
@@ -345,15 +333,16 @@ class PresetManager(QtWidgets.QDialog):
345 if index == -1: 333 if index == -1:
346 return 334 return
347 filename, _ = QtWidgets.QFileDialog.getSaveFileName( 335 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
348 self, "Export Preset", 336 self,
337 "Export Preset",
349 self.settings.value("presetDir"), 338 self.settings.value("presetDir"),
350 "Preset Files (*.avl)") 339 "Preset Files (*.avl)",
340 )
351 if filename: 341 if filename:
352 comp, vers, name = self.presetRows[index] 342 comp, vers, name = self.presetRows[index]
353 if not self.core.exportPreset(filename, comp, vers, name): 343 if not self.core.exportPreset(filename, comp, vers, name):
354 self.parent.showMessage( 344 self.parent.showMessage(
355 msg='Couldn\'t export %s.' % filename, 345 msg="Couldn't export %s." % filename, parent=self
356 parent=self
357 ) 346 )
358 self.settings.setValue("presetDir", os.path.dirname(filename)) 347 self.settings.setValue("presetDir", os.path.dirname(filename))
359 348
diff --git a/src/gui/preview_thread.py b/src/gui/preview_thread.py
index 3943a5c..1d78516 100644
--- a/src/gui/preview_thread.py
+++ b/src/gui/preview_thread.py
@@ -1,9 +1,10 @@
1''' 1"""
2 Thread that runs to create QImages for MainWindow's preview label. 2Thread that runs to create QImages for MainWindow's preview label.
3 Processes a queue of component lists. 3Processes a queue of component lists.
4''' 4"""
5from PyQt5 import QtCore, QtGui, uic 5
6from PyQt5.QtCore import pyqtSignal, pyqtSlot 6from PyQt6 import QtCore, QtGui, uic
7from PyQt6.QtCore import pyqtSignal, pyqtSlot
7from PIL import Image 8from PIL import Image
8from PIL.ImageQt import ImageQt 9from PIL.ImageQt import ImageQt
9from queue import Queue, Empty 10from queue import Queue, Empty
@@ -26,8 +27,8 @@ class Worker(QtCore.QObject):
26 super().__init__() 27 super().__init__()
27 self.core = core 28 self.core = core
28 self.settings = settings 29 self.settings = settings
29 width = int(self.settings.value('outputWidth')) 30 width = int(self.settings.value("outputWidth"))
30 height = int(self.settings.value('outputHeight')) 31 height = int(self.settings.value("outputHeight"))
31 self.queue = queue 32 self.queue = queue
32 self.background = Checkerboard(width, height) 33 self.background = Checkerboard(width, height)
33 34
@@ -35,10 +36,10 @@ class Worker(QtCore.QObject):
35 @pyqtSlot(list) 36 @pyqtSlot(list)
36 def createPreviewImage(self, components): 37 def createPreviewImage(self, components):
37 dic = { 38 dic = {
38 "components": components, 39 "components": components,
39 } 40 }
40 self.queue.put(dic) 41 self.queue.put(dic)
41 log.debug('Preview thread id: {}'.format(int(QtCore.QThread.currentThreadId()))) 42 log.debug("Preview thread id: {}".format(int(QtCore.QThread.currentThreadId())))
42 43
43 @pyqtSlot() 44 @pyqtSlot()
44 def process(self): 45 def process(self):
@@ -49,31 +50,34 @@ class Worker(QtCore.QObject):
49 self.queue.get(block=False) 50 self.queue.get(block=False)
50 except Empty: 51 except Empty:
51 continue 52 continue
52 width = int(self.settings.value('outputWidth')) 53 width = int(self.settings.value("outputWidth"))
53 height = int(self.settings.value('outputHeight')) 54 height = int(self.settings.value("outputHeight"))
54 if self.background.width != width \ 55 if self.background.width != width or self.background.height != height:
55 or self.background.height != height:
56 self.background = Checkerboard(width, height) 56 self.background = Checkerboard(width, height)
57 57
58 frame = self.background.copy() 58 frame = self.background.copy()
59 log.info('Creating new preview frame') 59 log.info("Creating new preview frame")
60 components = nextPreviewInformation["components"] 60 components = nextPreviewInformation["components"]
61 for component in reversed(components): 61 for component in reversed(components):
62 try: 62 try:
63 component.lockSize(width, height) 63 component.lockSize(width, height)
64 newFrame = component.previewRender() 64 newFrame = component.previewRender()
65 component.unlockSize() 65 component.unlockSize()
66 frame = Image.alpha_composite( 66 frame = Image.alpha_composite(frame, newFrame)
67 frame, newFrame
68 )
69 67
70 except ValueError as e: 68 except ValueError as e:
71 errMsg = "Bad frame returned by %s's preview renderer. " \ 69 errMsg = (
72 "%s. New frame size was %s*%s; should be %s*%s." % ( 70 "Bad frame returned by %s's preview renderer. "
73 str(component), str(e).capitalize(), 71 "%s. New frame size was %s*%s; should be %s*%s."
74 newFrame.width, newFrame.height, 72 % (
75 width, height 73 str(component),
74 str(e).capitalize(),
75 newFrame.width,
76 newFrame.height,
77 width,
78 height,
76 ) 79 )
80 )
77 log.critical(errMsg) 81 log.critical(errMsg)
78 self.error.emit(errMsg) 82 self.error.emit(errMsg)
79 break 83 break
diff --git a/src/gui/preview_win.py b/src/gui/preview_win.py
index d910456..f52f8a3 100644
--- a/src/gui/preview_win.py
+++ b/src/gui/preview_win.py
@@ -1,18 +1,20 @@
1from PyQt5 import QtCore, QtGui, QtWidgets 1from PyQt6 import QtCore, QtGui, QtWidgets
2import logging 2import logging
3 3
4log = logging.getLogger('AVP.Gui.PreviewWindow') 4log = logging.getLogger("AVP.Gui.PreviewWindow")
5 5
6 6
7class PreviewWindow(QtWidgets.QLabel): 7class PreviewWindow(QtWidgets.QLabel):
8 ''' 8 """
9 Paints the preview QLabel in MainWindow and maintains the aspect ratio 9 Paints the preview QLabel in MainWindow and maintains the aspect ratio
10 when the window is resized. 10 when the window is resized.
11 ''' 11 """
12
12 def __init__(self, parent, img): 13 def __init__(self, parent, img):
13 super().__init__() 14 super().__init__()
14 self.parent = parent 15 self.parent = parent
15 self.setFrameStyle(QtWidgets.QFrame.StyledPanel) 16 # FIXME
17 # self.setFrameStyle(QtWidgets.QFrame.StyledPanel)
16 self.pixmap = QtGui.QPixmap(img) 18 self.pixmap = QtGui.QPixmap(img)
17 19
18 def paintEvent(self, event): 20 def paintEvent(self, event):
@@ -21,12 +23,13 @@ class PreviewWindow(QtWidgets.QLabel):
21 point = QtCore.QPoint(0, 0) 23 point = QtCore.QPoint(0, 0)
22 scaledPix = self.pixmap.scaled( 24 scaledPix = self.pixmap.scaled(
23 size, 25 size,
24 QtCore.Qt.KeepAspectRatio, 26 QtCore.Qt.AspectRatioMode.KeepAspectRatio,
25 transformMode=QtCore.Qt.SmoothTransformation) 27 transformMode=QtCore.Qt.TransformationMode.SmoothTransformation,
28 )
26 29
27 # start painting the label from left upper corner 30 # start painting the label from left upper corner
28 point.setX(int((size.width() - scaledPix.width())/2)) 31 point.setX(int((size.width() - scaledPix.width()) / 2))
29 point.setY(int((size.height() - scaledPix.height())/2)) 32 point.setY(int((size.height() - scaledPix.height()) / 2))
30 painter.drawPixmap(point, scaledPix) 33 painter.drawPixmap(point, scaledPix)
31 34
32 def changePixmap(self, img): 35 def changePixmap(self, img):
@@ -40,22 +43,16 @@ class PreviewWindow(QtWidgets.QLabel):
40 i = self.parent.listWidget_componentList.currentRow() 43 i = self.parent.listWidget_componentList.currentRow()
41 if i >= 0: 44 if i >= 0:
42 component = self.parent.core.selectedComponents[i] 45 component = self.parent.core.selectedComponents[i]
43 if not hasattr(component, 'previewClickEvent'): 46 if not hasattr(component, "previewClickEvent"):
44 return 47 return
45 pos = (event.x(), event.y()) 48 qpoint = event.position().toPoint()
49 pos = (qpoint.x(), qpoint.y())
46 size = (self.width(), self.height()) 50 size = (self.width(), self.height())
47 butt = event.button() 51 butt = event.button()
48 log.info('Click event for #%s: %s button %s' % ( 52 log.info("Click event for #%s: %s button %s" % (i, pos, butt))
49 i, pos, butt)) 53 component.previewClickEvent(pos, size, butt)
50 component.previewClickEvent(
51 pos, size, butt
52 )
53 54
54 @QtCore.pyqtSlot(str) 55 @QtCore.pyqtSlot(str)
55 def threadError(self, msg): 56 def threadError(self, msg):
56 self.parent.showMessage( 57 self.parent.showMessage(msg=msg, icon="Critical", parent=self)
57 msg=msg, 58 log.info("%", repr(self.parent))
58 icon='Critical',
59 parent=self
60 )
61 log.info('%', repr(self.parent))
diff --git a/src/tests/__init__.py b/src/tests/__init__.py
index 345bd96..e2d83e7 100644
--- a/src/tests/__init__.py
+++ b/src/tests/__init__.py
@@ -5,20 +5,22 @@ from ..core import Core
5 5
6 6
7def getTestDataPath(filename): 7def getTestDataPath(filename):
8 return os.path.join(Core.wd, 'tests', 'data', filename) 8 return os.path.join(Core.wd, "tests", "data", filename)
9 9
10 10
11def run(logFile): 11def run(logFile):
12 """Run Pytest, which then imports and runs all tests in this module.""" 12 """Run Pytest, which then imports and runs all tests in this module."""
13 os.environ["PYTEST_QT_API"] = "pyqt5" 13 os.environ["PYTEST_QT_API"] = "PyQt6"
14 with open(logFile, "w") as f: 14 with open(logFile, "w") as f:
15 # temporarily redirect stdout to a text file so we capture pytest's output 15 # temporarily redirect stdout to a text file so we capture pytest's output
16 sys.stdout = f 16 sys.stdout = f
17 try: 17 try:
18 val = pytest.main([ 18 val = pytest.main(
19 os.path.dirname(__file__), 19 [
20 "-s", # disable pytest's internal capturing of stdout etc. 20 os.path.dirname(__file__),
21 ]) 21 "-s", # disable pytest's internal capturing of stdout etc.
22 ]
23 )
22 finally: 24 finally:
23 sys.stdout = sys.__stdout__ 25 sys.stdout = sys.__stdout__
24 26
diff --git a/src/tests/test_commandline_export.py b/src/tests/test_commandline_export.py
index 7f3530f..6126da7 100644
--- a/src/tests/test_commandline_export.py
+++ b/src/tests/test_commandline_export.py
@@ -7,11 +7,20 @@ from pytestqt import qtbot
7 7
8 8
9def test_commandline_classic_export(qtbot): 9def test_commandline_classic_export(qtbot):
10 '''Run Qt event loop and create a video in the system /tmp or /temp''' 10 """Run Qt event loop and create a video in the system /tmp or /temp"""
11 soundFile = getTestDataPath("test.ogg") 11 soundFile = getTestDataPath("test.ogg")
12 outputDir = tempfile.mkdtemp(prefix="avp-test-") 12 outputDir = tempfile.mkdtemp(prefix="avp-test-")
13 outputFilename = os.path.join(outputDir, "output.mp4") 13 outputFilename = os.path.join(outputDir, "output.mp4")
14 sys.argv = ['', '-c', '0', 'classic', '-i', soundFile, '-o', outputFilename] 14 sys.argv = [
15 "",
16 "-c",
17 "0",
18 "classic",
19 "-i",
20 soundFile,
21 "-o",
22 outputFilename,
23 ]
15 24
16 command = Command() 25 command = Command()
17 command.quit = lambda _: None 26 command.quit = lambda _: None
@@ -19,10 +28,10 @@ def test_commandline_classic_export(qtbot):
19 # Command object now has a video_thread Worker which is exporting the video 28 # Command object now has a video_thread Worker which is exporting the video
20 29
21 with qtbot.waitSignal(command.worker.videoCreated, timeout=10000): 30 with qtbot.waitSignal(command.worker.videoCreated, timeout=10000):
22 ''' 31 """
23 Wait until videoCreated is emitted by the video_thread Worker 32 Wait until videoCreated is emitted by the video_thread Worker
24 or until 10 second timeout has passed 33 or until 10 second timeout has passed
25 ''' 34 """
26 print(f"Test Video created at {outputFilename}") 35 print(f"Test Video created at {outputFilename}")
27 36
28 assert os.path.exists(outputFilename) 37 assert os.path.exists(outputFilename)
diff --git a/src/tests/test_commandline_parser.py b/src/tests/test_commandline_parser.py
index 0813e00..5d1232b 100644
--- a/src/tests/test_commandline_parser.py
+++ b/src/tests/test_commandline_parser.py
@@ -5,28 +5,28 @@ from ..command import Command
5 5
6def test_commandline_help(): 6def test_commandline_help():
7 command = Command() 7 command = Command()
8 sys.argv = ['', '--help'] 8 sys.argv = ["", "--help"]
9 with pytest.raises(SystemExit): 9 with pytest.raises(SystemExit):
10 command.parseArgs() 10 command.parseArgs()
11 11
12 12
13def test_commandline_help_if_bad_args(): 13def test_commandline_help_if_bad_args():
14 command = Command() 14 command = Command()
15 sys.argv = ['', '--junk'] 15 sys.argv = ["", "--junk"]
16 with pytest.raises(SystemExit): 16 with pytest.raises(SystemExit):
17 command.parseArgs() 17 command.parseArgs()
18 18
19 19
20def test_commandline_launches_gui_if_debug(): 20def test_commandline_launches_gui_if_debug():
21 command = Command() 21 command = Command()
22 sys.argv = ['', '--debug'] 22 sys.argv = ["", "--debug"]
23 mode = command.parseArgs() 23 mode = command.parseArgs()
24 assert mode == "GUI" 24 assert mode == "GUI"
25 25
26 26
27def test_commandline_launches_gui_if_debug_with_project(): 27def test_commandline_launches_gui_if_debug_with_project():
28 command = Command() 28 command = Command()
29 sys.argv = ['', 'test', '--debug'] 29 sys.argv = ["", "test", "--debug"]
30 mode = command.parseArgs() 30 mode = command.parseArgs()
31 assert mode == "GUI" 31 assert mode == "GUI"
32 32
@@ -34,11 +34,12 @@ def test_commandline_launches_gui_if_debug_with_project():
34def test_commandline_tries_to_export(): 34def test_commandline_tries_to_export():
35 command = Command() 35 command = Command()
36 didCallFunction = False 36 didCallFunction = False
37
37 def captureFunction(*args): 38 def captureFunction(*args):
38 nonlocal didCallFunction 39 nonlocal didCallFunction
39 didCallFunction = True 40 didCallFunction = True
40 41
41 sys.argv = ['', '-c', '0', 'classic', '-i', '_', '-o', '_'] 42 sys.argv = ["", "-c", "0", "classic", "-i", "_", "-o", "_"]
42 command.createAudioVisualization = captureFunction 43 command.createAudioVisualization = captureFunction
43 command.parseArgs() 44 command.parseArgs()
44 assert didCallFunction 45 assert didCallFunction
diff --git a/src/tests/test_core_init.py b/src/tests/test_core_init.py
index 438f7fe..950dc13 100644
--- a/src/tests/test_core_init.py
+++ b/src/tests/test_core_init.py
@@ -4,15 +4,15 @@ from ..core import Core
4def test_component_names(): 4def test_component_names():
5 core = Core() 5 core = Core()
6 assert core.compNames == [ 6 assert core.compNames == [
7 'Classic Visualizer', 7 "Classic Visualizer",
8 'Color', 8 "Color",
9 "Conway's Game of Life", 9 "Conway's Game of Life",
10 'Image', 10 "Image",
11 'Sound', 11 "Sound",
12 'Spectrum', 12 "Spectrum",
13 'Title Text', 13 "Title Text",
14 'Video', 14 "Video",
15 'Waveform', 15 "Waveform",
16 ] 16 ]
17 17
18 18
diff --git a/src/toolkit/common.py b/src/toolkit/common.py
index 2e800eb..e35aba2 100644
--- a/src/toolkit/common.py
+++ b/src/toolkit/common.py
@@ -1,7 +1,8 @@
1''' 1"""
2 Common functions 2Common functions
3''' 3"""
4from PyQt5 import QtWidgets 4
5from PyQt6 import QtWidgets
5import string 6import string
6import os 7import os
7import sys 8import sys
@@ -11,43 +12,38 @@ from copy import copy
11from collections import OrderedDict 12from collections import OrderedDict
12 13
13 14
14log = logging.getLogger('AVP.Toolkit.Common') 15log = logging.getLogger("AVP.Toolkit.Common")
15 16
16 17
17class blockSignals: 18class blockSignals:
18 ''' 19 """
19 Context manager to temporarily block list of QtWidgets from updating, 20 Context manager to temporarily block list of QtWidgets from updating,
20 and guarantee restoring the previous state afterwards. 21 and guarantee restoring the previous state afterwards.
21 ''' 22 """
23
22 def __init__(self, widgets): 24 def __init__(self, widgets):
23 if type(widgets) is dict: 25 if type(widgets) is dict:
24 self.widgets = concatDictVals(widgets) 26 self.widgets = concatDictVals(widgets)
25 else: 27 else:
26 self.widgets = ( 28 self.widgets = widgets if hasattr(widgets, "__iter__") else [widgets]
27 widgets if hasattr(widgets, '__iter__')
28 else [widgets]
29 )
30 29
31 def __enter__(self): 30 def __enter__(self):
32 log.verbose( 31 log.verbose(
33 'Blocking signals for %s', 32 "Blocking signals for %s",
34 ", ".join([ 33 ", ".join([str(w.__class__.__name__) for w in self.widgets]),
35 str(w.__class__.__name__) for w in self.widgets
36 ])
37 ) 34 )
38 self.oldStates = [w.signalsBlocked() for w in self.widgets] 35 self.oldStates = [w.signalsBlocked() for w in self.widgets]
39 for w in self.widgets: 36 for w in self.widgets:
40 w.blockSignals(True) 37 w.blockSignals(True)
41 38
42 def __exit__(self, *args): 39 def __exit__(self, *args):
43 log.verbose( 40 log.verbose("Resetting blockSignals to %s", str(bool(sum(self.oldStates))))
44 'Resetting blockSignals to %s', str(bool(sum(self.oldStates))))
45 for w, state in zip(self.widgets, self.oldStates): 41 for w, state in zip(self.widgets, self.oldStates):
46 w.blockSignals(state) 42 w.blockSignals(state)
47 43
48 44
49def concatDictVals(d): 45def concatDictVals(d):
50 '''Concatenates all values in given dict into one list.''' 46 """Concatenates all values in given dict into one list."""
51 key, value = d.popitem() 47 key, value = d.popitem()
52 d[key] = value 48 d[key] = value
53 final = copy(value) 49 final = copy(value)
@@ -60,22 +56,22 @@ def concatDictVals(d):
60 56
61 57
62def badName(name): 58def badName(name):
63 '''Returns whether a name contains non-alphanumeric chars''' 59 """Returns whether a name contains non-alphanumeric chars"""
64 return any([letter in string.punctuation for letter in name]) 60 return any([letter in string.punctuation for letter in name])
65 61
66 62
67def alphabetizeDict(dictionary): 63def alphabetizeDict(dictionary):
68 '''Alphabetizes a dict into OrderedDict ''' 64 """Alphabetizes a dict into OrderedDict"""
69 return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0])) 65 return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0]))
70 66
71 67
72def presetToString(dictionary): 68def presetToString(dictionary):
73 '''Returns string repr of a preset''' 69 """Returns string repr of a preset"""
74 return repr(alphabetizeDict(dictionary)) 70 return repr(alphabetizeDict(dictionary))
75 71
76 72
77def presetFromString(string): 73def presetFromString(string):
78 '''Turns a string repr of OrderedDict into a regular dict''' 74 """Turns a string repr of OrderedDict into a regular dict"""
79 return dict(eval(string)) 75 return dict(eval(string))
80 76
81 77
@@ -86,19 +82,21 @@ def appendUppercase(lst):
86 82
87 83
88def pipeWrapper(func): 84def pipeWrapper(func):
89 '''A decorator to insert proper kwargs into Popen objects.''' 85 """A decorator to insert proper kwargs into Popen objects."""
86
90 def pipeWrapper(commandList, **kwargs): 87 def pipeWrapper(commandList, **kwargs):
91 if sys.platform == 'win32': 88 if sys.platform == "win32":
92 # Stop CMD window from appearing on Windows 89 # Stop CMD window from appearing on Windows
93 startupinfo = subprocess.STARTUPINFO() 90 startupinfo = subprocess.STARTUPINFO()
94 startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 91 startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
95 kwargs['startupinfo'] = startupinfo 92 kwargs["startupinfo"] = startupinfo
96 93
97 if 'bufsize' not in kwargs: 94 if "bufsize" not in kwargs:
98 kwargs['bufsize'] = 10**8 95 kwargs["bufsize"] = 10**8
99 if 'stdin' not in kwargs: 96 if "stdin" not in kwargs:
100 kwargs['stdin'] = subprocess.DEVNULL 97 kwargs["stdin"] = subprocess.DEVNULL
101 return func(commandList, **kwargs) 98 return func(commandList, **kwargs)
99
102 return pipeWrapper 100 return pipeWrapper
103 101
104 102
@@ -113,6 +111,7 @@ def disableWhenEncoding(func):
113 return 111 return
114 else: 112 else:
115 return func(self, *args, **kwargs) 113 return func(self, *args, **kwargs)
114
116 return decorator 115 return decorator
117 116
118 117
@@ -122,13 +121,14 @@ def disableWhenOpeningProject(func):
122 return 121 return
123 else: 122 else:
124 return func(self, *args, **kwargs) 123 return func(self, *args, **kwargs)
124
125 return decorator 125 return decorator
126 126
127 127
128def rgbFromString(string): 128def rgbFromString(string):
129 '''Turns an RGB string like "255, 255, 255" into a tuple''' 129 """Turns an RGB string like "255, 255, 255" into a tuple"""
130 try: 130 try:
131 tup = tuple([int(i) for i in string.split(',')]) 131 tup = tuple([int(i) for i in string.split(",")])
132 if len(tup) != 3: 132 if len(tup) != 3:
133 raise ValueError 133 raise ValueError
134 for i in tup: 134 for i in tup:
@@ -141,42 +141,42 @@ def rgbFromString(string):
141 141
142def formatTraceback(tb=None): 142def formatTraceback(tb=None):
143 import traceback 143 import traceback
144
144 if tb is None: 145 if tb is None:
145 import sys 146 import sys
147
146 tb = sys.exc_info()[2] 148 tb = sys.exc_info()[2]
147 return 'Traceback:\n%s' % "\n".join(traceback.format_tb(tb)) 149 return "Traceback:\n%s" % "\n".join(traceback.format_tb(tb))
148 150
149 151
150def connectWidget(widget, func): 152def connectWidget(widget, func):
151 if type(widget) == QtWidgets.QLineEdit: 153 if type(widget) == QtWidgets.QLineEdit:
152 widget.textChanged.connect(func) 154 widget.textChanged.connect(func)
153 elif type(widget) == QtWidgets.QSpinBox \ 155 elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox:
154 or type(widget) == QtWidgets.QDoubleSpinBox:
155 widget.valueChanged.connect(func) 156 widget.valueChanged.connect(func)
156 elif type(widget) == QtWidgets.QCheckBox: 157 elif type(widget) == QtWidgets.QCheckBox:
157 widget.stateChanged.connect(func) 158 widget.stateChanged.connect(func)
158 elif type(widget) == QtWidgets.QComboBox: 159 elif type(widget) == QtWidgets.QComboBox:
159 widget.currentIndexChanged.connect(func) 160 widget.currentIndexChanged.connect(func)
160 else: 161 else:
161 log.warning('Failed to connect %s ', str(widget.__class__.__name__)) 162 log.warning("Failed to connect %s ", str(widget.__class__.__name__))
162 return False 163 return False
163 return True 164 return True
164 165
165 166
166def setWidgetValue(widget, val): 167def setWidgetValue(widget, val):
167 '''Generic setValue method for use with any typical QtWidget''' 168 """Generic setValue method for use with any typical QtWidget"""
168 log.verbose('Setting %s to %s' % (str(widget.__class__.__name__), val)) 169 log.verbose("Setting %s to %s" % (str(widget.__class__.__name__), val))
169 if type(widget) == QtWidgets.QLineEdit: 170 if type(widget) == QtWidgets.QLineEdit:
170 widget.setText(val) 171 widget.setText(val)
171 elif type(widget) == QtWidgets.QSpinBox \ 172 elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox:
172 or type(widget) == QtWidgets.QDoubleSpinBox:
173 widget.setValue(val) 173 widget.setValue(val)
174 elif type(widget) == QtWidgets.QCheckBox: 174 elif type(widget) == QtWidgets.QCheckBox:
175 widget.setChecked(val) 175 widget.setChecked(val)
176 elif type(widget) == QtWidgets.QComboBox: 176 elif type(widget) == QtWidgets.QComboBox:
177 widget.setCurrentIndex(val) 177 widget.setCurrentIndex(val)
178 else: 178 else:
179 log.warning('Failed to set %s ', str(widget.__class__.__name__)) 179 log.warning("Failed to set %s ", str(widget.__class__.__name__))
180 return False 180 return False
181 return True 181 return True
182 182
@@ -184,8 +184,7 @@ def setWidgetValue(widget, val):
184def getWidgetValue(widget): 184def getWidgetValue(widget):
185 if type(widget) == QtWidgets.QLineEdit: 185 if type(widget) == QtWidgets.QLineEdit:
186 return widget.text() 186 return widget.text()
187 elif type(widget) == QtWidgets.QSpinBox \ 187 elif type(widget) == QtWidgets.QSpinBox or type(widget) == QtWidgets.QDoubleSpinBox:
188 or type(widget) == QtWidgets.QDoubleSpinBox:
189 return widget.value() 188 return widget.value()
190 elif type(widget) == QtWidgets.QCheckBox: 189 elif type(widget) == QtWidgets.QCheckBox:
191 return widget.isChecked() 190 return widget.isChecked()
diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py
index ff06469..5aedff3 100644
--- a/src/toolkit/ffmpeg.py
+++ b/src/toolkit/ffmpeg.py
@@ -1,6 +1,7 @@
1''' 1"""
2 Tools for using ffmpeg 2Tools for using ffmpeg
3''' 3"""
4
4import numpy 5import numpy
5import sys 6import sys
6import os 7import os
@@ -14,67 +15,74 @@ from .. import core
14from .common import checkOutput, pipeWrapper 15from .common import checkOutput, pipeWrapper
15 16
16 17
17log = logging.getLogger('AVP.Toolkit.Ffmpeg') 18log = logging.getLogger("AVP.Toolkit.Ffmpeg")
18 19
19 20
20class FfmpegVideo: 21class FfmpegVideo:
21 '''Opens a pipe to ffmpeg and stores a buffer of raw video frames.''' 22 """Opens a pipe to ffmpeg and stores a buffer of raw video frames."""
22 23
23 # error from the thread used to fill the buffer 24 # error from the thread used to fill the buffer
24 threadError = None 25 threadError = None
25 26
26 def __init__(self, **kwargs): 27 def __init__(self, **kwargs):
27 mandatoryArgs = [ 28 mandatoryArgs = [
28 'inputPath', 29 "inputPath",
29 'filter_', 30 "filter_",
30 'width', 31 "width",
31 'height', 32 "height",
32 'frameRate', # frames per second 33 "frameRate", # frames per second
33 'chunkSize', # number of bytes in one frame 34 "chunkSize", # number of bytes in one frame
34 'parent', # mainwindow object 35 "parent", # mainwindow object
35 'component', # component object 36 "component", # component object
36 ] 37 ]
37 for arg in mandatoryArgs: 38 for arg in mandatoryArgs:
38 setattr(self, arg, kwargs[arg]) 39 setattr(self, arg, kwargs[arg])
39 40
40 self.frameNo = -1 41 self.frameNo = -1
41 self.currentFrame = 'None' 42 self.currentFrame = "None"
42 self.map_ = None 43 self.map_ = None
43 44
44 if 'loopVideo' in kwargs and kwargs['loopVideo']: 45 if "loopVideo" in kwargs and kwargs["loopVideo"]:
45 self.loopValue = '-1' 46 self.loopValue = "-1"
46 else: 47 else:
47 self.loopValue = '0' 48 self.loopValue = "0"
48 if 'filter_' in kwargs: 49 if "filter_" in kwargs:
49 if kwargs['filter_'][0] != '-filter_complex': 50 if kwargs["filter_"][0] != "-filter_complex":
50 kwargs['filter_'].insert(0, '-filter_complex') 51 kwargs["filter_"].insert(0, "-filter_complex")
51 else: 52 else:
52 kwargs['filter_'] = None 53 kwargs["filter_"] = None
53 54
54 self.command = [ 55 self.command = [
55 core.Core.FFMPEG_BIN, 56 core.Core.FFMPEG_BIN,
56 '-thread_queue_size', '512', 57 "-thread_queue_size",
57 '-r', str(self.frameRate), 58 "512",
58 '-stream_loop', str(self.loopValue), 59 "-r",
59 '-i', self.inputPath, 60 str(self.frameRate),
60 '-f', 'image2pipe', 61 "-stream_loop",
61 '-pix_fmt', 'rgba', 62 str(self.loopValue),
63 "-i",
64 self.inputPath,
65 "-f",
66 "image2pipe",
67 "-pix_fmt",
68 "rgba",
62 ] 69 ]
63 if type(kwargs['filter_']) is list: 70 if type(kwargs["filter_"]) is list:
64 self.command.extend( 71 self.command.extend(kwargs["filter_"])
65 kwargs['filter_'] 72 self.command.extend(
66 ) 73 [
67 self.command.extend([ 74 "-codec:v",
68 '-codec:v', 'rawvideo', '-', 75 "rawvideo",
69 ]) 76 "-",
77 ]
78 )
70 79
71 self.frameBuffer = PriorityQueue() 80 self.frameBuffer = PriorityQueue()
72 self.frameBuffer.maxsize = self.frameRate 81 self.frameBuffer.maxsize = self.frameRate
73 self.finishedFrames = {} 82 self.finishedFrames = {}
74 83
75 self.thread = threading.Thread( 84 self.thread = threading.Thread(
76 target=self.fillBuffer, 85 target=self.fillBuffer, name="FFmpeg Frame-Fetcher"
77 name='FFmpeg Frame-Fetcher'
78 ) 86 )
79 self.thread.daemon = True 87 self.thread.daemon = True
80 self.thread.start() 88 self.thread.start()
@@ -91,22 +99,29 @@ class FfmpegVideo:
91 99
92 def fillBuffer(self): 100 def fillBuffer(self):
93 from ..component import ComponentError 101 from ..component import ComponentError
102
94 if core.Core.logEnabled: 103 if core.Core.logEnabled:
95 logFilename = os.path.join( 104 logFilename = os.path.join(
96 core.Core.logDir, 'render_%s.log' % str(self.component.compPos) 105 core.Core.logDir, "render_%s.log" % str(self.component.compPos)
97 ) 106 )
98 log.debug('Creating ffmpeg process (log at %s)', logFilename) 107 log.debug("Creating ffmpeg process (log at %s)", logFilename)
99 with open(logFilename, 'w') as logf: 108 with open(logFilename, "w") as logf:
100 logf.write(" ".join(self.command) + '\n\n') 109 logf.write(" ".join(self.command) + "\n\n")
101 with open(logFilename, 'a') as logf: 110 with open(logFilename, "a") as logf:
102 self.pipe = openPipe( 111 self.pipe = openPipe(
103 self.command, stdin=subprocess.DEVNULL, 112 self.command,
104 stdout=subprocess.PIPE, stderr=logf, bufsize=10**8 113 stdin=subprocess.DEVNULL,
114 stdout=subprocess.PIPE,
115 stderr=logf,
116 bufsize=10**8,
105 ) 117 )
106 else: 118 else:
107 self.pipe = openPipe( 119 self.pipe = openPipe(
108 self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, 120 self.command,
109 stderr=subprocess.DEVNULL, bufsize=10**8 121 stdin=subprocess.DEVNULL,
122 stdout=subprocess.PIPE,
123 stderr=subprocess.DEVNULL,
124 bufsize=10**8,
110 ) 125 )
111 126
112 while True: 127 while True:
@@ -117,12 +132,13 @@ class FfmpegVideo:
117 # If we run out of frames, use the last good frame and loop. 132 # If we run out of frames, use the last good frame and loop.
118 try: 133 try:
119 if len(self.currentFrame) == 0: 134 if len(self.currentFrame) == 0:
120 self.frameBuffer.put((self.frameNo-1, self.lastFrame)) 135 self.frameBuffer.put((self.frameNo - 1, self.lastFrame))
121 continue 136 continue
122 except AttributeError: 137 except AttributeError:
123 FfmpegVideo.threadError = ComponentError( 138 FfmpegVideo.threadError = ComponentError(
124 self.component, 'video', 139 self.component,
125 "Video seemed playable but wasn't." 140 "video",
141 "Video seemed playable but wasn't.",
126 ) 142 )
127 break 143 break
128 144
@@ -130,11 +146,12 @@ class FfmpegVideo:
130 self.currentFrame = self.pipe.stdout.read(self.chunkSize) 146 self.currentFrame = self.pipe.stdout.read(self.chunkSize)
131 except ValueError as e: 147 except ValueError as e:
132 if str(e) == "PyMemoryView_FromBuffer(): info->buf must not be NULL": 148 if str(e) == "PyMemoryView_FromBuffer(): info->buf must not be NULL":
133 log.debug("Ignored 'info->buf must not be NULL' error from FFmpeg pipe") 149 log.debug(
150 "Ignored 'info->buf must not be NULL' error from FFmpeg pipe"
151 )
134 return 152 return
135 else: 153 else:
136 FfmpegVideo.threadError = ComponentError( 154 FfmpegVideo.threadError = ComponentError(self.component, "video")
137 self.component, 'video')
138 155
139 if len(self.currentFrame) != 0: 156 if len(self.currentFrame) != 0:
140 self.frameBuffer.put((self.frameNo, self.currentFrame)) 157 self.frameBuffer.put((self.frameNo, self.currentFrame))
@@ -153,19 +170,17 @@ def closePipe(pipe):
153 170
154def findFfmpeg(): 171def findFfmpeg():
155 if sys.platform == "win32": 172 if sys.platform == "win32":
156 bin = 'ffmpeg.exe' 173 bin = "ffmpeg.exe"
157 else: 174 else:
158 bin = 'ffmpeg' 175 bin = "ffmpeg"
159 176
160 if getattr(sys, 'frozen', False): 177 if getattr(sys, "frozen", False):
161 # The application is frozen 178 # The application is frozen
162 bin = os.path.join(core.Core.wd, bin) 179 bin = os.path.join(core.Core.wd, bin)
163 180
164 with open(os.devnull, "w") as f: 181 with open(os.devnull, "w") as f:
165 try: 182 try:
166 checkOutput( 183 checkOutput([bin, "-version"], stderr=f)
167 [bin, '-version'], stderr=f
168 )
169 except (subprocess.CalledProcessError, FileNotFoundError): 184 except (subprocess.CalledProcessError, FileNotFoundError):
170 bin = "" 185 bin = ""
171 186
@@ -173,9 +188,9 @@ def findFfmpeg():
173 188
174 189
175def createFfmpegCommand(inputFile, outputFile, components, duration=-1): 190def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
176 ''' 191 """
177 Constructs the major ffmpeg command used to export the video 192 Constructs the major ffmpeg command used to export the video
178 ''' 193 """
179 if duration == -1: 194 if duration == -1:
180 duration = getAudioDuration(inputFile) 195 duration = getAudioDuration(inputFile)
181 safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters 196 safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters
@@ -183,31 +198,33 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
183 Core = core.Core 198 Core = core.Core
184 199
185 # Test if user has libfdk_aac 200 # Test if user has libfdk_aac
186 encoders = checkOutput( 201 encoders = checkOutput("%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True)
187 "%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True
188 )
189 encoders = encoders.decode("utf-8") 202 encoders = encoders.decode("utf-8")
190 203
191 acodec = Core.settings.value('outputAudioCodec') 204 acodec = Core.settings.value("outputAudioCodec")
192 205
193 options = Core.encoderOptions 206 options = Core.encoderOptions
194 containerName = Core.settings.value('outputContainer') 207 containerName = Core.settings.value("outputContainer")
195 vcodec = Core.settings.value('outputVideoCodec') 208 vcodec = Core.settings.value("outputVideoCodec")
196 vbitrate = str(Core.settings.value('outputVideoBitrate'))+'k' 209 vbitrate = str(Core.settings.value("outputVideoBitrate")) + "k"
197 acodec = Core.settings.value('outputAudioCodec') 210 acodec = Core.settings.value("outputAudioCodec")
198 abitrate = str(Core.settings.value('outputAudioBitrate'))+'k' 211 abitrate = str(Core.settings.value("outputAudioBitrate")) + "k"
199 212
200 for cont in options['containers']: 213 for cont in options["containers"]:
201 if cont['name'] == containerName: 214 if cont["name"] == containerName:
202 container = cont['container'] 215 container = cont["container"]
203 break 216 break
204 217
205 vencoders = options['video-codecs'][vcodec] 218 vencoders = options["video-codecs"][vcodec]
206 aencoders = options['audio-codecs'][acodec] 219 aencoders = options["audio-codecs"][acodec]
207 220
208 def error(): 221 def error():
209 nonlocal encoders, encoder 222 nonlocal encoders, encoder
210 log.critical("Selected encoder (%s) is not supported by Ffmpeg. The supported encoders are: %s", encoder, encoders) 223 log.critical(
224 "Selected encoder (%s) is not supported by Ffmpeg. The supported encoders are: %s",
225 encoder,
226 encoders,
227 )
211 return [] 228 return []
212 229
213 for encoder in vencoders: 230 for encoder in vencoders:
@@ -226,57 +243,75 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
226 243
227 ffmpegCommand = [ 244 ffmpegCommand = [
228 Core.FFMPEG_BIN, 245 Core.FFMPEG_BIN,
229 '-thread_queue_size', '512', 246 "-thread_queue_size",
230 '-y', # overwrite the output file if it already exists. 247 "512",
231 248 "-y", # overwrite the output file if it already exists.
232 # INPUT VIDEO 249 # INPUT VIDEO
233 '-f', 'rawvideo', 250 "-f",
234 '-vcodec', 'rawvideo', 251 "rawvideo",
235 '-s', f'{Core.settings.value("outputWidth")}x{Core.settings.value("outputHeight")}', 252 "-vcodec",
236 '-pix_fmt', 'rgba', 253 "rawvideo",
237 '-r', str(Core.settings.value('outputFrameRate')), 254 "-s",
238 '-t', duration, 255 f'{Core.settings.value("outputWidth")}x{Core.settings.value("outputHeight")}',
239 '-an', # the video input has no sound 256 "-pix_fmt",
240 '-i', '-', # the video input comes from a pipe 257 "rgba",
241 258 "-r",
259 str(Core.settings.value("outputFrameRate")),
260 "-t",
261 duration,
262 "-an", # the video input has no sound
263 "-i",
264 "-", # the video input comes from a pipe
242 # INPUT SOUND 265 # INPUT SOUND
243 '-t', duration, 266 "-t",
244 '-i', inputFile 267 duration,
268 "-i",
269 inputFile,
245 ] 270 ]
246 271
247 extraAudio = [ 272 extraAudio = [comp.audio for comp in components if "audio" in comp.properties()]
248 comp.audio for comp in components
249 if 'audio' in comp.properties()
250 ]
251 segment = createAudioFilterCommand(extraAudio, safeDuration) 273 segment = createAudioFilterCommand(extraAudio, safeDuration)
252 ffmpegCommand.extend(segment) 274 ffmpegCommand.extend(segment)
253 # Map audio from the filters or the single audio input, and map video from the pipe 275 # Map audio from the filters or the single audio input, and map video from the pipe
254 ffmpegCommand.extend([ 276 ffmpegCommand.extend(
255 '-map', '0:v', 277 [
256 '-map', '[a]' if segment else '1:a', 278 "-map",
257 ]) 279 "0:v",
258 280 "-map",
259 ffmpegCommand.extend([ 281 "[a]" if segment else "1:a",
260 # OUTPUT 282 ]
261 '-vcodec', vencoder, 283 )
262 '-acodec', aencoder, 284
263 '-b:v', vbitrate, 285 ffmpegCommand.extend(
264 '-b:a', abitrate, 286 [
265 '-pix_fmt', Core.settings.value('outputVideoFormat'), 287 # OUTPUT
266 '-preset', Core.settings.value('outputPreset'), 288 "-vcodec",
267 '-f', container 289 vencoder,
268 ]) 290 "-acodec",
269 291 aencoder,
270 if acodec == 'aac': 292 "-b:v",
271 ffmpegCommand.append('-strict') 293 vbitrate,
272 ffmpegCommand.append('-2') 294 "-b:a",
295 abitrate,
296 "-pix_fmt",
297 Core.settings.value("outputVideoFormat"),
298 "-preset",
299 Core.settings.value("outputPreset"),
300 "-f",
301 container,
302 ]
303 )
304
305 if acodec == "aac":
306 ffmpegCommand.append("-strict")
307 ffmpegCommand.append("-2")
273 308
274 ffmpegCommand.append(outputFile) 309 ffmpegCommand.append(outputFile)
275 return ffmpegCommand 310 return ffmpegCommand
276 311
277 312
278def createAudioFilterCommand(extraAudio, duration): 313def createAudioFilterCommand(extraAudio, duration):
279 '''Add extra inputs and any needed filters to the main ffmpeg command.''' 314 """Add extra inputs and any needed filters to the main ffmpeg command."""
280 # NOTE: Global filters are currently hard-coded here for debugging use 315 # NOTE: Global filters are currently hard-coded here for debugging use
281 globalFilters = 0 # increase to add global filters 316 globalFilters = 0 # increase to add global filters
282 317
@@ -288,21 +323,23 @@ def createAudioFilterCommand(extraAudio, duration):
288 extraFilters = {} 323 extraFilters = {}
289 for streamNo, params in enumerate(reversed(extraAudio)): 324 for streamNo, params in enumerate(reversed(extraAudio)):
290 extraInputFile, params = params 325 extraInputFile, params = params
291 ffmpegCommand.extend([ 326 ffmpegCommand.extend(
292 '-t', duration, 327 [
293 # Tell ffmpeg about shorter clips (seemingly not needed) 328 "-t",
294 # streamDuration = getAudioDuration(extraInputFile) 329 duration,
295 # if streamDuration and streamDuration > float(safeDuration) 330 # Tell ffmpeg about shorter clips (seemingly not needed)
296 # else "{0:.3f}".format(streamDuration), 331 # streamDuration = getAudioDuration(extraInputFile)
297 '-i', extraInputFile 332 # if streamDuration and streamDuration > float(safeDuration)
298 ]) 333 # else "{0:.3f}".format(streamDuration),
334 "-i",
335 extraInputFile,
336 ]
337 )
299 # Construct dataset of extra filters we'll need to add later 338 # Construct dataset of extra filters we'll need to add later
300 for ffmpegFilter in params: 339 for ffmpegFilter in params:
301 if streamNo + 2 not in extraFilters: 340 if streamNo + 2 not in extraFilters:
302 extraFilters[streamNo + 2] = [] 341 extraFilters[streamNo + 2] = []
303 extraFilters[streamNo + 2].append(( 342 extraFilters[streamNo + 2].append((ffmpegFilter, params[ffmpegFilter]))
304 ffmpegFilter, params[ffmpegFilter]
305 ))
306 343
307 # Start creating avfilters! Popen-style, so don't use semicolons; 344 # Start creating avfilters! Popen-style, so don't use semicolons;
308 extraFilterCommand = [] 345 extraFilterCommand = []
@@ -318,63 +355,73 @@ def createAudioFilterCommand(extraAudio, duration):
318 extraFilters[streamNo + 1] = [] 355 extraFilters[streamNo + 1] = []
319 # Also filter the primary audio track 356 # Also filter the primary audio track
320 extraFilters[1] = [] 357 extraFilters[1] = []
321 tmpInputs = { 358 tmpInputs = {streamNo: globalFilters - 1 for streamNo in extraFilters}
322 streamNo: globalFilters - 1
323 for streamNo in extraFilters
324 }
325 359
326 # Add the global filters! 360 # Add the global filters!
327 # NOTE: list length must = globalFilters, currently hardcoded 361 # NOTE: list length must = globalFilters, currently hardcoded
328 if tmpInputs: 362 if tmpInputs:
329 extraFilterCommand.extend([ 363 extraFilterCommand.extend(
330 '[%s:a] ashowinfo [%stmp0]' % ( 364 [
331 str(streamNo), 365 "[%s:a] ashowinfo [%stmp0]" % (str(streamNo), str(streamNo))
332 str(streamNo) 366 for streamNo in tmpInputs
333 ) 367 ]
334 for streamNo in tmpInputs 368 )
335 ])
336 369
337 # Now add the per-stream filters! 370 # Now add the per-stream filters!
338 for streamNo, paramList in extraFilters.items(): 371 for streamNo, paramList in extraFilters.items():
339 for param in paramList: 372 for param in paramList:
340 source = '[%s:a]' % str(streamNo) \ 373 source = (
341 if tmpInputs[streamNo] == -1 else \ 374 "[%s:a]" % str(streamNo)
342 '[%stmp%s]' % ( 375 if tmpInputs[streamNo] == -1
343 str(streamNo), str(tmpInputs[streamNo]) 376 else "[%stmp%s]" % (str(streamNo), str(tmpInputs[streamNo]))
344 ) 377 )
345 tmpInputs[streamNo] = tmpInputs[streamNo] + 1 378 tmpInputs[streamNo] = tmpInputs[streamNo] + 1
346 extraFilterCommand.append( 379 extraFilterCommand.append(
347 '%s %s%s [%stmp%s]' % ( 380 "%s %s%s [%stmp%s]"
348 source, param[0], param[1], str(streamNo), 381 % (
349 str(tmpInputs[streamNo]) 382 source,
383 param[0],
384 param[1],
385 str(streamNo),
386 str(tmpInputs[streamNo]),
350 ) 387 )
351 ) 388 )
352 389
353 # Join all the filters together and combine into 1 stream 390 # Join all the filters together and combine into 1 stream
354 extraFilterCommand = "; ".join(extraFilterCommand) + '; ' \ 391 extraFilterCommand = "; ".join(extraFilterCommand) + "; " if tmpInputs else ""
355 if tmpInputs else '' 392 ffmpegCommand.extend(
356 ffmpegCommand.extend([ 393 [
357 '-filter_complex', 394 "-filter_complex",
358 extraFilterCommand + 395 extraFilterCommand
359 '%s amix=inputs=%s:duration=first [a]' 396 + "%s amix=inputs=%s:duration=first [a]"
360 % ( 397 % (
361 "".join([ 398 "".join(
362 '[%stmp%s]' % (str(i), tmpInputs[i]) 399 [
363 if i in extraFilters else '[%s:a]' % str(i) 400 (
364 for i in range(1, len(extraAudio) + 2) 401 "[%stmp%s]" % (str(i), tmpInputs[i])
365 ]), 402 if i in extraFilters
366 str(len(extraAudio) + 1) 403 else "[%s:a]" % str(i)
367 ), 404 )
368 ]) 405 for i in range(1, len(extraAudio) + 2)
406 ]
407 ),
408 str(len(extraAudio) + 1),
409 ),
410 ]
411 )
369 return ffmpegCommand 412 return ffmpegCommand
370 413
371 414
372def testAudioStream(filename): 415def testAudioStream(filename):
373 '''Test if an audio stream definitely exists''' 416 """Test if an audio stream definitely exists"""
374 audioTestCommand = [ 417 audioTestCommand = [
375 core.Core.FFMPEG_BIN, 418 core.Core.FFMPEG_BIN,
376 '-i', filename, 419 "-i",
377 '-vn', '-f', 'null', '-' 420 filename,
421 "-vn",
422 "-f",
423 "null",
424 "-",
378 ] 425 ]
379 try: 426 try:
380 checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) 427 checkOutput(audioTestCommand, stderr=subprocess.DEVNULL)
@@ -385,8 +432,8 @@ def testAudioStream(filename):
385 432
386 433
387def getAudioDuration(filename): 434def getAudioDuration(filename):
388 '''Try to get duration of audio file as float, or False if not possible''' 435 """Try to get duration of audio file as float, or False if not possible"""
389 command = [core.Core.FFMPEG_BIN, '-i', filename] 436 command = [core.Core.FFMPEG_BIN, "-i", filename]
390 437
391 try: 438 try:
392 fileInfo = checkOutput(command, stderr=subprocess.STDOUT) 439 fileInfo = checkOutput(command, stderr=subprocess.STDOUT)
@@ -397,17 +444,17 @@ def getAudioDuration(filename):
397 return False 444 return False
398 445
399 try: 446 try:
400 info = fileInfo.decode("utf-8").split('\n') 447 info = fileInfo.decode("utf-8").split("\n")
401 except UnicodeDecodeError as e: 448 except UnicodeDecodeError as e:
402 log.error('Unicode error:', str(e)) 449 log.error("Unicode error:", str(e))
403 return False 450 return False
404 451
405 for line in info: 452 for line in info:
406 if 'Duration' in line: 453 if "Duration" in line:
407 d = line.split(',')[0] 454 d = line.split(",")[0]
408 d = d.split(' ')[3] 455 d = d.split(" ")[3]
409 d = d.split(':') 456 d = d.split(":")
410 duration = float(d[0])*3600 + float(d[1])*60 + float(d[2]) 457 duration = float(d[0]) * 3600 + float(d[1]) * 60 + float(d[2])
411 break 458 break
412 else: 459 else:
413 # String not found in output 460 # String not found in output
@@ -416,10 +463,10 @@ def getAudioDuration(filename):
416 463
417 464
418def readAudioFile(filename, videoWorker): 465def readAudioFile(filename, videoWorker):
419 ''' 466 """
420 Creates the completeAudioArray given to components 467 Creates the completeAudioArray given to components
421 and used to draw the classic visualizer. 468 and used to draw the classic visualizer.
422 ''' 469 """
423 duration = getAudioDuration(filename) 470 duration = getAudioDuration(filename)
424 if not duration: 471 if not duration:
425 log.error(f"Audio file {filename} doesn't exist or unreadable.") 472 log.error(f"Audio file {filename} doesn't exist or unreadable.")
@@ -427,15 +474,23 @@ def readAudioFile(filename, videoWorker):
427 474
428 command = [ 475 command = [
429 core.Core.FFMPEG_BIN, 476 core.Core.FFMPEG_BIN,
430 '-i', filename, 477 "-i",
431 '-f', 's16le', 478 filename,
432 '-acodec', 'pcm_s16le', 479 "-f",
433 '-ar', '44100', # ouput will have 44100 Hz 480 "s16le",
434 '-ac', '1', # mono (set to '2' for stereo) 481 "-acodec",
435 '-'] 482 "pcm_s16le",
483 "-ar",
484 "44100", # ouput will have 44100 Hz
485 "-ac",
486 "1", # mono (set to '2' for stereo)
487 "-",
488 ]
436 in_pipe = openPipe( 489 in_pipe = openPipe(
437 command, 490 command,
438 stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8 491 stdout=subprocess.PIPE,
492 stderr=subprocess.DEVNULL,
493 bufsize=10**8,
439 ) 494 )
440 495
441 completeAudioArray = numpy.empty(0, dtype="int16") 496 completeAudioArray = numpy.empty(0, dtype="int16")
@@ -447,18 +502,18 @@ def readAudioFile(filename, videoWorker):
447 return 502 return
448 # read 2 seconds of audio 503 # read 2 seconds of audio
449 progress += 4 504 progress += 4
450 raw_audio = in_pipe.stdout.read(88200*4) 505 raw_audio = in_pipe.stdout.read(88200 * 4)
451 if len(raw_audio) == 0: 506 if len(raw_audio) == 0:
452 break 507 break
453 audio_array = numpy.fromstring(raw_audio, dtype="int16") 508 audio_array = numpy.frombuffer(raw_audio, dtype="int16")
454 completeAudioArray = numpy.append(completeAudioArray, audio_array) 509 completeAudioArray = numpy.append(completeAudioArray, audio_array)
455 510
456 percent = int(100*(progress/duration)) 511 percent = int(100 * (progress / duration))
457 if percent >= 100: 512 if percent >= 100:
458 percent = 100 513 percent = 100
459 514
460 if lastPercent != percent: 515 if lastPercent != percent:
461 string = 'Loading audio file: '+str(percent)+'%' 516 string = "Loading audio file: " + str(percent) + "%"
462 videoWorker.progressBarSetText.emit(string) 517 videoWorker.progressBarSetText.emit(string)
463 videoWorker.progressBarUpdate.emit(percent) 518 videoWorker.progressBarUpdate.emit(percent)
464 519
@@ -468,25 +523,23 @@ def readAudioFile(filename, videoWorker):
468 in_pipe.wait() 523 in_pipe.wait()
469 524
470 # add 0s the end 525 # add 0s the end
471 completeAudioArrayCopy = numpy.zeros( 526 completeAudioArrayCopy = numpy.zeros(len(completeAudioArray) + 44100, dtype="int16")
472 len(completeAudioArray) + 44100, dtype="int16") 527 completeAudioArrayCopy[: len(completeAudioArray)] = completeAudioArray
473 completeAudioArrayCopy[:len(completeAudioArray)] = completeAudioArray
474 completeAudioArray = completeAudioArrayCopy 528 completeAudioArray = completeAudioArrayCopy
475 529
476 return (completeAudioArray, duration) 530 return (completeAudioArray, duration)
477 531
478 532
479def exampleSound( 533def exampleSound(style="white", extra="apulsator=offset_l=0.35:offset_r=0.67"):
480 style='white', extra='apulsator=offset_l=0.35:offset_r=0.67'): 534 """Help generate an example sound for use in creating a preview"""
481 '''Help generate an example sound for use in creating a preview'''
482 535
483 if style == 'white': 536 if style == "white":
484 src = '-2+random(0)' 537 src = "-2+random(0)"
485 elif style == 'freq': 538 elif style == "freq":
486 src = 'sin(1000*t*PI*t)' 539 src = "sin(1000*t*PI*t)"
487 elif style == 'wave': 540 elif style == "wave":
488 src = 'sin(random(0)*2*PI*t)*tan(random(0)*2*PI*t)' 541 src = "sin(random(0)*2*PI*t)*tan(random(0)*2*PI*t)"
489 elif style == 'stereo': 542 elif style == "stereo":
490 src = '0.1*sin(2*PI*(360-2.5/2)*t) | 0.1*sin(2*PI*(360+2.5/2)*t)' 543 src = "0.1*sin(2*PI*(360-2.5/2)*t) | 0.1*sin(2*PI*(360+2.5/2)*t)"
491 544
492 return "aevalsrc='%s', %s%s" % (src, extra, ', ' if extra else '') 545 return "aevalsrc='%s', %s%s" % (src, extra, ", " if extra else "")
diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py
index 520bd43..94537a6 100644
--- a/src/toolkit/frame.py
+++ b/src/toolkit/frame.py
@@ -1,25 +1,27 @@
1''' 1"""
2 Common tools for drawing compatible frames in a Component's frameRender() 2Common tools for drawing compatible frames in a Component's frameRender()
3''' 3"""
4from PyQt5 import QtGui 4
5from PyQt6 import QtGui
5from PIL import Image 6from PIL import Image
6from PIL.ImageQt import ImageQt 7from PIL.ImageQt import ImageQt
8from PyQt6 import QtCore
7import sys 9import sys
8import os 10import os
9import math 11import math
10import logging 12import logging
11
12from .. import core 13from .. import core
13 14
14 15
15log = logging.getLogger('AVP.Toolkit.Frame') 16log = logging.getLogger("AVP.Toolkit.Frame")
16 17
17 18
18class FramePainter(QtGui.QPainter): 19class FramePainter(QtGui.QPainter):
19 ''' 20 """
20 A QPainter for a blank frame, which can be converted into a 21 A QPainter for a blank frame, which can be converted into a
21 Pillow image with finalize() 22 Pillow image with finalize()
22 ''' 23 """
24
23 def __init__(self, width, height): 25 def __init__(self, width, height):
24 image = BlankFrame(width, height) 26 image = BlankFrame(width, height)
25 log.debug("Creating QImage from PIL image object") 27 log.debug("Creating QImage from PIL image object")
@@ -34,21 +36,33 @@ class FramePainter(QtGui.QPainter):
34 36
35 def finalize(self): 37 def finalize(self):
36 log.verbose("Finalizing FramePainter") 38 log.verbose("Finalizing FramePainter")
39 buffer = QtCore.QBuffer()
40 buffer.open(QtCore.QBuffer.OpenModeFlag.ReadWrite)
41 self.image.save(buffer, "PNG")
42 import io
43
44 frame = Image.open(io.BytesIO(buffer.data()))
45 buffer.close()
46 self.end()
47 return frame
37 imBytes = self.image.bits().asstring(self.image.byteCount()) 48 imBytes = self.image.bits().asstring(self.image.byteCount())
38 frame = Image.frombytes( 49 frame = Image.frombytes(
39 'RGBA', (self.image.width(), self.image.height()), imBytes 50 "RGBA", (self.image.width(), self.image.height()), imBytes
40 ) 51 )
41 self.end() 52 self.end()
42 return frame 53 return frame
43 54
44 55
45class PaintColor(QtGui.QColor): 56class PaintColor(QtGui.QColor):
46 '''Reverse the painter colour if the hardware stores RGB values backward''' 57 """
58 Subclass of QtGui.QColor with an added scale() method
59 Previously this class reversed the painter colour to solve
60 hardware issues related to endianness,
61 but Qt appears to deal with this itself nowadays
62 """
63
47 def __init__(self, r, g, b, a=255): 64 def __init__(self, r, g, b, a=255):
48 if sys.byteorder == 'big': 65 super().__init__(r, g, b, a)
49 super().__init__(r, g, b, a)
50 else:
51 super().__init__(b, g, r, a)
52 66
53 67
54def scale(scalePercent, width, height, returntype=None): 68def scale(scalePercent, width, height, returntype=None):
@@ -63,7 +77,8 @@ def scale(scalePercent, width, height, returntype=None):
63 77
64 78
65def defaultSize(framefunc): 79def defaultSize(framefunc):
66 '''Makes width/height arguments optional''' 80 """Makes width/height arguments optional"""
81
67 def decorator(*args): 82 def decorator(*args):
68 if len(args) < 2: 83 if len(args) < 2:
69 newArgs = list(args) 84 newArgs = list(args)
@@ -75,6 +90,7 @@ def defaultSize(framefunc):
75 newArgs.insert(0, width) 90 newArgs.insert(0, width)
76 args = tuple(newArgs) 91 args = tuple(newArgs)
77 return framefunc(*args) 92 return framefunc(*args)
93
78 return decorator 94 return decorator
79 95
80 96
@@ -84,21 +100,18 @@ def FloodFrame(width, height, RgbaTuple):
84 100
85@defaultSize 101@defaultSize
86def BlankFrame(width, height): 102def BlankFrame(width, height):
87 '''The base frame used by each component to start drawing.''' 103 """The base frame used by each component to start drawing."""
88 return FloodFrame(width, height, (0, 0, 0, 0)) 104 return FloodFrame(width, height, (0, 0, 0, 0))
89 105
90 106
91@defaultSize 107@defaultSize
92def Checkerboard(width, height): 108def Checkerboard(width, height):
93 ''' 109 """
94 A checkerboard to represent transparency to the user. 110 A checkerboard to represent transparency to the user.
95 TODO: Would be cool to generate this image with numpy instead. 111 """
96 ''' 112 # TODO: Would be cool to generate this image with numpy instead.
97 log.debug('Creating new %s*%s checkerboard' % (width, height)) 113 log.debug("Creating new %s*%s checkerboard" % (width, height))
98 image = FloodFrame(1920, 1080, (0, 0, 0, 0)) 114 image = FloodFrame(1920, 1080, (0, 0, 0, 0))
99 image.paste(Image.open( 115 image.paste(Image.open(os.path.join(core.Core.wd, "gui", "background.png")), (0, 0))
100 os.path.join(core.Core.wd, 'gui', "background.png")),
101 (0, 0)
102 )
103 image = image.resize((width, height)) 116 image = image.resize((width, height))
104 return image 117 return image
diff --git a/src/video_thread.py b/src/video_thread.py
index 70e4d5d..5d72409 100644
--- a/src/video_thread.py
+++ b/src/video_thread.py
@@ -1,4 +1,4 @@
1''' 1"""
2Worker thread created to export a video. It has a slot to begin export using 2Worker thread created to export a video. It has a slot to begin export using
3an input file, output path, and component list. 3an input file, output path, and component list.
4 4
@@ -6,9 +6,10 @@ Signals are emitted to update MainWindow's progress bar, detail text, and previe
6A Command object takes the place of MainWindow while in commandline mode. 6A Command object takes the place of MainWindow while in commandline mode.
7 7
8Export can be cancelled with cancel() 8Export can be cancelled with cancel()
9''' 9"""
10from PyQt5 import QtCore, QtGui 10
11from PyQt5.QtCore import pyqtSignal, pyqtSlot 11from PyQt6 import QtCore, QtGui
12from PyQt6.QtCore import pyqtSignal, pyqtSlot
12from PIL import Image 13from PIL import Image
13from PIL.ImageQt import ImageQt 14from PIL.ImageQt import ImageQt
14import numpy 15import numpy
@@ -22,8 +23,10 @@ import logging
22from .component import ComponentError 23from .component import ComponentError
23from .toolkit.frame import Checkerboard 24from .toolkit.frame import Checkerboard
24from .toolkit.ffmpeg import ( 25from .toolkit.ffmpeg import (
25 openPipe, readAudioFile, 26 openPipe,
26 getAudioDuration, createFfmpegCommand 27 readAudioFile,
28 getAudioDuration,
29 createFfmpegCommand,
27) 30)
28 31
29 32
@@ -32,7 +35,7 @@ log = logging.getLogger("AVP.VideoThread")
32 35
33class Worker(QtCore.QObject): 36class Worker(QtCore.QObject):
34 37
35 imageCreated = pyqtSignal('QImage') 38 imageCreated = pyqtSignal("QImage")
36 videoCreated = pyqtSignal() 39 videoCreated = pyqtSignal()
37 progressBarUpdate = pyqtSignal(int) 40 progressBarUpdate = pyqtSignal(int)
38 progressBarSetText = pyqtSignal(str) 41 progressBarSetText = pyqtSignal(str)
@@ -61,31 +64,34 @@ class Worker(QtCore.QObject):
61 self.inputFile, self.outputFile, self.components, duration 64 self.inputFile, self.outputFile, self.components, duration
62 ) 65 )
63 except sp.CalledProcessError as e: 66 except sp.CalledProcessError as e:
64 #FIXME video_thread should own this error signal, not components 67 # FIXME video_thread should own this error signal, not components
65 self.components[0]._error.emit("Ffmpeg could not be found. Is it installed?", str(e)) 68 self.components[0]._error.emit(
69 "Ffmpeg could not be found. Is it installed?", str(e)
70 )
66 self.error = True 71 self.error = True
67 return 72 return
68 73
69 if not ffmpegCommand: 74 if not ffmpegCommand:
70 #FIXME video_thread should own this error signal, not components 75 # FIXME video_thread should own this error signal, not components
71 self.components[0]._error.emit("The FFmpeg command could not be generated.", "") 76 self.components[0]._error.emit(
72 log.critical("Cancelling render process due to failure while generating the ffmpeg command.") 77 "The FFmpeg command could not be generated.", ""
78 )
79 log.critical(
80 "Cancelling render process due to failure while generating the ffmpeg command."
81 )
73 self.failExport() 82 self.failExport()
74 return 83 return
75 return ffmpegCommand 84 return ffmpegCommand
76 85
77 def determineAudioLength(self): 86 def determineAudioLength(self):
78 ''' 87 """
79 Returns audio length which determines length of final video, or False if failure occurs 88 Returns audio length which determines length of final video, or False if failure occurs
80 ''' 89 """
81 if any([ 90 if any(
82 True if 'pcm' in comp.properties() else False 91 [True if "pcm" in comp.properties() else False for comp in self.components]
83 for comp in self.components 92 ):
84 ]):
85 self.progressBarSetText.emit("Loading audio file...") 93 self.progressBarSetText.emit("Loading audio file...")
86 audioFileTraits = readAudioFile( 94 audioFileTraits = readAudioFile(self.inputFile, self)
87 self.inputFile, self
88 )
89 if audioFileTraits is None: 95 if audioFileTraits is None:
90 self.cancelExport() 96 self.cancelExport()
91 return False 97 return False
@@ -95,25 +101,27 @@ class Worker(QtCore.QObject):
95 duration = getAudioDuration(self.inputFile) 101 duration = getAudioDuration(self.inputFile)
96 self.completeAudioArray = [] 102 self.completeAudioArray = []
97 self.audioArrayLen = int( 103 self.audioArrayLen = int(
98 ((duration * self.hertz) + 104 ((duration * self.hertz) + self.hertz) - self.sampleSize
99 self.hertz) - self.sampleSize) 105 )
100 return duration 106 return duration
101 107
102 def preFrameRender(self): 108 def preFrameRender(self):
103 ''' 109 """
104 Initializes components that need to pre-compute stuff. 110 Initializes components that need to pre-compute stuff.
105 Also prerenders "static" components like text and merges them if possible 111 Also prerenders "static" components like text and merges them if possible
106 ''' 112 """
107 self.staticComponents = {} 113 self.staticComponents = {}
108 114
109 # Call preFrameRender on each component 115 # Call preFrameRender on each component
110 canceledByComponent = False 116 canceledByComponent = False
111 initText = ", ".join([ 117 initText = ", ".join(
112 "%s) %s" % (num, str(component)) 118 [
113 for num, component in enumerate(reversed(self.components)) 119 "%s) %s" % (num, str(component))
114 ]) 120 for num, component in enumerate(reversed(self.components))
115 print('Loaded Components:', initText) 121 ]
116 log.info('Calling preFrameRender for %s', initText) 122 )
123 print("Loaded Components:", initText)
124 log.info("Calling preFrameRender for %s", initText)
117 for compNo, comp in enumerate(reversed(self.components)): 125 for compNo, comp in enumerate(reversed(self.components)):
118 try: 126 try:
119 comp.preFrameRender( 127 comp.preFrameRender(
@@ -122,80 +130,85 @@ class Worker(QtCore.QObject):
122 audioArrayLen=self.audioArrayLen, 130 audioArrayLen=self.audioArrayLen,
123 sampleSize=self.sampleSize, 131 sampleSize=self.sampleSize,
124 progressBarUpdate=self.progressBarUpdate, 132 progressBarUpdate=self.progressBarUpdate,
125 progressBarSetText=self.progressBarSetText 133 progressBarSetText=self.progressBarSetText,
126 ) 134 )
127 except ComponentError: 135 except ComponentError:
128 log.warning( 136 log.warning(
129 '#%s %s encountered an error in its preFrameRender method', 137 "#%s %s encountered an error in its preFrameRender method",
130 compNo, 138 compNo,
131 comp 139 comp,
132 ) 140 )
133 141
134 compProps = comp.properties() 142 compProps = comp.properties()
135 if 'error' in compProps or comp._lockedError is not None: 143 if "error" in compProps or comp._lockedError is not None:
136 self.cancel() 144 self.cancel()
137 self.canceled = True 145 self.canceled = True
138 canceledByComponent = True 146 canceledByComponent = True
139 compError = comp.error() \ 147 compError = (
140 if type(comp.error()) is tuple else (comp.error(), '') 148 comp.error() if type(comp.error()) is tuple else (comp.error(), "")
149 )
141 errMsg = ( 150 errMsg = (
142 "Component #%s (%s) encountered an error!" % ( 151 "Component #%s (%s) encountered an error!"
143 str(compNo), comp.name 152 % (str(compNo), comp.name)
144 ) 153 if comp.error() is None
145 if comp.error() is None else 154 else "Export cancelled by component #%s (%s): %s"
146 'Export cancelled by component #%s (%s): %s' % ( 155 % (str(compNo), comp.name, compError[0])
147 str(compNo),
148 comp.name,
149 compError[0]
150 )
151 ) 156 )
152 log.error(errMsg) 157 log.error(errMsg)
153 comp._error.emit(errMsg, compError[1]) 158 comp._error.emit(errMsg, compError[1])
154 break 159 break
155 if 'static' in compProps: 160 if "static" in compProps:
156 log.info('Saving static frame from #%s %s', compNo, comp) 161 log.info("Saving static frame from #%s %s", compNo, comp)
157 self.staticComponents[compNo] = \ 162 self.staticComponents[compNo] = comp.frameRender(0).copy()
158 comp.frameRender(0).copy()
159 163
160 # Check if any errors occured 164 # Check if any errors occured
161 log.debug("Checking if a component wishes to cancel the export...") 165 log.debug("Checking if a component wishes to cancel the export...")
162 if self.canceled: 166 if self.canceled:
163 if canceledByComponent: 167 if canceledByComponent:
164 log.error( 168 log.error(
165 'Export cancelled by component #%s (%s): %s', 169 "Export cancelled by component #%s (%s): %s",
166 compNo, 170 compNo,
167 comp.name, 171 comp.name,
168 'No message.' if comp.error() is None else ( 172 (
169 comp.error() if type(comp.error()) is str 173 "No message."
170 else comp.error()[0] 174 if comp.error() is None
171 ) 175 else (
176 comp.error()
177 if type(comp.error()) is str
178 else comp.error()[0]
179 )
180 ),
172 ) 181 )
173 self.cancelExport() 182 self.cancelExport()
174 183
175 # Merge static frames that can be merged to reduce workload 184 # Merge static frames that can be merged to reduce workload
176 def mergeConsecutiveStaticComponentFrames(self): 185 def mergeConsecutiveStaticComponentFrames(self):
177 log.info("Merging consecutive static component frames") 186 log.info("Merging consecutive static component frames")
178 for compNo in range(len(self.components)): 187 for compNo in range(len(self.components)):
179 if compNo not in self.staticComponents \ 188 if (
180 or compNo + 1 not in self.staticComponents: 189 compNo not in self.staticComponents
190 or compNo + 1 not in self.staticComponents
191 ):
181 continue 192 continue
182 self.staticComponents[compNo + 1] = Image.alpha_composite( 193 self.staticComponents[compNo + 1] = Image.alpha_composite(
183 self.staticComponents.pop(compNo), 194 self.staticComponents.pop(compNo),
184 self.staticComponents[compNo + 1] 195 self.staticComponents[compNo + 1],
185 ) 196 )
186 self.staticComponents[compNo] = None 197 self.staticComponents[compNo] = None
198
187 mergeConsecutiveStaticComponentFrames(self) 199 mergeConsecutiveStaticComponentFrames(self)
188 200
189 def frameRender(self, audioI): 201 def frameRender(self, audioI):
190 ''' 202 """
191 Renders a frame composited together from the frames returned by each component 203 Renders a frame composited together from the frames returned by each component
192 audioI is a multiple of self.sampleSize, which can be divided to determine frameNo 204 audioI is a multiple of self.sampleSize, which can be divided to determine frameNo
193 ''' 205 """
206
194 def err(): 207 def err():
195 self.closePipe() 208 self.closePipe()
196 self.cancelExport() 209 self.cancelExport()
197 self.error = True 210 self.error = True
198 msg = 'A call to renderFrame in the video thread failed critically.' 211 msg = "A call to renderFrame in the video thread failed critically."
199 log.critical(msg) 212 log.critical(msg)
200 comp._error.emit(msg, str(e)) 213 comp._error.emit(msg, str(e))
201 214
@@ -222,18 +235,16 @@ class Worker(QtCore.QObject):
222 if frame is None: # bottom-most layer 235 if frame is None: # bottom-most layer
223 frame = comp.frameRender(bgI) 236 frame = comp.frameRender(bgI)
224 else: 237 else:
225 frame = Image.alpha_composite( 238 frame = Image.alpha_composite(frame, comp.frameRender(bgI))
226 frame, comp.frameRender(bgI)
227 )
228 except Exception as e: 239 except Exception as e:
229 err() 240 err()
230 return frame 241 return frame
231 242
232 def showPreview(self, frame): 243 def showPreview(self, frame):
233 ''' 244 """
234 Receives a final frame that will be piped to FFmpeg, 245 Receives a final frame that will be piped to FFmpeg,
235 adds it to the MainWindow for the live preview 246 adds it to the MainWindow for the live preview
236 ''' 247 """
237 # We must store a reference to this QImage 248 # We must store a reference to this QImage
238 # or else Qt will garbage-collect it on the C++ side 249 # or else Qt will garbage-collect it on the C++ side
239 self.latestPreview = ImageQt(frame) 250 self.latestPreview = ImageQt(frame)
@@ -241,7 +252,7 @@ class Worker(QtCore.QObject):
241 252
242 @pyqtSlot() 253 @pyqtSlot()
243 def createVideo(self): 254 def createVideo(self):
244 ''' 255 """
245 1. Numpy is set to ignore division errors during this method 256 1. Numpy is set to ignore division errors during this method
246 2. Determine length of final video 257 2. Determine length of final video
247 3. Call preFrameRender on each component 258 3. Call preFrameRender on each component
@@ -250,15 +261,14 @@ class Worker(QtCore.QObject):
250 6. Iterate over the audio data array and call frameRender on the components to get frames 261 6. Iterate over the audio data array and call frameRender on the components to get frames
251 7. Close the out_pipe 262 7. Close the out_pipe
252 8. Call postFrameRender on each component 263 8. Call postFrameRender on each component
253 ''' 264 """
254 log.debug("Video worker received signal to createVideo") 265 log.debug("Video worker received signal to createVideo")
255 log.debug( 266 log.debug("Video thread id: {}".format(int(QtCore.QThread.currentThreadId())))
256 'Video thread id: {}'.format(int(QtCore.QThread.currentThreadId()))) 267 numpy.seterr(divide="ignore")
257 numpy.seterr(divide='ignore')
258 self.encoding.emit(True) 268 self.encoding.emit(True)
259 self.extraAudio = [] 269 self.extraAudio = []
260 self.width = int(self.settings.value('outputWidth')) 270 self.width = int(self.settings.value("outputWidth"))
261 self.height = int(self.settings.value('outputHeight')) 271 self.height = int(self.settings.value("outputHeight"))
262 272
263 # Set core.Core.canceled to False and call .reset() on each component 273 # Set core.Core.canceled to False and call .reset() on each component
264 self.reset() 274 self.reset()
@@ -284,16 +294,18 @@ class Worker(QtCore.QObject):
284 if not ffmpegCommand: 294 if not ffmpegCommand:
285 return 295 return
286 cmd = " ".join(ffmpegCommand) 296 cmd = " ".join(ffmpegCommand)
287 print('###### FFMPEG COMMAND ######\n%s' % cmd) 297 print("###### FFMPEG COMMAND ######\n%s" % cmd)
288 print('############################') 298 print("############################")
289 log.info(cmd) 299 log.info(cmd)
290 300
291 # Open pipe to FFmpeg 301 # Open pipe to FFmpeg
292 log.info('Opening pipe to FFmpeg') 302 log.info("Opening pipe to FFmpeg")
293 try: 303 try:
294 self.out_pipe = openPipe( 304 self.out_pipe = openPipe(
295 ffmpegCommand, 305 ffmpegCommand,
296 stdin=sp.PIPE, stdout=sys.stdout, stderr=sys.stdout 306 stdin=sp.PIPE,
307 stdout=sys.stdout,
308 stderr=sys.stdout,
297 ) 309 )
298 except sp.CalledProcessError: 310 except sp.CalledProcessError:
299 log.critical("Out_Pipe to FFmpeg couldn't be created!", exc_info=True) 311 log.critical("Out_Pipe to FFmpeg couldn't be created!", exc_info=True)
@@ -334,7 +346,7 @@ class Worker(QtCore.QObject):
334 # Finished creating the video! 346 # Finished creating the video!
335 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ 347 # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
336 348
337 numpy.seterr(all='print') 349 numpy.seterr(all="print")
338 350
339 self.closePipe() 351 self.closePipe()
340 352
@@ -348,14 +360,14 @@ class Worker(QtCore.QObject):
348 except Exception: 360 except Exception:
349 pass 361 pass
350 self.progressBarUpdate.emit(0) 362 self.progressBarUpdate.emit(0)
351 self.progressBarSetText.emit('Export Canceled') 363 self.progressBarSetText.emit("Export Canceled")
352 else: 364 else:
353 if self.error: 365 if self.error:
354 self.failExport() 366 self.failExport()
355 else: 367 else:
356 print("Export Complete") 368 print("Export Complete")
357 self.progressBarUpdate.emit(100) 369 self.progressBarUpdate.emit(100)
358 self.progressBarSetText.emit('Export Complete') 370 self.progressBarSetText.emit("Export Complete")
359 371
360 self.error = False 372 self.error = False
361 self.canceled = False 373 self.canceled = False
@@ -366,21 +378,21 @@ class Worker(QtCore.QObject):
366 try: 378 try:
367 self.out_pipe.stdin.close() 379 self.out_pipe.stdin.close()
368 except (BrokenPipeError, OSError): 380 except (BrokenPipeError, OSError):
369 log.debug('Broken pipe to FFmpeg!') 381 log.debug("Broken pipe to FFmpeg!")
370 if self.out_pipe.stderr is not None: 382 if self.out_pipe.stderr is not None:
371 log.error(self.out_pipe.stderr.read()) 383 log.error(self.out_pipe.stderr.read())
372 self.out_pipe.stderr.close() 384 self.out_pipe.stderr.close()
373 self.error = True 385 self.error = True
374 self.out_pipe.wait() 386 self.out_pipe.wait()
375 387
376 def cancelExport(self, message='Export Canceled'): 388 def cancelExport(self, message="Export Canceled"):
377 self.progressBarUpdate.emit(0) 389 self.progressBarUpdate.emit(0)
378 self.progressBarSetText.emit(message) 390 self.progressBarSetText.emit(message)
379 self.encoding.emit(False) 391 self.encoding.emit(False)
380 self.videoCreated.emit() 392 self.videoCreated.emit()
381 393
382 def failExport(self): 394 def failExport(self):
383 self.cancelExport('Export Failed') 395 self.cancelExport("Export Failed")
384 396
385 def updateProgress(self, pStr, pVal): 397 def updateProgress(self, pStr, pVal):
386 self.progressBarValue.emit(pVal) 398 self.progressBarValue.emit(pVal)