diff options
58 files changed, 12568 insertions, 1433 deletions
@@ -1,3 +1,22 @@ __pycache__ -settings.ini -build/*
\ No newline at end of file +*.py[cod] +*.egg-info +.pytest_cache +build/ +dist/ +env/ +.vscode/ +*.mkv +*.mp4 +*.wav +*.mp3 +*.aif +*.ac3 +*.zip +*.tar +*.tar.* +*.exe +ffmpeg +*.bak +*~ +*.goutput* @@ -0,0 +1,5 @@ +Martin Kaistra <djfun>, +Brianna Rainey <tassaron>, +DH4, +HunterwolfAT, +rikai diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..2b2d794 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +recursive-include src/tests +include src/components/*.ui +include src/gui/*.ui +include src/gui/background.png +include src/encoder-options.json +global-exclude src/components/__template__.ui +global-exclude *.py[cod] @@ -1,43 +1,58 @@ -audio-visualizer-python -======================= +# audio-visualizer-python +**We need a good name that is not as generic as "audio-visualizer-python"!** -This is a little GUI tool which creates an audio visualization video from an input audio. -You can also give it a background image and set a title text. +This 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. -I have tested the program on Linux (Ubuntu 16.04) and Windows (Windows 7), it should also work on Mac OS X. If you encounter problems -running it or have other bug reports or features, that you wish to see implemented, please fork the project and send me a pull request and/or file an issue on this project. +Projects can be created from the GUI and used in commandline mode for easy automation of video production. For more information use `avp --help` or for help with a particular component use `avp -c 0 componentName help`. -I also need a good name that is not as generic as "audio-visualizer-python"! +The 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 on this project. To gather extra information to help us debug the problem, run `avp -t` and include the text file it creates. -Dependencies ------------- -You need Python 3, PyQt4, PIL (or Pillow), numpy and the program ffmpeg, which is used to read the audio and render the video. -Installation ------------- -### Manual installation on Ubuntu -* Get all the python stuff: `sudo apt install python3 python3-pyqt4 python3-pil python3-numpy` -* If you have PyQt5 installed, get pillow (at least version 3.3.0) from pip: `apt install python3-pip; pip3 install pillow` -* Get ffmpeg/avconv: -You can either use `avconv` from the standard repositories (package `libav-tools`) or get `ffmpeg` from the [website](http://ffmpeg.org/) or from a PPA (e.g. [https://launchpad.net/~jon-severinsson/+archive/ubuntu/ffmpeg](https://launchpad.net/~jon-severinsson/+archive/ubuntu/ffmpeg). The program does automatically detect if you don't have the ffmpeg binary and tries to use avconv instead. +# Examples +## What the app creates +* **[YouTube: A day in spring](https://www.youtube.com/watch?v=-M3jR1NuJHM)** -Download audio-visualizer-python from this repository and run it with `python3 main.py`. +## Graphical version demo +* [YouTube: Audio Visualizer demonstration](https://www.youtube.com/watch?v=EVt2ckQs1Yg) -### Manual installation on Windows -* Download and install Python 3.4 from [https://www.python.org/downloads/windows/](https://www.python.org/downloads/windows/) -* Download and install PyQt4 for Python 3.4 and Qt4 from [http://www.riverbankcomputing.co.uk/software/pyqt/download](http://www.riverbankcomputing.co.uk/software/pyqt/download) -* Download and install numpy from [http://www.scipy.org/scipylib/download.html](http://www.scipy.org/scipylib/download.html). There is an installer available, make sure to get the one for Python 3.4 -* Download and install Pillow from [https://pypi.python.org/pypi/Pillow/3.3.0](https://pypi.python.org/pypi/Pillow/3.3.0) -* Download and install ffmpeg from [https://www.ffmpeg.org/download.html](https://www.ffmpeg.org/download.html). You can use the static builds. -* Add ffmpeg to your system PATH environment variable. +## Commandline snippets +* Create a simple visualization: `avp -c 0 classic -i something.mp3 -o output.mp4` +* Create the same visualization but with split layout and more extreme fluctuations: `avp -c 0 classic layout=split scale=40 -i something.mp3 -o output.mp4` +* Create a template project named `template` with your typical visualizers and watermarks using the GUI, then add text to the top layer from commandline: `avp template -c 99 text "title=Episode 371" -i /this/weeks/audio.ogg -o out` + + +# Dependencies +* Python 3.10 +* FFmpeg 4.4.1 +* PyQt5 (Qt v5.15.3) +* Pillow +* NumPy +* Pytest -Download audio-visualizer-python from this repository and run it from the command line with `C:\Python34\python.exe main.py`. -### Manual installation on macOS +# Installation +## Manual installation on Ubuntu 22.04 +* Install ffmpeg: `sudo apt install ffmpeg` +* Install pip: `sudo apt install python3-pip` +* Install PyQt5: `sudo apt install python3-pyqt5` +* Download this repo and run `pip install .` in this directory +* Run the program with `python3 -m avp` + + +## Manual installation on Windows +* Install Python from the Windows Store +* Add Python to your system PATH (it should ask during the installation process) +* Download this repo +* Open command prompt, `cd` into the repo directory, and run: `pip install .` +* Download and install ffmpeg from [https://www.ffmpeg.org/download.html](https://www.ffmpeg.org/download.html). You can use the static builds. +* Add ffmpeg to the system PATH as well, or copy ffmpeg.exe into the directory with the rest of the app +* Now run `python3 -m avp` from a command prompt window to start the app + +## Manual installation on macOS +* **[Outdated]**: No one has updated these instructions for a while. * Install [Homebrew](http://brew.sh/) * Use the following commands to install the needed dependencies: - ``` brew install python3 brew install ffmpeg --with-fdk-aac --with-ffplay --with-freetype --with-libass --with-libquvi --with-libvorbis --with-libvpx --with-opus --with-x265 @@ -48,14 +63,39 @@ pip3 install --upgrade pip pip3 install pillow pip3 install numpy ``` - Download audio-visualizer-python from this repository and run it with `python3 main.py`. -Example -------- -You can find an example video here: -[Youtube: A day in spring](https://www.youtube.com/watch?v=-M3jR1NuJHM) -License -------- -audio-visualizer-python is licensed under the MIT license. +# Faster Export Times +* [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](http://pillow.readthedocs.io/en/3.1.x/installation.html). +* **Warning:** [Compiling from source is difficult on Windows](http://pillow.readthedocs.io/en/3.1.x/installation.html#building-on-windows). + + +# Keyboard Shortcuts +| Key Combo | Effect | +| ------------------------- | -------------------------------------------------- | +| Ctrl+S | Save Current Project | +| Ctrl+A | Save Project As... | +| Ctrl+O | Open Project | +| Ctrl+N | New Project (prompts to save current project) | +| Ctrl+Z | Undo | +| Ctrl+Shift+Z _or_ Ctrl+Y | Redo | +| Ctrl+T _or_ Insert | Add Component | +| Ctrl+R _or_ Delete | Remove Component | +| Ctrl+Space | Focus Component List | +| Ctrl+Shift+S | Save Component Preset | +| Ctrl+Shift+C | Remove Preset from Component | +| Ctrl+Up | Move Selected Component Up | +| Ctrl+Down | Move Selected Component Down | +| Ctrl+Home | Move Selected Component to Top | +| Ctrl+End | Move Selected Component to Bottom | +| Ctrl+Shift+U | Open Undo History | +| Ctrl+Shift+F | Show FFmpeg Command | +| Ctrl+Alt+Shift+R | Force redraw preview (must use `--debug`) | +| Ctrl+Alt+Shift+A | Dump MainWindow data into log (must use `--debug`) | + + +# License +Source code of audio-visualizer-python is licensed under the MIT license. + +Some 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.
\ No newline at end of file diff --git a/core.py b/core.py deleted file mode 100644 index 900a98f..0000000 --- a/core.py +++ /dev/null @@ -1,210 +0,0 @@ -import sys, io, os -from PyQt4 import QtCore, QtGui, uic -from PyQt4.QtGui import QPainter, QColor -from os.path import expanduser -import subprocess as sp -import numpy -from PIL import Image, ImageDraw, ImageFont -from PIL.ImageQt import ImageQt -import tempfile -from shutil import rmtree -import atexit - -class Core(): - - def __init__(self): - self.lastBackgroundImage = "" - self._image = None - - self.FFMPEG_BIN = self.findFfmpeg() - self.tempDir = None - atexit.register(self.deleteTempDir) - - def findFfmpeg(self): - if sys.platform == "win32": - return "ffmpeg.exe" - else: - try: - with open(os.devnull, "w") as f: - sp.check_call(['ffmpeg', '-version'], stdout=f, stderr=f) - return "ffmpeg" - except: - return "avconv" - - def parseBaseImage(self, backgroundImage, preview=False): - ''' determines if the base image is a single frame or list of frames ''' - if backgroundImage == "": - return [] - else: - _, bgExt = os.path.splitext(backgroundImage) - if not bgExt == '.mp4': - return [backgroundImage] - else: - return self.getVideoFrames(backgroundImage, preview) - - def drawBaseImage(self, backgroundFile, titleText, titleFont, fontSize, alignment,\ - xOffset, yOffset, textColor, visColor): - if backgroundFile == '': - im = Image.new("RGB", (1280, 720), "black") - else: - im = Image.open(backgroundFile) - - if self._image == None or not self.lastBackgroundImage == backgroundFile: - self.lastBackgroundImage = backgroundFile - - # resize if necessary - if not im.size == (1280, 720): - im = im.resize((1280, 720), Image.ANTIALIAS) - - self._image = ImageQt(im) - - self._image1 = QtGui.QImage(self._image) - painter = QPainter(self._image1) - font = titleFont - font.setPixelSize(fontSize) - painter.setFont(font) - painter.setPen(QColor(*textColor)) - - yPosition = yOffset - - fm = QtGui.QFontMetrics(font) - if alignment == 0: #Left - xPosition = xOffset - if alignment == 1: #Middle - xPosition = xOffset - fm.width(titleText)/2 - if alignment == 2: #Right - xPosition = xOffset - fm.width(titleText) - painter.drawText(xPosition, yPosition, titleText) - painter.end() - - buffer = QtCore.QBuffer() - buffer.open(QtCore.QIODevice.ReadWrite) - self._image1.save(buffer, "PNG") - - strio = io.BytesIO() - strio.write(buffer.data()) - buffer.close() - strio.seek(0) - return Image.open(strio) - - def drawBars(self, spectrum, image, color): - - imTop = Image.new("RGBA", (1280, 360)) - draw = ImageDraw.Draw(imTop) - r, g, b = color - color2 = (r, g, b, 50) - for j in range(0, 63): - draw.rectangle((10 + j * 20, 325, 10 + j * 20 + 20, 325 - spectrum[j * 4] * 1 - 10), fill=color2) - draw.rectangle((15 + j * 20, 320, 15 + j * 20 + 10, 320 - spectrum[j * 4] * 1), fill=color) - - - imBottom = imTop.transpose(Image.FLIP_TOP_BOTTOM) - - im = Image.new("RGB", (1280, 720), "black") - im.paste(image, (0, 0)) - im.paste(imTop, (0, 0), mask=imTop) - im.paste(imBottom, (0, 360), mask=imBottom) - - return im - - def readAudioFile(self, filename): - command = [ self.FFMPEG_BIN, - '-i', filename, - '-f', 's16le', - '-acodec', 'pcm_s16le', - '-ar', '44100', # ouput will have 44100 Hz - '-ac', '1', # mono (set to '2' for stereo) - '-'] - in_pipe = sp.Popen(command, stdout=sp.PIPE, stderr=sp.DEVNULL, bufsize=10**8) - - completeAudioArray = numpy.empty(0, dtype="int16") - - while True: - # read 2 seconds of audio - raw_audio = in_pipe.stdout.read(88200*4) - if len(raw_audio) == 0: - break - audio_array = numpy.fromstring(raw_audio, dtype="int16") - completeAudioArray = numpy.append(completeAudioArray, audio_array) - # print(audio_array) - - in_pipe.kill() - in_pipe.wait() - - # add 0s the end - completeAudioArrayCopy = numpy.zeros(len(completeAudioArray) + 44100, dtype="int16") - completeAudioArrayCopy[:len(completeAudioArray)] = completeAudioArray - completeAudioArray = completeAudioArrayCopy - - return completeAudioArray - - def transformData(self, i, completeAudioArray, sampleSize, smoothConstantDown, smoothConstantUp, lastSpectrum): - if len(completeAudioArray) < (i + sampleSize): - sampleSize = len(completeAudioArray) - i - - window = numpy.hanning(sampleSize) - data = completeAudioArray[i:i+sampleSize][::1] * window - paddedSampleSize = 2048 - paddedData = numpy.pad(data, (0, paddedSampleSize - sampleSize), 'constant') - spectrum = numpy.fft.fft(paddedData) - sample_rate = 44100 - frequencies = numpy.fft.fftfreq(len(spectrum), 1./sample_rate) - - y = abs(spectrum[0:int(paddedSampleSize/2) - 1]) - - # filter the noise away - # y[y<80] = 0 - - y = 20 * numpy.log10(y) - y[numpy.isinf(y)] = 0 - - if lastSpectrum is not None: - lastSpectrum[y < lastSpectrum] = y[y < lastSpectrum] * smoothConstantDown + lastSpectrum[y < lastSpectrum] * (1 - smoothConstantDown) - lastSpectrum[y >= lastSpectrum] = y[y >= lastSpectrum] * smoothConstantUp + lastSpectrum[y >= lastSpectrum] * (1 - smoothConstantUp) - else: - lastSpectrum = y - - x = frequencies[0:int(paddedSampleSize/2) - 1] - - return lastSpectrum - - def deleteTempDir(self): - if self.tempDir and os.path.exists(self.tempDir): - rmtree(self.tempDir) - - - def getVideoFrames(self, videoPath, firstOnly=False): - self.tempDir = os.path.join(tempfile.gettempdir(), 'audio-visualizer-python-data') - # recreate the temporary directory so it is empty - self.deleteTempDir() - os.mkdir(self.tempDir) - if firstOnly: - filename = 'preview%s.jpg' % os.path.basename(videoPath).split('.', 1)[0] - options = '-ss 10 -vframes 1' - else: - filename = '$frame%05d.jpg' - options = '' - sp.call( \ - '%s -i "%s" -y %s "%s"' % ( \ - self.FFMPEG_BIN, - videoPath, - options, - os.path.join(self.tempDir, filename) - ), - shell=True - ) - return sorted([os.path.join(self.tempDir, f) for f in os.listdir(self.tempDir)]) - - @staticmethod - def RGBFromString(string): - ''' turns an RGB string like "255, 255, 255" into a tuple ''' - try: - tup = tuple([int(i) for i in string.split(',')]) - if len(tup) != 3: - raise ValueError - for i in tup: - if i > 255 or i < 0: - raise ValueError - return tup - except: - return (255, 255, 255) diff --git a/freeze.py b/freeze.py new file mode 100644 index 0000000..520b445 --- /dev/null +++ b/freeze.py @@ -0,0 +1,59 @@ +from cx_Freeze import setup, Executable +import sys +import os + +from setup import __version__ + + +deps = [os.path.join('src', p) for p in os.listdir('src') if p] +deps.append('ffmpeg.exe' if sys.platform == 'win32' else 'ffmpeg') + +buildOptions = dict( + excludes=[ + "apport", + "apt", + "curses", + "distutils", + "email", + "html", + "http", + "xmlrpc", + "nose", + 'tkinter', + ], + includes=[ + "encodings", + "json", + "filecmp", + "numpy.core._methods", + "numpy.lib.format", + "PyQt5.QtCore", + "PyQt5.QtGui", + "PyQt5.QtWidgets", + "PyQt5.uic", + "PIL.Image", + "PIL.ImageQt", + "PIL.ImageDraw", + "PIL.ImageEnhance", + ], + include_files=deps, +) + +base = 'Win32GUI' if sys.platform == 'win32' else None + +executables = [ + Executable( + 'src/main.py', + base=base, + targetName='audio-visualizer-python' + ), +] + + +setup( + name='audio-visualizer-python', + version=__version__, + description='GUI tool to render visualization videos of audio files', + options=dict(build_exe=buildOptions), + executables=executables +) diff --git a/main.py b/main.py deleted file mode 100644 index 9f608d8..0000000 --- a/main.py +++ /dev/null @@ -1,341 +0,0 @@ -import sys, io, os -from PyQt4 import QtCore, QtGui, uic -from PyQt4.QtGui import QPainter, QColor, QFont -from os.path import expanduser -import subprocess as sp -import numpy -from PIL import Image, ImageDraw, ImageFont -from PIL.ImageQt import ImageQt -import atexit -from queue import Queue -from PyQt4.QtCore import QSettings -import signal - -import preview_thread, core, video_thread - -class Command(QtCore.QObject): - - videoTask = QtCore.pyqtSignal(str, str, QFont, int, int, int, int, tuple, tuple, str, str) - - def __init__(self): - QtCore.QObject.__init__(self) - - import argparse - self.parser = argparse.ArgumentParser(description='Create a visualization for an audio file') - self.parser.add_argument('-i', '--input', dest='input', help='input audio file', required=True) - self.parser.add_argument('-o', '--output', dest='output', help='output video file', required=True) - self.parser.add_argument('-b', '--background', dest='bgimage', help='background image file', required=True) - self.parser.add_argument('-t', '--text', dest='text', help='title text', required=True) - self.parser.add_argument('-f', '--font', dest='font', help='title font', required=False) - self.parser.add_argument('-s', '--fontsize', dest='fontsize', help='title font size', required=False) - self.parser.add_argument('-c', '--textcolor', dest='textcolor', help='title text color in r,g,b format', required=False) - self.parser.add_argument('-C', '--viscolor', dest='viscolor', help='visualization color in r,g,b format', required=False) - self.parser.add_argument('-x', '--xposition', dest='xposition', help='x position', required=False) - self.parser.add_argument('-y', '--yposition', dest='yposition', help='y position', required=False) - self.parser.add_argument('-a', '--alignment', dest='alignment', help='title alignment', required=False, type=int, choices=[0, 1, 2]) - self.args = self.parser.parse_args() - - self.settings = QSettings('settings.ini', QSettings.IniFormat) - - # load colours as tuples from comma-separated strings - self.textColor = core.Core.RGBFromString(self.settings.value("textColor", '255, 255, 255')) - self.visColor = core.Core.RGBFromString(self.settings.value("visColor", '255, 255, 255')) - if self.args.textcolor: - self.textColor = core.Core.RGBFromString(self.args.textcolor) - if self.args.viscolor: - self.visColor = core.Core.RGBFromString(self.args.viscolor) - - # font settings - if self.args.font: - self.font = QFont(self.args.font) - else: - self.font = QFont(self.settings.value("titleFont", QFont())) - - if self.args.fontsize: - self.fontsize = int(self.args.fontsize) - else: - self.fontsize = int(self.settings.value("fontSize", 35)) - if self.args.alignment: - self.alignment = int(self.args.alignment) - else: - self.alignment = int(self.settings.value("alignment", 0)) - - if self.args.xposition: - self.textX = int(self.args.xposition) - else: - self.textX = int(self.settings.value("xPosition", 70)) - - if self.args.yposition: - self.textY = int(self.args.yposition) - else: - self.textY = int(self.settings.value("yPosition", 375)) - - ffmpeg_cmd = self.settings.value("ffmpeg_cmd", expanduser("~")) - - self.videoThread = QtCore.QThread(self) - self.videoWorker = video_thread.Worker(self) - - self.videoWorker.moveToThread(self.videoThread) - self.videoWorker.videoCreated.connect(self.videoCreated) - - self.videoThread.start() - self.videoTask.emit(self.args.bgimage, - self.args.text, - self.font, - self.fontsize, - self.alignment, - self.textX, - self.textY, - self.textColor, - self.visColor, - self.args.input, - self.args.output) - - def videoCreated(self): - self.videoThread.quit() - self.videoThread.wait() - self.cleanUp() - - def cleanUp(self): - self.settings.setValue("titleFont", self.font.toString()) - self.settings.setValue("alignment", str(self.alignment)) - self.settings.setValue("fontSize", str(self.fontsize)) - self.settings.setValue("xPosition", str(self.textX)) - self.settings.setValue("yPosition", str(self.textY)) - self.settings.setValue("visColor", '%s,%s,%s' % self.visColor) - self.settings.setValue("textColor", '%s,%s,%s' % self.textColor) - sys.exit(0) - -class Main(QtCore.QObject): - - newTask = QtCore.pyqtSignal(str, str, QFont, int, int, int, int, tuple, tuple) - processTask = QtCore.pyqtSignal() - videoTask = QtCore.pyqtSignal(str, str, QFont, int, int, int, int, tuple, tuple, str, str) - - def __init__(self, window): - QtCore.QObject.__init__(self) - - # print('main thread id: {}'.format(QtCore.QThread.currentThreadId())) - self.window = window - self.core = core.Core() - self.settings = QSettings('settings.ini', QSettings.IniFormat) - - # load colors as tuples from a comma-separated string - self.textColor = core.Core.RGBFromString(self.settings.value("textColor", '255, 255, 255')) - self.visColor = core.Core.RGBFromString(self.settings.value("visColor", '255, 255, 255')) - - self.previewQueue = Queue() - - self.previewThread = QtCore.QThread(self) - self.previewWorker = preview_thread.Worker(self, self.previewQueue) - - self.previewWorker.moveToThread(self.previewThread) - self.previewWorker.imageCreated.connect(self.showPreviewImage) - - self.previewThread.start() - - self.timer = QtCore.QTimer(self) - self.timer.timeout.connect(self.processTask.emit) - self.timer.start(500) - - window.pushButton_selectInput.clicked.connect(self.openInputFileDialog) - window.pushButton_selectOutput.clicked.connect(self.openOutputFileDialog) - window.pushButton_createVideo.clicked.connect(self.createAudioVisualisation) - window.pushButton_selectBackground.clicked.connect(self.openBackgroundFileDialog) - - window.progressBar_create.setValue(0) - window.setWindowTitle("Audio Visualizer") - window.pushButton_selectInput.setText("Select Input Music File") - window.pushButton_selectOutput.setText("Select Output Video File") - window.pushButton_selectBackground.setText("Select Background Image") - window.label_font.setText("Title Font") - window.label_alignment.setText("Title Options") - window.label_colorOptions.setText("Colors") - window.label_fontsize.setText("Fontsize") - window.label_title.setText("Title Text") - window.label_textColor.setText("Text:") - window.label_visColor.setText("Visualizer:") - window.pushButton_createVideo.setText("Create Video") - window.groupBox_create.setTitle("Create") - window.groupBox_settings.setTitle("Settings") - window.groupBox_preview.setTitle("Preview") - - window.alignmentComboBox.addItem("Left") - window.alignmentComboBox.addItem("Middle") - window.alignmentComboBox.addItem("Right") - window.fontsizeSpinBox.setValue(35) - window.textXSpinBox.setValue(70) - window.textYSpinBox.setValue(375) - window.lineEdit_textColor.setText('%s,%s,%s' % self.textColor) - window.lineEdit_visColor.setText('%s,%s,%s' % self.visColor) - window.pushButton_textColor.clicked.connect(lambda: self.pickColor('text')) - window.pushButton_visColor.clicked.connect(lambda: self.pickColor('vis')) - btnStyle = "QPushButton { background-color : %s; outline: none; }" % QColor(*self.textColor).name() - window.pushButton_textColor.setStyleSheet(btnStyle) - btnStyle = "QPushButton { background-color : %s; outline: none; }" % QColor(*self.visColor).name() - window.pushButton_visColor.setStyleSheet(btnStyle) - - titleFont = self.settings.value("titleFont") - if not titleFont == None: - window.fontComboBox.setCurrentFont(QFont(titleFont)) - - alignment = self.settings.value("alignment") - if not alignment == None: - window.alignmentComboBox.setCurrentIndex(int(alignment)) - fontSize = self.settings.value("fontSize") - if not fontSize == None: - window.fontsizeSpinBox.setValue(int(fontSize)) - xPosition = self.settings.value("xPosition") - if not xPosition == None: - window.textXSpinBox.setValue(int(xPosition)) - yPosition = self.settings.value("yPosition") - if not yPosition == None: - window.textYSpinBox.setValue(int(yPosition)) - - window.fontComboBox.currentFontChanged.connect(self.drawPreview) - window.lineEdit_title.textChanged.connect(self.drawPreview) - window.alignmentComboBox.currentIndexChanged.connect(self.drawPreview) - window.textXSpinBox.valueChanged.connect(self.drawPreview) - window.textYSpinBox.valueChanged.connect(self.drawPreview) - window.fontsizeSpinBox.valueChanged.connect(self.drawPreview) - window.lineEdit_textColor.textChanged.connect(self.drawPreview) - window.lineEdit_visColor.textChanged.connect(self.drawPreview) - - self.drawPreview() - - window.show() - - def cleanUp(self): - self.timer.stop() - self.previewThread.quit() - self.previewThread.wait() - - self.settings.setValue("titleFont", self.window.fontComboBox.currentFont().toString()) - self.settings.setValue("alignment", str(self.window.alignmentComboBox.currentIndex())) - self.settings.setValue("fontSize", str(self.window.fontsizeSpinBox.value())) - self.settings.setValue("xPosition", str(self.window.textXSpinBox.value())) - self.settings.setValue("yPosition", str(self.window.textYSpinBox.value())) - self.settings.setValue("visColor", self.window.lineEdit_visColor.text()) - self.settings.setValue("textColor", self.window.lineEdit_textColor.text()) - - def openInputFileDialog(self): - inputDir = self.settings.value("inputDir", expanduser("~")) - - fileName = QtGui.QFileDialog.getOpenFileName(self.window, - "Open Music File", inputDir, "Music Files (*.mp3 *.wav *.ogg *.flac)"); - - if not fileName == "": - self.settings.setValue("inputDir", os.path.dirname(fileName)) - self.window.label_input.setText(fileName) - - def openOutputFileDialog(self): - outputDir = self.settings.value("outputDir", expanduser("~")) - - fileName = QtGui.QFileDialog.getSaveFileName(self.window, - "Set Output Video File", outputDir, "Video Files (*.mkv)"); - - if not fileName == "": - self.settings.setValue("outputDir", os.path.dirname(fileName)) - self.window.label_output.setText(fileName) - - def openBackgroundFileDialog(self): - backgroundDir = self.settings.value("backgroundDir", expanduser("~")) - - fileName = QtGui.QFileDialog.getOpenFileName(self.window, - "Open Background Image", backgroundDir, "Image Files (*.jpg *.png);; Video Files (*.mp4)"); - - if not fileName == "": - self.settings.setValue("backgroundDir", os.path.dirname(fileName)) - self.window.label_background.setText(fileName) - self.drawPreview() - - def createAudioVisualisation(self): - ffmpeg_cmd = self.settings.value("ffmpeg_cmd", expanduser("~")) - - self.videoThread = QtCore.QThread(self) - self.videoWorker = video_thread.Worker(self) - - self.videoWorker.moveToThread(self.videoThread) - self.videoWorker.videoCreated.connect(self.videoCreated) - self.videoWorker.progressBarUpdate.connect(self.progressBarUpdated) - self.videoWorker.progressBarSetText.connect(self.progressBarSetText) - - self.videoThread.start() - self.videoTask.emit(self.window.label_background.text(), - self.window.lineEdit_title.text(), - self.window.fontComboBox.currentFont(), - self.window.fontsizeSpinBox.value(), - self.window.alignmentComboBox.currentIndex(), - self.window.textXSpinBox.value(), - self.window.textYSpinBox.value(), - core.Core.RGBFromString(self.window.lineEdit_textColor.text()), - core.Core.RGBFromString(self.window.lineEdit_visColor.text()), - self.window.label_input.text(), - self.window.label_output.text()) - - - def progressBarUpdated(self, value): - self.window.progressBar_create.setValue(value) - - def progressBarSetText(self, value): - self.window.progressBar_create.setFormat(value) - - def videoCreated(self): - self.videoThread.quit() - self.videoThread.wait() - - def drawPreview(self): - self.newTask.emit(self.window.label_background.text(), - self.window.lineEdit_title.text(), - self.window.fontComboBox.currentFont(), - self.window.fontsizeSpinBox.value(), - self.window.alignmentComboBox.currentIndex(), - self.window.textXSpinBox.value(), - self.window.textYSpinBox.value(), - core.Core.RGBFromString(self.window.lineEdit_textColor.text()), - core.Core.RGBFromString(self.window.lineEdit_visColor.text())) - # self.processTask.emit() - - def showPreviewImage(self, image): - self._scaledPreviewImage = image - self._previewPixmap = QtGui.QPixmap.fromImage(self._scaledPreviewImage) - - self.window.label_preview.setPixmap(self._previewPixmap) - - def pickColor(self, colorTarget): - color = QtGui.QColorDialog.getColor() - if color.isValid(): - RGBstring = '%s,%s,%s' % (str(color.red()), str(color.green()), str(color.blue())) - btnStyle = "QPushButton { background-color : %s; outline: none; }" % color.name() - if colorTarget == 'text': - self.window.lineEdit_textColor.setText(RGBstring) - window.pushButton_textColor.setStyleSheet(btnStyle) - elif colorTarget == 'vis': - self.window.lineEdit_visColor.setText(RGBstring) - window.pushButton_visColor.setStyleSheet(btnStyle) - -if len(sys.argv) > 1: - # command line mode - app = QtGui.QApplication(sys.argv, False) - command = Command() - signal.signal(signal.SIGINT, command.cleanUp) - sys.exit(app.exec_()) -else: - # gui mode - if __name__ == "__main__": - app = QtGui.QApplication(sys.argv) - window = uic.loadUi("main.ui") - # window.adjustSize() - desc = QtGui.QDesktopWidget() - dpi = desc.physicalDpiX() - topMargin = 0 if (dpi == 96) else int(10 * (dpi / 96)) - - window.resize(window.width() * (dpi / 96), window.height() * (dpi / 96)) - window.verticalLayout_2.setContentsMargins(0, topMargin, 0, 0) - - main = Main(window) - - signal.signal(signal.SIGINT, main.cleanUp) - atexit.register(main.cleanUp) - - sys.exit(app.exec_()) diff --git a/main.ui b/main.ui deleted file mode 100644 index c2892c5..0000000 --- a/main.ui +++ /dev/null @@ -1,602 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<ui version="4.0"> - <class>MainWindow</class> - <widget class="QMainWindow" name="MainWindow"> - <property name="geometry"> - <rect> - <x>0</x> - <y>0</y> - <width>635</width> - <height>600</height> - </rect> - </property> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> - <horstretch>0</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="minimumSize"> - <size> - <width>635</width> - <height>600</height> - </size> - </property> - <property name="windowTitle"> - <string>MainWindow</string> - </property> - <widget class="QWidget" name="centralwidget"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> - <horstretch>0</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <layout class="QVBoxLayout" name="verticalLayout_3"> - <item> - <layout class="QVBoxLayout" name="verticalLayout"> - <item> - <widget class="QGroupBox" name="groupBox_settings"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Preferred"> - <horstretch>0</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="minimumSize"> - <size> - <width>0</width> - <height>200</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>16777215</width> - <height>16777215</height> - </size> - </property> - <property name="title"> - <string>GroupBox</string> - </property> - <layout class="QVBoxLayout" name="verticalLayout_6"> - <item> - <layout class="QVBoxLayout" name="verticalLayout_2"> - <item> - <layout class="QHBoxLayout" name="horizontalLayout"> - <item> - <widget class="QPushButton" name="pushButton_selectInput"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> - <horstretch>1</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="minimumSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>16777215</width> - <height>16777215</height> - </size> - </property> - <property name="text"> - <string>PushButton</string> - </property> - </widget> - </item> - <item> - <widget class="QLabel" name="label_input"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> - <horstretch>2</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="frameShape"> - <enum>QFrame::Box</enum> - </property> - <property name="text"> - <string/> - </property> - </widget> - </item> - </layout> - </item> - <item> - <layout class="QHBoxLayout" name="horizontalLayout_2"> - <item> - <widget class="QPushButton" name="pushButton_selectOutput"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> - <horstretch>1</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="minimumSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>16777215</width> - <height>16777215</height> - </size> - </property> - <property name="text"> - <string>PushButton</string> - </property> - </widget> - </item> - <item> - <widget class="QLabel" name="label_output"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> - <horstretch>2</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="frameShape"> - <enum>QFrame::Box</enum> - </property> - <property name="text"> - <string/> - </property> - </widget> - </item> - </layout> - </item> - <item> - <layout class="QHBoxLayout" name="horizontalLayout_4"> - <item> - <widget class="QPushButton" name="pushButton_selectBackground"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> - <horstretch>1</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="minimumSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>16777215</width> - <height>16777215</height> - </size> - </property> - <property name="text"> - <string>PushButton</string> - </property> - </widget> - </item> - <item> - <widget class="QLabel" name="label_background"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> - <horstretch>2</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="frameShape"> - <enum>QFrame::Box</enum> - </property> - <property name="text"> - <string/> - </property> - </widget> - </item> - </layout> - </item> - <item> - <layout class="QHBoxLayout" name="horizontalLayout_5"> - <item> - <widget class="QLabel" name="label_font"> - <property name="minimumSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>200</width> - <height>16777215</height> - </size> - </property> - <property name="baseSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="frameShape"> - <enum>QFrame::NoFrame</enum> - </property> - <property name="text"> - <string/> - </property> - </widget> - </item> - <item> - <widget class="QFontComboBox" name="fontComboBox"/> - </item> - </layout> - </item> - <item> - <layout class="QHBoxLayout" name="horizontalLayout_5"> - <item> - <widget class="QLabel" name="label_alignment"> - <property name="minimumSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>200</width> - <height>16777215</height> - </size> - </property> - <property name="baseSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="frameShape"> - <enum>QFrame::NoFrame</enum> - </property> - <property name="text"> - <string/> - </property> - </widget> - </item> - <item> - <layout class="QHBoxLayout" name="horizontalLayout_3"> - <item> - <widget class="QComboBox" name="alignmentComboBox"/> - </item> - <item> - <widget class="QLabel" name="label_fontsize"> - <property name="text"> - <string/> - </property> - </widget> - </item> - <item> - <widget class="QSpinBox" name="fontsizeSpinBox"> - <property name="maximum"> - <number>999</number> - </property> - </widget> - </item> - <item> - <widget class="QLabel" name="label_textX"> - <property name="layoutDirection"> - <enum>Qt::LeftToRight</enum> - </property> - <property name="text"> - <string>X</string> - </property> - <property name="alignment"> - <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> - </property> - </widget> - </item> - <item> - <widget class="QSpinBox" name="textXSpinBox"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> - <horstretch>0</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="minimum"> - <number>-99999</number> - </property> - <property name="maximum"> - <number>99999</number> - </property> - </widget> - </item> - <item> - <widget class="QLabel" name="label_textY"> - <property name="text"> - <string>Y</string> - </property> - <property name="alignment"> - <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> - </property> - </widget> - </item> - <item> - <widget class="QSpinBox" name="textYSpinBox"> - <property name="minimum"> - <number>-99999</number> - </property> - <property name="maximum"> - <number>99999</number> - </property> - </widget> - </item> - </layout> - </item> - </layout> - </item> - <item> - <layout class="QHBoxLayout" name="horizontalLayout_6"> - <item> - <widget class="QLabel" name="label_colorOptions"> - <property name="minimumSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>200</width> - <height>16777215</height> - </size> - </property> - <property name="baseSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="frameShape"> - <enum>QFrame::NoFrame</enum> - </property> - <property name="text"> - <string/> - </property> - </widget> - </item> - <item> - <widget class="QLabel" name="label_textColor"> - <property name="text"> - <string/> - </property> - <property name="alignment"> - <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> - </property> - </widget> - </item> - <item> - <widget class="QPushButton" name="pushButton_textColor"> - <property name="maximumSize"> - <size> - <width>32</width> - <height>32</height> - </size> - </property> - <property name="text"> - <string/> - </property> - <property name="MaximumSize" stdset="0"> - <size> - <width>32</width> - <height>32</height> - </size> - </property> - </widget> - </item> - <item> - <widget class="QLineEdit" name="lineEdit_textColor"/> - </item> - <item> - <widget class="QLabel" name="label_visColor"> - <property name="text"> - <string/> - </property> - <property name="alignment"> - <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> - </property> - </widget> - </item> - <item> - <widget class="QPushButton" name="pushButton_visColor"> - <property name="maximumSize"> - <size> - <width>32</width> - <height>32</height> - </size> - </property> - <property name="text"> - <string/> - </property> - <property name="MaximumSize" stdset="0"> - <size> - <width>32</width> - <height>32</height> - </size> - </property> - </widget> - </item> - <item> - <widget class="QLineEdit" name="lineEdit_visColor"/> - </item> - </layout> - </item> - </layout> - </item> - <item> - <layout class="QHBoxLayout" name="horizontalLayout_7"> - <item> - <widget class="QLabel" name="label_title"> - <property name="minimumSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>200</width> - <height>16777215</height> - </size> - </property> - <property name="baseSize"> - <size> - <width>200</width> - <height>0</height> - </size> - </property> - <property name="frameShape"> - <enum>QFrame::NoFrame</enum> - </property> - <property name="text"> - <string/> - </property> - </widget> - </item> - <item> - <widget class="QLineEdit" name="lineEdit_title"/> - </item> - </layout> - </item> - </layout> - </widget> - </item> - <item> - <widget class="QGroupBox" name="groupBox_preview"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Preferred"> - <horstretch>0</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="minimumSize"> - <size> - <width>0</width> - <height>220</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>16777215</width> - <height>220</height> - </size> - </property> - <property name="title"> - <string>GroupBox</string> - </property> - <property name="alignment"> - <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> - </property> - <layout class="QVBoxLayout" name="verticalLayout_5"> - <item> - <layout class="QHBoxLayout" name="horizontalLayout_7"> - <item> - <widget class="QLabel" name="label_preview"> - <property name="minimumSize"> - <size> - <width>320</width> - <height>180</height> - </size> - </property> - <property name="maximumSize"> - <size> - <width>320</width> - <height>180</height> - </size> - </property> - <property name="frameShape"> - <enum>QFrame::Box</enum> - </property> - <property name="text"> - <string/> - </property> - <property name="alignment"> - <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> - </property> - </widget> - </item> - </layout> - </item> - </layout> - </widget> - </item> - <item> - <widget class="QGroupBox" name="groupBox_create"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> - <horstretch>0</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="maximumSize"> - <size> - <width>16777215</width> - <height>16777215</height> - </size> - </property> - <property name="title"> - <string>GroupBox</string> - </property> - <layout class="QVBoxLayout" name="verticalLayout_7"> - <item> - <layout class="QVBoxLayout" name="verticalLayout_4"> - <item> - <widget class="QProgressBar" name="progressBar_create"> - <property name="value"> - <number>24</number> - </property> - <property name="alignment"> - <set>Qt::AlignCenter</set> - </property> - <property name="textVisible"> - <bool>true</bool> - </property> - </widget> - </item> - <item> - <widget class="QPushButton" name="pushButton_createVideo"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> - <horstretch>0</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="text"> - <string>PushButton</string> - </property> - </widget> - </item> - </layout> - </item> - </layout> - </widget> - </item> - <item> - <spacer name="verticalSpacer"> - <property name="orientation"> - <enum>Qt::Vertical</enum> - </property> - <property name="sizeHint" stdset="0"> - <size> - <width>20</width> - <height>0</height> - </size> - </property> - </spacer> - </item> - </layout> - </item> - </layout> - </widget> - </widget> - <resources/> - <connections/> -</ui> diff --git a/preview_thread.py b/preview_thread.py deleted file mode 100644 index 041d39e..0000000 --- a/preview_thread.py +++ /dev/null @@ -1,79 +0,0 @@ -from PyQt4 import QtCore, QtGui, uic -from PyQt4.QtCore import pyqtSignal, pyqtSlot -from PIL import Image, ImageDraw, ImageFont -from PIL.ImageQt import ImageQt -import core -import time -from queue import Queue, Empty -import numpy - -class Worker(QtCore.QObject): - - imageCreated = pyqtSignal(['QImage']) - - def __init__(self, parent=None, queue=None): - QtCore.QObject.__init__(self) - parent.newTask.connect(self.createPreviewImage) - parent.processTask.connect(self.process) - self.core = core.Core() - self.queue = queue - - - @pyqtSlot(str, str, QtGui.QFont, int, int, int, int, tuple, tuple) - def createPreviewImage(self, backgroundImage, titleText, titleFont, fontSize,\ - alignment, xOffset, yOffset, textColor, visColor): - # print('worker thread id: {}'.format(QtCore.QThread.currentThreadId())) - dic = { - "backgroundImage": backgroundImage, - "titleText": titleText, - "titleFont": titleFont, - "fontSize": fontSize, - "alignment": alignment, - "xoffset": xOffset, - "yoffset": yOffset, - "textColor" : textColor, - "visColor" : visColor - } - self.queue.put(dic) - - @pyqtSlot() - def process(self): - try: - nextPreviewInformation = self.queue.get(block=False) - while self.queue.qsize() >= 2: - try: - self.queue.get(block=False) - except Empty: - continue - - bgImage = self.core.parseBaseImage(\ - nextPreviewInformation["backgroundImage"], - preview=True - ) - if bgImage == []: - bgImage = '' - else: - bgImage = bgImage[0] - - im = self.core.drawBaseImage( - bgImage, - nextPreviewInformation["titleText"], - nextPreviewInformation["titleFont"], - nextPreviewInformation["fontSize"], - nextPreviewInformation["alignment"], - nextPreviewInformation["xoffset"], - nextPreviewInformation["yoffset"], - nextPreviewInformation["textColor"], - nextPreviewInformation["visColor"]) - spectrum = numpy.fromfunction(lambda x: 0.008*(x-128)**2, (255,), dtype="int16") - - im = self.core.drawBars(spectrum, im, nextPreviewInformation["visColor"]) - - self._image = ImageQt(im) - self._previewImage = QtGui.QImage(self._image) - - self._scaledPreviewImage = self._previewImage.scaled(320, 180, QtCore.Qt.IgnoreAspectRatio, QtCore.Qt.SmoothTransformation) - - self.imageCreated.emit(self._scaledPreviewImage) - except Empty: - True @@ -1,30 +1,62 @@ -from cx_Freeze import setup, Executable - -# Dependencies are automatically detected, but it might need -# fine tuning. -buildOptions = dict(packages = [], excludes = [ - "apport", - "apt", - "ctypes", - "curses", - "distutils", - "email", - "html", - "http", - "json", - "xmlrpc", - "nose" - ], include_files = ["main.ui"]) - -import sys -base = 'Win32GUI' if sys.platform=='win32' else None - -executables = [ - Executable('main.py', base=base, targetName = 'audio-visualizer-python') -] - -setup(name='audio-visualizer-python', - version = '1.0', - description = 'a little GUI tool to render visualization videos of audio files', - options = dict(build_exe = buildOptions), - executables = executables) +from setuptools import setup, find_packages +from importlib import import_module +from os import path +import re + + +def getTextFromFile(filename, fallback): + try: + with open( + path.join(path.abspath(path.dirname(__file__)), filename), encoding="utf-8" + ) as f: + output = f.read() + except Exception: + output = fallback + return output + + +PACKAGE_NAME = 'avp' +SOURCE_DIRECTORY = 'src' +SOURCE_PACKAGE_REGEX = re.compile(rf'^{SOURCE_DIRECTORY}') +PACKAGE_DESCRIPTION = 'Create audio visualization videos from a GUI or commandline' + + +avp = import_module(SOURCE_DIRECTORY) +source_packages = find_packages(include=[SOURCE_DIRECTORY, f'{SOURCE_DIRECTORY}.*']) +proj_packages = [SOURCE_PACKAGE_REGEX.sub(PACKAGE_NAME, name) for name in source_packages] + + +setup( + name='audio_visualizer_python', + version=avp.__version__, + url='https://github.com/djfun/audio-visualizer-python', + license='MIT', + description=PACKAGE_DESCRIPTION, + author=getTextFromFile('AUTHORS', 'djfun, tassaron'), + long_description=getTextFromFile('README.md', PACKAGE_DESCRIPTION), + classifiers=[ + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3 :: Only', + 'Intended Audience :: End Users/Desktop', + 'Topic :: Multimedia :: Video :: Non-Linear Editor', + ], + keywords=[ + 'visualizer', 'visualization', 'commandline video', + 'video editor', 'ffmpeg', 'podcast' + ], + packages=proj_packages, + package_dir={PACKAGE_NAME: SOURCE_DIRECTORY}, + include_package_data=True, + install_requires=[ + 'Pillow', + 'PyQt5', + 'numpy', + 'pytest' + ], + entry_points={ + 'gui_scripts': [ + f'avp = {PACKAGE_NAME}.main:main' + ], + } +) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..08131ce --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,36 @@ +import sys +import os +import logging + + +__version__ = '2.0.0rc6' + + +class Logger(logging.getLoggerClass()): + ''' + Custom Logger class to handle custom VERBOSE log level. + Levels used in this program are as follows: + VERBOSE Annoyingly frequent debug messages (e.g, in loops) + DEBUG Ordinary debug information + INFO Expected events that are expensive or irreversible + WARNING A non-fatal error or suspicious behaviour + ERROR Any error that would interrupt the user + CRITICAL Things that really shouldn't happen at all + ''' + def __init__(self, name, level=logging.NOTSET): + super().__init__(name, level) + logging.addLevelName(5, "VERBOSE") + + def verbose(self, msg, *args, **kwargs): + if self.isEnabledFor(5): + self._log(5, msg, args, **kwargs) +logging.setLoggerClass(Logger) +logging.VERBOSE = 5 + + +if getattr(sys, 'frozen', False): + # frozen + wd = os.path.dirname(sys.executable) +else: + # unfrozen + wd = os.path.dirname(os.path.realpath(__file__)) diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..3206bc8 --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,5 @@ +# Allows for launching with python3 -m avp + +from .main import main + +main() diff --git a/src/command.py b/src/command.py new file mode 100644 index 0000000..bf7941a --- /dev/null +++ b/src/command.py @@ -0,0 +1,263 @@ +''' + When using commandline mode, this module's object handles interpreting + the arguments and giving them to Core, which tracks the main program state. + Then it immediately exports a video. +''' +from PyQt5 import QtCore +import argparse +import os +import sys +import time +import signal +import shutil +import logging + +from . import core + + +log = logging.getLogger('AVP.Commandline') + + +class Command(QtCore.QObject): + """ + This replaces the GUI MainWindow when in commandline mode. + """ + + createVideo = QtCore.pyqtSignal() + + def __init__(self): + super().__init__() + self.core = core.Core() + core.Core.mode = 'commandline' + self.dataDir = self.core.dataDir + self.canceled = False + self.settings = core.Core.settings + + # ctrl-c stops the export thread + signal.signal(signal.SIGINT, self.stopVideo) + + def parseArgs(self): + self.parser = argparse.ArgumentParser( + description='Create a visualization for an audio file', + epilog='EXAMPLE COMMAND: main.py myvideotemplate.avp ' + '-i ~/Music/song.mp3 -o ~/video.mp4 ' + '-c 0 image path=~/Pictures/thisWeeksPicture.jpg ' + '-c 1 video "preset=My Logo" -c 2 vis layout=classic' + ) + self.parser.add_argument( + '-i', '--input', metavar='SOUND', + help='input audio file' + ) + self.parser.add_argument( + '-o', '--output', metavar='OUTPUT', + help='output video file' + ) + self.parser.add_argument( + '--export-project', action='store_true', + help='ignore -i and -o, use input and output from project file' + ) + self.parser.add_argument( + '--test', action='store_true', + help='run tests, generate logfiles, then exit' + ) + self.parser.add_argument( + '--debug', action='store_true', + help='create bigger logfiles while program is running' + ) + + # optional arguments + self.parser.add_argument( + 'projpath', metavar='path-to-project', + help='open a project file (.avp)', nargs='?') + self.parser.add_argument( + '-c', '--comp', metavar=('LAYER', 'ARG'), + help='first arg must be component NAME to insert at LAYER.' + '"help" for information about possible args for a component.', + nargs='*', action='append') + + self.args = self.parser.parse_args() + + if self.args.debug: + core.FILE_LOGLVL = logging.DEBUG + core.STDOUT_LOGLVL = logging.DEBUG + core.Core.makeLogger() + + if self.args.test: + self.runTests() + quit(0) + + if self.args.projpath: + projPath = self.args.projpath + if not os.path.dirname(projPath): + projPath = os.path.join( + self.settings.value("projectDir"), + projPath + ) + if not projPath.endswith('.avp'): + projPath += '.avp' + success = self.core.openProject(self, projPath) + if not success: + quit(1) + self.core.selectedComponents = list( + reversed(self.core.selectedComponents)) + self.core.componentListChanged() + + if self.args.comp: + for comp in self.args.comp: + pos = comp[0] + name = comp[1] + args = comp[2:] + try: + pos = int(pos) + except ValueError: + print(pos, 'is not a layer number.') + quit(1) + realName = self.parseCompName(name) + if not realName: + print(name, 'is not a valid component name.') + quit(1) + modI = self.core.moduleIndexFor(realName) + i = self.core.insertComponent(pos, modI, self) + for arg in args: + self.core.selectedComponents[i].command(arg) + + if self.args.export_project and self.args.projpath: + errcode, data = self.core.parseAvFile(projPath) + for key, value in data['WindowFields']: + if 'outputFile' in key: + output = value + if not os.path.dirname(value): + output = os.path.join( + os.path.expanduser('~'), + output + ) + if 'audioFile' in key: + input = value + self.createAudioVisualisation(input, output) + return "commandline" + + elif self.args.input and self.args.output: + self.createAudioVisualisation(self.args.input, self.args.output) + return "commandline" + + elif 'help' not in sys.argv and self.args.projpath is None and '--debug' not in sys.argv: + self.parser.print_help() + quit(1) + + return "GUI" + + def createAudioVisualisation(self, input, output): + self.core.selectedComponents = list( + reversed(self.core.selectedComponents)) + self.core.componentListChanged() + self.worker = self.core.newVideoWorker( + self, input, output + ) + # quit(0) after video is created + self.worker.videoCreated.connect(self.videoCreated) + self.lastProgressUpdate = time.time() + self.worker.progressBarSetText.connect(self.progressBarSetText) + self.createVideo.emit() + + def stopVideo(self, *args): + self.worker.error = True + self.worker.cancelExport() + self.worker.cancel() + + @QtCore.pyqtSlot(str) + def progressBarSetText(self, value): + if 'Export ' in value: + # Don't duplicate completion/failure messages + return + if not value.startswith('Exporting') \ + and time.time() - self.lastProgressUpdate >= 0.05: + # Show most messages very often + print(value) + elif time.time() - self.lastProgressUpdate >= 2.0: + # Give user time to read ffmpeg's output during the export + print('##### %s' % value) + else: + return + self.lastProgressUpdate = time.time() + + @QtCore.pyqtSlot() + def videoCreated(self): + quit(0) + + def showMessage(self, **kwargs): + print(kwargs['msg']) + if 'detail' in kwargs: + print(kwargs['detail']) + + @QtCore.pyqtSlot(str, str) + def videoThreadError(self, msg, detail): + print(msg) + print(detail) + quit(1) + + def drawPreview(self, *args): + pass + + def parseCompName(self, name): + '''Deduces a proper component name out of a commandline arg''' + + if name.title() in self.core.compNames: + return name.title() + for compName in self.core.compNames: + if name.capitalize() in compName: + return compName + + compFileNames = [ + os.path.splitext( + os.path.basename(mod.__file__) + )[0] + for mod in self.core.modules + ] + for i, compFileName in enumerate(compFileNames): + if name.lower() in compFileName: + return self.core.compNames[i] + return + + return None + + def runTests(self): + core.FILE_LOGLVL = logging.DEBUG + core.Core.makeLogger() + from . import tests + test_report = os.path.join(core.Core.logDir, "test_report.log") + tests.run(test_report) + + # Print test report into terminal + with open(test_report, "r") as f: + output = f.readlines() + test_output = "".join(output) + print(test_output) + + # Choose a numbered location to put the output file + logNumber = 0 + def getFilename(): + """Get a numbered filename for the final test report""" + nonlocal logNumber + name = os.path.join(os.path.expanduser('~'), "avp_test_report") + while True: + possibleName = f"{name}{logNumber:0>2}.txt" + if os.path.exists(possibleName) and logNumber < 100: + logNumber += 1 + continue + break + return possibleName + + # Copy latest debug log to chosen test report location + filename = getFilename() + if logNumber == 100: + print("Test Report could not be created.") + return + try: + shutil.copy(os.path.join(core.Core.logDir, "avp_debug.log"), filename) + except FileNotFoundError: + print("No debug log found.") + # Append actual test report to debug log + with open(filename, "a") as f: + f.write(f"{'='*59} debug log ends {'='*59}\n") + f.write(test_output) + print(f"Test Report created at {filename}") diff --git a/src/component.py b/src/component.py new file mode 100644 index 0000000..1e10f66 --- /dev/null +++ b/src/component.py @@ -0,0 +1,924 @@ +''' + Base classes for components to import. Read comments for some documentation + on making a valid component. +''' +from PyQt5 import uic, QtCore, QtWidgets +from PyQt5.QtGui import QColor +import os +import sys +import math +import time +import logging +from copy import copy + +from .toolkit.frame import BlankFrame +from .toolkit import ( + getWidgetValue, setWidgetValue, connectWidget, rgbFromString, blockSignals +) + + +log = logging.getLogger('AVP.ComponentHandler') + + +class ComponentMetaclass(type(QtCore.QObject)): + ''' + Checks the validity of each Component class and mutates some attrs. + E.g., takes only major version from version string & decorates methods + ''' + + def initializationWrapper(func): + def initializationWrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except Exception: + try: + raise ComponentError(self, 'initialization process') + except ComponentError: + return + return initializationWrapper + + def renderWrapper(func): + def renderWrapper(self, *args, **kwargs): + try: + log.verbose( + '### %s #%s renders a preview frame ###', + self.__class__.name, str(self.compPos), + ) + return func(self, *args, **kwargs) + except Exception as e: + try: + if e.__class__.__name__.startswith('Component'): + raise + else: + raise ComponentError(self, 'renderer') + except ComponentError: + return BlankFrame() + return renderWrapper + + def commandWrapper(func): + '''Intercepts the command() method to check for global args''' + def commandWrapper(self, arg): + if arg.startswith('preset='): + _, preset = arg.split('=', 1) + path = os.path.join(self.core.getPresetDir(self), preset) + if not os.path.exists(path): + print('Couldn\'t locate preset "%s"' % preset) + quit(1) + else: + print('Opening "%s" preset on layer %s' % ( + preset, self.compPos) + ) + self.core.openPreset(path, self.compPos, preset) + # Don't call the component's command() method + return + else: + return func(self, arg) + return commandWrapper + + def propertiesWrapper(func): + '''Intercepts the usual properties if the properties are locked.''' + def propertiesWrapper(self): + if self._lockedProperties is not None: + return self._lockedProperties + else: + try: + return func(self) + except Exception: + try: + raise ComponentError(self, 'properties') + except ComponentError: + return [] + return propertiesWrapper + + def errorWrapper(func): + '''Intercepts the usual error message if it is locked.''' + def errorWrapper(self): + if self._lockedError is not None: + return self._lockedError + else: + return func(self) + return errorWrapper + + def loadPresetWrapper(func): + '''Wraps loadPreset to handle the self.openingPreset boolean''' + class openingPreset: + def __init__(self, comp): + self.comp = comp + + def __enter__(self): + self.comp.openingPreset = True + + def __exit__(self, *args): + self.comp.openingPreset = False + + def presetWrapper(self, *args): + with openingPreset(self): + try: + return func(self, *args) + except Exception: + try: + raise ComponentError(self, 'preset loader') + except ComponentError: + return + return presetWrapper + + def updateWrapper(func): + ''' + Calls _preUpdate before every subclass update(). + Afterwards, for non-user updates, calls _autoUpdate(). + For undoable updates triggered by the user, calls _userUpdate() + ''' + class wrap: + def __init__(self, comp, auto): + self.comp = comp + self.auto = auto + + def __enter__(self): + self.comp._preUpdate() + + def __exit__(self, *args): + if self.auto or self.comp.openingPreset \ + or not hasattr(self.comp.parent, 'undoStack'): + log.verbose('Automatic update') + self.comp._autoUpdate() + else: + log.verbose('User update') + self.comp._userUpdate() + + def updateWrapper(self, **kwargs): + auto = kwargs['auto'] if 'auto' in kwargs else False + with wrap(self, auto): + try: + return func(self) + except Exception: + try: + raise ComponentError(self, 'update method') + except ComponentError: + return + return updateWrapper + + def widgetWrapper(func): + '''Connects all widgets to update method after the subclass's method''' + class wrap: + def __init__(self, comp): + self.comp = comp + + def __enter__(self): + pass + + def __exit__(self, *args): + for widgetList in self.comp._allWidgets.values(): + for widget in widgetList: + log.verbose('Connecting %s', str( + widget.__class__.__name__)) + connectWidget(widget, self.comp.update) + + def widgetWrapper(self, *args, **kwargs): + auto = kwargs['auto'] if 'auto' in kwargs else False + with wrap(self): + try: + return func(self, *args, **kwargs) + except Exception: + try: + raise ComponentError(self, 'widget creation') + except ComponentError: + return + return widgetWrapper + + def __new__(cls, name, parents, attrs): + if 'ui' not in attrs: + # Use module name as ui filename by default + attrs['ui'] = '%s.ui' % os.path.splitext( + attrs['__module__'].split('.')[-1] + )[0] + + decorate = ( + 'names', # Class methods + 'error', 'audio', 'properties', # Properties + 'preFrameRender', 'previewRender', + 'loadPreset', 'command', + 'update', 'widget', + ) + + # Auto-decorate methods + for key in decorate: + if key not in attrs: + continue + if key in ('names'): + attrs[key] = classmethod(attrs[key]) + elif key in ('audio'): + attrs[key] = property(attrs[key]) + elif key == 'command': + attrs[key] = cls.commandWrapper(attrs[key]) + elif key == 'previewRender': + attrs[key] = cls.renderWrapper(attrs[key]) + elif key == 'preFrameRender': + attrs[key] = cls.initializationWrapper(attrs[key]) + elif key == 'properties': + attrs[key] = cls.propertiesWrapper(attrs[key]) + elif key == 'error': + attrs[key] = cls.errorWrapper(attrs[key]) + elif key == 'loadPreset': + attrs[key] = cls.loadPresetWrapper(attrs[key]) + elif key == 'update': + attrs[key] = cls.updateWrapper(attrs[key]) + elif key == 'widget' and parents[0] != QtCore.QObject: + attrs[key] = cls.widgetWrapper(attrs[key]) + + # Turn version string into a number + try: + if 'version' not in attrs: + log.error( + 'No version attribute in %s. Defaulting to 1', + attrs['name']) + attrs['version'] = 1 + else: + attrs['version'] = int(attrs['version'].split('.')[0]) + except ValueError: + log.critical( + '%s component has an invalid version string:\n%s', + attrs['name'], str(attrs['version']) + ) + except KeyError: + log.critical('%s component has no version string.', attrs['name']) + else: + return super().__new__(cls, name, parents, attrs) + quit(1) + + +class Component(QtCore.QObject, metaclass=ComponentMetaclass): + ''' + The base class for components to inherit. + ''' + + name = 'Component' + # ui = 'name_Of_Non_Default_Ui_File' + + version = '1.0.0' + # The major version (before the first dot) is used to determine + # preset compatibility; the rest is ignored so it can be non-numeric. + + modified = QtCore.pyqtSignal(int, dict) + _error = QtCore.pyqtSignal(str, str) + + def __init__(self, moduleIndex, compPos, core): + super().__init__() + self.moduleIndex = moduleIndex + self.compPos = compPos + self.core = core + + # STATUS VARIABLES + self.currentPreset = None + self._allWidgets = {} + self._trackedWidgets = {} + self._presetNames = {} + self._commandArgs = {} + self._colorWidgets = {} + self._colorFuncs = {} + self._relativeWidgets = {} + # Pixel values stored as floats + self._relativeValues = {} + # Maximum values of spinBoxes at 1080p (Core.resolutions[0]) + self._relativeMaximums = {} + + # LOCKING VARIABLES + self.openingPreset = False + self.mergeUndo = True + self._lockedProperties = None + self._lockedError = None + self._lockedSize = None + # If set to a dict, values are used as basis to update relative widgets + self.oldAttrs = None + # Stop lengthy processes in response to this variable + self.canceled = False + + def __str__(self): + return self.__class__.name + + def __repr__(self): + import pprint + try: + preset = self.savePreset() + except Exception as e: + preset = '%s occurred while saving preset' % str(e) + + return ( + 'Component(module %s, pos %s) (%s)\n' + 'Name: %s v%s\nPreset: %s' % ( + self.moduleIndex, self.compPos, + object.__repr__(self), + self.__class__.name, str(self.__class__.version), + pprint.pformat(preset) + ) + ) + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Render Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def previewRender(self): + image = BlankFrame(self.width, self.height) + return image + + def preFrameRender(self, **kwargs): + ''' + Must call super() when subclassing + Triggered only before a video is exported (video_thread.py) + self.audioFile = filepath to the main input audio file + self.completeAudioArray = a list of audio samples + self.sampleSize = number of audio samples per video frame + self.progressBarUpdate = signal to set progress bar number + self.progressBarSetText = signal to set progress bar text + Use the latter two signals to update the MainWindow if needed + for a long initialization procedure (i.e., for a visualizer) + ''' + for key, value in kwargs.items(): + setattr(self, key, value) + + def frameRender(self, frameNo): + audioArrayIndex = frameNo * self.sampleSize + image = BlankFrame(self.width, self.height) + return image + + def postFrameRender(self): + pass + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Properties + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def properties(self): + ''' + Return a list of properties to signify if your component is + non-animated ('static'), returns sound ('audio'), or has + encountered an error in configuration ('error'). + ''' + return [] + + def error(self): + ''' + Return a string containing an error message, or None for a default. + Or tuple of two strings for a message with details. + Alternatively use lockError(msgString) within properties() + to skip this method entirely. + ''' + return + + def audio(self): + ''' + Return audio to mix into master as a tuple with two elements: + The first element can be: + - A string (path to audio file), + - Or an object that returns audio data through a pipe + The second element must be a dictionary of ffmpeg filters/options + to apply to the input stream. See the filter docs for ideas: + https://ffmpeg.org/ffmpeg-filters.html + ''' + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Idle Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def widget(self, parent): + ''' + Call super().widget(*args) to create the component widget + which also auto-connects any common widgets (e.g., checkBoxes) + to self.update(). Then in a subclass connect special actions + (e.g., pushButtons to select a file) and initialize + ''' + self.parent = parent + self.settings = parent.settings + log.verbose( + 'Creating UI for %s #%s\'s widget', + self.__class__.name, self.compPos + ) + self.page = self.loadUi(self.__class__.ui) + + # Find all normal widgets which will be connected after subclass method + self._allWidgets = { + 'lineEdit': self.page.findChildren(QtWidgets.QLineEdit), + 'checkBox': self.page.findChildren(QtWidgets.QCheckBox), + 'spinBox': self.page.findChildren(QtWidgets.QSpinBox), + 'comboBox': self.page.findChildren(QtWidgets.QComboBox), + } + self._allWidgets['spinBox'].extend( + self.page.findChildren(QtWidgets.QDoubleSpinBox) + ) + + def update(self): + ''' + Starting point for a component update. A subclass should override + this method, and the base class will then magically insert a call + to either _autoUpdate() or _userUpdate() at the end. + ''' + + def loadPreset(self, presetDict, presetName=None): + ''' + Subclasses should take (presetDict, *args) as args. + Must use super().loadPreset(presetDict, *args) first, + then update self.page widgets using the preset dict. + ''' + self.currentPreset = presetName \ + if presetName is not None else presetDict['preset'] + for attr, widget in self._trackedWidgets.items(): + key = attr if attr not in self._presetNames \ + else self._presetNames[attr] + try: + val = presetDict[key] + except KeyError as e: + log.info( + '%s missing value %s. Outdated preset?', + self.currentPreset, str(e) + ) + val = getattr(self, key) + + if attr in self._colorWidgets: + widget.setText('%s,%s,%s' % val) + btnStyle = ( + "QPushButton { background-color : %s; outline: none; }" + % QColor(*val).name() + ) + self._colorWidgets[attr].setStyleSheet(btnStyle) + elif attr in self._relativeWidgets: + self._relativeValues[attr] = val + pixelVal = self.pixelValForAttr(attr, val) + setWidgetValue(widget, pixelVal) + else: + setWidgetValue(widget, val) + + def savePreset(self): + saveValueStore = {} + for attr, widget in self._trackedWidgets.items(): + presetAttrName = ( + attr if attr not in self._presetNames + else self._presetNames[attr] + ) + if attr in self._relativeWidgets: + try: + val = self._relativeValues[attr] + except AttributeError: + val = self.floatValForAttr(attr) + else: + val = getattr(self, attr) + + saveValueStore[presetAttrName] = val + return saveValueStore + + def commandHelp(self): + '''Help text as string for this component's commandline arguments''' + + def command(self, arg=''): + ''' + Configure a component using an arg from the commandline. This is + never called if global args like 'preset=' are found in the arg. + So simply check for any non-global args in your component and + call super().command() at the end to get a Help message. + ''' + print( + self.__class__.name, 'Usage:\n' + 'Open a preset for this component:\n' + ' "preset=Preset Name"' + ) + self.commandHelp() + quit(0) + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # "Private" Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + def _preUpdate(self): + '''Happens before subclass update()''' + for attr in self._relativeWidgets: + self.updateRelativeWidget(attr) + + def _userUpdate(self): + '''Happens after subclass update() for an undoable update by user.''' + oldWidgetVals = { + attr: copy(getattr(self, attr)) + for attr in self._trackedWidgets + } + newWidgetVals = { + attr: getWidgetValue(widget) + if attr not in self._colorWidgets else rgbFromString(widget.text()) + for attr, widget in self._trackedWidgets.items() + } + modifiedWidgets = { + attr: val + for attr, val in newWidgetVals.items() + if val != oldWidgetVals[attr] + } + if modifiedWidgets: + action = ComponentUpdate(self, oldWidgetVals, modifiedWidgets) + self.parent.undoStack.push(action) + + def _autoUpdate(self): + '''Happens after subclass update() for an internal component update.''' + newWidgetVals = { + attr: getWidgetValue(widget) + for attr, widget in self._trackedWidgets.items() + } + self.setAttrs(newWidgetVals) + self._sendUpdateSignal() + + def setAttrs(self, attrDict): + ''' + Sets attrs (linked to trackedWidgets) in this component to + the values in the attrDict. Mutates certain widget values if needed + ''' + for attr, val in attrDict.items(): + if attr in self._colorWidgets: + # Color Widgets must have a tuple & have a button to update + if type(val) is tuple: + rgbTuple = val + else: + rgbTuple = rgbFromString(val) + btnStyle = ( + "QPushButton { background-color : %s; outline: none; }" + % QColor(*rgbTuple).name()) + self._colorWidgets[attr].setStyleSheet(btnStyle) + setattr(self, attr, rgbTuple) + + else: + # Normal tracked widget + setattr(self, attr, val) + log.verbose('Setting %s self.%s to %s' % ( + self.__class__.name, attr, val)) + + def setWidgetValues(self, attrDict): + ''' + Sets widgets defined by keys in trackedWidgets in this preset to + the values in the attrDict. + ''' + affectedWidgets = [ + self._trackedWidgets[attr] for attr in attrDict + ] + with blockSignals(affectedWidgets): + for attr, val in attrDict.items(): + widget = self._trackedWidgets[attr] + if attr in self._colorWidgets: + val = '%s,%s,%s' % val + setWidgetValue(widget, val) + + def _sendUpdateSignal(self): + if not self.core.openingProject: + self.parent.drawPreview() + saveValueStore = self.savePreset() + saveValueStore['preset'] = self.currentPreset + self.modified.emit(self.compPos, saveValueStore) + + def trackWidgets(self, trackDict, **kwargs): + ''' + Name widgets to track in update(), savePreset(), loadPreset(), and + command(). Requires a dict of attr names as keys, widgets as values + + Optional args: + 'presetNames': preset variable names to replace attr names + 'commandArgs': arg keywords that differ from attr names + 'colorWidgets': identify attr as RGB tuple & update button CSS + 'relativeWidgets': change value proportionally to resolution + + NOTE: Any kwarg key set to None will selectively disable tracking. + ''' + self._trackedWidgets = trackDict + for kwarg in kwargs: + try: + if kwarg in ( + 'presetNames', + 'commandArgs', + 'colorWidgets', + 'relativeWidgets', + ): + setattr(self, '_{}'.format(kwarg), kwargs[kwarg]) + else: + raise ComponentError( + self, 'Nonsensical keywords to trackWidgets.') + except ComponentError: + continue + + if kwarg == 'colorWidgets': + def makeColorFunc(attr): + def pickColor_(): + self.mergeUndo = False + self.pickColor( + self._trackedWidgets[attr], + self._colorWidgets[attr] + ) + self.mergeUndo = True + return pickColor_ + self._colorFuncs = { + attr: makeColorFunc(attr) for attr in kwargs[kwarg] + } + for attr, func in self._colorFuncs.items(): + self._colorWidgets[attr].clicked.connect(func) + self._colorWidgets[attr].setStyleSheet( + "QPushButton {" + "background-color : #FFFFFF; outline: none; }" + ) + + if kwarg == 'relativeWidgets': + # store maximum values of spinBoxes to be scaled appropriately + for attr in kwargs[kwarg]: + self._relativeMaximums[attr] = \ + self._trackedWidgets[attr].maximum() + self.updateRelativeWidgetMaximum(attr) + setattr( + self, attr, getWidgetValue(self._trackedWidgets[attr]) + ) + + self._preUpdate() + self._autoUpdate() + + def pickColor(self, textWidget, button): + '''Use color picker to get color input from the user.''' + dialog = QtWidgets.QColorDialog() + dialog.setOption(QtWidgets.QColorDialog.ShowAlphaChannel, True) + color = dialog.getColor() + if color.isValid(): + RGBstring = '%s,%s,%s' % ( + str(color.red()), str(color.green()), str(color.blue())) + btnStyle = "QPushButton{background-color: %s; outline: none;}" \ + % color.name() + textWidget.setText(RGBstring) + button.setStyleSheet(btnStyle) + + def lockProperties(self, propList): + self._lockedProperties = propList + + def lockError(self, msg): + self._lockedError = msg + + def lockSize(self, w, h): + self._lockedSize = (w, h) + + def unlockProperties(self): + self._lockedProperties = None + + def unlockError(self): + self._lockedError = None + + def unlockSize(self): + self._lockedSize = None + + def loadUi(self, filename): + '''Load a Qt Designer ui file to use for this component's widget''' + return uic.loadUi(os.path.join(self.core.componentsPath, filename)) + + @property + def width(self): + if self._lockedSize is None: + return int(self.settings.value('outputWidth')) + else: + return self._lockedSize[0] + + @property + def height(self): + if self._lockedSize is None: + return int(self.settings.value('outputHeight')) + else: + return self._lockedSize[1] + + def cancel(self): + '''Stop any lengthy process in response to this variable.''' + self.canceled = True + + def reset(self): + self.canceled = False + self.unlockProperties() + self.unlockError() + + def relativeWidgetAxis(func): + def relativeWidgetAxis(self, attr, *args, **kwargs): + hasVerticalWords = ( + lambda attr: + 'height' in attr.lower() or + 'ypos' in attr.lower() or + attr == 'y' + ) + if 'axis' not in kwargs: + axis = self.width + if hasVerticalWords(attr): + axis = self.height + kwargs['axis'] = axis + if 'axis' in kwargs and type(kwargs['axis']) is tuple: + axis = kwargs['axis'][0] + if hasVerticalWords(attr): + axis = kwargs['axis'][1] + kwargs['axis'] = axis + return func(self, attr, *args, **kwargs) + return relativeWidgetAxis + + @relativeWidgetAxis + def pixelValForAttr(self, attr, val=None, **kwargs): + if val is None: + val = self._relativeValues[attr] + if val > 50.0: + log.warning( + '%s #%s attempted to set %s to dangerously high number %s', + self.__class__.name, self.compPos, attr, val + ) + val = 50.0 + result = math.ceil(kwargs['axis'] * val) + log.verbose( + 'Converting %s: f%s to px%s using axis %s', + attr, val, result, kwargs['axis'] + ) + return result + + @relativeWidgetAxis + def floatValForAttr(self, attr, val=None, **kwargs): + if val is None: + val = self._trackedWidgets[attr].value() + return val / kwargs['axis'] + + def setRelativeWidget(self, attr, floatVal): + '''Set a relative widget using a float''' + pixelVal = self.pixelValForAttr(attr, floatVal) + with blockSignals(self._trackedWidgets[attr]): + self._trackedWidgets[attr].setValue(pixelVal) + self.update(auto=True) + + def getOldAttr(self, attr): + ''' + Returns previous state of this attr. Used to determine whether + a relative widget must be updated. Required because undoing/redoing + can make determining the 'previous' value tricky. + ''' + if self.oldAttrs is not None: + return self.oldAttrs[attr] + else: + try: + return getattr(self, attr) + except AttributeError: + log.error('Using visible values instead of oldAttrs') + return self._trackedWidgets[attr].value() + + def updateRelativeWidget(self, attr): + '''Called by _preUpdate() for each relativeWidget before each update''' + oldUserValue = self.getOldAttr(attr) + newUserValue = self._trackedWidgets[attr].value() + newRelativeVal = self.floatValForAttr(attr, newUserValue) + + if attr in self._relativeValues: + oldRelativeVal = self._relativeValues[attr] + if oldUserValue == newUserValue \ + and oldRelativeVal != newRelativeVal: + # Float changed without pixel value changing, which + # means the pixel value needs to be updated + log.debug( + 'Updating %s #%s\'s relative widget: %s', + self.__class__.name, self.compPos, attr) + with blockSignals(self._trackedWidgets[attr]): + self.updateRelativeWidgetMaximum(attr) + pixelVal = self.pixelValForAttr(attr, oldRelativeVal) + self._trackedWidgets[attr].setValue(pixelVal) + + if attr not in self._relativeValues \ + or oldUserValue != newUserValue: + self._relativeValues[attr] = newRelativeVal + + def updateRelativeWidgetMaximum(self, attr): + maxRes = int(self.core.resolutions[0].split('x')[0]) + newMaximumValue = self.width * ( + self._relativeMaximums[attr] / + maxRes + ) + self._trackedWidgets[attr].setMaximum(int(newMaximumValue)) + + +class ComponentError(RuntimeError): + '''Gives the MainWindow a traceback to display, and cancels the export.''' + + prevErrors = [] + lastTime = time.time() + + def __init__(self, caller, name, msg=None): + if msg is None and sys.exc_info()[0] is not None: + msg = str(sys.exc_info()[1]) + else: + msg = 'Unknown error.' + log.error("ComponentError by %s's %s: %s" % ( + caller.name, name, msg)) + + # Don't create multiple windows for quickly repeated messages + if len(ComponentError.prevErrors) > 1: + ComponentError.prevErrors.pop() + ComponentError.prevErrors.insert(0, name) + curTime = time.time() + if name in ComponentError.prevErrors[1:] \ + and curTime - ComponentError.lastTime < 1.0: + return + ComponentError.lastTime = time.time() + + from .toolkit import formatTraceback + if sys.exc_info()[0] is not None: + string = ( + "%s component (#%s): %s encountered %s %s: %s" % ( + caller.__class__.name, + str(caller.compPos), + name, + 'an' if any([ + sys.exc_info()[0].__name__.startswith(vowel) + for vowel in ('A', 'I', 'U', 'O', 'E') + ]) else 'a', + sys.exc_info()[0].__name__, + str(sys.exc_info()[1]) + ) + ) + detail = formatTraceback(sys.exc_info()[2]) + else: + string = name + detail = "Attributes:\n%s" % ( + "\n".join( + [m for m in dir(caller) if not m.startswith('_')] + ) + ) + + super().__init__(string) + caller.lockError(string) + caller._error.emit(string, detail) + + +class ComponentUpdate(QtWidgets.QUndoCommand): + '''Command object for making a component action undoable''' + def __init__(self, parent, oldWidgetVals, modifiedVals): + super().__init__( + 'change %s component #%s' % ( + parent.name, parent.compPos + ) + ) + self.undone = False + self.res = (int(parent.width), int(parent.height)) + self.parent = parent + self.oldWidgetVals = { + attr: copy(val) + if attr not in self.parent._relativeWidgets + else self.parent.floatValForAttr(attr, val, axis=self.res) + for attr, val in oldWidgetVals.items() + if attr in modifiedVals + } + self.modifiedVals = { + attr: val + if attr not in self.parent._relativeWidgets + else self.parent.floatValForAttr(attr, val, axis=self.res) + for attr, val in modifiedVals.items() + } + + # Because relative widgets change themselves every update based on + # their previous value, we must store ALL their values in case of undo + self.relativeWidgetValsAfterUndo = { + attr: copy(getattr(self.parent, attr)) + for attr in self.parent._relativeWidgets + } + + # Determine if this update is mergeable + self.id_ = -1 + if len(self.modifiedVals) == 1 and self.parent.mergeUndo: + attr, val = self.modifiedVals.popitem() + self.id_ = sum([ord(letter) for letter in attr[-14:]]) + self.modifiedVals[attr] = val + else: + log.warning( + '%s component settings changed at once. (%s)', + len(self.modifiedVals), repr(self.modifiedVals) + ) + + def id(self): + '''If 2 consecutive updates have same id, Qt will call mergeWith()''' + return self.id_ + + def mergeWith(self, other): + self.modifiedVals.update(other.modifiedVals) + return True + + def setWidgetValues(self, attrDict): + ''' + Mask the component's usual method to handle our + relative widgets in case the resolution has changed. + ''' + newAttrDict = { + attr: val if attr not in self.parent._relativeWidgets + else self.parent.pixelValForAttr(attr, val) + for attr, val in attrDict.items() + } + self.parent.setWidgetValues(newAttrDict) + + def redo(self): + if self.undone: + log.info('Redoing component update') + self.parent.oldAttrs = self.relativeWidgetValsAfterUndo + self.setWidgetValues(self.modifiedVals) + self.parent.update(auto=True) + self.parent.oldAttrs = None + if not self.undone: + self.relativeWidgetValsAfterRedo = { + attr: copy(getattr(self.parent, attr)) + for attr in self.parent._relativeWidgets + } + self.parent._sendUpdateSignal() + + def undo(self): + log.info('Undoing component update') + self.undone = True + self.parent.oldAttrs = self.relativeWidgetValsAfterRedo + self.setWidgetValues(self.oldWidgetVals) + self.parent.update(auto=True) + self.parent.oldAttrs = None diff --git a/src/components/__init__.py b/src/components/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/components/__init__.py @@ -0,0 +1 @@ + diff --git a/src/components/__template__.ui b/src/components/__template__.ui new file mode 100644 index 0000000..301a2b7 --- /dev/null +++ b/src/components/__template__.ui @@ -0,0 +1,119 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_6"> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_5"> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_4"> + <item> + <spacer name="horizontalSpacer_4"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/color.py b/src/components/color.py new file mode 100644 index 0000000..8d0edd2 --- /dev/null +++ b/src/components/color.py @@ -0,0 +1,160 @@ +from PyQt5 import QtGui +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor + + +log = logging.getLogger('AVP.Components.Color') + + +class Component(Component): + name = 'Color' + version = '1.0.0' + + def widget(self, *args): + self.x = 0 + self.y = 0 + super().widget(*args) + + # disable color #2 until non-default 'fill' option gets changed + self.page.lineEdit_color2.setDisabled(True) + self.page.pushButton_color2.setDisabled(True) + self.page.spinBox_width.setValue( + int(self.settings.value("outputWidth"))) + self.page.spinBox_height.setValue( + int(self.settings.value("outputHeight"))) + + self.fillLabels = [ + 'Solid', + 'Linear Gradient', + 'Radial Gradient', + ] + for label in self.fillLabels: + self.page.comboBox_fill.addItem(label) + self.page.comboBox_fill.setCurrentIndex(0) + + self.trackWidgets({ + 'x': self.page.spinBox_x, + 'y': self.page.spinBox_y, + 'sizeWidth': self.page.spinBox_width, + 'sizeHeight': self.page.spinBox_height, + 'trans': self.page.checkBox_trans, + 'spread': self.page.comboBox_spread, + 'stretch': self.page.checkBox_stretch, + 'RG_start': self.page.spinBox_radialGradient_start, + 'LG_start': self.page.spinBox_linearGradient_start, + 'RG_end': self.page.spinBox_radialGradient_end, + 'LG_end': self.page.spinBox_linearGradient_end, + 'RG_centre': self.page.spinBox_radialGradient_spread, + 'fillType': self.page.comboBox_fill, + 'color1': self.page.lineEdit_color1, + 'color2': self.page.lineEdit_color2, + }, presetNames={ + 'sizeWidth': 'width', + 'sizeHeight': 'height', + }, colorWidgets={ + 'color1': self.page.pushButton_color1, + 'color2': self.page.pushButton_color2, + }, relativeWidgets=[ + 'x', 'y', + 'sizeWidth', 'sizeHeight', + 'LG_start', 'LG_end', + 'RG_start', 'RG_end', 'RG_centre', + ]) + + def update(self): + fillType = self.page.comboBox_fill.currentIndex() + if fillType == 0: + self.page.lineEdit_color2.setEnabled(False) + self.page.pushButton_color2.setEnabled(False) + self.page.checkBox_trans.setEnabled(False) + self.page.checkBox_stretch.setEnabled(False) + self.page.comboBox_spread.setEnabled(False) + else: + self.page.lineEdit_color2.setEnabled(True) + self.page.pushButton_color2.setEnabled(True) + self.page.checkBox_trans.setEnabled(True) + self.page.checkBox_stretch.setEnabled(True) + self.page.comboBox_spread.setEnabled(True) + if self.page.checkBox_trans.isChecked(): + self.page.lineEdit_color2.setEnabled(False) + self.page.pushButton_color2.setEnabled(False) + self.page.fillWidget.setCurrentIndex(fillType) + + def previewRender(self): + return self.drawFrame(self.width, self.height) + + def properties(self): + return ['static'] + + def frameRender(self, frameNo): + log.debug("Color component is drawing frame #%s", frameNo) + return self.drawFrame(self.width, self.height) + + def drawFrame(self, width, height): + r, g, b = self.color1 + shapeSize = (self.sizeWidth, self.sizeHeight) + # in default state, skip all this logic and return a plain fill + if self.fillType == 0 and shapeSize == (width, height) \ + and self.x == 0 and self.y == 0: + return FloodFrame(width, height, (r, g, b, 255)) + + # Return a solid image at x, y + if self.fillType == 0: + frame = BlankFrame(width, height) + image = FloodFrame(self.sizeWidth, self.sizeHeight, (r, g, b, 255)) + frame.paste(image, box=(self.x, self.y)) + return frame + + # Now fills that require using Qt... + elif self.fillType > 0: + image = FramePainter(width, height) + + if self.stretch: + w = width + h = height + else: + w = self.sizeWidth + h = self.sizeWidth + + if self.fillType == 1: # Linear Gradient + brush = QtGui.QLinearGradient( + self.LG_start, + self.LG_start, + self.LG_end+width/3, + self.LG_end) + + elif self.fillType == 2: # Radial Gradient + brush = QtGui.QRadialGradient( + self.RG_start, + self.RG_end, + w, h, + self.RG_centre) + + brush.setSpread(self.spread) + brush.setColorAt(0.0, PaintColor(*self.color1)) + if self.trans: + brush.setColorAt(1.0, PaintColor(0, 0, 0, 0)) + elif self.fillType == 1 and self.stretch: + brush.setColorAt(0.2, PaintColor(*self.color2)) + else: + brush.setColorAt(1.0, PaintColor(*self.color2)) + image.setBrush(brush) + image.drawRect( + self.x, self.y, + self.sizeWidth, self.sizeHeight + ) + + return image.finalize() + + def commandHelp(self): + print('Specify a color:\n color=255,255,255') + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + if key == 'color': + self.page.lineEdit_color1.setText(arg) + return + super().command(arg) diff --git a/src/components/color.ui b/src/components/color.ui new file mode 100644 index 0000000..c1713fb --- /dev/null +++ b/src/components/color.ui @@ -0,0 +1,666 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Color #1</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_color1"> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_color1"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>0,0,0</string> + </property> + <property name="maxLength"> + <number>12</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_textColor_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Color #2</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_color2"> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_color2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>133,133,133</string> + </property> + <property name="maxLength"> + <number>12</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_7"> + <property name="leftMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label_xTitleAlign_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Width</string> + </property> + <property name="alignment"> + <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_width"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>0</number> + </property> + <property name="maximum"> + <number>19200</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Height</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_height"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="maximum"> + <number>10800</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_7"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_x"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <property name="leftMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label_textLayout"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Fill </string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_fill"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="currentIndex"> + <number>-1</number> + </property> + <property name="sizeAdjustPolicy"> + <enum>QComboBox::AdjustToContentsOnFirstShow</enum> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_trans"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Transparent</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_stretch"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Stretch</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_spread"> + <item> + <property name="text"> + <string>Pad</string> + </property> + </item> + <item> + <property name="text"> + <string>Reflect</string> + </property> + </item> + <item> + <property name="text"> + <string>Repeat</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QStackedWidget" name="fillWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Minimum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="lineWidth"> + <number>0</number> + </property> + <property name="currentIndex"> + <number>2</number> + </property> + <widget class="QWidget" name="blank"/> + <widget class="QWidget" name="linearGradient"> + <widget class="QWidget" name="horizontalLayoutWidget"> + <property name="geometry"> + <rect> + <x>-1</x> + <y>0</y> + <width>561</width> + <height>31</height> + </rect> + </property> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QLabel" name="label_xTitleAlign_4"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Start</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_linearGradient_start"> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="singleStep"> + <number>10</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>End</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_linearGradient_end"> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="singleStep"> + <number>10</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + </widget> + <widget class="QWidget" name="radialGradient"> + <widget class="QWidget" name="horizontalLayoutWidget_3"> + <property name="geometry"> + <rect> + <x>-1</x> + <y>-1</y> + <width>561</width> + <height>31</height> + </rect> + </property> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QLabel" name="label_xTitleAlign_6"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Start</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_radialGradient_start"> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="singleStep"> + <number>10</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>End</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_radialGradient_end"> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="singleStep"> + <number>10</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_4"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Centre</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_radialGradient_spread"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::PlusMinus</enum> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="value"> + <number>3</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + </widget> + </widget> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/image.py b/src/components/image.py new file mode 100644 index 0000000..42f9564 --- /dev/null +++ b/src/components/image.py @@ -0,0 +1,126 @@ +from PIL import Image, ImageDraw, ImageEnhance +from PyQt5 import QtGui, QtCore, QtWidgets +import os + +from ..component import Component +from ..toolkit.frame import BlankFrame + + +class Component(Component): + name = 'Image' + version = '1.0.1' + + def widget(self, *args): + super().widget(*args) + self.page.pushButton_image.clicked.connect(self.pickImage) + self.trackWidgets({ + 'imagePath': self.page.lineEdit_image, + 'scale': self.page.spinBox_scale, + 'stretchScale': self.page.spinBox_scale_stretch, + 'rotate': self.page.spinBox_rotate, + 'color': self.page.spinBox_color, + 'xPosition': self.page.spinBox_x, + 'yPosition': self.page.spinBox_y, + 'stretched': self.page.checkBox_stretch, + 'mirror': self.page.checkBox_mirror, + }, presetNames={ + 'imagePath': 'image', + 'xPosition': 'x', + 'yPosition': 'y', + }, relativeWidgets=[ + 'xPosition', 'yPosition', 'scale' + ]) + + def previewRender(self): + return self.drawFrame(self.width, self.height) + + def properties(self): + props = ['static'] + if not os.path.exists(self.imagePath): + props.append('error') + return props + + def error(self): + if not self.imagePath: + return "There is no image selected." + if not os.path.exists(self.imagePath): + return "The image selected does not exist!" + + def frameRender(self, frameNo): + return self.drawFrame(self.width, self.height) + + def drawFrame(self, width, height): + frame = BlankFrame(width, height) + if self.imagePath and os.path.exists(self.imagePath): + scale = self.scale if not self.stretched else self.stretchScale + image = Image.open(self.imagePath) + + # Modify image's appearance + if self.color != 100: + image = ImageEnhance.Color(image).enhance( + float(self.color / 100) + ) + if self.mirror: + image = image.transpose(Image.FLIP_LEFT_RIGHT) + if self.stretched and image.size != (width, height): + image = image.resize((width, height), Image.ANTIALIAS) + if scale != 100: + newHeight = int((image.height / 100) * scale) + newWidth = int((image.width / 100) * scale) + image = image.resize((newWidth, newHeight), Image.ANTIALIAS) + + # Paste image at correct position + frame.paste(image, box=(self.xPosition, self.yPosition)) + if self.rotate != 0: + frame = frame.rotate(self.rotate) + + return frame + + def pickImage(self): + imgDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, "Choose Image", imgDir, + "Image Files (%s)" % " ".join(self.core.imageFormats)) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_image.setText(filename) + self.mergeUndo = True + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + if key == 'path' and os.path.exists(arg): + try: + Image.open(arg) + self.page.lineEdit_image.setText(arg) + self.page.checkBox_stretch.setChecked(True) + return + except OSError as e: + print("Not a supported image format") + quit(1) + super().command(arg) + + def commandHelp(self): + print('Load an image:\n path=/filepath/to/image.png') + + def savePreset(self): + # Maintain the illusion that the scale spinbox is one widget + scaleBox = self.page.spinBox_scale + stretchScaleBox = self.page.spinBox_scale_stretch + if self.page.checkBox_stretch.isChecked(): + scaleBox.setValue(stretchScaleBox.value()) + else: + stretchScaleBox.setValue(scaleBox.value()) + return super().savePreset() + + def update(self): + # Maintain the illusion that the scale spinbox is one widget + scaleBox = self.page.spinBox_scale + stretchScaleBox = self.page.spinBox_scale_stretch + if self.page.checkBox_stretch.isChecked(): + scaleBox.setVisible(False) + stretchScaleBox.setVisible(True) + else: + scaleBox.setVisible(True) + stretchScaleBox.setVisible(False) diff --git a/src/components/image.ui b/src/components/image.ui new file mode 100644 index 0000000..2dad127 --- /dev/null +++ b/src/components/image.ui @@ -0,0 +1,388 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Image</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_image"> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_image"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_x"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>-1000</number> + </property> + <property name="maximum"> + <number>1000</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <item> + <widget class="QCheckBox" name="checkBox_stretch"> + <property name="text"> + <string>Stretch</string> + </property> + <property name="checked"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_10"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QCheckBox" name="checkBox_mirror"> + <property name="text"> + <string>Mirror</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Rotate</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_rotate"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string notr="true">°</string> + </property> + <property name="minimum"> + <number>0</number> + </property> + <property name="maximum"> + <number>359</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>10</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Scale</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>400</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale_stretch"> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>400</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Color</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_color"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>0</number> + </property> + <property name="maximum"> + <number>999</number> + </property> + <property name="singleStep"> + <number>1</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/life.py b/src/components/life.py new file mode 100644 index 0000000..94704bc --- /dev/null +++ b/src/components/life.py @@ -0,0 +1,475 @@ +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtWidgets import QUndoCommand +from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter +import os +import math + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale + + +class Component(Component): + name = 'Conway\'s Game of Life' + version = '1.0.0' + + def widget(self, *args): + super().widget(*args) + self.scale = 32 + self.updateGridSize() + self.startingGrid = set() + self.page.pushButton_pickImage.clicked.connect(self.pickImage) + self.trackWidgets({ + 'tickRate': self.page.spinBox_tickRate, + 'scale': self.page.spinBox_scale, + 'color': self.page.lineEdit_color, + 'shapeType': self.page.comboBox_shapeType, + 'shadow': self.page.checkBox_shadow, + 'customImg': self.page.checkBox_customImg, + 'showGrid': self.page.checkBox_showGrid, + 'image': self.page.lineEdit_image, + }, colorWidgets={ + 'color': self.page.pushButton_color, + }) + self.shiftButtons = ( + self.page.toolButton_up, + self.page.toolButton_down, + self.page.toolButton_left, + self.page.toolButton_right, + ) + + def shiftFunc(i): + def shift(): + self.shiftGrid(i) + return shift + shiftFuncs = [shiftFunc(i) for i in range(len(self.shiftButtons))] + for i, widget in enumerate(self.shiftButtons): + widget.clicked.connect(shiftFuncs[i]) + self.page.spinBox_scale.setValue(self.scale) + self.page.spinBox_scale.valueChanged.connect(self.updateGridSize) + + def pickImage(self): + imgDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, "Choose Image", imgDir, + "Image Files (%s)" % " ".join(self.core.imageFormats)) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_image.setText(filename) + self.mergeUndo = True + + def shiftGrid(self, d): + action = ShiftGrid(self, d) + self.parent.undoStack.push(action) + + def update(self): + self.updateGridSize() + if self.page.checkBox_customImg.isChecked(): + self.page.label_color.setVisible(False) + self.page.lineEdit_color.setVisible(False) + self.page.pushButton_color.setVisible(False) + self.page.label_shape.setVisible(False) + self.page.comboBox_shapeType.setVisible(False) + self.page.label_image.setVisible(True) + self.page.lineEdit_image.setVisible(True) + self.page.pushButton_pickImage.setVisible(True) + else: + self.page.label_color.setVisible(True) + self.page.lineEdit_color.setVisible(True) + self.page.pushButton_color.setVisible(True) + self.page.label_shape.setVisible(True) + self.page.comboBox_shapeType.setVisible(True) + self.page.label_image.setVisible(False) + self.page.lineEdit_image.setVisible(False) + self.page.pushButton_pickImage.setVisible(False) + enabled = (len(self.startingGrid) > 0) + for widget in self.shiftButtons: + widget.setEnabled(enabled) + + def previewClickEvent(self, pos, size, button): + pos = ( + math.ceil((pos[0] / size[0]) * self.gridWidth) - 1, + math.ceil((pos[1] / size[1]) * self.gridHeight) - 1 + ) + action = ClickGrid(self, pos, button) + self.parent.undoStack.push(action) + + def updateGridSize(self): + w, h = self.core.resolutions[-1].split('x') + self.gridWidth = int(int(w) / self.scale) + self.gridHeight = int(int(h) / self.scale) + self.pxWidth = math.ceil(self.width / self.gridWidth) + self.pxHeight = math.ceil(self.height / self.gridHeight) + + def previewRender(self): + return self.drawGrid(self.startingGrid) + + def preFrameRender(self, *args, **kwargs): + super().preFrameRender(*args, **kwargs) + self.progressBarSetText.emit("Computing evolution...") + self.tickGrids = {0: self.startingGrid} + tick = 0 + for frameNo in range( + self.tickRate, self.audioArrayLen, self.sampleSize + ): + if self.parent.canceled: + break + if frameNo % self.tickRate == 0: + tick += 1 + self.tickGrids[tick] = self.gridForTick(tick) + + # update progress bar + progress = int(100*(frameNo/self.audioArrayLen)) + if progress >= 100: + progress = 100 + pStr = "Computing evolution: "+str(progress)+'%' + self.progressBarSetText.emit(pStr) + self.progressBarUpdate.emit(int(progress)) + + def properties(self): + if self.customImg and ( + not self.image or not os.path.exists(self.image) + ): + return ['error'] + return [] + + def error(self): + return "No image selected to represent life." + + def frameRender(self, frameNo): + tick = math.floor(frameNo / self.tickRate) + grid = self.tickGrids[tick] + return self.drawGrid(grid) + + def drawGrid(self, grid): + frame = BlankFrame(self.width, self.height) + + def drawCustomImg(): + try: + img = Image.open(self.image) + except Exception: + return + img = img.resize((self.pxWidth, self.pxHeight), Image.ANTIALIAS) + frame.paste(img, box=(drawPtX, drawPtY)) + + def drawShape(): + drawer = ImageDraw.Draw(frame) + rect = ( + (drawPtX, drawPtY), + (drawPtX + self.pxWidth, drawPtY + self.pxHeight) + ) + shape = self.page.comboBox_shapeType.currentText().lower() + + # Rectangle + if shape == 'rectangle': + drawer.rectangle(rect, fill=self.color) + + # Elliptical + elif shape == 'elliptical': + drawer.ellipse(rect, fill=self.color) + + tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int) + smallerShape = ( + (drawPtX + tenthX + int(tenthX / 4), + drawPtY + tenthY + int(tenthY / 2)), + (drawPtX + self.pxWidth - tenthX - int(tenthX / 4), + drawPtY + self.pxHeight - (tenthY + int(tenthY / 2))) + ) + outlineShape = ( + (drawPtX + int(tenthX / 4), + drawPtY + int(tenthY / 2)), + (drawPtX + self.pxWidth - int(tenthX / 4), + drawPtY + self.pxHeight - int(tenthY / 2)) + ) + # Circle + if shape == 'circle': + drawer.ellipse(outlineShape, fill=self.color) + drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) + + # Lilypad + elif shape == 'lilypad': + drawer.pieslice(smallerShape, 290, 250, fill=self.color) + + # Pac-Man + elif shape == 'pac-man': + drawer.pieslice(outlineShape, 35, 320, fill=self.color) + + hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline + tX, tY = scale(33, self.pxWidth, self.pxHeight, int) # thirdline + qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline + + # Path + if shape == 'path': + drawer.ellipse(rect, fill=self.color) + rects = { + direction: False + for direction in ( + 'up', 'down', 'left', 'right', + ) + } + for cell in self.nearbyCoords(x, y): + if cell not in grid: + continue + if cell[0] == x: + if cell[1] < y: + rects['up'] = True + if cell[1] > y: + rects['down'] = True + if cell[1] == y: + if cell[0] < x: + rects['left'] = True + if cell[0] > x: + rects['right'] = True + + for direction, rect in rects.items(): + if rect: + if direction == 'up': + sect = ( + (drawPtX, drawPtY), + (drawPtX + self.pxWidth, drawPtY + hY) + ) + elif direction == 'down': + sect = ( + (drawPtX, drawPtY + hY), + (drawPtX + self.pxWidth, + drawPtY + self.pxHeight) + ) + elif direction == 'left': + sect = ( + (drawPtX, drawPtY), + (drawPtX + hX, + drawPtY + self.pxHeight) + ) + elif direction == 'right': + sect = ( + (drawPtX + hX, drawPtY), + (drawPtX + self.pxWidth, + drawPtY + self.pxHeight) + ) + drawer.rectangle(sect, fill=self.color) + + # Duck + elif shape == 'duck': + duckHead = ( + (drawPtX + qX, drawPtY + qY), + (drawPtX + int(qX * 3), drawPtY + int(tY * 2)) + ) + duckBeak = ( + (drawPtX + hX, drawPtY + qY), + (drawPtX + self.pxWidth + qX, + drawPtY + int(qY * 3)) + ) + duckWing = ( + (drawPtX, drawPtY + hY), + rect[1] + ) + duckBody = ( + (drawPtX + int(qX / 4), drawPtY + int(qY * 3)), + (drawPtX + int(tX * 2), drawPtY + self.pxHeight) + ) + drawer.ellipse(duckBody, fill=self.color) + drawer.ellipse(duckHead, fill=self.color) + drawer.pieslice(duckWing, 130, 200, fill=self.color) + drawer.pieslice(duckBeak, 145, 200, fill=self.color) + + # Peace + elif shape == 'peace': + line = (( + drawPtX + hX - int(tenthX / 2), drawPtY + int(tenthY / 2)), + (drawPtX + hX + int(tenthX / 2), + drawPtY + self.pxHeight - int(tenthY / 2)) + ) + drawer.ellipse(outlineShape, fill=self.color) + drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) + drawer.rectangle(line, fill=self.color) + + def slantLine(difference): + return ( + (drawPtX + difference), + (drawPtY + self.pxHeight - qY) + ), + ( + (drawPtX + hX), + (drawPtY + hY) + ) + + drawer.line( + slantLine(qX), + fill=self.color, + width=tenthX + ) + drawer.line( + slantLine(self.pxWidth - qX), + fill=self.color, + width=tenthX + ) + + for x, y in grid: + drawPtX = x * self.pxWidth + if drawPtX > self.width: + continue + drawPtY = y * self.pxHeight + if drawPtY > self.height: + continue + + if self.customImg: + drawCustomImg() + else: + drawShape() + + if self.shadow: + shadImg = ImageEnhance.Contrast(frame).enhance(0.0) + shadImg = shadImg.filter(ImageFilter.GaussianBlur(5.00)) + shadImg = ImageChops.offset(shadImg, -2, 2) + shadImg.paste(frame, box=(0, 0), mask=frame) + frame = shadImg + if self.showGrid: + drawer = ImageDraw.Draw(frame) + w, h = scale(0.05, self.width, self.height, int) + for x in range(self.pxWidth, self.width, self.pxWidth): + drawer.rectangle( + ((x, 0), + (x + w, self.height)), + fill=self.color, + ) + for y in range(self.pxHeight, self.height, self.pxHeight): + drawer.rectangle( + ((0, y), + (self.width, y + h)), + fill=self.color, + ) + + return frame + + def gridForTick(self, tick): + '''Given a tick number over 0, returns a new grid set of tuples''' + lastGrid = self.tickGrids[tick - 1] + + def neighbours(x, y): + return { + cell for cell in self.nearbyCoords(x, y) + if cell in lastGrid + } + + newGrid = set() + for x, y in lastGrid: + surrounding = len(neighbours(x, y)) + if surrounding == 2 or surrounding == 3: + newGrid.add((x, y)) + potentialNewCells = { + coordTup for origin in lastGrid + for coordTup in list(self.nearbyCoords(*origin)) + } + for x, y in potentialNewCells: + if (x, y) in newGrid: + continue + surrounding = len(neighbours(x, y)) + if surrounding == 3: + newGrid.add((x, y)) + + return newGrid + + def savePreset(self): + pr = super().savePreset() + pr['GRID'] = sorted(self.startingGrid) + return pr + + def loadPreset(self, pr, *args): + self.startingGrid = set(pr['GRID']) + if self.startingGrid: + for widget in self.shiftButtons: + widget.setEnabled(True) + super().loadPreset(pr, *args) + + def nearbyCoords(self, x, y): + yield x + 1, y + 1 + yield x + 1, y - 1 + yield x - 1, y + 1 + yield x - 1, y - 1 + yield x, y + 1 + yield x, y - 1 + yield x + 1, y + yield x - 1, y + + +class ClickGrid(QUndoCommand): + def __init__(self, comp, pos, id_): + super().__init__( + "click %s component #%s" % (comp.name, comp.compPos)) + self.comp = comp + self.pos = [pos] + self.id_ = id_ + + def id(self): + return self.id_ + + def mergeWith(self, other): + self.pos.extend(other.pos) + return True + + def add(self): + for pos in self.pos[:]: + self.comp.startingGrid.add(pos) + self.comp.update(auto=True) + + def remove(self): + for pos in self.pos[:]: + self.comp.startingGrid.discard(pos) + self.comp.update(auto=True) + + def redo(self): + if self.id_ == 1: # Left-click + self.add() + elif self.id_ == 2: # Right-click + self.remove() + + def undo(self): + if self.id_ == 1: # Left-click + self.remove() + elif self.id_ == 2: # Right-click + self.add() + +class ShiftGrid(QUndoCommand): + def __init__(self, comp, direction): + super().__init__( + "change %s component #%s" % (comp.name, comp.compPos)) + self.comp = comp + self.direction = direction + self.distance = 1 + + def id(self): + return self.direction + + def mergeWith(self, other): + self.distance += other.distance + return True + + def newGrid(self, Xchange, Ychange): + return { + (x + Xchange, y + Ychange) + for x, y in self.comp.startingGrid + } + + def redo(self): + if self.direction == 0: + newGrid = self.newGrid(0, -self.distance) + elif self.direction == 1: + newGrid = self.newGrid(0, self.distance) + elif self.direction == 2: + newGrid = self.newGrid(-self.distance, 0) + elif self.direction == 3: + newGrid = self.newGrid(self.distance, 0) + self.comp.startingGrid = newGrid + self.comp._sendUpdateSignal() + + def undo(self): + if self.direction == 0: + newGrid = self.newGrid(0, self.distance) + elif self.direction == 1: + newGrid = self.newGrid(0, -self.distance) + elif self.direction == 2: + newGrid = self.newGrid(self.distance, 0) + elif self.direction == 3: + newGrid = self.newGrid(-self.distance, 0) + self.comp.startingGrid = newGrid + self.comp._sendUpdateSignal() diff --git a/src/components/life.ui b/src/components/life.ui new file mode 100644 index 0000000..85b2926 --- /dev/null +++ b/src/components/life.ui @@ -0,0 +1,405 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_4"> + <item> + <widget class="QLabel" name="label_5"> + <property name="text"> + <string>Simulation Speed</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_tickRate"> + <property name="suffix"> + <string> frames per tick</string> + </property> + <property name="minimum"> + <number>1</number> + </property> + <property name="maximum"> + <number>30</number> + </property> + <property name="value"> + <number>5</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_color"> + <property name="maximumSize"> + <size> + <width>0</width> + <height>16777215</height> + </size> + </property> + <property name="text"> + <string>0,0,0</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_5"> + <item> + <widget class="QLabel" name="label_7"> + <property name="text"> + <string>Grid Scale</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="minimum"> + <number>22</number> + </property> + <property name="maximum"> + <number>128</number> + </property> + <property name="value"> + <number>32</number> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_customImg"> + <property name="text"> + <string>Custom Image</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_image"> + <property name="text"> + <string>Image</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_image"/> + </item> + <item> + <widget class="QPushButton" name="pushButton_pickImage"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_color"> + <property name="text"> + <string>Color</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_color_3"> + <property name="maximumSize"> + <size> + <width>0</width> + <height>16777215</height> + </size> + </property> + <property name="text"> + <string>0,0,0</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_color"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="default"> + <bool>false</bool> + </property> + <property name="flat"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_shape"> + <property name="text"> + <string>Shape</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_shapeType"> + <item> + <property name="text"> + <string>Path</string> + </property> + </item> + <item> + <property name="text"> + <string>Rectangle</string> + </property> + </item> + <item> + <property name="text"> + <string>Elliptical</string> + </property> + </item> + <item> + <property name="text"> + <string>Circle</string> + </property> + </item> + <item> + <property name="text"> + <string>Lilypad</string> + </property> + </item> + <item> + <property name="text"> + <string>Pac-Man</string> + </property> + </item> + <item> + <property name="text"> + <string>Duck</string> + </property> + </item> + <item> + <property name="text"> + <string>Peace</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_8"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_6"> + <item> + <widget class="QCheckBox" name="checkBox_shadow"> + <property name="text"> + <string>Shadow</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_showGrid"> + <property name="text"> + <string>Show Grid</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <item> + <widget class="QToolButton" name="toolButton_up"> + <property name="text"> + <string>Up</string> + </property> + <property name="arrowType"> + <enum>Qt::UpArrow</enum> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_down"> + <property name="text"> + <string>Down</string> + </property> + <property name="arrowType"> + <enum>Qt::DownArrow</enum> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_left"> + <property name="text"> + <string>Left</string> + </property> + <property name="arrowType"> + <enum>Qt::LeftArrow</enum> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_right"> + <property name="text"> + <string>Right</string> + </property> + <property name="arrowType"> + <enum>Qt::RightArrow</enum> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </item> + <item> + <widget class="QTextBrowser" name="textBrowser"> + <property name="html"> + <string><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Click the preview window to place a cell. Right-click to remove.</span></p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with less than 2 neighbours will die from underpopulation</p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with more than 3 neighbours will die from overpopulation.</p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- An empty space surrounded by 3 live cells will cause reproduction.</p></body></html></string> + </property> + <property name="tabStopWidth"> + <number>80</number> + </property> + <property name="textInteractionFlags"> + <set>Qt::NoTextInteraction</set> + </property> + <property name="openLinks"> + <bool>false</bool> + </property> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/original.py b/src/components/original.py new file mode 100644 index 0000000..80228fe --- /dev/null +++ b/src/components/original.py @@ -0,0 +1,203 @@ +import numpy +from PIL import Image, ImageDraw +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtGui import QColor +import os +import time +from copy import copy + +from ..component import Component +from ..toolkit.frame import BlankFrame + + +class Component(Component): + name = 'Classic Visualizer' + version = '1.0.0' + + def names(*args): + return ['Original Audio Visualization'] + + def properties(self): + return ['pcm'] + + def widget(self, *args): + self.scale = 20 + self.y = 0 + super().widget(*args) + + self.page.comboBox_visLayout.addItem("Classic") + self.page.comboBox_visLayout.addItem("Split") + self.page.comboBox_visLayout.addItem("Bottom") + self.page.comboBox_visLayout.addItem("Top") + self.page.comboBox_visLayout.setCurrentIndex(0) + + self.page.lineEdit_visColor.setText('255,255,255') + + self.trackWidgets({ + 'visColor': self.page.lineEdit_visColor, + 'layout': self.page.comboBox_visLayout, + 'scale': self.page.spinBox_scale, + 'y': self.page.spinBox_y, + }, colorWidgets={ + 'visColor': self.page.pushButton_visColor, + }, relativeWidgets=[ + 'y', + ]) + + def previewRender(self): + spectrum = numpy.fromfunction( + lambda x: float(self.scale)/2500*(x-128)**2, (255,), dtype="int16") + return self.drawBars( + self.width, self.height, spectrum, self.visColor, self.layout + ) + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + self.smoothConstantDown = 0.08 + self.smoothConstantUp = 0.8 + self.lastSpectrum = None + self.spectrumArray = {} + + for i in range(0, len(self.completeAudioArray), self.sampleSize): + if self.canceled: + break + self.lastSpectrum = self.transformData( + i, self.completeAudioArray, self.sampleSize, + self.smoothConstantDown, self.smoothConstantUp, + self.lastSpectrum) + self.spectrumArray[i] = copy(self.lastSpectrum) + + progress = int(100*(i/len(self.completeAudioArray))) + if progress >= 100: + progress = 100 + pStr = "Analyzing audio: "+str(progress)+'%' + self.progressBarSetText.emit(pStr) + self.progressBarUpdate.emit(int(progress)) + + def frameRender(self, frameNo): + arrayNo = frameNo * self.sampleSize + return self.drawBars( + self.width, self.height, + self.spectrumArray[arrayNo], + self.visColor, self.layout) + + def transformData( + self, i, completeAudioArray, sampleSize, + smoothConstantDown, smoothConstantUp, lastSpectrum): + if len(completeAudioArray) < (i + sampleSize): + sampleSize = len(completeAudioArray) - i + + window = numpy.hanning(sampleSize) + data = completeAudioArray[i:i+sampleSize][::1] * window + paddedSampleSize = 2048 + paddedData = numpy.pad( + data, (0, paddedSampleSize - sampleSize), 'constant') + spectrum = numpy.fft.fft(paddedData) + sample_rate = 44100 + frequencies = numpy.fft.fftfreq(len(spectrum), 1./sample_rate) + + y = abs(spectrum[0:int(paddedSampleSize/2) - 1]) + + # filter the noise away + # y[y<80] = 0 + + y = self.scale * numpy.log10(y) + y[numpy.isinf(y)] = 0 + + if lastSpectrum is not None: + lastSpectrum[y < lastSpectrum] = \ + y[y < lastSpectrum] * smoothConstantDown + \ + lastSpectrum[y < lastSpectrum] * (1 - smoothConstantDown) + + lastSpectrum[y >= lastSpectrum] = \ + y[y >= lastSpectrum] * smoothConstantUp + \ + lastSpectrum[y >= lastSpectrum] * (1 - smoothConstantUp) + else: + lastSpectrum = y + + x = frequencies[0:int(paddedSampleSize/2) - 1] + + return lastSpectrum + + def drawBars(self, width, height, spectrum, color, layout): + vH = height-height/8 + bF = width / 64 + bH = bF / 2 + bQ = bF / 4 + imTop = BlankFrame(width, height) + draw = ImageDraw.Draw(imTop) + r, g, b = color + color2 = (r, g, b, 125) + + bP = height / 1200 + + for j in range(0, 63): + draw.rectangle(( + bH + j * bF, vH+bQ, bH + j * bF + bF, vH + bQ - + spectrum[j * 4] * bP - bH), fill=color2) + + draw.rectangle(( + bH + bQ + j * bF, vH, bH + bQ + j * bF + bH, vH - + spectrum[j * 4] * bP), fill=color) + + imBottom = imTop.transpose(Image.FLIP_TOP_BOTTOM) + + im = BlankFrame(width, height) + + if layout == 0: # Classic + y = self.y - int(height/100*43) + im.paste(imTop, (0, y), mask=imTop) + y = self.y + int(height/100*43) + im.paste(imBottom, (0, y), mask=imBottom) + + if layout == 1: # Split + y = self.y + int(height/100*10) + im.paste(imTop, (0, y), mask=imTop) + y = self.y - int(height/100*10) + im.paste(imBottom, (0, y), mask=imBottom) + + if layout == 2: # Bottom + y = self.y + int(height/100*10) + im.paste(imTop, (0, y), mask=imTop) + + if layout == 3: # Top + y = self.y - int(height/100*10) + im.paste(imBottom, (0, y), mask=imBottom) + + return im + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + try: + if key == 'color': + self.page.lineEdit_visColor.setText(arg) + return + elif key == 'layout': + if arg == 'classic': + self.page.comboBox_visLayout.setCurrentIndex(0) + elif arg == 'split': + self.page.comboBox_visLayout.setCurrentIndex(1) + elif arg == 'bottom': + self.page.comboBox_visLayout.setCurrentIndex(2) + elif arg == 'top': + self.page.comboBox_visLayout.setCurrentIndex(3) + return + elif key == 'scale': + arg = int(arg) + self.page.spinBox_scale.setValue(arg) + return + elif key == 'y': + arg = int(arg) + self.page.spinBox_y.setValue(arg) + return + except ValueError: + print('You must enter a number.') + quit(1) + super().command(arg) + + def commandHelp(self): + print('Give a layout name:\n layout=[classic/split/bottom/top]') + print('Specify a color:\n color=255,255,255') + print('Visualizer scale (20 is default):\n scale=number') + print('Y position:\n y=number') diff --git a/src/components/original.ui b/src/components/original.ui new file mode 100644 index 0000000..a4d5119 --- /dev/null +++ b/src/components/original.ui @@ -0,0 +1,193 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>178</height> + </rect> + </property> + <property name="minimumSize"> + <size> + <width>180</width> + <height>0</height> + </size> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <widget class="QLabel" name="label_visLayout"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Layout</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_visLayout"/> + </item> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Scale</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::PlusMinus</enum> + </property> + <property name="minimum"> + <number>1</number> + </property> + <property name="value"> + <number>20</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_visColor"> + <property name="text"> + <string>Color</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_visColor"> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_visColor"/> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="minimum"> + <number>-5000</number> + </property> + <property name="maximum"> + <number>5000</number> + </property> + <property name="singleStep"> + <number>10</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Expanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/sound.py b/src/components/sound.py new file mode 100644 index 0000000..118ea23 --- /dev/null +++ b/src/components/sound.py @@ -0,0 +1,73 @@ +from PyQt5 import QtGui, QtCore, QtWidgets +import os + +from ..component import Component +from ..toolkit.frame import BlankFrame + + +class Component(Component): + name = 'Sound' + version = '1.0.0' + + def widget(self, *args): + super().widget(*args) + self.page.pushButton_sound.clicked.connect(self.pickSound) + self.trackWidgets({ + 'sound': self.page.lineEdit_sound, + 'chorus': self.page.checkBox_chorus, + 'delay': self.page.spinBox_delay, + 'volume': self.page.spinBox_volume, + }, commandArgs={ + 'sound': None, + }) + + def properties(self): + props = ['static', 'audio'] + if not os.path.exists(self.sound): + props.append('error') + return props + + def error(self): + if not self.sound: + return "No audio file selected." + if not os.path.exists(self.sound): + return "The audio file selected no longer exists!" + + def audio(self): + params = {} + if self.delay != 0.0: + params['adelay'] = '=%s' % str(int(self.delay * 1000.00)) + if self.chorus: + params['chorus'] = \ + '=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3' + if self.volume != 1.0: + params['volume'] = '=%s:replaygain_noclip=0' % str(self.volume) + + return (self.sound, params) + + def pickSound(self): + sndDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, "Choose Sound", sndDir, + "Audio Files (%s)" % " ".join(self.core.audioFormats)) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_sound.setText(filename) + self.mergeUndo = True + + def commandHelp(self): + print('Path to audio file:\n path=/filepath/to/sound.ogg') + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + if key == 'path': + if '*%s' % os.path.splitext(arg)[1] \ + not in self.core.audioFormats: + print("Not a supported audio format") + quit(1) + self.page.lineEdit_sound.setText(arg) + return + + super().command(arg) diff --git a/src/components/sound.ui b/src/components/sound.ui new file mode 100644 index 0000000..4c11332 --- /dev/null +++ b/src/components/sound.ui @@ -0,0 +1,172 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Audio File</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_sound"> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_sound"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Volume</string> + </property> + </widget> + </item> + <item> + <widget class="QDoubleSpinBox" name="spinBox_volume"> + <property name="suffix"> + <string>x</string> + </property> + <property name="maximum"> + <double>10.000000000000000</double> + </property> + <property name="singleStep"> + <double>0.100000000000000</double> + </property> + <property name="value"> + <double>1.000000000000000</double> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Delay</string> + </property> + </widget> + </item> + <item> + <widget class="QDoubleSpinBox" name="spinBox_delay"> + <property name="suffix"> + <string>s</string> + </property> + <property name="maximum"> + <double>9999999.990000000223517</double> + </property> + <property name="singleStep"> + <double>0.500000000000000</double> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_chorus"> + <property name="text"> + <string>Chorus</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/spectrum.py b/src/components/spectrum.py new file mode 100644 index 0000000..91f2afb --- /dev/null +++ b/src/components/spectrum.py @@ -0,0 +1,316 @@ +from PIL import Image +from PyQt5 import QtGui, QtCore, QtWidgets +import os +import math +import subprocess +import time +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit import checkOutput, connectWidget +from ..toolkit.ffmpeg import ( + openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound +) + + +log = logging.getLogger('AVP.Components.Spectrum') + + +class Component(Component): + name = 'Spectrum' + version = '1.0.1' + + def widget(self, *args): + self.previewFrame = None + super().widget(*args) + self._image = BlankFrame(self.width, self.height) + self.chunkSize = 4 * self.width * self.height + self.changedOptions = True + self.previewSize = (214, 120) + self.previewPipe = None + + if hasattr(self.parent, 'lineEdit_audioFile'): + # update preview when audio file changes (if genericPreview is off) + self.parent.lineEdit_audioFile.textChanged.connect( + self.update + ) + + self.trackWidgets({ + 'filterType': self.page.comboBox_filterType, + 'window': self.page.comboBox_window, + 'mode': self.page.comboBox_mode, + 'amplitude': self.page.comboBox_amplitude0, + 'amplitude1': self.page.comboBox_amplitude1, + 'amplitude2': self.page.comboBox_amplitude2, + 'display': self.page.comboBox_display, + 'zoom': self.page.spinBox_zoom, + 'tc': self.page.spinBox_tc, + 'x': self.page.spinBox_x, + 'y': self.page.spinBox_y, + 'mirror': self.page.checkBox_mirror, + 'draw': self.page.checkBox_draw, + 'scale': self.page.spinBox_scale, + 'color': self.page.comboBox_color, + 'compress': self.page.checkBox_compress, + 'mono': self.page.checkBox_mono, + 'hue': self.page.spinBox_hue, + }, relativeWidgets=[ + 'x', 'y', + ]) + for widget in self._trackedWidgets.values(): + connectWidget(widget, lambda: self.changed()) + + def changed(self): + self.changedOptions = True + + def update(self): + filterType = self.page.comboBox_filterType.currentIndex() + self.page.stackedWidget.setCurrentIndex(filterType) + if filterType == 3: + self.page.spinBox_hue.setEnabled(False) + else: + self.page.spinBox_hue.setEnabled(True) + if filterType == 2 or filterType == 4: + self.page.checkBox_mono.setEnabled(False) + else: + self.page.checkBox_mono.setEnabled(True) + + def previewRender(self): + changedSize = self.updateChunksize() + if not changedSize \ + and not self.changedOptions \ + and self.previewFrame is not None: + log.debug( + 'Spectrum #%s is reusing old preview frame' % self.compPos) + return self.previewFrame + + frame = self.getPreviewFrame() + self.changedOptions = False + if not frame: + log.warning( + 'Spectrum #%s failed to create a preview frame' % self.compPos) + self.previewFrame = None + return BlankFrame(self.width, self.height) + else: + self.previewFrame = frame + return frame + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + if self.previewPipe is not None: + self.previewPipe.wait() + self.updateChunksize() + w, h = scale(self.scale, self.width, self.height, str) + self.video = FfmpegVideo( + inputPath=self.audioFile, + filter_=self.makeFfmpegFilter(), + width=w, height=h, + chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, component=self, + ) + + def frameRender(self, frameNo): + if FfmpegVideo.threadError is not None: + raise FfmpegVideo.threadError + return self.finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def getPreviewFrame(self): + genericPreview = self.settings.value("pref_genericPreview") + startPt = 0 + if not genericPreview: + inputFile = self.parent.lineEdit_audioFile.text() + if not inputFile or not os.path.exists(inputFile): + return + duration = getAudioDuration(inputFile) + if not duration: + return + startPt = duration / 3 + + command = [ + self.core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-r', self.settings.value("outputFrameRate"), + '-ss', "{0:.3f}".format(startPt), + '-i', + self.core.junkStream + if genericPreview else inputFile, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) + command.extend([ + '-an', + '-s:v', '%sx%s' % scale(self.scale, self.width, self.height, str), + '-codec:v', 'rawvideo', '-', + '-frames:v', '1', + ]) + + if self.core.logEnabled: + logFilename = os.path.join( + self.core.logDir, 'preview_%s.log' % str(self.compPos)) + log.debug('Creating ffmpeg process (log at %s)' % logFilename) + with open(logFilename, 'w') as logf: + logf.write(" ".join(command) + '\n\n') + with open(logFilename, 'a') as logf: + self.previewPipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=logf, bufsize=10**8 + ) + else: + self.previewPipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=10**8 + ) + byteFrame = self.previewPipe.stdout.read(self.chunkSize) + closePipe(self.previewPipe) + + frame = self.finalizeFrame(byteFrame) + return frame + + def makeFfmpegFilter(self, preview=False, startPt=0): + if preview: + w, h = self.previewSize + else: + w, h = (self.width, self.height) + color = self.page.comboBox_color.currentText().lower() + genericPreview = self.settings.value("pref_genericPreview") + + if self.filterType == 0: # Spectrum + if self.amplitude == 0: + amplitude = 'sqrt' + elif self.amplitude == 1: + amplitude = 'cbrt' + elif self.amplitude == 2: + amplitude = '4thrt' + elif self.amplitude == 3: + amplitude = '5thrt' + elif self.amplitude == 4: + amplitude = 'lin' + elif self.amplitude == 5: + amplitude = 'log' + filter_ = ( + 'showspectrum=s=%sx%s:slide=scroll:win_func=%s:' + 'color=%s:scale=%s,' + 'colorkey=color=black:similarity=0.1:blend=0.5' % ( + w, h, + self.page.comboBox_window.currentText(), + color, amplitude, + ) + ) + elif self.filterType == 1: # Histogram + if self.amplitude1 == 0: + amplitude = 'log' + elif self.amplitude1 == 1: + amplitude = 'lin' + if self.display == 0: + display = 'log' + elif self.display == 1: + display = 'sqrt' + elif self.display == 2: + display = 'cbrt' + elif self.display == 3: + display = 'lin' + elif self.display == 4: + display = 'rlog' + filter_ = ( + 'ahistogram=r=%s:s=%sx%s:dmode=separate:ascale=%s:scale=%s' % ( + self.settings.value("outputFrameRate"), + w, h, + amplitude, display + ) + ) + elif self.filterType == 2: # Vector Scope + if self.amplitude2 == 0: + amplitude = 'log' + elif self.amplitude2 == 1: + amplitude = 'sqrt' + elif self.amplitude2 == 2: + amplitude = 'cbrt' + elif self.amplitude2 == 3: + amplitude = 'lin' + m = self.page.comboBox_mode.currentText() + filter_ = ( + 'avectorscope=s=%sx%s:draw=%s:m=%s:scale=%s:zoom=%s' % ( + w, h, + 'line'if self.draw else 'dot', + m, amplitude, str(self.zoom), + ) + ) + elif self.filterType == 3: # Musical Scale + filter_ = ( + 'showcqt=r=%s:s=%sx%s:count=30:text=0:tc=%s,' + 'colorkey=color=black:similarity=0.1:blend=0.5 ' % ( + self.settings.value("outputFrameRate"), + w, h, + str(self.tc), + ) + ) + elif self.filterType == 4: # Phase + filter_ = ( + 'aphasemeter=r=%s:s=%sx%s:video=1 [atrash][vtmp1]; ' + '[atrash] anullsink; ' + '[vtmp1] colorkey=color=black:similarity=0.1:blend=0.5, ' + 'crop=in_w/8:in_h:(in_w/8)*7:0 ' % ( + self.settings.value("outputFrameRate"), + w, h, + ) + ) + + if self.filterType < 2: + exampleSnd = exampleSound('freq') + elif self.filterType == 2 or self.filterType == 4: + exampleSnd = exampleSound('stereo') + elif self.filterType == 3: + exampleSnd = exampleSound('white') + + return [ + '-filter_complex', + '%s%s%s%s [v1]; ' + '[v1] %s%s%s%s%s [v]' % ( + exampleSnd if preview and genericPreview else '[0:a] ', + 'compand=gain=4,' if self.compress else '', + 'aformat=channel_layouts=mono,' + if self.mono and self.filterType not in (2, 4) else '', + filter_, + 'hflip, ' if self.mirror else '', + 'trim=start=%s:end=%s, ' % ( + "{0:.3f}".format(startPt + 12), + "{0:.3f}".format(startPt + 12.5) + ) if preview else '', + 'scale=%sx%s' % scale( + self.scale, self.width, self.height, str), + ', hue=h=%s:s=10' % str(self.hue) + if self.hue > 0 and self.filterType != 3 else '', + ', 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 '' + ), + '-map', '[v]', + ] + + def updateChunksize(self): + width, height = scale(self.scale, self.width, self.height, int) + oldChunkSize = int(self.chunkSize) + self.chunkSize = 4 * width * height + changed = self.chunkSize != oldChunkSize + return changed + + def finalizeFrame(self, imageData): + try: + image = Image.frombytes( + 'RGBA', + scale(self.scale, self.width, self.height, int), + imageData + ) + self._image = image + except ValueError: + image = self._image + + frame = BlankFrame(self.width, self.height) + frame.paste(image, box=(self.x, self.y)) + return frame diff --git a/src/components/spectrum.ui b/src/components/spectrum.ui new file mode 100644 index 0000000..c6a8a15 --- /dev/null +++ b/src/components/spectrum.ui @@ -0,0 +1,946 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>197</height> + </size> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_5"/> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_4"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Type</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_filterType"> + <item> + <property name="text"> + <string>Spectrum</string> + </property> + </item> + <item> + <property name="text"> + <string>Histogram</string> + </property> + </item> + <item> + <property name="text"> + <string>Vector Scope</string> + </property> + </item> + <item> + <property name="text"> + <string>Musical Scale</string> + </property> + </item> + <item> + <property name="text"> + <string>Phase</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_x"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <item> + <widget class="QCheckBox" name="checkBox_compress"> + <property name="text"> + <string>Compress</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_mono"> + <property name="text"> + <string>Mono</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_mirror"> + <property name="text"> + <string>Mirror</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_11"> + <property name="text"> + <string>Hue</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_hue"> + <property name="suffix"> + <string>° </string> + </property> + <property name="maximum"> + <number>359</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Scale</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>400</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QStackedWidget" name="stackedWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="autoFillBackground"> + <bool>false</bool> + </property> + <property name="frameShape"> + <enum>QFrame::NoFrame</enum> + </property> + <property name="frameShadow"> + <enum>QFrame::Plain</enum> + </property> + <property name="currentIndex"> + <number>0</number> + </property> + <widget class="QWidget" name="page"> + <widget class="QWidget" name="verticalLayoutWidget"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>561</width> + <height>66</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <property name="sizeConstraint"> + <enum>QLayout::SetMaximumSize</enum> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Window</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_window"> + <item> + <property name="text"> + <string>hann</string> + </property> + </item> + <item> + <property name="text"> + <string>gauss</string> + </property> + </item> + <item> + <property name="text"> + <string>tukey</string> + </property> + </item> + <item> + <property name="text"> + <string>dolph</string> + </property> + </item> + <item> + <property name="text"> + <string>cauchy</string> + </property> + </item> + <item> + <property name="text"> + <string>parzen</string> + </property> + </item> + <item> + <property name="text"> + <string>poisson</string> + </property> + </item> + <item> + <property name="text"> + <string>rect</string> + </property> + </item> + <item> + <property name="text"> + <string>bartlett</string> + </property> + </item> + <item> + <property name="text"> + <string>hanning</string> + </property> + </item> + <item> + <property name="text"> + <string>hamming</string> + </property> + </item> + <item> + <property name="text"> + <string>blackman</string> + </property> + </item> + <item> + <property name="text"> + <string>welch</string> + </property> + </item> + <item> + <property name="text"> + <string>flattop</string> + </property> + </item> + <item> + <property name="text"> + <string>bharris</string> + </property> + </item> + <item> + <property name="text"> + <string>bnuttall</string> + </property> + </item> + <item> + <property name="text"> + <string>lanczos</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Amplitude</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_amplitude0"> + <item> + <property name="text"> + <string>Square root</string> + </property> + </item> + <item> + <property name="text"> + <string>Cubic root</string> + </property> + </item> + <item> + <property name="text"> + <string>4thrt</string> + </property> + </item> + <item> + <property name="text"> + <string>5thrt</string> + </property> + </item> + <item> + <property name="text"> + <string>Linear</string> + </property> + </item> + <item> + <property name="text"> + <string>Logarithmic</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_4"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>10</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QLabel" name="label_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Color </string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_color"> + <item> + <property name="text"> + <string>Channel</string> + </property> + </item> + <item> + <property name="text"> + <string>Intensity</string> + </property> + </item> + <item> + <property name="text"> + <string>Rainbow</string> + </property> + </item> + <item> + <property name="text"> + <string>Moreland</string> + </property> + </item> + <item> + <property name="text"> + <string>Nebulae</string> + </property> + </item> + <item> + <property name="text"> + <string>Fire</string> + </property> + </item> + <item> + <property name="text"> + <string>Fiery</string> + </property> + </item> + <item> + <property name="text"> + <string>Fruit</string> + </property> + </item> + <item> + <property name="text"> + <string>Cool</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>10</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <widget class="QWidget" name="page_2"> + <widget class="QWidget" name="verticalLayoutWidget_2"> + <property name="geometry"> + <rect> + <x>-1</x> + <y>-1</y> + <width>561</width> + <height>31</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_4"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QLabel" name="label_6"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Display Scale</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_display"> + <item> + <property name="text"> + <string>Logarithmic</string> + </property> + </item> + <item> + <property name="text"> + <string>Square root</string> + </property> + </item> + <item> + <property name="text"> + <string>Cubic root</string> + </property> + </item> + <item> + <property name="text"> + <string>Linear</string> + </property> + </item> + <item> + <property name="text"> + <string>Reverse Log</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QLabel" name="label_5"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Amplitude</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_amplitude1"> + <item> + <property name="text"> + <string>Logarithmic</string> + </property> + </item> + <item> + <property name="text"> + <string>Linear</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <widget class="QWidget" name="page_3"> + <widget class="QWidget" name="verticalLayoutWidget_3"> + <property name="geometry"> + <rect> + <x>-1</x> + <y>-1</y> + <width>585</width> + <height>64</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_5"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QLabel" name="label_9"> + <property name="text"> + <string>Mode</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_mode"> + <item> + <property name="text"> + <string>lissajous</string> + </property> + </item> + <item> + <property name="text"> + <string>lissajous_xy</string> + </property> + </item> + <item> + <property name="text"> + <string>polar</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QLabel" name="label_7"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Amplitude</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_amplitude2"> + <item> + <property name="text"> + <string>Linear</string> + </property> + </item> + <item> + <property name="text"> + <string>Square root</string> + </property> + </item> + <item> + <property name="text"> + <string>Cubic root</string> + </property> + </item> + <item> + <property name="text"> + <string>Logarithmic</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_6"> + <item> + <widget class="QLabel" name="label_8"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Zoom</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_zoom"> + <property name="minimum"> + <number>1</number> + </property> + <property name="maximum"> + <number>10</number> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_draw"> + <property name="text"> + <string>Line</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <widget class="QWidget" name="page_4"> + <widget class="QWidget" name="verticalLayoutWidget_4"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>561</width> + <height>31</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_6"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_7"> + <item> + <widget class="QLabel" name="label_10"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Timeclamp</string> + </property> + <property name="margin"> + <number>4</number> + </property> + </widget> + </item> + <item> + <widget class="QDoubleSpinBox" name="spinBox_tc"> + <property name="suffix"> + <string>s</string> + </property> + <property name="decimals"> + <number>3</number> + </property> + <property name="minimum"> + <double>0.002000000000000</double> + </property> + <property name="maximum"> + <double>1.000000000000000</double> + </property> + <property name="singleStep"> + <double>0.010000000000000</double> + </property> + <property name="value"> + <double>0.017000000000000</double> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_7"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <widget class="QWidget" name="page_5"> + <widget class="QWidget" name="verticalLayoutWidget_5"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>551</width> + <height>31</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_7"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_11"/> + </item> + </layout> + </widget> + </widget> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>10</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/text.py b/src/components/text.py new file mode 100644 index 0000000..e8c5a9c --- /dev/null +++ b/src/components/text.py @@ -0,0 +1,203 @@ +from PIL import ImageEnhance, ImageFilter, ImageChops +from PyQt5.QtGui import QColor, QFont +from PyQt5 import QtGui, QtCore, QtWidgets +import os +import logging + +from ..component import Component +from ..toolkit.frame import FramePainter, PaintColor + +log = logging.getLogger('AVP.Components.Text') + + +class Component(Component): + name = 'Title Text' + version = '1.0.1' + + def widget(self, *args): + super().widget(*args) + self.title = 'Text' + self.alignment = 1 + self.titleFont = QFont() + self.fontSize = self.height / 13.5 + + self.page.comboBox_textAlign.addItem("Left") + self.page.comboBox_textAlign.addItem("Middle") + self.page.comboBox_textAlign.addItem("Right") + self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) + + self.page.spinBox_fontSize.setValue(int(self.fontSize)) + self.page.lineEdit_title.setText(self.title) + + self.page.pushButton_center.clicked.connect(self.centerXY) + self.page.fontComboBox_titleFont.currentFontChanged.connect( + self.update + ) + + self.trackWidgets({ + 'textColor': self.page.lineEdit_textColor, + 'title': self.page.lineEdit_title, + 'alignment': self.page.comboBox_textAlign, + 'fontSize': self.page.spinBox_fontSize, + 'xPosition': self.page.spinBox_xTextAlign, + 'yPosition': self.page.spinBox_yTextAlign, + 'fontStyle': self.page.comboBox_fontStyle, + 'stroke': self.page.spinBox_stroke, + 'strokeColor': self.page.lineEdit_strokeColor, + 'shadow': self.page.checkBox_shadow, + 'shadX': self.page.spinBox_shadX, + 'shadY': self.page.spinBox_shadY, + 'shadBlur': self.page.spinBox_shadBlur, + }, colorWidgets={ + 'textColor': self.page.pushButton_textColor, + 'strokeColor': self.page.pushButton_strokeColor, + }, relativeWidgets=[ + 'xPosition', 'yPosition', 'fontSize', + 'stroke', 'shadX', 'shadY', 'shadBlur' + ]) + self.centerXY() + + def update(self): + self.titleFont = self.page.fontComboBox_titleFont.currentFont() + if self.page.checkBox_shadow.isChecked(): + self.page.label_shadX.setHidden(False) + self.page.spinBox_shadX.setHidden(False) + self.page.spinBox_shadY.setHidden(False) + self.page.label_shadBlur.setHidden(False) + self.page.spinBox_shadBlur.setHidden(False) + else: + self.page.label_shadX.setHidden(True) + self.page.spinBox_shadX.setHidden(True) + self.page.spinBox_shadY.setHidden(True) + self.page.label_shadBlur.setHidden(True) + self.page.spinBox_shadBlur.setHidden(True) + + def centerXY(self): + self.setRelativeWidget('xPosition', 0.5) + self.setRelativeWidget('yPosition', 0.521) + + def getXY(self): + '''Returns true x, y after considering alignment settings''' + fm = QtGui.QFontMetrics(self.titleFont) + x = self.pixelValForAttr('xPosition') + + if self.alignment == 1: # Middle + offset = int(fm.width(self.title)/2) + x -= offset + if self.alignment == 2: # Right + offset = fm.width(self.title) + x -= offset + + return x, self.yPosition + + def loadPreset(self, pr, *args): + super().loadPreset(pr, *args) + + font = QFont() + font.fromString(pr['titleFont']) + self.page.fontComboBox_titleFont.setCurrentFont(font) + + def savePreset(self): + saveValueStore = super().savePreset() + saveValueStore['titleFont'] = self.titleFont.toString() + return saveValueStore + + def previewRender(self): + return self.addText(self.width, self.height) + + def properties(self): + props = ['static'] + if not self.title: + props.append('error') + return props + + def error(self): + return "No text provided." + + def frameRender(self, frameNo): + return self.addText(self.width, self.height) + + def addText(self, width, height): + font = self.titleFont + font.setPixelSize(self.fontSize) + font.setStyle(QFont.StyleNormal) + font.setWeight(QFont.Normal) + font.setCapitalization(QFont.MixedCase) + if self.fontStyle == 1: + font.setWeight(QFont.DemiBold) + if self.fontStyle == 2: + font.setWeight(QFont.Bold) + elif self.fontStyle == 3: + font.setStyle(QFont.StyleItalic) + elif self.fontStyle == 4: + font.setWeight(QFont.Bold) + font.setStyle(QFont.StyleItalic) + elif self.fontStyle == 5: + font.setStyle(QFont.StyleOblique) + elif self.fontStyle == 6: + font.setCapitalization(QFont.SmallCaps) + + image = FramePainter(width, height) + x, y = self.getXY() + log.debug('Text position translates to %s, %s', x, y) + if self.stroke > 0: + outliner = QtGui.QPainterPathStroker() + outliner.setWidth(self.stroke) + path = QtGui.QPainterPath() + if self.fontStyle == 6: + # PathStroker ignores smallcaps so we need this weird hack + path.addText(x, y, font, self.title[0]) + fm = QtGui.QFontMetrics(font) + newX = x + fm.width(self.title[0]) + strokeFont = self.page.fontComboBox_titleFont.currentFont() + strokeFont.setCapitalization(QFont.SmallCaps) + strokeFont.setPixelSize(int((self.fontSize / 7) * 5)) + strokeFont.setLetterSpacing(QFont.PercentageSpacing, 139) + path.addText(newX, y, strokeFont, self.title[1:]) + else: + path.addText(x, y, font, self.title) + path = outliner.createStroke(path) + image.setPen(QtCore.Qt.NoPen) + image.setBrush(PaintColor(*self.strokeColor)) + image.drawPath(path) + + image.setFont(font) + image.setPen(self.textColor) + image.drawText(x, y, self.title) + + # turn QImage into Pillow frame + frame = image.finalize() + if self.shadow: + shadImg = ImageEnhance.Contrast(frame).enhance(0.0) + shadImg = shadImg.filter(ImageFilter.GaussianBlur(self.shadBlur)) + shadImg = ImageChops.offset(shadImg, self.shadX, self.shadY) + shadImg.paste(frame, box=(0, 0), mask=frame) + frame = shadImg + + return frame + + def commandHelp(self): + print('Enter a string to use as centred white text:') + print(' "title=User Error"') + print('Specify a text color:\n color=255,255,255') + print('Set custom x, y position:\n x=500 y=500') + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + if key == 'color': + self.page.lineEdit_textColor.setText(arg) + return + elif key == 'size': + self.page.spinBox_fontSize.setValue(int(arg)) + return + elif key == 'x': + self.page.spinBox_xTextAlign.setValue(int(arg)) + return + elif key == 'y': + self.page.spinBox_yTextAlign.setValue(int(arg)) + return + elif key == 'title': + self.page.lineEdit_title.setText(arg) + return + super().command(arg) diff --git a/src/components/text.ui b/src/components/text.ui new file mode 100644 index 0000000..b62e0ed --- /dev/null +++ b/src/components/text.ui @@ -0,0 +1,671 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="spacing"> + <number>6</number> + </property> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QLabel" name="label_title"> + <property name="text"> + <string>Title</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_title"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Testing New GUI</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Font</string> + </property> + </widget> + </item> + <item> + <widget class="QFontComboBox" name="fontComboBox_titleFont"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_7"> + <property name="leftMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label_textLayout"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Text Layout</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_textAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>100</width> + <height>16777215</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_center"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Center Text</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_xTextAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>50</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>0</number> + </property> + <property name="maximum"> + <number>999999999</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_yTextAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>50</width> + <height>16777215</height> + </size> + </property> + <property name="maximum"> + <number>999999999</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>16777215</height> + </size> + </property> + <property name="text"> + <string>Text Color</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_8"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_fontSize"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Font Size</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_fontSize"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="suffix"> + <string/> + </property> + <property name="prefix"> + <string/> + </property> + <property name="minimum"> + <number>1</number> + </property> + <property name="maximum"> + <number>500</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_7"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Font Style</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_fontStyle"> + <item> + <property name="text"> + <string>Normal</string> + </property> + </item> + <item> + <property name="text"> + <string>Semi-Bold</string> + </property> + </item> + <item> + <property name="text"> + <string>Bold</string> + </property> + </item> + <item> + <property name="text"> + <string>Italic</string> + </property> + </item> + <item> + <property name="text"> + <string>Bold Italic</string> + </property> + </item> + <item> + <property name="text"> + <string>Faux Italic</string> + </property> + </item> + <item> + <property name="text"> + <string>Small Caps</string> + </property> + </item> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_12"> + <item> + <widget class="QLineEdit" name="lineEdit_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>0</width> + <height>16777215</height> + </size> + </property> + <property name="focusPolicy"> + <enum>Qt::NoFocus</enum> + </property> + <property name="text"> + <string>255,255,255</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Stroke</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_stroke"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="suffix"> + <string>px</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_5"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Stroke Color</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_strokeColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>0</width> + <height>16777215</height> + </size> + </property> + <property name="focusPolicy"> + <enum>Qt::NoFocus</enum> + </property> + <property name="text"> + <string>0,0,0</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_strokeColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QCheckBox" name="checkBox_shadow"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Shadow</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_shadX"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Shadow Offset</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_shadX"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimum"> + <number>-1000</number> + </property> + <property name="maximum"> + <number>1000</number> + </property> + <property name="value"> + <number>-4</number> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_shadY"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimum"> + <number>-1000</number> + </property> + <property name="maximum"> + <number>1000</number> + </property> + <property name="value"> + <number>8</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_shadBlur"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Shadow Blur</string> + </property> + </widget> + </item> + <item> + <widget class="QDoubleSpinBox" name="spinBox_shadBlur"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximum"> + <double>99.000000000000000</double> + </property> + <property name="singleStep"> + <double>0.100000000000000</double> + </property> + <property name="value"> + <double>5.000000000000000</double> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/video.py b/src/components/video.py new file mode 100644 index 0000000..9fffc26 --- /dev/null +++ b/src/components/video.py @@ -0,0 +1,226 @@ +from PIL import Image +from PyQt5 import QtGui, QtCore, QtWidgets +import os +import math +import subprocess +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit.ffmpeg import openPipe, closePipe, testAudioStream, FfmpegVideo +from ..toolkit import checkOutput + + +log = logging.getLogger('AVP.Components.Video') + + +class Component(Component): + name = 'Video' + version = '1.0.0' + + def widget(self, *args): + self.videoPath = '' + self.badAudio = False + self.x = 0 + self.y = 0 + self.loopVideo = False + super().widget(*args) + self._image = BlankFrame(self.width, self.height) + self.page.pushButton_video.clicked.connect(self.pickVideo) + self.trackWidgets({ + 'videoPath': self.page.lineEdit_video, + 'loopVideo': self.page.checkBox_loop, + 'useAudio': self.page.checkBox_useAudio, + 'distort': self.page.checkBox_distort, + 'scale': self.page.spinBox_scale, + 'volume': self.page.spinBox_volume, + 'xPosition': self.page.spinBox_x, + 'yPosition': self.page.spinBox_y, + }, presetNames={ + 'videoPath': 'video', + 'loopVideo': 'loop', + 'xPosition': 'x', + 'yPosition': 'y', + }, relativeWidgets=[ + 'xPosition', 'yPosition', + ]) + + def update(self): + if self.page.checkBox_useAudio.isChecked(): + self.page.label_volume.setEnabled(True) + self.page.spinBox_volume.setEnabled(True) + else: + self.page.label_volume.setEnabled(False) + self.page.spinBox_volume.setEnabled(False) + + def previewRender(self): + self.updateChunksize() + frame = self.getPreviewFrame(self.width, self.height) + if not frame: + return BlankFrame(self.width, self.height) + else: + return frame + + def properties(self): + props = [] + if hasattr(self.parent, 'lineEdit_outputFile'): + outputFile = self.parent.lineEdit_outputFile.text() + else: + outputFile = str(self.parent.args.output) + + if not self.videoPath: + self.lockError("There is no video selected.") + elif not os.path.exists(self.videoPath): + self.lockError("The video selected does not exist!") + elif os.path.realpath(self.videoPath) == os.path.realpath(outputFile): + self.lockError("Input and output paths match.") + + if self.useAudio: + props.append('audio') + if not testAudioStream(self.videoPath) \ + and self.error() is None: + self.lockError( + "Could not identify an audio stream in this video.") + + return props + + def audio(self): + params = {} + if self.volume != 1.0: + params['volume'] = '=%s:replaygain_noclip=0' % str(self.volume) + return (self.videoPath, params) + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + self.updateChunksize() + self.video = FfmpegVideo( + inputPath=self.videoPath, filter_=self.makeFfmpegFilter(), + width=self.width, height=self.height, chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, loopVideo=self.loopVideo, + component=self + ) if os.path.exists(self.videoPath) else None + + def frameRender(self, frameNo): + if FfmpegVideo.threadError is not None: + raise FfmpegVideo.threadError + return self.finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def pickVideo(self): + imgDir = self.settings.value("componentDir", os.path.expanduser("~")) + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.page, "Choose Video", + imgDir, "Video Files (%s)" % " ".join(self.core.videoFormats) + ) + if filename: + self.settings.setValue("componentDir", os.path.dirname(filename)) + self.mergeUndo = False + self.page.lineEdit_video.setText(filename) + self.mergeUndo = True + + def getPreviewFrame(self, width, height): + if not self.videoPath or not os.path.exists(self.videoPath): + return + + command = [ + self.core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-i', self.videoPath, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + command.extend(self.makeFfmpegFilter()) + command.extend([ + '-codec:v', 'rawvideo', '-', + '-ss', '90', + '-frames:v', '1', + ]) + + if self.core.logEnabled: + logFilename = os.path.join( + self.core.logDir, 'preview_%s.log' % str(self.compPos)) + log.debug('Creating ffmpeg process (log at %s)' % logFilename) + with open(logFilename, 'w') as logf: + logf.write(" ".join(command) + '\n\n') + with open(logFilename, 'a') as logf: + pipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=logf, bufsize=10**8 + ) + else: + pipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=10**8 + ) + + byteFrame = pipe.stdout.read(self.chunkSize) + closePipe(pipe) + + frame = self.finalizeFrame(byteFrame) + return frame + + def makeFfmpegFilter(self): + return [ + '-filter_complex', + '[0:v] scale=%s:%s' % scale( + self.scale, self.width, self.height, str), + ] + + def updateChunksize(self): + if self.scale != 100 and not self.distort: + width, height = scale(self.scale, self.width, self.height, int) + else: + width, height = self.width, self.height + self.chunkSize = 4 * width * height + + def command(self, arg): + if '=' in arg: + key, arg = arg.split('=', 1) + if key == 'path' and os.path.exists(arg): + if '*%s' % os.path.splitext(arg)[1] in self.core.videoFormats: + self.page.lineEdit_video.setText(arg) + self.page.spinBox_scale.setValue(100) + self.page.checkBox_loop.setChecked(True) + return + else: + print("Not a supported video format") + quit(1) + elif arg == 'audio': + if not self.page.lineEdit_video.text(): + print("'audio' option must follow a video selection") + quit(1) + self.page.checkBox_useAudio.setChecked(True) + return + super().command(arg) + + def commandHelp(self): + print('Load a video:\n path=/filepath/to/video.mp4') + print('Using audio:\n path=/filepath/to/video.mp4 audio') + + def finalizeFrame(self, imageData): + try: + if self.distort: + image = Image.frombytes( + 'RGBA', + (self.width, self.height), + imageData) + else: + image = Image.frombytes( + 'RGBA', + scale(self.scale, self.width, self.height, int), + imageData) + self._image = image + except ValueError: + # use last good frame + image = self._image + + if self.scale != 100 \ + or self.xPosition != 0 or self.yPosition != 0: + frame = BlankFrame(self.width, self.height) + frame.paste(image, box=(self.xPosition, self.yPosition)) + else: + frame = image + return frame diff --git a/src/components/video.ui b/src/components/video.ui new file mode 100644 index 0000000..08d15d3 --- /dev/null +++ b/src/components/video.ui @@ -0,0 +1,328 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>197</height> + </size> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Video</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_video"> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_video"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>1</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + <property name="MaximumSize" stdset="0"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_x"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <item> + <widget class="QCheckBox" name="checkBox_loop"> + <property name="text"> + <string>Loop</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QCheckBox" name="checkBox_distort"> + <property name="text"> + <string>Distort by scale</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Scale</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>400</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <item> + <widget class="QCheckBox" name="checkBox_useAudio"> + <property name="text"> + <string>Use Audio</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_volume"> + <property name="text"> + <string>Volume</string> + </property> + </widget> + </item> + <item> + <widget class="QDoubleSpinBox" name="spinBox_volume"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="suffix"> + <string>x</string> + </property> + <property name="minimum"> + <double>0.000000000000000</double> + </property> + <property name="maximum"> + <double>10.000000000000000</double> + </property> + <property name="singleStep"> + <double>0.100000000000000</double> + </property> + <property name="value"> + <double>1.000000000000000</double> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/components/waveform.py b/src/components/waveform.py new file mode 100644 index 0000000..227f711 --- /dev/null +++ b/src/components/waveform.py @@ -0,0 +1,215 @@ +from PIL import Image +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtGui import QColor +import os +import math +import subprocess +import logging + +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit import checkOutput +from ..toolkit.ffmpeg import ( + openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound +) + + +log = logging.getLogger('AVP.Components.Waveform') + + +class Component(Component): + name = 'Waveform' + version = '1.0.0' + + def widget(self, *args): + super().widget(*args) + self._image = BlankFrame(self.width, self.height) + + self.page.lineEdit_color.setText('255,255,255') + + if hasattr(self.parent, 'lineEdit_audioFile'): + self.parent.lineEdit_audioFile.textChanged.connect( + self.update + ) + + self.trackWidgets({ + 'color': self.page.lineEdit_color, + 'mode': self.page.comboBox_mode, + 'amplitude': self.page.comboBox_amplitude, + 'x': self.page.spinBox_x, + 'y': self.page.spinBox_y, + 'mirror': self.page.checkBox_mirror, + 'scale': self.page.spinBox_scale, + 'opacity': self.page.spinBox_opacity, + 'compress': self.page.checkBox_compress, + 'mono': self.page.checkBox_mono, + }, colorWidgets={ + 'color': self.page.pushButton_color, + }, relativeWidgets=[ + 'x', 'y', + ]) + + def previewRender(self): + self.updateChunksize() + frame = self.getPreviewFrame(self.width, self.height) + if not frame: + return BlankFrame(self.width, self.height) + else: + return frame + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + self.updateChunksize() + w, h = scale(self.scale, self.width, self.height, str) + self.video = FfmpegVideo( + inputPath=self.audioFile, + filter_=self.makeFfmpegFilter(), + width=w, height=h, + chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, component=self, debug=True, + ) + + def frameRender(self, frameNo): + if FfmpegVideo.threadError is not None: + raise FfmpegVideo.threadError + return self.finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def getPreviewFrame(self, width, height): + genericPreview = self.settings.value("pref_genericPreview") + startPt = 0 + if not genericPreview: + inputFile = self.parent.lineEdit_audioFile.text() + if not inputFile or not os.path.exists(inputFile): + return + duration = getAudioDuration(inputFile) + if not duration: + return + startPt = duration / 3 + if startPt + 3 > duration: + startPt += startPt - 3 + + command = [ + self.core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-r', self.settings.value("outputFrameRate"), + '-ss', "{0:.3f}".format(startPt), + '-i', + self.core.junkStream + if genericPreview else inputFile, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + command.extend(self.makeFfmpegFilter(preview=True, startPt=startPt)) + command.extend([ + '-an', + '-s:v', '%sx%s' % scale(self.scale, self.width, self.height, str), + '-codec:v', 'rawvideo', '-', + '-frames:v', '1', + ]) + if self.core.logEnabled: + logFilename = os.path.join( + self.core.logDir, 'preview_%s.log' % str(self.compPos)) + log.debug('Creating ffmpeg log at %s', logFilename) + with open(logFilename, 'w') as logf: + logf.write(" ".join(command) + '\n\n') + with open(logFilename, 'a') as logf: + pipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=logf, bufsize=10**8 + ) + else: + pipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=10**8 + ) + byteFrame = pipe.stdout.read(self.chunkSize) + closePipe(pipe) + + frame = self.finalizeFrame(byteFrame) + return frame + + def makeFfmpegFilter(self, preview=False, startPt=0): + w, h = scale(self.scale, self.width, self.height, str) + if self.amplitude == 0: + amplitude = 'lin' + elif self.amplitude == 1: + amplitude = 'log' + elif self.amplitude == 2: + amplitude = 'sqrt' + elif self.amplitude == 3: + amplitude = 'cbrt' + hexcolor = QColor(*self.color).name() + opacity = "{0:.1f}".format(self.opacity / 100) + genericPreview = self.settings.value("pref_genericPreview") + if self.mode < 3: + filter_ = ( + 'showwaves=' + 'r=%s:s=%sx%s:mode=%s:colors=%s@%s:scale=%s' % ( + self.settings.value("outputFrameRate"), + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + self.page.comboBox_mode.currentText().lower() + if self.mode != 3 else 'p2p', + hexcolor, opacity, amplitude, + ) + ) + elif self.mode > 2: + filter_ = ( + 'showfreqs=s=%sx%s:mode=%s:colors=%s@%s' + ':ascale=%s:fscale=%s' % ( + self.settings.value("outputWidth"), + self.settings.value("outputHeight"), + 'line' if self.mode == 4 else 'bar', + hexcolor, opacity, amplitude, + 'log' if self.mono else 'lin' + ) + ) + + baselineHeight = int(self.height * (4 / 1080)) + return [ + '-filter_complex', + '%s%s%s' + '%s%s%s [v1]; ' + '[v1] scale=%s:%s%s [v]' % ( + exampleSound('wave', extra='') + if preview and genericPreview else '[0:a] ', + 'compand=gain=4,' if self.compress else '', + 'aformat=channel_layouts=mono,' + if self.mono and self.mode < 3 else '', + filter_, + ', drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=%s:color=%s@%s' % ( + baselineHeight, hexcolor, opacity, + ) if self.mode < 2 else '', + ', hflip' if self.mirror else'', + w, h, + ', trim=duration=%s' % "{0:.3f}".format(startPt + 3) + if preview else '', + ), + '-map', '[v]', + ] + + def updateChunksize(self): + width, height = scale(self.scale, self.width, self.height, int) + self.chunkSize = 4 * width * height + + def finalizeFrame(self, imageData): + try: + image = Image.frombytes( + 'RGBA', + scale(self.scale, self.width, self.height, int), + imageData + ) + self._image = image + except ValueError: + image = self._image + if self.scale != 100 \ + or self.x != 0 or self.y != 0: + frame = BlankFrame(self.width, self.height) + frame.paste(image, box=(self.x, self.y)) + else: + frame = image + return frame diff --git a/src/components/waveform.ui b/src/components/waveform.ui new file mode 100644 index 0000000..5473f33 --- /dev/null +++ b/src/components/waveform.ui @@ -0,0 +1,383 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>586</width> + <height>197</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>197</height> + </size> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>4</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8"> + <item> + <widget class="QLabel" name="label_textColor"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>31</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Mode</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_mode"> + <item> + <property name="text"> + <string>Cline</string> + </property> + </item> + <item> + <property name="text"> + <string>Line</string> + </property> + </item> + <item> + <property name="text"> + <string>Point</string> + </property> + </item> + <item> + <property name="text"> + <string>Frequency Bar</string> + </property> + </item> + <item> + <property name="text"> + <string>Frequency Line</string> + </property> + </item> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_9"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_xTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>X</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_x"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label_yTitleAlign"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Y</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_y"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="minimum"> + <number>-10000</number> + </property> + <property name="maximum"> + <number>10000</number> + </property> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9"> + <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Color</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_color"> + <property name="inputMethodHints"> + <set>Qt::ImhNone</set> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_color"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>32</width> + <height>32</height> + </size> + </property> + <property name="text"> + <string/> + </property> + <property name="default"> + <bool>false</bool> + </property> + <property name="flat"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_4"> + <property name="text"> + <string>Opacity</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_opacity"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>0</number> + </property> + <property name="maximum"> + <number>100</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Scale</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_scale"> + <property name="buttonSymbols"> + <enum>QAbstractSpinBox::UpDownArrows</enum> + </property> + <property name="suffix"> + <string>%</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>400</number> + </property> + <property name="value"> + <number>100</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <item> + <widget class="QCheckBox" name="checkBox_compress"> + <property name="text"> + <string>Compress</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_mono"> + <property name="text"> + <string>Mono</string> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="checkBox_mirror"> + <property name="text"> + <string>Mirror</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_3"> + <property name="text"> + <string>Amplitude</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_amplitude"> + <item> + <property name="text"> + <string>Linear</string> + </property> + </item> + <item> + <property name="text"> + <string>Logarithmic</string> + </property> + </item> + <item> + <property name="text"> + <string>Square root</string> + </property> + </item> + <item> + <property name="text"> + <string>Cubic root</string> + </property> + </item> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/core.py b/src/core.py new file mode 100644 index 0000000..77b0894 --- /dev/null +++ b/src/core.py @@ -0,0 +1,614 @@ +''' + Home to the Core class which tracks program state. Used by GUI & commandline + to create a list of components and create a video thread to export. +''' +from PyQt5 import QtCore, QtGui, uic +import sys +import os +import json +from importlib import import_module +import logging + +from . import toolkit + + +log = logging.getLogger('AVP.Core') +STDOUT_LOGLVL = logging.WARNING +FILE_LIBLOGLVL = logging.WARNING +FILE_LOGLVL = logging.INFO + + +class Core: + ''' + MainWindow and Command module both use an instance of this class + to store the core program state. This object tracks the components, + talks to the components, handles opening/creating project files + and presets, and creates the video thread to export. + This class also stores constants as class variables. + ''' + + def __init__(self): + self.importComponents() + self.selectedComponents = [] + self.savedPresets = {} # copies of presets to detect modification + self.openingProject = False + + def __repr__(self): + return "\n=~=~=~=\n".join( + [repr(comp) for comp in self.selectedComponents] + ) + + def importComponents(self): + def findComponents(): + for f in os.listdir(Core.componentsPath): + name, ext = os.path.splitext(f) + if name.startswith("__"): + continue + elif ext == '.py': + yield name + log.debug('Importing component modules') + self.modules = [ + import_module('.components.%s' % name, __package__) + for name in findComponents() + ] + # store canonical module names and indexes + self.moduleIndexes = [i for i in range(len(self.modules))] + self.compNames = [mod.Component.name for mod in self.modules] + # alphabetize modules by Component name + sortedModules = sorted(zip(self.compNames, self.modules)) + self.compNames = [y[0] for y in sortedModules] + self.modules = [y[1] for y in sortedModules] + + # store alternative names for modules + self.altCompNames = [] + for i, mod in enumerate(self.modules): + if hasattr(mod.Component, 'names'): + for name in mod.Component.names(): + self.altCompNames.append((name, i)) + + def componentListChanged(self): + for i, component in enumerate(self.selectedComponents): + component.compPos = i + + def insertComponent(self, compPos, component, loader): + ''' + Creates a new component using these args: + (compPos, component obj or moduleIndex, MWindow/Command/Core obj) + ''' + if compPos < 0 or compPos > len(self.selectedComponents): + compPos = len(self.selectedComponents) + if len(self.selectedComponents) > 50: + return -1 + if type(component) is int: + # create component using module index in self.modules + moduleIndex = int(component) + log.debug( + 'Creating new component from module #%s', str(moduleIndex)) + component = self.modules[moduleIndex].Component( + moduleIndex, compPos, self + ) + component.widget(loader) + else: + moduleIndex = -1 + log.debug( + 'Inserting previously-created %s component', component.name) + + component._error.connect( + loader.videoThreadError + ) + self.selectedComponents.insert( + compPos, + component + ) + if hasattr(loader, 'insertComponent'): + loader.insertComponent(compPos) + + self.componentListChanged() + self.updateComponent(compPos) + return compPos + + def moveComponent(self, startI, endI): + comp = self.selectedComponents.pop(startI) + self.selectedComponents.insert(endI, comp) + + self.componentListChanged() + return endI + + def removeComponent(self, i): + self.selectedComponents.pop(i) + self.componentListChanged() + + def clearComponents(self): + self.selectedComponents = list() + self.componentListChanged() + + def updateComponent(self, i): + log.debug( + 'Auto-updating %s #%s', + self.selectedComponents[i], str(i)) + self.selectedComponents[i].update(auto=True) + + def moduleIndexFor(self, compName): + try: + index = self.compNames.index(compName) + return self.moduleIndexes[index] + except ValueError: + for altName, modI in self.altCompNames: + if altName == compName: + return self.moduleIndexes[modI] + + def clearPreset(self, compIndex): + self.selectedComponents[compIndex].currentPreset = None + + def openPreset(self, filepath, compIndex, presetName): + '''Applies a preset to a specific component''' + saveValueStore = self.getPreset(filepath) + if not saveValueStore: + return False + comp = self.selectedComponents[compIndex] + comp.loadPreset( + saveValueStore, + presetName + ) + + self.savedPresets[presetName] = dict(saveValueStore) + return True + + def getPreset(self, filepath): + '''Returns the preset dict stored at this filepath''' + if not os.path.exists(filepath): + return False + with open(filepath, 'r') as f: + for line in f: + saveValueStore = toolkit.presetFromString(line.strip()) + break + return saveValueStore + + def getPresetDir(self, comp): + '''Get the preset subdir for a particular version of a component''' + return os.path.join(Core.presetDir, comp.name, str(comp.version)) + + def openProject(self, loader, filepath): + ''' loader is the object calling this method which must have + its own showMessage(**kwargs) method for displaying errors. + ''' + if not os.path.exists(filepath): + loader.showMessage(msg='Project file not found.') + return + + errcode, data = self.parseAvFile(filepath) + if errcode == 0: + self.openingProject = True + try: + if hasattr(loader, 'window'): + for widget, value in data['WindowFields']: + widget = eval('loader.%s' % widget) + with toolkit.blockSignals(widget): + toolkit.setWidgetValue(widget, value) + + for key, value in data['Settings']: + Core.settings.setValue(key, value) + for tup in data['Components']: + name, vers, preset = tup + clearThis = False + modified = False + + # add loaded named presets to savedPresets dict + if 'preset' in preset and preset['preset'] is not None: + nam = preset['preset'] + filepath2 = os.path.join( + Core.presetDir, name, str(vers), nam) + origSaveValueStore = self.getPreset(filepath2) + if origSaveValueStore: + self.savedPresets[nam] = dict(origSaveValueStore) + modified = not origSaveValueStore == preset + else: + # saved preset was renamed or deleted + clearThis = True + + # create the actual component object & get its index + i = self.insertComponent( + -1, + self.moduleIndexFor(name), + loader + ) + if i == -1: + loader.showMessage(msg="Too many components!") + break + + try: + if 'preset' in preset and preset['preset'] is not None: + self.selectedComponents[i].loadPreset( + preset + ) + else: + self.selectedComponents[i].loadPreset( + preset, + preset['preset'] + ) + except KeyError as e: + log.warning('%s missing value: %s' % ( + self.selectedComponents[i], e) + ) + + if clearThis: + self.clearPreset(i) + if hasattr(loader, 'updateComponentTitle'): + loader.updateComponentTitle(i, modified) + self.openingProject = False + return True + except Exception: + errcode = 1 + data = sys.exc_info() + + if errcode == 1: + typ, value, tb = data + if typ.__name__ == 'KeyError': + # probably just an old version, still loadable + log.warning('Project file missing value: %s' % value) + return + if hasattr(loader, 'createNewProject'): + loader.createNewProject(prompt=False) + msg = '%s: %s\n\n' % (typ.__name__, value) + msg += toolkit.formatTraceback(tb) + loader.showMessage( + msg="Project file '%s' is corrupted." % filepath, + showCancel=False, + icon='Warning', + detail=msg) + self.openingProject = False + return False + + def parseAvFile(self, filepath): + ''' + Parses an avp (project) or avl (preset package) file. + Returns dictionary with section names as the keys, each one + contains a list of tuples: (compName, version, compPresetDict) + ''' + log.debug('Parsing av file: %s', filepath) + validSections = ( + 'Components', + 'Settings', + 'WindowFields' + ) + data = {sect: [] for sect in validSections} + try: + with open(filepath, 'r') as f: + def parseLine(line): + '''Decides if a file line is a section header''' + line = line.strip() + newSection = '' + + if line.startswith('[') and line.endswith(']') \ + and line[1:-1] in validSections: + newSection = line[1:-1] + + return line, newSection + + section = '' + i = 0 + for line in f: + line, newSection = parseLine(line) + if newSection: + section = str(newSection) + continue + if line and section == 'Components': + if i == 0: + lastCompName = str(line) + i += 1 + elif i == 1: + lastCompVers = str(line) + i += 1 + elif i == 2: + lastCompPreset = toolkit.presetFromString(line) + data[section].append(( + lastCompName, + lastCompVers, + lastCompPreset + )) + i = 0 + elif line and section: + key, value = line.split('=', 1) + data[section].append((key, value.strip())) + + return 0, data + except Exception: + return 1, sys.exc_info() + + def importPreset(self, filepath): + errcode, data = self.parseAvFile(filepath) + returnList = [] + if errcode == 0: + name, vers, preset = data['Components'][0] + presetName = preset['preset'] \ + if preset['preset'] else os.path.basename(filepath)[:-4] + newPath = os.path.join( + Core.presetDir, + name, + vers, + presetName + ) + if os.path.exists(newPath): + return False, newPath + preset['preset'] = presetName + self.createPresetFile( + name, vers, presetName, preset + ) + return True, presetName + elif errcode == 1: + # TODO: an error message + return False, '' + + def exportPreset(self, exportPath, compName, vers, origName): + internalPath = os.path.join( + Core.presetDir, compName, str(vers), origName + ) + if not os.path.exists(internalPath): + return + if os.path.exists(exportPath): + os.remove(exportPath) + with open(internalPath, 'r') as f: + internalData = [line for line in f] + try: + saveValueStore = toolkit.presetFromString(internalData[0].strip()) + self.createPresetFile( + compName, vers, + origName, saveValueStore, + exportPath + ) + return True + except Exception: + return False + + def createPresetFile( + self, compName, vers, presetName, saveValueStore, filepath=''): + '''Create a preset file (.avl) at filepath using args. + Or if filepath is empty, create an internal preset using args''' + if not filepath: + dirname = os.path.join(Core.presetDir, compName, str(vers)) + if not os.path.exists(dirname): + os.makedirs(dirname) + filepath = os.path.join(dirname, presetName) + internal = True + else: + if not filepath.endswith('.avl'): + filepath += '.avl' + internal = False + + with open(filepath, 'w') as f: + if not internal: + f.write('[Components]\n') + f.write('%s\n' % compName) + f.write('%s\n' % str(vers)) + f.write(toolkit.presetToString(saveValueStore)) + + def createProjectFile(self, filepath, window=None): + '''Create a project file (.avp) using the current program state''' + log.info('Creating %s', filepath) + settingsKeys = [ + 'componentDir', + 'inputDir', + 'outputDir', + 'presetDir', + 'projectDir', + ] + try: + if not filepath.endswith(".avp"): + filepath += '.avp' + if os.path.exists(filepath): + os.remove(filepath) + + with open(filepath, 'w') as f: + f.write('[Components]\n') + for comp in self.selectedComponents: + saveValueStore = comp.savePreset() + saveValueStore['preset'] = comp.currentPreset + f.write('%s\n' % str(comp)) + f.write('%s\n' % str(comp.version)) + f.write('%s\n' % toolkit.presetToString(saveValueStore)) + + f.write('\n[Settings]\n') + for key in Core.settings.allKeys(): + if key in settingsKeys: + f.write('%s=%s\n' % (key, Core.settings.value(key))) + + if window: + f.write('\n[WindowFields]\n') + f.write( + 'lineEdit_audioFile=%s\n' + 'lineEdit_outputFile=%s\n' % ( + window.lineEdit_audioFile.text(), + window.lineEdit_outputFile.text() + ) + ) + return True + except Exception: + return False + + def newVideoWorker(self, loader, audioFile, outputPath): + '''loader is MainWindow or Command object which must own the thread''' + from . import video_thread + self.videoThread = QtCore.QThread(loader) + videoWorker = video_thread.Worker( + loader, audioFile, outputPath, self.selectedComponents + ) + videoWorker.moveToThread(self.videoThread) + videoWorker.videoCreated.connect(self.stopVideoThread) + + self.videoThread.start() + return videoWorker + + def stopVideoThread(self): + self.videoThread.quit() + self.videoThread.wait() + + def cancel(self): + Core.canceled = True + + def reset(self): + Core.canceled = False + + @classmethod + def storeSettings(cls): + '''Store settings/paths to directories as class variables''' + from .__init__ import wd + from .toolkit.ffmpeg import findFfmpeg + + cls.wd = wd + dataDir = QtCore.QStandardPaths.writableLocation( + QtCore.QStandardPaths.AppConfigLocation + ) + # Windows: C:/Users/<USER>/AppData/Local/audio-visualizer + # macOS: ~/Library/Preferences/audio-visualizer + # Linux: ~/.config/audio-visualizer + with open(os.path.join(wd, 'encoder-options.json')) as json_file: + encoderOptions = json.load(json_file) + + # Locate FFmpeg + ffmpegBin = findFfmpeg() + if not ffmpegBin: + print("Could not find FFmpeg") + + settings = { + 'canceled': False, + 'FFMPEG_BIN': ffmpegBin, + 'dataDir': dataDir, + 'settings': QtCore.QSettings( + os.path.join(dataDir, 'settings.ini'), + QtCore.QSettings.IniFormat), + 'presetDir': os.path.join(dataDir, 'presets'), + 'componentsPath': os.path.join(wd, 'components'), + 'junkStream': os.path.join(wd, 'gui', 'background.png'), + 'encoderOptions': encoderOptions, + 'resolutions': [ + '1920x1080', + '1280x720', + '854x480', + ], + 'logDir': os.path.join(dataDir, 'log'), + 'logEnabled': False, + } + + settings['videoFormats'] = toolkit.appendUppercase([ + '*.mp4', + '*.mov', + '*.mkv', + '*.avi', + '*.webm', + '*.flv', + ]) + settings['audioFormats'] = toolkit.appendUppercase([ + '*.mp3', + '*.wav', + '*.ogg', + '*.fla', + '*.flac', + '*.aac', + ]) + settings['imageFormats'] = toolkit.appendUppercase([ + '*.png', + '*.jpg', + '*.tif', + '*.tiff', + '*.gif', + '*.bmp', + '*.ico', + '*.xbm', + '*.xpm', + ]) + + # Register all settings as class variables + for classvar, val in settings.items(): + setattr(cls, classvar, val) + + cls.loadDefaultSettings() + if not os.path.exists(cls.dataDir): + os.makedirs(cls.dataDir) + for neededDirectory in ( + cls.presetDir, cls.logDir, cls.settings.value("projectDir")): + if not os.path.exists(neededDirectory): + os.mkdir(neededDirectory) + cls.makeLogger(deleteOldLogs=True) + + @classmethod + def loadDefaultSettings(cls): + # settings that get saved into the ini file + cls.defaultSettings = { + "outputWidth": 1280, + "outputHeight": 720, + "outputFrameRate": 30, + "outputAudioCodec": "AAC", + "outputAudioBitrate": "192", + "outputVideoCodec": "H264", + "outputVideoBitrate": "2500", + "outputVideoFormat": "yuv420p", + "outputPreset": "medium", + "outputFormat": "mp4", + "outputContainer": "MP4", + "projectDir": os.path.join(cls.dataDir, 'projects'), + "pref_insertCompAtTop": True, + "pref_genericPreview": True, + "pref_undoLimit": 10, + } + + for parm, value in cls.defaultSettings.items(): + if cls.settings.value(parm) is None: + cls.settings.setValue(parm, value) + + # Allow manual editing of prefs. (Surprisingly necessary as Qt seems to + # store True as 'true' but interprets a manually-added 'true' as str.) + for key in cls.settings.allKeys(): + if not key.startswith('pref_'): + continue + val = cls.settings.value(key) + try: + val = int(val) + except ValueError: + if val == 'true': + val = True + elif val == 'false': + val = False + cls.settings.setValue(key, val) + + @staticmethod + def makeLogger(deleteOldLogs=False): + # send critical log messages to stdout + logStream = logging.StreamHandler() + logStream.setLevel(STDOUT_LOGLVL) + streamFormatter = logging.Formatter( + '<%(name)s> %(levelname)s: %(message)s' + ) + logStream.setFormatter(streamFormatter) + log = logging.getLogger('AVP') + log.addHandler(logStream) + + if FILE_LOGLVL is not None: + # write log files as well! + Core.logEnabled = True + logFilename = os.path.join(Core.logDir, 'avp_debug.log') + libLogFilename = os.path.join(Core.logDir, 'global_debug.log') + + if deleteOldLogs: + for log_ in (logFilename, libLogFilename): + if os.path.exists(log_): + os.remove(log_) + + logFile = logging.FileHandler(logFilename, delay=True) + logFile.setLevel(FILE_LOGLVL) + libLogFile = logging.FileHandler(libLogFilename, delay=True) + libLogFile.setLevel(FILE_LIBLOGLVL) + fileFormatter = logging.Formatter( + '[%(asctime)s] %(threadName)-10.10s %(name)-23.23s %(levelname)s: ' + '%(message)s' + ) + logFile.setFormatter(fileFormatter) + libLogFile.setFormatter(fileFormatter) + + libLog = logging.getLogger() + log.addHandler(logFile) + libLog.addHandler(libLogFile) + # lowest level must be explicitly set on the root Logger + libLog.setLevel(0) + +# always store settings in class variables even if a Core object is not created +Core.storeSettings() diff --git a/src/encoder-options.json b/src/encoder-options.json new file mode 100644 index 0000000..78bc940 --- /dev/null +++ b/src/encoder-options.json @@ -0,0 +1,130 @@ +{ + "containers":[ + { + "name": "MP4", + "container": "mp4", + "default-vcodec": "H264", + "default-acodec": "AAC", + "video-codecs": [ + "H264", + "H264 (nvenc)", + "MPEG4" + ], + "audio-codecs": [ + "AAC", + "AC3", + "MP3" + ] + }, + { + "name": "MOV", + "container": "mov", + "default-vcodec": "H264", + "default-acodec": "AAC", + "video-codecs": [ + "H264", + "H264 (nvenc)", + "MPEG4", + "XVID" + ], + "audio-codecs": [ + "AAC", + "AC3", + "MP3", + "PCM s16 LE" + ] + }, + { + "name": "MKV", + "container": "matroska", + "default-vcodec": "H264", + "default-acodec": "AAC", + "video-codecs": [ + "H264", + "H264 (nvenc)", + "MPEG4", + "MPEG2", + "DV", + "WMV" + ], + "audio-codecs": [ + "AAC", + "AC3", + "MP3", + "PCM s16 LE", + "WMA" + ] + }, + { + "name": "AVI", + "container": "avi", + "default-vcodec": "H264", + "default-acodec": "AAC", + "video-codecs": [ + "H264", + "H264 (nvenc)", + "MPEG4", + "MPEG2", + "DV", + "WMV" + ], + "audio-codecs": [ + "AAC", + "AC3", + "MP3", + "PCM s16 LE", + "WMA" + ] + }, + { + "name": "WEBM", + "container": "webm", + "default-vcodec": "VP9", + "default-acodec": "Vorbis", + "video-codecs": [ + "VP9", + "VP8" + ], + "audio-codecs": [ + "Vorbis" + ] + }, + { + "name": "FLV", + "container": "flv", + "default-vcodec": "FLV", + "default-acodec": "Vorbis", + "video-codecs": [ + "Sorenson (flv)", + "H264", + "H264 (nvenc)", + "MPEG4" + ], + "audio-codecs": [ + "MP3", + "PCM s16 LE", + "Vorbis" + ] + } + ], + "video-codecs":{ + "H264": ["libx264"], + "H264 (nvenc)": ["h264_nvenc", "nvenc_h264"], + "MPEG4": ["mpeg4"], + "VP9": ["libvpx-vp9"], + "VP8": ["libvpx"], + "XVID": ["libxvid"], + "Sorenson (flv)": ["flv"], + "MPEG2": ["mp2video"], + "DV": ["dvvideo"], + "WMV": ["wmv2"] + }, + "audio-codecs": { + "AAC": ["libfdk_aac", "aac"], + "AC3": ["ac3"], + "MP3": ["libmp3lame"], + "PCM s16 LE": ["pcm_s16le"], + "WMA": ["wmav2"], + "Vorbis": ["libvorbis"] + } +}
\ No newline at end of file diff --git a/src/gui/__init__.py b/src/gui/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/gui/__init__.py diff --git a/src/gui/actions.py b/src/gui/actions.py new file mode 100644 index 0000000..afb980a --- /dev/null +++ b/src/gui/actions.py @@ -0,0 +1,191 @@ +''' + QCommand classes for every undoable user action performed in the MainWindow +''' +from PyQt5.QtWidgets import QUndoCommand +import os +from copy import copy + +from ..core import Core + + +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# COMPONENT ACTIONS +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + +class AddComponent(QUndoCommand): + def __init__(self, parent, compI, moduleI): + super().__init__( + "create new %s component" % + parent.core.modules[moduleI].Component.name + ) + self.parent = parent + self.moduleI = moduleI + self.compI = compI + self.comp = None + + def redo(self): + if self.comp is None: + self.parent.core.insertComponent( + self.compI, self.moduleI, self.parent) + else: + # inserting previously-created component + self.parent.core.insertComponent( + self.compI, self.comp, self.parent) + + def undo(self): + self.comp = self.parent.core.selectedComponents[self.compI] + self.parent._removeComponent(self.compI) + + +class RemoveComponent(QUndoCommand): + def __init__(self, parent, selectedRows): + super().__init__('remove component') + self.parent = parent + componentList = self.parent.listWidget_componentList + self.selectedRows = [ + componentList.row(selected) for selected in selectedRows + ] + self.components = [ + parent.core.selectedComponents[i] for i in self.selectedRows + ] + + def redo(self): + self.parent._removeComponent(self.selectedRows[0]) + + def undo(self): + componentList = self.parent.listWidget_componentList + for index, comp in zip(self.selectedRows, self.components): + self.parent.core.insertComponent( + index, comp, self.parent + ) + self.parent.drawPreview() + + +class MoveComponent(QUndoCommand): + def __init__(self, parent, row, newRow, tag): + super().__init__("move component %s" % tag) + self.parent = parent + self.row = row + self.newRow = newRow + self.id_ = ord(tag[0]) + + def id(self): + '''If 2 consecutive updates have same id, Qt will call mergeWith()''' + return self.id_ + + def mergeWith(self, other): + self.newRow = other.newRow + return True + + def do(self, rowa, rowb): + componentList = self.parent.listWidget_componentList + + page = self.parent.pages.pop(rowa) + self.parent.pages.insert(rowb, page) + + item = componentList.takeItem(rowa) + componentList.insertItem(rowb, item) + + stackedWidget = self.parent.stackedWidget + widget = stackedWidget.removeWidget(page) + stackedWidget.insertWidget(rowb, page) + componentList.setCurrentRow(rowb) + stackedWidget.setCurrentIndex(rowb) + self.parent.core.moveComponent(rowa, rowb) + self.parent.drawPreview(True) + + def redo(self): + self.do(self.row, self.newRow) + + def undo(self): + self.do(self.newRow, self.row) + + +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# PRESET ACTIONS +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + +class ClearPreset(QUndoCommand): + def __init__(self, parent, compI): + super().__init__("clear preset") + self.parent = parent + self.compI = compI + self.component = self.parent.core.selectedComponents[compI] + self.store = self.component.savePreset() + self.store['preset'] = self.component.currentPreset + + def redo(self): + self.parent.core.clearPreset(self.compI) + self.parent.updateComponentTitle(self.compI, False) + + def undo(self): + self.parent.core.selectedComponents[self.compI].loadPreset(self.store) + self.parent.updateComponentTitle(self.compI, self.store) + + +class OpenPreset(QUndoCommand): + def __init__(self, parent, presetName, compI): + super().__init__("open %s preset" % presetName) + self.parent = parent + self.presetName = presetName + self.compI = compI + + comp = self.parent.core.selectedComponents[compI] + self.store = comp.savePreset() + self.store['preset'] = copy(comp.currentPreset) + + def redo(self): + self.parent._openPreset(self.presetName, self.compI) + + def undo(self): + self.parent.core.selectedComponents[self.compI].loadPreset( + self.store) + self.parent.parent.updateComponentTitle(self.compI, self.store) + + +class RenamePreset(QUndoCommand): + def __init__(self, parent, path, oldName, newName): + super().__init__('rename preset') + self.parent = parent + self.path = path + self.oldName = oldName + self.newName = newName + + def redo(self): + self.parent.renamePreset(self.path, self.oldName, self.newName) + + def undo(self): + self.parent.renamePreset(self.path, self.newName, self.oldName) + + +class DeletePreset(QUndoCommand): + def __init__(self, parent, compName, vers, presetFile): + self.parent = parent + self.preset = (compName, vers, presetFile) + self.path = os.path.join( + Core.presetDir, compName, str(vers), presetFile + ) + self.store = self.parent.core.getPreset(self.path) + self.presetName = self.store['preset'] + super().__init__('delete %s preset (%s)' % (self.presetName, compName)) + self.loadedPresets = [ + i for i, comp in enumerate(self.parent.core.selectedComponents) + if self.presetName == str(comp.currentPreset) + ] + + def redo(self): + os.remove(self.path) + for i in self.loadedPresets: + self.parent.core.clearPreset(i) + self.parent.parent.updateComponentTitle(i, False) + self.parent.findPresets() + self.parent.drawPresetList() + + def undo(self): + self.parent.createNewPreset(*self.preset, self.store) + selectedComponents = self.parent.core.selectedComponents + for i in self.loadedPresets: + selectedComponents[i].currentPreset = self.presetName + self.parent.parent.updateComponentTitle(i) + self.parent.findPresets() + self.parent.drawPresetList() diff --git a/src/gui/background.png b/src/gui/background.png Binary files differnew file mode 100644 index 0000000..fb58593 --- /dev/null +++ b/src/gui/background.png diff --git a/src/gui/mainwindow.py b/src/gui/mainwindow.py new file mode 100644 index 0000000..f6de763 --- /dev/null +++ b/src/gui/mainwindow.py @@ -0,0 +1,1057 @@ +''' + When using GUI mode, this module's object (the main window) takes + user input to construct a program state (stored in the Core object). + This shows a preview of the video being created and allows for saving + projects and exporting the video at a later time. +''' +from PyQt5 import QtCore, QtGui, QtWidgets, uic +import PyQt5.QtWidgets as QtWidgets +from PIL import Image +from queue import Queue +import sys +import os +import signal +import filecmp +import time +import logging + +from ..core import Core +from . import preview_thread +from .preview_win import PreviewWindow +from .presetmanager import PresetManager +from .actions import * +from ..toolkit import ( + disableWhenEncoding, disableWhenOpeningProject, checkOutput, blockSignals +) + + +log = logging.getLogger('AVP.Gui.MainWindow') + + +class MainWindow(QtWidgets.QMainWindow): + ''' + The MainWindow wraps many Core methods in order to update the GUI + accordingly. E.g., instead of self.core.openProject(), it will use + self.openProject() and update the window titlebar within the wrapper. + + MainWindow manages the autosave feature, although Core has the + primary functions for opening and creating project files. + ''' + + createVideo = QtCore.pyqtSignal() + newTask = QtCore.pyqtSignal(list) # for the preview window + processTask = QtCore.pyqtSignal() + + def __init__(self, project): + super().__init__() + log.debug( + 'Main thread id: {}'.format(int(QtCore.QThread.currentThreadId()))) + uic.loadUi(os.path.join(Core.wd, "gui", "mainwindow.ui"), self) + desk = QtWidgets.QDesktopWidget() + dpi = desk.physicalDpiX() + log.info("Detected screen DPI: %s", dpi) + + self.resize( + int(self.width() * + (dpi / 96)), + int(self.height() * + (dpi / 96)) + ) + + self.core = Core() + Core.mode = 'GUI' + # widgets of component settings + self.pages = [] + self.lastAutosave = time.time() + # list of previous five autosave times, used to reduce update spam + self.autosaveTimes = [] + self.autosaveCooldown = 0.2 + self.encoding = False + + # Find settings created by Core object + self.dataDir = Core.dataDir + self.presetDir = Core.presetDir + self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') + self.settings = Core.settings + + # Create stack of undoable user actions + self.undoStack = QtWidgets.QUndoStack(self) + undoLimit = self.settings.value("pref_undoLimit") + self.undoStack.setUndoLimit(undoLimit) + + # Create Preset Manager + self.presetManager = PresetManager(self) + + # Create the preview window and its thread, queues, and timers + log.debug('Creating preview window') + self.previewWindow = PreviewWindow(self, os.path.join( + Core.wd, 'gui', "background.png")) + self.verticalLayout_previewWrapper.addWidget(self.previewWindow) + + log.debug('Starting preview thread') + self.previewQueue = Queue() + self.previewThread = QtCore.QThread(self) + self.previewWorker = preview_thread.Worker( + self.core, + self.settings, + self.previewQueue + ) + self.previewWorker.moveToThread(self.previewThread) + self.newTask.connect(self.previewWorker.createPreviewImage) + self.processTask.connect(self.previewWorker.process) + self.previewWorker.error.connect(self.previewWindow.threadError) + self.previewWorker.imageCreated.connect(self.showPreviewImage) + self.previewThread.start() + self.previewThread.finished.connect(lambda: log.info('Preview thread finished.')) + + timeout = 500 + log.debug( + 'Preview timer set to trigger when idle for %sms' % str(timeout) + ) + self.timer = QtCore.QTimer(self) + self.timer.timeout.connect(self.processTask.emit) + self.timer.start(timeout) + + # Begin decorating the window and connecting events + componentList = self.listWidget_componentList + + # Undo Feature + def toggleUndoButtonEnabled(*_): + """ Enable/disable undo button depending on whether UndoStack contains Actions """ + try: + undoButton.setEnabled(self.undoStack.count()) + except RuntimeError: + # program is probably in midst of exiting + pass + + style = self.pushButton_undo.style() + undoButton = self.pushButton_undo + undoButton.setIcon( + style.standardIcon(QtWidgets.QStyle.SP_FileDialogBack) + ) + undoButton.clicked.connect(self.undoStack.undo) + undoButton.setEnabled(False) + self.undoStack.cleanChanged.connect(toggleUndoButtonEnabled) + self.undoMenu = QtWidgets.QMenu() + self.undoMenu.addAction( + self.undoStack.createUndoAction(self) + ) + self.undoMenu.addAction( + self.undoStack.createRedoAction(self) + ) + action = self.undoMenu.addAction('Show History...') + action.triggered.connect( + lambda _: self.showUndoStack() + ) + undoButton.setMenu(self.undoMenu) + # end of Undo Feature + + style = self.pushButton_listMoveUp.style() + self.pushButton_listMoveUp.setIcon( + style.standardIcon(QtWidgets.QStyle.SP_ArrowUp) + ) + style = self.pushButton_listMoveDown.style() + self.pushButton_listMoveDown.setIcon( + style.standardIcon(QtWidgets.QStyle.SP_ArrowDown) + ) + style = self.pushButton_removeComponent.style() + self.pushButton_removeComponent.setIcon( + style.standardIcon(QtWidgets.QStyle.SP_DialogDiscardButton) + ) + + if sys.platform == 'darwin': + log.debug( + 'Darwin detected: showing progress label below progress bar') + self.progressBar_createVideo.setTextVisible(False) + else: + self.progressLabel.setHidden(True) + + self.toolButton_selectAudioFile.clicked.connect( + self.openInputFileDialog) + + self.toolButton_selectOutputFile.clicked.connect( + self.openOutputFileDialog) + + def changedField(): + self.autosave() + self.updateWindowTitle() + + self.lineEdit_audioFile.textChanged.connect(changedField) + self.lineEdit_outputFile.textChanged.connect(changedField) + + self.progressBar_createVideo.setValue(0) + + self.pushButton_createVideo.clicked.connect( + self.createAudioVisualisation) + + self.pushButton_Cancel.clicked.connect(self.stopVideo) + + for i, container in enumerate(Core.encoderOptions['containers']): + self.comboBox_videoContainer.addItem(container['name']) + if container['name'] == self.settings.value('outputContainer'): + selectedContainer = i + + self.comboBox_videoContainer.setCurrentIndex(selectedContainer) + self.comboBox_videoContainer.currentIndexChanged.connect( + self.updateCodecs + ) + + self.updateCodecs() + + for i in range(self.comboBox_videoCodec.count()): + codec = self.comboBox_videoCodec.itemText(i) + if codec == self.settings.value('outputVideoCodec'): + self.comboBox_videoCodec.setCurrentIndex(i) + + for i in range(self.comboBox_audioCodec.count()): + codec = self.comboBox_audioCodec.itemText(i) + if codec == self.settings.value('outputAudioCodec'): + self.comboBox_audioCodec.setCurrentIndex(i) + + self.comboBox_videoCodec.currentIndexChanged.connect( + self.updateCodecSettings + ) + + self.comboBox_audioCodec.currentIndexChanged.connect( + self.updateCodecSettings + ) + + vBitrate = int(self.settings.value('outputVideoBitrate')) + aBitrate = int(self.settings.value('outputAudioBitrate')) + + self.spinBox_vBitrate.setValue(vBitrate) + self.spinBox_aBitrate.setValue(aBitrate) + self.spinBox_vBitrate.valueChanged.connect(self.updateCodecSettings) + self.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings) + + # Make component buttons + self.compMenu = QtWidgets.QMenu() + for i, comp in enumerate(self.core.modules): + action = self.compMenu.addAction(comp.Component.name) + action.triggered.connect( + lambda _, item=i: self.addComponent(0, item) + ) + + self.pushButton_addComponent.setMenu(self.compMenu) + + componentList.dropEvent = self.dragComponent + componentList.itemSelectionChanged.connect( + self.changeComponentWidget + ) + componentList.itemSelectionChanged.connect( + self.presetManager.clearPresetListSelection + ) + self.pushButton_removeComponent.clicked.connect( + lambda: self.removeComponent() + ) + + componentList.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + componentList.customContextMenuRequested.connect( + self.componentContextMenu + ) + + currentRes = str(self.settings.value('outputWidth'))+'x' + \ + str(self.settings.value('outputHeight')) + for i, res in enumerate(Core.resolutions): + self.comboBox_resolution.addItem(res) + if res == currentRes: + currentRes = i + self.comboBox_resolution.setCurrentIndex(currentRes) + self.comboBox_resolution.currentIndexChanged.connect( + self.updateResolution + ) + + self.pushButton_listMoveUp.clicked.connect( + lambda: self.moveComponent(-1) + ) + self.pushButton_listMoveDown.clicked.connect( + lambda: self.moveComponent(1) + ) + + # Configure the Projects Menu + self.projectMenu = QtWidgets.QMenu() + self.menuButton_newProject = self.projectMenu.addAction( + "New Project" + ) + self.menuButton_newProject.triggered.connect( + lambda: self.createNewProject() + ) + self.menuButton_openProject = self.projectMenu.addAction( + "Open Project" + ) + self.menuButton_openProject.triggered.connect( + lambda: self.openOpenProjectDialog() + ) + + action = self.projectMenu.addAction("Save Project") + action.triggered.connect(self.saveCurrentProject) + + action = self.projectMenu.addAction("Save Project As") + action.triggered.connect(self.openSaveProjectDialog) + + self.pushButton_projects.setMenu(self.projectMenu) + + # Configure the Presets Button + self.pushButton_presets.clicked.connect( + self.openPresetManager + ) + + self.updateWindowTitle() + log.debug('Showing main window') + self.show() + + if project and project != self.autosavePath: + if not project.endswith('.avp'): + project += '.avp' + # open a project from the commandline + if not os.path.dirname(project): + project = os.path.join( + self.settings.value("projectDir"), project + ) + self.currentProject = project + self.settings.setValue("currentProject", project) + if os.path.exists(self.autosavePath): + os.remove(self.autosavePath) + else: + # open the last currentProject from settings + self.currentProject = self.settings.value("currentProject") + + # delete autosave if it's identical to this project + if self.autosaveExists(identical=True): + os.remove(self.autosavePath) + + if self.currentProject and os.path.exists(self.autosavePath): + ch = self.showMessage( + msg="Restore unsaved changes in project '%s'?" + % os.path.basename(self.currentProject)[:-4], + showCancel=True) + if ch: + self.saveProjectChanges() + else: + os.remove(self.autosavePath) + + self.openProject(self.currentProject, prompt=False) + self.drawPreview(True) + + log.info("Pillow version %s", Image.__version__) + + # verify Ffmpeg version + if not self.core.FFMPEG_BIN: + self.showMessage( + msg="FFmpeg could not be found. This is a critical error. " + "Install FFmpeg, or download it and place the program executable " + "in the same folder as this program.", + icon='Critical' + ) + else: + if not self.settings.value("ffmpegMsgShown"): + try: + with open(os.devnull, "w") as f: + ffmpegVers = checkOutput( + [self.core.FFMPEG_BIN, '-version'], stderr=f + ) + goodVersion = str(ffmpegVers).split()[2].startswith('4') + except Exception: + goodVersion = False + else: + goodVersion = True + + if not goodVersion: + self.showMessage( + msg="You're using an old version of Ffmpeg. " + "Some features may not work as expected." + ) + self.settings.setValue("ffmpegMsgShown", True) + + # Hotkeys for projects + QtWidgets.QShortcut("Ctrl+S", self, self.saveCurrentProject) + QtWidgets.QShortcut("Ctrl+A", self, self.openSaveProjectDialog) + QtWidgets.QShortcut("Ctrl+O", self, self.openOpenProjectDialog) + QtWidgets.QShortcut("Ctrl+N", self, self.createNewProject) + + # Hotkeys for undo/redo + QtWidgets.QShortcut("Ctrl+Z", self, self.undoStack.undo) + QtWidgets.QShortcut("Ctrl+Y", self, self.undoStack.redo) + QtWidgets.QShortcut("Ctrl+Shift+Z", self, self.undoStack.redo) + + # Hotkeys for component list + for inskey in ("Ctrl+T", QtCore.Qt.Key_Insert): + QtWidgets.QShortcut( + inskey, self, + activated=lambda: self.pushButton_addComponent.click() + ) + for delkey in ("Ctrl+R", QtCore.Qt.Key_Delete): + QtWidgets.QShortcut( + delkey, self.listWidget_componentList, + self.removeComponent + ) + QtWidgets.QShortcut( + "Ctrl+Space", self, + activated=lambda: self.listWidget_componentList.setFocus() + ) + QtWidgets.QShortcut( + "Ctrl+Shift+S", self, + self.presetManager.openSavePresetDialog + ) + QtWidgets.QShortcut( + "Ctrl+Shift+C", self, self.presetManager.clearPreset + ) + + QtWidgets.QShortcut( + "Ctrl+Up", self.listWidget_componentList, + activated=lambda: self.moveComponent(-1) + ) + QtWidgets.QShortcut( + "Ctrl+Down", self.listWidget_componentList, + activated=lambda: self.moveComponent(1) + ) + QtWidgets.QShortcut( + "Ctrl+Home", self.listWidget_componentList, + activated=lambda: self.moveComponent('top') + ) + QtWidgets.QShortcut( + "Ctrl+End", self.listWidget_componentList, + activated=lambda: self.moveComponent('bottom') + ) + + QtWidgets.QShortcut( + "Ctrl+Shift+F", self, self.showFfmpegCommand + ) + QtWidgets.QShortcut( + "Ctrl+Shift+U", self, self.showUndoStack + ) + + if log.isEnabledFor(logging.DEBUG): + QtWidgets.QShortcut( + "Ctrl+Alt+Shift+R", self, self.drawPreview + ) + QtWidgets.QShortcut( + "Ctrl+Alt+Shift+A", self, lambda: log.debug(repr(self)) + ) + + # Close MainWindow when receiving Ctrl+C from terminal + signal.signal(signal.SIGINT, lambda *args: self.close()) + + def __repr__(self): + return ( + '%s\n' + '\n%s\n' + '#####\n' + 'Preview thread is %s\n' % ( + super().__repr__(), + "core not initialized" if not hasattr(self, "core") else repr(self.core), + 'live' if hasattr(self, "previewThread") and self.previewThread.isRunning() else 'dead', + ) + ) + + def closeEvent(self, event): + log.info('Ending the preview thread') + self.timer.stop() + self.previewThread.quit() + self.previewThread.wait() + return super().closeEvent(event) + + @disableWhenOpeningProject + def updateWindowTitle(self): + log.debug("Setting main window's title") + appName = 'Audio Visualizer' + try: + if self.currentProject: + appName += ' - %s' % \ + os.path.splitext( + os.path.basename(self.currentProject))[0] + if self.autosaveExists(identical=False): + appName += '*' + except AttributeError: + pass + log.verbose(f'Window title is "{appName}"') + self.setWindowTitle(appName) + + @QtCore.pyqtSlot(int, dict) + def updateComponentTitle(self, pos, presetStore=False): + ''' + Sets component title to modified or unmodified when given boolean. + If given a preset dict, compares it against the component to + determine if it is modified. + A component with no preset is always unmodified. + ''' + if type(presetStore) is dict: + name = presetStore['preset'] + if name is None or name not in self.core.savedPresets: + modified = False + else: + modified = (presetStore != self.core.savedPresets[name]) + + modified = bool(presetStore) + if pos < 0: + pos = len(self.core.selectedComponents)-1 + name = self.core.selectedComponents[pos].name + title = str(name) + if self.core.selectedComponents[pos].currentPreset: + title += ' - %s' % self.core.selectedComponents[pos].currentPreset + if modified: + title += '*' + if type(presetStore) is bool: + log.debug( + 'Forcing %s #%s\'s modified status to %s: %s', + name, pos, modified, title + ) + else: + log.debug( + 'Setting %s #%s\'s title: %s', + name, pos, title + ) + self.listWidget_componentList.item(pos).setText(title) + + def updateCodecs(self): + containerWidget = self.comboBox_videoContainer + vCodecWidget = self.comboBox_videoCodec + aCodecWidget = self.comboBox_audioCodec + index = containerWidget.currentIndex() + name = containerWidget.itemText(index) + self.settings.setValue('outputContainer', name) + + vCodecWidget.clear() + aCodecWidget.clear() + + for container in Core.encoderOptions['containers']: + if container['name'] == name: + for vCodec in container['video-codecs']: + vCodecWidget.addItem(vCodec) + for aCodec in container['audio-codecs']: + aCodecWidget.addItem(aCodec) + + def updateCodecSettings(self): + '''Updates settings.ini to match encoder option widgets''' + vCodecWidget = self.comboBox_videoCodec + vBitrateWidget = self.spinBox_vBitrate + aBitrateWidget = self.spinBox_aBitrate + aCodecWidget = self.comboBox_audioCodec + currentVideoCodec = vCodecWidget.currentIndex() + currentVideoCodec = vCodecWidget.itemText(currentVideoCodec) + currentVideoBitrate = vBitrateWidget.value() + currentAudioCodec = aCodecWidget.currentIndex() + currentAudioCodec = aCodecWidget.itemText(currentAudioCodec) + currentAudioBitrate = aBitrateWidget.value() + self.settings.setValue('outputVideoCodec', currentVideoCodec) + self.settings.setValue('outputAudioCodec', currentAudioCodec) + self.settings.setValue('outputVideoBitrate', currentVideoBitrate) + self.settings.setValue('outputAudioBitrate', currentAudioBitrate) + + @disableWhenOpeningProject + def autosave(self, force=False): + if not self.currentProject: + if os.path.exists(self.autosavePath): + os.remove(self.autosavePath) + elif force or time.time() - self.lastAutosave >= self.autosaveCooldown: + self.core.createProjectFile(self.autosavePath, self) + self.lastAutosave = time.time() + if len(self.autosaveTimes) >= 5: + # Do some math to reduce autosave spam. This gives a smooth + # curve up to 5 seconds cooldown and maintains that for 30 secs + # if a component is continuously updated + timeDiff = self.lastAutosave - self.autosaveTimes.pop() + if not force and timeDiff >= 1.0 \ + and timeDiff <= 10.0: + if self.autosaveCooldown / 4.0 < 0.5: + self.autosaveCooldown += 1.0 + self.autosaveCooldown = ( + 5.0 * (self.autosaveCooldown / 5.0) + ) + (self.autosaveCooldown / 5.0) * 2 + elif force or timeDiff >= self.autosaveCooldown * 5: + self.autosaveCooldown = 0.2 + self.autosaveTimes.insert(0, self.lastAutosave) + else: + log.debug('Autosave rejected by cooldown') + + def autosaveExists(self, identical=True): + '''Determines if creating the autosave should be blocked.''' + try: + if self.currentProject and os.path.exists(self.autosavePath) \ + and filecmp.cmp( + self.autosavePath, self.currentProject) == identical: + log.debug( + 'Autosave found %s to be identical' + % 'not' if not identical else '' + ) + return True + except FileNotFoundError: + log.error( + 'Project file couldn\'t be located: %s', self.currentProject) + return identical + return False + + def saveProjectChanges(self): + '''Overwrites project file with autosave file''' + try: + os.remove(self.currentProject) + os.rename(self.autosavePath, self.currentProject) + return True + except (FileNotFoundError, IsADirectoryError) as e: + self.showMessage( + msg='Project file couldn\'t be saved.', + detail=str(e)) + return False + + def openInputFileDialog(self): + inputDir = self.settings.value("inputDir", os.path.expanduser("~")) + + fileName, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Open Audio File", + inputDir, "Audio Files (%s)" % " ".join(Core.audioFormats)) + + if fileName: + self.settings.setValue("inputDir", os.path.dirname(fileName)) + self.lineEdit_audioFile.setText(fileName) + + def openOutputFileDialog(self): + outputDir = self.settings.value("outputDir", os.path.expanduser("~")) + + fileName, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Set Output Video File", + outputDir, + "Video Files (%s);; All Files (*)" % " ".join( + Core.videoFormats)) + + if fileName: + self.settings.setValue("outputDir", os.path.dirname(fileName)) + self.lineEdit_outputFile.setText(fileName) + + def stopVideo(self): + log.info('Export cancelled') + self.videoWorker.cancel() + self.canceled = True + + def createAudioVisualisation(self): + # create output video if mandatory settings are filled in + audioFile = self.lineEdit_audioFile.text() + outputPath = self.lineEdit_outputFile.text() + + if audioFile and outputPath and self.core.selectedComponents: + if not os.path.dirname(outputPath): + outputPath = os.path.join( + os.path.expanduser("~"), outputPath) + if outputPath and os.path.isdir(outputPath): + self.showMessage( + msg='Chosen filename matches a directory, which ' + 'cannot be overwritten. Please choose a different ' + 'filename or move the directory.', + icon='Warning', + ) + return + else: + if not audioFile or not outputPath: + self.showMessage( + msg="You must select an audio file and output filename." + ) + elif not self.core.selectedComponents: + self.showMessage( + msg="Not enough components." + ) + return + + self.canceled = False + self.progressBarUpdated(-1) + self.videoWorker = self.core.newVideoWorker( + self, audioFile, outputPath + ) + self.videoWorker.progressBarUpdate.connect(self.progressBarUpdated) + self.videoWorker.progressBarSetText.connect( + self.progressBarSetText) + self.videoWorker.imageCreated.connect(self.showPreviewImage) + self.videoWorker.encoding.connect(self.changeEncodingStatus) + self.createVideo.emit() + + @QtCore.pyqtSlot(str, str) + def videoThreadError(self, msg, detail): + try: + self.stopVideo() + except AttributeError as e: + if 'videoWorker' not in str(e): + raise + self.showMessage( + msg=msg, + detail=detail, + icon='Critical', + ) + log.info('%s', repr(self)) + + def changeEncodingStatus(self, status): + self.encoding = status + if status: + self.pushButton_createVideo.setEnabled(False) + self.pushButton_Cancel.setEnabled(True) + self.comboBox_resolution.setEnabled(False) + self.stackedWidget.setEnabled(False) + self.tab_encoderSettings.setEnabled(False) + self.label_audioFile.setEnabled(False) + self.toolButton_selectAudioFile.setEnabled(False) + self.label_outputFile.setEnabled(False) + self.toolButton_selectOutputFile.setEnabled(False) + self.lineEdit_audioFile.setEnabled(False) + self.lineEdit_outputFile.setEnabled(False) + self.pushButton_addComponent.setEnabled(False) + self.pushButton_removeComponent.setEnabled(False) + self.pushButton_listMoveDown.setEnabled(False) + self.pushButton_listMoveUp.setEnabled(False) + self.menuButton_newProject.setEnabled(False) + self.menuButton_openProject.setEnabled(False) + if sys.platform == 'darwin': + self.progressLabel.setHidden(False) + else: + self.listWidget_componentList.setEnabled(False) + else: + self.pushButton_createVideo.setEnabled(True) + self.pushButton_Cancel.setEnabled(False) + self.comboBox_resolution.setEnabled(True) + self.stackedWidget.setEnabled(True) + self.tab_encoderSettings.setEnabled(True) + self.label_audioFile.setEnabled(True) + self.toolButton_selectAudioFile.setEnabled(True) + self.lineEdit_audioFile.setEnabled(True) + self.label_outputFile.setEnabled(True) + self.toolButton_selectOutputFile.setEnabled(True) + self.lineEdit_outputFile.setEnabled(True) + self.pushButton_addComponent.setEnabled(True) + self.pushButton_removeComponent.setEnabled(True) + self.pushButton_listMoveDown.setEnabled(True) + self.pushButton_listMoveUp.setEnabled(True) + self.menuButton_newProject.setEnabled(True) + self.menuButton_openProject.setEnabled(True) + self.listWidget_componentList.setEnabled(True) + self.progressLabel.setHidden(True) + self.drawPreview(True) + + @QtCore.pyqtSlot(int) + def progressBarUpdated(self, value): + self.progressBar_createVideo.setValue(value) + + @QtCore.pyqtSlot(str) + def progressBarSetText(self, value): + if sys.platform == 'darwin': + self.progressLabel.setText(value) + else: + self.progressBar_createVideo.setFormat(value) + + def updateResolution(self): + resIndex = int(self.comboBox_resolution.currentIndex()) + res = Core.resolutions[resIndex].split('x') + changed = res[0] != self.settings.value("outputWidth") + self.settings.setValue('outputWidth', res[0]) + self.settings.setValue('outputHeight', res[1]) + if changed: + for i in range(len(self.core.selectedComponents)): + self.core.updateComponent(i) + + def drawPreview(self, force=False, **kwargs): + '''Use autosave keyword arg to force saving or not saving if needed''' + self.newTask.emit(self.core.selectedComponents) + # self.processTask.emit() + if force or 'autosave' in kwargs: + if force or kwargs['autosave']: + self.autosave(True) + else: + self.autosave() + self.updateWindowTitle() + + @QtCore.pyqtSlot(QtGui.QImage) + def showPreviewImage(self, image): + self.previewWindow.changePixmap(image) + + def showUndoStack(self): + dialog = QtWidgets.QDialog(self) + undoView = QtWidgets.QUndoView(self.undoStack) + layout = QtWidgets.QVBoxLayout() + layout.addWidget(undoView) + dialog.setLayout(layout) + dialog.show() + + def showFfmpegCommand(self): + from textwrap import wrap + from ..toolkit.ffmpeg import createFfmpegCommand + command = createFfmpegCommand( + self.lineEdit_audioFile.text(), + self.lineEdit_outputFile.text(), + self.core.selectedComponents + ) + command = " ".join(command) + log.info(f"FFmpeg command: {command}") + lines = wrap(command, 49) + self.showMessage( + msg=f"Current FFmpeg command:\n\n{' '.join(lines)}" + ) + + def addComponent(self, compPos, moduleIndex): + '''Creates an undoable action that adds a new component.''' + action = AddComponent(self, compPos, moduleIndex) + self.undoStack.push(action) + + def insertComponent(self, index): + '''Triggered by Core to finish initializing a new component.''' + componentList = self.listWidget_componentList + stackedWidget = self.stackedWidget + + componentList.insertItem( + index, + self.core.selectedComponents[index].name) + componentList.setCurrentRow(index) + + # connect to signal that adds an asterisk when modified + self.core.selectedComponents[index].modified.connect( + self.updateComponentTitle) + + self.pages.insert(index, self.core.selectedComponents[index].page) + stackedWidget.insertWidget(index, self.pages[index]) + stackedWidget.setCurrentIndex(index) + + return index + + def removeComponent(self): + componentList = self.listWidget_componentList + selected = componentList.selectedItems() + if selected: + action = RemoveComponent(self, selected) + self.undoStack.push(action) + + def _removeComponent(self, index): + stackedWidget = self.stackedWidget + componentList = self.listWidget_componentList + stackedWidget.removeWidget(self.pages[index]) + componentList.takeItem(index) + self.core.removeComponent(index) + self.pages.pop(index) + self.changeComponentWidget() + self.drawPreview() + + @disableWhenEncoding + def moveComponent(self, change): + '''Moves a component relatively from its current position''' + componentList = self.listWidget_componentList + tag = change + if change == 'top': + change = -componentList.currentRow() + elif change == 'bottom': + change = len(componentList)-componentList.currentRow()-1 + else: + tag = 'down' if change == 1 else 'up' + + row = componentList.currentRow() + newRow = row + change + if newRow > -1 and newRow < componentList.count(): + action = MoveComponent(self, row, newRow, tag) + self.undoStack.push(action) + + def getComponentListMousePos(self, position): + ''' + Given a QPos, returns the component index under the mouse cursor + or -1 if no component is there. + ''' + componentList = self.listWidget_componentList + + modelIndexes = [ + componentList.model().index(i) + for i in range(componentList.count()) + ] + rects = [ + componentList.visualRect(modelIndex) + for modelIndex in modelIndexes + ] + mousePos = [rect.contains(position) for rect in rects] + if not any(mousePos): + # Not clicking a component + mousePos = -1 + else: + mousePos = mousePos.index(True) + log.debug('Click component list row %s' % mousePos) + return mousePos + + @disableWhenEncoding + def dragComponent(self, event): + '''Used as Qt drop event for the component listwidget''' + componentList = self.listWidget_componentList + mousePos = self.getComponentListMousePos(event.pos()) + if mousePos > -1: + change = (componentList.currentRow() - mousePos) * -1 + else: + change = (componentList.count() - componentList.currentRow() - 1) + self.moveComponent(change) + + def changeComponentWidget(self): + selected = self.listWidget_componentList.selectedItems() + if selected: + index = self.listWidget_componentList.row(selected[0]) + self.stackedWidget.setCurrentIndex(index) + + def openPresetManager(self): + '''Preset manager for importing, exporting, renaming, deleting''' + self.presetManager.show_() + + def clear(self): + '''Get a blank slate''' + self.core.clearComponents() + self.listWidget_componentList.clear() + for widget in self.pages: + self.stackedWidget.removeWidget(widget) + self.pages = [] + for field in ( + self.lineEdit_audioFile, + self.lineEdit_outputFile + ): + with blockSignals(field): + field.setText('') + self.progressBarUpdated(0) + self.progressBarSetText('') + self.undoStack.clear() + + @disableWhenEncoding + def createNewProject(self, prompt=True): + if prompt: + self.openSaveChangesDialog('starting a new project') + + self.clear() + self.currentProject = None + self.settings.setValue("currentProject", None) + self.drawPreview(True) + + def saveCurrentProject(self): + if self.currentProject: + self.core.createProjectFile(self.currentProject, self) + try: + os.remove(self.autosavePath) + except FileNotFoundError: + pass + self.updateWindowTitle() + else: + self.openSaveProjectDialog() + + def openSaveChangesDialog(self, phrase): + success = True + if self.autosaveExists(identical=False): + ch = self.showMessage( + msg="You have unsaved changes in project '%s'. " + "Save before %s?" % ( + os.path.basename(self.currentProject)[:-4], + phrase + ), + showCancel=True) + if ch: + success = self.saveProjectChanges() + + if success and os.path.exists(self.autosavePath): + os.remove(self.autosavePath) + + def openSaveProjectDialog(self): + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Create Project File", + self.settings.value("projectDir"), + "Project Files (*.avp)") + if not filename: + return + if not filename.endswith(".avp"): + filename += '.avp' + self.settings.setValue("projectDir", os.path.dirname(filename)) + self.settings.setValue("currentProject", filename) + self.currentProject = filename + self.core.createProjectFile(filename, self) + self.updateWindowTitle() + + @disableWhenEncoding + def openOpenProjectDialog(self): + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Open Project File", + self.settings.value("projectDir"), + "Project Files (*.avp)") + self.openProject(filename) + + def openProject(self, filepath, prompt=True): + if not filepath or not os.path.exists(filepath) \ + or not filepath.endswith('.avp'): + return + + self.clear() + # ask to save any changes that are about to get deleted + if prompt: + self.openSaveChangesDialog('opening another project') + + self.currentProject = filepath + self.settings.setValue("currentProject", filepath) + self.settings.setValue("projectDir", os.path.dirname(filepath)) + # actually load the project using core method + self.core.openProject(self, filepath) + self.drawPreview(autosave=False) + self.updateWindowTitle() + + def showMessage(self, **kwargs): + parent = kwargs['parent'] if 'parent' in kwargs else self + msg = QtWidgets.QMessageBox(parent) + msg.setModal(True) + msg.setText(kwargs['msg']) + msg.setIcon( + eval('QtWidgets.QMessageBox.%s' % kwargs['icon']) + if 'icon' in kwargs else QtWidgets.QMessageBox.Information + ) + msg.setDetailedText(kwargs['detail'] if 'detail' in kwargs else None) + if 'showCancel'in kwargs and kwargs['showCancel']: + msg.setStandardButtons( + QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) + else: + msg.setStandardButtons(QtWidgets.QMessageBox.Ok) + ch = msg.exec_() + if ch == 1024: + return True + return False + + @disableWhenEncoding + def componentContextMenu(self, QPos): + '''Appears when right-clicking the component list''' + componentList = self.listWidget_componentList + self.menu = QtWidgets.QMenu() + parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) + + index = self.getComponentListMousePos(QPos) + if index > -1: + # Show preset menu if clicking a component + self.presetManager.findPresets() + menuItem = self.menu.addAction("Save Preset") + menuItem.triggered.connect( + self.presetManager.openSavePresetDialog + ) + + # submenu for opening presets + try: + presets = self.presetManager.presets[ + str(self.core.selectedComponents[index]) + ] + self.presetSubmenu = QtWidgets.QMenu("Open Preset") + self.menu.addMenu(self.presetSubmenu) + + for version, presetName in presets: + menuItem = self.presetSubmenu.addAction(presetName) + menuItem.triggered.connect( + lambda _, presetName=presetName: + self.presetManager.openPreset(presetName) + ) + except KeyError: + pass + + if self.core.selectedComponents[index].currentPreset: + menuItem = self.menu.addAction("Clear Preset") + menuItem.triggered.connect( + self.presetManager.clearPreset + ) + self.menu.addSeparator() + + # "Add Component" submenu + self.submenu = QtWidgets.QMenu("Add") + self.menu.addMenu(self.submenu) + insertCompAtTop = self.settings.value("pref_insertCompAtTop") + for i, comp in enumerate(self.core.modules): + menuItem = self.submenu.addAction(comp.Component.name) + menuItem.triggered.connect( + lambda _, item=i: self.addComponent( + 0 if insertCompAtTop else index, item + ) + ) + + self.menu.move(parentPosition + QPos) + self.menu.show() diff --git a/src/gui/mainwindow.ui b/src/gui/mainwindow.ui new file mode 100644 index 0000000..cd8454d --- /dev/null +++ b/src/gui/mainwindow.ui @@ -0,0 +1,835 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>MainWindow</class> + <widget class="QMainWindow" name="MainWindow"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>1008</width> + <height>575</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="focusPolicy"> + <enum>Qt::StrongFocus</enum> + </property> + <property name="windowTitle"> + <string>MainWindow</string> + </property> + <widget class="QWidget" name="centralwidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="autoFillBackground"> + <bool>false</bool> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>9</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <spacer name="verticalSpacer_2"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>0</width> + <height>360</height> + </size> + </property> + </spacer> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_previewWrapper"> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <item> + <spacer name="horizontalSpacer_previewSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>420</width> + <height>0</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <property name="leftMargin"> + <number>3</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout_4"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <property name="leftMargin"> + <number>3</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_16"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <item> + <widget class="QPushButton" name="pushButton_undo"> + <property name="text"> + <string>Undo</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>140</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_projects"> + <property name="text"> + <string>Projects</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_presets"> + <property name="text"> + <string>Presets</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>2</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QListWidget" name="listWidget_componentList"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>16777215</height> + </size> + </property> + <property name="acceptDrops"> + <bool>true</bool> + </property> + <property name="frameShape"> + <enum>QFrame::StyledPanel</enum> + </property> + <property name="frameShadow"> + <enum>QFrame::Sunken</enum> + </property> + <property name="lineWidth"> + <number>1</number> + </property> + <property name="tabKeyNavigation"> + <bool>true</bool> + </property> + <property name="dragEnabled"> + <bool>true</bool> + </property> + <property name="dragDropOverwriteMode"> + <bool>false</bool> + </property> + <property name="dragDropMode"> + <enum>QAbstractItemView::InternalMove</enum> + </property> + <property name="defaultDropAction"> + <enum>Qt::MoveAction</enum> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_14"> + <item> + <widget class="QPushButton" name="pushButton_addComponent"> + <property name="text"> + <string>Add</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_removeComponent"> + <property name="text"> + <string>Remove</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_listMoveUp"> + <property name="text"> + <string>Up</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_listMoveDown"> + <property name="text"> + <string>Down</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_7"> + <property name="leftMargin"> + <number>4</number> + </property> + <property name="rightMargin"> + <number>2</number> + </property> + </layout> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <property name="sizeConstraint"> + <enum>QLayout::SetFixedSize</enum> + </property> + <property name="topMargin"> + <number>4</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <item> + <widget class="QTabWidget" name="tabWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>500</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>180</height> + </size> + </property> + <property name="tabPosition"> + <enum>QTabWidget::North</enum> + </property> + <property name="tabShape"> + <enum>QTabWidget::Rounded</enum> + </property> + <property name="currentIndex"> + <number>0</number> + </property> + <widget class="QWidget" name="tab_exportVideo"> + <attribute name="title"> + <string>Export Video</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_10"> + <property name="margin"> + <number>10</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_4"> + <property name="topMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label_audioFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>80</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Audio File</string> + </property> + <property name="alignment"> + <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_audioFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_selectAudioFile"> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_11"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_6"> + <item> + <widget class="QLabel" name="label_outputFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Output File</string> + </property> + <property name="alignment"> + <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_outputFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_selectOutputFile"> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <property name="margin"> + <number>0</number> + </property> + <item> + <widget class="QProgressBar" name="progressBar_createVideo"> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="value"> + <number>24</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>10</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_createVideo"> + <property name="text"> + <string>Create Video</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_Cancel"> + <property name="enabled"> + <bool>false</bool> + </property> + <property name="text"> + <string>Cancel</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QLabel" name="progressLabel"> + <property name="text"> + <string/> + </property> + <property name="scaledContents"> + <bool>true</bool> + </property> + <property name="alignment"> + <set>Qt::AlignCenter</set> + </property> + <property name="indent"> + <number>-1</number> + </property> + </widget> + </item> + </layout> + <zorder></zorder> + <zorder></zorder> + <zorder>progressLabel</zorder> + </widget> + <widget class="QWidget" name="tab_encoderSettings"> + <attribute name="title"> + <string>Encoder Settings</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_9"> + <property name="margin"> + <number>10</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_13"> + <item> + <widget class="QLabel" name="label_videoFormat"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Container</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_videoContainer"> + <property name="minimumSize"> + <size> + <width>150</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>5</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_videoPreset"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Resolution</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_resolution"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <item> + <widget class="QLabel" name="label_videoCodec"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Video Codec</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_videoCodec"> + <property name="minimumSize"> + <size> + <width>150</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_4"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>5</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_resolution"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Video Bitrate (Kbps)</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_vBitrate"> + <property name="maximum"> + <number>99999</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_11"> + <item> + <widget class="QLabel" name="label_audioCodec"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Audio Codec</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_audioCodec"> + <property name="minimumSize"> + <size> + <width>150</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>10</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_audioBitrate"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Audio Bitrate (Kbps)</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_aBitrate"> + <property name="maximum"> + <number>9999</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_5"> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>500</width> + <height>0</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QStackedWidget" name="stackedWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>180</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>180</height> + </size> + </property> + <property name="currentIndex"> + <number>-1</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/gui/presetmanager.py b/src/gui/presetmanager.py new file mode 100644 index 0000000..9cf95b4 --- /dev/null +++ b/src/gui/presetmanager.py @@ -0,0 +1,361 @@ +''' + Preset manager object handles all interactions with presets, including + the context menu accessed from MainWindow. +''' +from PyQt5 import QtCore, QtWidgets, uic +import string +import os +import logging + +from ..toolkit import badName +from ..core import Core +from .actions import * + + +log = logging.getLogger('AVP.Gui.PresetManager') + + +class PresetManager(QtWidgets.QDialog): + def __init__(self, parent): + super().__init__() + uic.loadUi( + os.path.join(Core.wd, 'gui', 'presetmanager.ui'), self) + self.parent = parent + self.core = parent.core + self.settings = parent.settings + self.presetDir = parent.presetDir + if not self.settings.value('presetDir'): + self.settings.setValue( + "presetDir", + os.path.join(parent.dataDir, 'projects')) + + self.findPresets() + + # window + self.lastFilter = '*' + self.presetRows = [] # list of (comp, vers, name) tuples + self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) + + # connect button signals + self.pushButton_delete.clicked.connect( + self.openDeletePresetDialog + ) + self.pushButton_rename.clicked.connect( + self.openRenamePresetDialog + ) + self.pushButton_import.clicked.connect( + self.openImportDialog + ) + self.pushButton_export.clicked.connect( + self.openExportDialog + ) + self.pushButton_close.clicked.connect( + self.close + ) + + # create filter box and preset list + self.drawFilterList() + self.comboBox_filter.currentIndexChanged.connect( + lambda: self.drawPresetList( + self.comboBox_filter.currentText(), + self.lineEdit_search.text() + ) + ) + + # make auto-completion for search bar + self.autocomplete = QtCore.QStringListModel() + completer = QtWidgets.QCompleter() + completer.setModel(self.autocomplete) + self.lineEdit_search.setCompleter(completer) + self.lineEdit_search.textChanged.connect( + lambda: self.drawPresetList( + self.comboBox_filter.currentText(), + self.lineEdit_search.text() + ) + ) + self.drawPresetList('*') + + def show_(self): + '''Open a new preset manager window from the mainwindow''' + self.findPresets() + self.drawFilterList() + self.drawPresetList('*') + self.show() + + def findPresets(self): + log.debug("Searching %s for presets", self.presetDir) + parseList = [] + for dirpath, dirnames, filenames in os.walk(self.presetDir): + # anything without a subdirectory must be a preset folder + if dirnames: + continue + for preset in filenames: + compName = os.path.basename(os.path.dirname(dirpath)) + if compName not in self.core.compNames: + continue + compVers = os.path.basename(dirpath) + try: + parseList.append((compName, int(compVers), preset)) + except ValueError: + continue + self.presets = { + compName: [ + (vers, preset) + for name, vers, preset in parseList + if name == compName + ] + for compName, _, __ in parseList + } + + def drawPresetList(self, compFilter=None, presetFilter=''): + self.listWidget_presets.clear() + if compFilter: + self.lastFilter = str(compFilter) + else: + compFilter = str(self.lastFilter) + self.presetRows = [] + presetNames = [] + for component, presets in self.presets.items(): + if compFilter != '*' and component != compFilter: + continue + for vers, preset in presets: + if not presetFilter or presetFilter in preset: + self.listWidget_presets.addItem( + '%s: %s' % (component, preset) + ) + self.presetRows.append((component, vers, preset)) + if preset not in presetNames: + presetNames.append(preset) + self.autocomplete.setStringList(presetNames) + + def drawFilterList(self): + self.comboBox_filter.clear() + self.comboBox_filter.addItem('*') + for component in self.presets: + self.comboBox_filter.addItem(component) + + def clearPreset(self, compI=None): + '''Functions on mainwindow level from the context menu''' + compI = self.parent.listWidget_componentList.currentRow() + action = ClearPreset(self.parent, compI) + self.parent.undoStack.push(action) + + def openSavePresetDialog(self): + '''Functions on mainwindow level from the context menu''' + selectedComponents = self.core.selectedComponents + componentList = self.parent.listWidget_componentList + + if componentList.currentRow() == -1: + return + while True: + index = componentList.currentRow() + currentPreset = selectedComponents[index].currentPreset + newName, OK = QtWidgets.QInputDialog.getText( + self.parent, + 'Audio Visualizer', + 'New Preset Name:', + QtWidgets.QLineEdit.Normal, + currentPreset + ) + if OK: + if badName(newName): + self.warnMessage(self.parent) + continue + if newName: + if index != -1: + selectedComponents[index].currentPreset = newName + saveValueStore = \ + selectedComponents[index].savePreset() + saveValueStore['preset'] = newName + componentName = str(selectedComponents[index]).strip() + vers = selectedComponents[index].version + self.createNewPreset( + componentName, vers, newName, + saveValueStore, window=self.parent) + self.findPresets() + self.drawPresetList() + self.openPreset(newName, index) + break + + def createNewPreset( + self, compName, vers, filename, saveValueStore, **kwargs): + path = os.path.join(self.presetDir, compName, str(vers), filename) + if self.presetExists(path, **kwargs): + return + self.core.createPresetFile(compName, vers, filename, saveValueStore) + + def presetExists(self, path, **kwargs): + if os.path.exists(path): + window = kwargs.get("window", self) + ch = self.parent.showMessage( + msg="%s already exists! Overwrite it?" % + os.path.basename(path), + showCancel=True, + icon='Warning', + parent=window) + if not ch: + # user clicked cancel + return True + + return False + + def openPreset(self, presetName, compPos=None): + componentList = self.parent.listWidget_componentList + index = compPos if compPos is not None else componentList.currentRow() + if index == -1: + return + action = OpenPreset(self, presetName, index) + self.parent.undoStack.push(action) + + def _openPreset(self, presetName, index): + selectedComponents = self.core.selectedComponents + + componentName = selectedComponents[index].name.strip() + version = selectedComponents[index].version + dirname = os.path.join(self.presetDir, componentName, str(version)) + filepath = os.path.join(dirname, presetName) + self.core.openPreset(filepath, index, presetName) + + self.parent.updateComponentTitle(index) + self.parent.drawPreview() + + def openDeletePresetDialog(self): + row = self.getPresetRow() + if row == -1: + return + comp, vers, name = self.presetRows[row] + ch = self.parent.showMessage( + msg='Really delete %s?' % name, + showCancel=True, + icon='Warning', + parent=self + ) + if not ch: + return + self.deletePreset(comp, vers, name) + + def deletePreset(self, comp, vers, name): + action = DeletePreset(self, comp, vers, name) + self.parent.undoStack.push(action) + + def warnMessage(self, window=None): + self.parent.showMessage( + msg='Preset names must contain only letters, ' + 'numbers, and spaces.', + parent=window if window else self) + + def getPresetRow(self): + row = self.listWidget_presets.currentRow() + if row > -1: + return row + + # check if component selected in MainWindow has preset loaded + componentList = self.parent.listWidget_componentList + compIndex = componentList.currentRow() + if compIndex == -1: + return compIndex + + preset = self.core.selectedComponents[compIndex].currentPreset + if preset is None: + return -1 + else: + rowTuple = ( + self.core.selectedComponents[compIndex].name, + self.core.selectedComponents[compIndex].version, + preset + ) + for i, tup in enumerate(self.presetRows): + if rowTuple == tup: + index = i + break + else: + return -1 + return index + + def openRenamePresetDialog(self): + presetList = self.listWidget_presets + index = self.getPresetRow() + if index == -1: + return + + while True: + newName, OK = QtWidgets.QInputDialog.getText( + self, + 'Preset Manager', + 'Rename Preset:', + QtWidgets.QLineEdit.Normal, + self.presetRows[index][2] + ) + if OK: + if badName(newName): + self.warnMessage() + continue + if newName: + comp, vers, oldName = self.presetRows[index] + path = os.path.join( + self.presetDir, comp, str(vers)) + newPath = os.path.join(path, newName) + if self.presetExists(newPath): + return + action = RenamePreset(self, path, oldName, newName) + self.parent.undoStack.push(action) + break + + def renamePreset(self, path, oldName, newName): + oldPath = os.path.join(path, oldName) + newPath = os.path.join(path, newName) + if os.path.exists(newPath): + os.remove(newPath) + os.rename(oldPath, newPath) + self.findPresets() + self.drawPresetList() + path = os.path.dirname(newPath) + for i, comp in enumerate(self.core.selectedComponents): + if self.core.getPresetDir(comp) == path \ + and comp.currentPreset == oldName: + self.core.openPreset(newPath, i, newName) + self.parent.updateComponentTitle(i, False) + self.parent.drawPreview() + + def openImportDialog(self): + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Import Preset File", + self.settings.value("presetDir"), + "Preset Files (*.avl)") + if filename: + # get installed path & ask user to overwrite if needed + path = '' + while True: + if path: + if self.presetExists(path): + break + else: + if os.path.exists(path): + os.remove(path) + success, path = self.core.importPreset(filename) + if success: + break + + self.findPresets() + self.drawPresetList() + self.settings.setValue("presetDir", os.path.dirname(filename)) + + def openExportDialog(self): + index = self.getPresetRow() + if index == -1: + return + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Export Preset", + self.settings.value("presetDir"), + "Preset Files (*.avl)") + if filename: + comp, vers, name = self.presetRows[index] + if not self.core.exportPreset(filename, comp, vers, name): + self.parent.showMessage( + msg='Couldn\'t export %s.' % filename, + parent=self + ) + self.settings.setValue("presetDir", os.path.dirname(filename)) + + def clearPresetListSelection(self): + self.listWidget_presets.setCurrentRow(-1) diff --git a/src/gui/presetmanager.ui b/src/gui/presetmanager.ui new file mode 100644 index 0000000..5257b1c --- /dev/null +++ b/src/gui/presetmanager.ui @@ -0,0 +1,150 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>presetmanager</class> + <widget class="QWidget" name="presetmanager"> + <property name="windowModality"> + <enum>Qt::NonModal</enum> + </property> + <property name="enabled"> + <bool>true</bool> + </property> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>497</width> + <height>377</height> + </rect> + </property> + <property name="windowTitle"> + <string>Preset Manager</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QLineEdit" name="lineEdit_search"> + <property name="text"> + <string/> + </property> + <property name="placeholderText"> + <string>Filter by name</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_filter"> + <property name="minimumSize"> + <size> + <width>200</width> + <height>0</height> + </size> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QListWidget" name="listWidget_presets"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="tabKeyNavigation"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <item> + <widget class="QPushButton" name="pushButton_import"> + <property name="text"> + <string>Import</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_export"> + <property name="text"> + <string>Export</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_rename"> + <property name="enabled"> + <bool>true</bool> + </property> + <property name="text"> + <string>Rename</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_delete"> + <property name="text"> + <string>Delete</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_4"> + <item alignment="Qt::AlignRight"> + <widget class="QLabel" name="label"> + <property name="text"> + <string><html><head/><body><p><span style=" font-size:10pt; font-style:italic;">Right-click components in the main window to create presets</span></p></body></html></string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_close"> + <property name="text"> + <string>Close</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/gui/preview_thread.py b/src/gui/preview_thread.py new file mode 100644 index 0000000..137864b --- /dev/null +++ b/src/gui/preview_thread.py @@ -0,0 +1,86 @@ +''' + Thread that runs to create QImages for MainWindow's preview label. + Processes a queue of component lists. +''' +from PyQt5 import QtCore, QtGui, uic +from PyQt5.QtCore import pyqtSignal, pyqtSlot +from PIL import Image +from PIL.ImageQt import ImageQt +from queue import Queue, Empty +import os +import logging + +from ..toolkit.frame import Checkerboard +from ..toolkit import disableWhenOpeningProject + + +log = logging.getLogger("AVP.Gui.PreviewThread") + + +class Worker(QtCore.QObject): + + imageCreated = pyqtSignal(QtGui.QImage) + error = pyqtSignal(str) + + def __init__(self, core, settings, queue): + super().__init__() + self.core = core + self.settings = settings + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) + self.queue = queue + self.background = Checkerboard(width, height) + + @disableWhenOpeningProject + @pyqtSlot(list) + def createPreviewImage(self, components): + dic = { + "components": components, + } + self.queue.put(dic) + + @pyqtSlot() + def process(self): + try: + nextPreviewInformation = self.queue.get(block=False) + while self.queue.qsize() >= 2: + try: + self.queue.get(block=False) + except Empty: + continue + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) + if self.background.width != width \ + or self.background.height != height: + self.background = Checkerboard(width, height) + + frame = self.background.copy() + log.info('Creating new preview frame') + components = nextPreviewInformation["components"] + for component in reversed(components): + try: + component.lockSize(width, height) + newFrame = component.previewRender() + component.unlockSize() + frame = Image.alpha_composite( + frame, newFrame + ) + + except ValueError as e: + errMsg = "Bad frame returned by %s's preview renderer. " \ + "%s. New frame size was %s*%s; should be %s*%s." % ( + str(component), str(e).capitalize(), + newFrame.width, newFrame.height, + width, height + ) + log.critical(errMsg) + self.error.emit(errMsg) + break + except RuntimeError as e: + log.error(str(e)) + else: + self.frame = ImageQt(frame) + self.imageCreated.emit(QtGui.QImage(self.frame)) + + except Empty: + True diff --git a/src/gui/preview_win.py b/src/gui/preview_win.py new file mode 100644 index 0000000..d910456 --- /dev/null +++ b/src/gui/preview_win.py @@ -0,0 +1,61 @@ +from PyQt5 import QtCore, QtGui, QtWidgets +import logging + +log = logging.getLogger('AVP.Gui.PreviewWindow') + + +class PreviewWindow(QtWidgets.QLabel): + ''' + Paints the preview QLabel in MainWindow and maintains the aspect ratio + when the window is resized. + ''' + def __init__(self, parent, img): + super().__init__() + self.parent = parent + self.setFrameStyle(QtWidgets.QFrame.StyledPanel) + self.pixmap = QtGui.QPixmap(img) + + def paintEvent(self, event): + size = self.size() + painter = QtGui.QPainter(self) + point = QtCore.QPoint(0, 0) + scaledPix = self.pixmap.scaled( + size, + QtCore.Qt.KeepAspectRatio, + transformMode=QtCore.Qt.SmoothTransformation) + + # start painting the label from left upper corner + point.setX(int((size.width() - scaledPix.width())/2)) + point.setY(int((size.height() - scaledPix.height())/2)) + painter.drawPixmap(point, scaledPix) + + def changePixmap(self, img): + self.pixmap = QtGui.QPixmap(img) + self.repaint() + + def mousePressEvent(self, event): + if self.parent.encoding: + return + + i = self.parent.listWidget_componentList.currentRow() + if i >= 0: + component = self.parent.core.selectedComponents[i] + if not hasattr(component, 'previewClickEvent'): + return + pos = (event.x(), event.y()) + size = (self.width(), self.height()) + butt = event.button() + log.info('Click event for #%s: %s button %s' % ( + i, pos, butt)) + component.previewClickEvent( + pos, size, butt + ) + + @QtCore.pyqtSlot(str) + def threadError(self, msg): + self.parent.showMessage( + msg=msg, + icon='Critical', + parent=self + ) + log.info('%', repr(self.parent)) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..709e5e7 --- /dev/null +++ b/src/main.py @@ -0,0 +1,53 @@ +from PyQt5 import uic, QtWidgets +import sys +import os +import logging +import re +import string + +from .__init__ import wd + + +log = logging.getLogger('AVP.Main') + + +def main(): + # Determine primary mode + proj = None + mode = 'GUI' + if len(sys.argv) > 2: + mode = 'commandline' + elif len(sys.argv) == 2: + if sys.argv[1].startswith('-'): + mode = 'commandline' + else: + # remove unsafe punctuation characters such as \/?*&^%$# + sys.argv[1] = re.sub(f'[{re.escape(string.punctuation)}]', '', sys.argv[1]) + # opening a project file with gui + proj = sys.argv[1] + + # Create Qt Application + app = QtWidgets.QApplication(sys.argv) + app.setApplicationName("audio-visualizer") + # Launch program + if mode == 'commandline': + from .command import Command + + main = Command() + mode = main.parseArgs() + log.debug("Finished creating command object") + + # Both branches here may occur in one execution: + # Commandline parsing could change mode back to GUI + if mode == 'GUI': + from .gui.mainwindow import MainWindow + + mainWindow = MainWindow(proj) + log.debug("Finished creating MainWindow") + mainWindow.raise_() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..062dca7 --- /dev/null +++ b/src/tests/__init__.py @@ -0,0 +1,36 @@ +import pytest +import os +import sys +from ..core import Core +from ..command import Command + + +@pytest.fixture +def core(): + return Core() + + +@pytest.fixture +def command(): + """Like a MainWindow for commandline mode, this owns the Core""" + return Command() + + +def getTestData(filename): + return os.path.join(Core.wd, 'tests', 'data', filename) + + +def run(logFile): + """Run Pytest, which then imports and runs all tests in this module.""" + with open(logFile, "w") as f: + # temporarily redirect stdout to a text file so we capture pytest's output + sys.stdout = f + try: + val = pytest.main([ + os.path.dirname(__file__), + "-s", # disable pytest's internal capturing of stdout etc. + ]) + finally: + sys.stdout = sys.__stdout__ + + return val diff --git a/src/tests/data/test.jpg b/src/tests/data/test.jpg Binary files differnew file mode 100644 index 0000000..86266d9 --- /dev/null +++ b/src/tests/data/test.jpg diff --git a/src/tests/data/test.ogg b/src/tests/data/test.ogg Binary files differnew file mode 100644 index 0000000..46af76c --- /dev/null +++ b/src/tests/data/test.ogg diff --git a/src/tests/data/test.png b/src/tests/data/test.png Binary files differnew file mode 100644 index 0000000..f1ffd4a --- /dev/null +++ b/src/tests/data/test.png diff --git a/src/tests/test_commandline_parser.py b/src/tests/test_commandline_parser.py new file mode 100644 index 0000000..d672441 --- /dev/null +++ b/src/tests/test_commandline_parser.py @@ -0,0 +1,39 @@ +import sys +import pytest +from .__init__ import command + + +def test_commandline_help(command): + sys.argv = ['', '--help'] + with pytest.raises(SystemExit): + command.parseArgs() + + +def test_commandline_help_if_bad_args(command): + sys.argv = ['', '--junk'] + with pytest.raises(SystemExit): + command.parseArgs() + + +def test_commandline_launches_gui_if_debug(command): + sys.argv = ['', '--debug'] + mode = command.parseArgs() + assert mode == "GUI" + + +def test_commandline_launches_gui_if_debug_with_project(command): + sys.argv = ['', 'test', '--debug'] + mode = command.parseArgs() + assert mode == "GUI" + + +def test_commandline_export_creates_audio_visualization(command): + didCallFunction = False + def captureFunction(*args): + nonlocal didCallFunction + didCallFunction = True + + sys.argv = ['', '-c', '0', 'classic', '-i', '_', '-o', '_'] + command.createAudioVisualisation = captureFunction + command.parseArgs() + assert didCallFunction diff --git a/src/tests/test_core_init.py b/src/tests/test_core_init.py new file mode 100644 index 0000000..696533a --- /dev/null +++ b/src/tests/test_core_init.py @@ -0,0 +1,19 @@ +from .__init__ import core + + +def test_component_names(core): + assert core.compNames == [ + 'Classic Visualizer', + 'Color', + "Conway's Game of Life", + 'Image', + 'Sound', + 'Spectrum', + 'Title Text', + 'Video', + 'Waveform', + ] + + +def test_moduleindex(core): + assert core.moduleIndexFor("Classic Visualizer") == 0 diff --git a/src/toolkit/__init__.py b/src/toolkit/__init__.py new file mode 100644 index 0000000..55e5f84 --- /dev/null +++ b/src/toolkit/__init__.py @@ -0,0 +1 @@ +from .common import * diff --git a/src/toolkit/common.py b/src/toolkit/common.py new file mode 100644 index 0000000..2e800eb --- /dev/null +++ b/src/toolkit/common.py @@ -0,0 +1,193 @@ +''' + Common functions +''' +from PyQt5 import QtWidgets +import string +import os +import sys +import subprocess +import logging +from copy import copy +from collections import OrderedDict + + +log = logging.getLogger('AVP.Toolkit.Common') + + +class blockSignals: + ''' + Context manager to temporarily block list of QtWidgets from updating, + and guarantee restoring the previous state afterwards. + ''' + def __init__(self, widgets): + if type(widgets) is dict: + self.widgets = concatDictVals(widgets) + else: + self.widgets = ( + widgets if hasattr(widgets, '__iter__') + else [widgets] + ) + + def __enter__(self): + log.verbose( + 'Blocking signals for %s', + ", ".join([ + str(w.__class__.__name__) for w in self.widgets + ]) + ) + self.oldStates = [w.signalsBlocked() for w in self.widgets] + for w in self.widgets: + w.blockSignals(True) + + def __exit__(self, *args): + log.verbose( + 'Resetting blockSignals to %s', str(bool(sum(self.oldStates)))) + for w, state in zip(self.widgets, self.oldStates): + w.blockSignals(state) + + +def concatDictVals(d): + '''Concatenates all values in given dict into one list.''' + key, value = d.popitem() + d[key] = value + final = copy(value) + if type(final) is not list: + final = [final] + final.extend([val for val in d.values()]) + else: + value.extend([item for val in d.values() for item in val]) + return final + + +def badName(name): + '''Returns whether a name contains non-alphanumeric chars''' + return any([letter in string.punctuation for letter in name]) + + +def alphabetizeDict(dictionary): + '''Alphabetizes a dict into OrderedDict ''' + return OrderedDict(sorted(dictionary.items(), key=lambda t: t[0])) + + +def presetToString(dictionary): + '''Returns string repr of a preset''' + return repr(alphabetizeDict(dictionary)) + + +def presetFromString(string): + '''Turns a string repr of OrderedDict into a regular dict''' + return dict(eval(string)) + + +def appendUppercase(lst): + for form, i in zip(lst, range(len(lst))): + lst.append(form.upper()) + return lst + + +def pipeWrapper(func): + '''A decorator to insert proper kwargs into Popen objects.''' + def pipeWrapper(commandList, **kwargs): + if sys.platform == 'win32': + # Stop CMD window from appearing on Windows + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + kwargs['startupinfo'] = startupinfo + + if 'bufsize' not in kwargs: + kwargs['bufsize'] = 10**8 + if 'stdin' not in kwargs: + kwargs['stdin'] = subprocess.DEVNULL + return func(commandList, **kwargs) + return pipeWrapper + + +@pipeWrapper +def checkOutput(commandList, **kwargs): + return subprocess.check_output(commandList, **kwargs) + + +def disableWhenEncoding(func): + def decorator(self, *args, **kwargs): + if self.encoding: + return + else: + return func(self, *args, **kwargs) + return decorator + + +def disableWhenOpeningProject(func): + def decorator(self, *args, **kwargs): + if self.core.openingProject: + return + else: + return func(self, *args, **kwargs) + return decorator + + +def rgbFromString(string): + '''Turns an RGB string like "255, 255, 255" into a tuple''' + try: + tup = tuple([int(i) for i in string.split(',')]) + if len(tup) != 3: + raise ValueError + for i in tup: + if i > 255 or i < 0: + raise ValueError + return tup + except: + return (255, 255, 255) + + +def formatTraceback(tb=None): + import traceback + if tb is None: + import sys + tb = sys.exc_info()[2] + return 'Traceback:\n%s' % "\n".join(traceback.format_tb(tb)) + + +def connectWidget(widget, func): + if type(widget) == QtWidgets.QLineEdit: + widget.textChanged.connect(func) + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + widget.valueChanged.connect(func) + elif type(widget) == QtWidgets.QCheckBox: + widget.stateChanged.connect(func) + elif type(widget) == QtWidgets.QComboBox: + widget.currentIndexChanged.connect(func) + else: + log.warning('Failed to connect %s ', str(widget.__class__.__name__)) + return False + return True + + +def setWidgetValue(widget, val): + '''Generic setValue method for use with any typical QtWidget''' + log.verbose('Setting %s to %s' % (str(widget.__class__.__name__), val)) + if type(widget) == QtWidgets.QLineEdit: + widget.setText(val) + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + widget.setValue(val) + elif type(widget) == QtWidgets.QCheckBox: + widget.setChecked(val) + elif type(widget) == QtWidgets.QComboBox: + widget.setCurrentIndex(val) + else: + log.warning('Failed to set %s ', str(widget.__class__.__name__)) + return False + return True + + +def getWidgetValue(widget): + if type(widget) == QtWidgets.QLineEdit: + return widget.text() + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + return widget.value() + elif type(widget) == QtWidgets.QCheckBox: + return widget.isChecked() + elif type(widget) == QtWidgets.QComboBox: + return widget.currentIndex() diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py new file mode 100644 index 0000000..1649670 --- /dev/null +++ b/src/toolkit/ffmpeg.py @@ -0,0 +1,492 @@ +''' + Tools for using ffmpeg +''' +import numpy +import sys +import os +import subprocess +import threading +import signal +from queue import PriorityQueue +import logging + +from .. import core +from .common import checkOutput, pipeWrapper + + +log = logging.getLogger('AVP.Toolkit.Ffmpeg') + + +class FfmpegVideo: + '''Opens a pipe to ffmpeg and stores a buffer of raw video frames.''' + + # error from the thread used to fill the buffer + threadError = None + + def __init__(self, **kwargs): + mandatoryArgs = [ + 'inputPath', + 'filter_', + 'width', + 'height', + 'frameRate', # frames per second + 'chunkSize', # number of bytes in one frame + 'parent', # mainwindow object + 'component', # component object + ] + for arg in mandatoryArgs: + setattr(self, arg, kwargs[arg]) + + self.frameNo = -1 + self.currentFrame = 'None' + self.map_ = None + + if 'loopVideo' in kwargs and kwargs['loopVideo']: + self.loopValue = '-1' + else: + self.loopValue = '0' + if 'filter_' in kwargs: + if kwargs['filter_'][0] != '-filter_complex': + kwargs['filter_'].insert(0, '-filter_complex') + else: + kwargs['filter_'] = None + + self.command = [ + core.Core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-r', str(self.frameRate), + '-stream_loop', str(self.loopValue), + '-i', self.inputPath, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + if type(kwargs['filter_']) is list: + self.command.extend( + kwargs['filter_'] + ) + self.command.extend([ + '-codec:v', 'rawvideo', '-', + ]) + + self.frameBuffer = PriorityQueue() + self.frameBuffer.maxsize = self.frameRate + self.finishedFrames = {} + + self.thread = threading.Thread( + target=self.fillBuffer, + name='FFmpeg Frame-Fetcher' + ) + self.thread.daemon = True + self.thread.start() + + def frame(self, num): + while True: + if num in self.finishedFrames: + image = self.finishedFrames.pop(num) + return image + + i, image = self.frameBuffer.get() + self.finishedFrames[i] = image + self.frameBuffer.task_done() + + def fillBuffer(self): + from ..component import ComponentError + if core.Core.logEnabled: + logFilename = os.path.join( + core.Core.logDir, 'render_%s.log' % str(self.component.compPos) + ) + log.debug('Creating ffmpeg process (log at %s)', logFilename) + with open(logFilename, 'w') as logf: + logf.write(" ".join(self.command) + '\n\n') + with open(logFilename, 'a') as logf: + self.pipe = openPipe( + self.command, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=logf, bufsize=10**8 + ) + else: + self.pipe = openPipe( + self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=10**8 + ) + + while True: + if self.parent.canceled: + break + self.frameNo += 1 + + # If we run out of frames, use the last good frame and loop. + try: + if len(self.currentFrame) == 0: + self.frameBuffer.put((self.frameNo-1, self.lastFrame)) + continue + except AttributeError: + FfmpegVideo.threadError = ComponentError( + self.component, 'video', + "Video seemed playable but wasn't." + ) + break + + try: + self.currentFrame = self.pipe.stdout.read(self.chunkSize) + except ValueError as e: + if str(e) == "PyMemoryView_FromBuffer(): info->buf must not be NULL": + log.debug("Ignored 'info->buf must not be NULL' error from FFmpeg pipe") + return + else: + FfmpegVideo.threadError = ComponentError( + self.component, 'video') + + if len(self.currentFrame) != 0: + self.frameBuffer.put((self.frameNo, self.currentFrame)) + self.lastFrame = self.currentFrame + + +@pipeWrapper +def openPipe(commandList, **kwargs): + return subprocess.Popen(commandList, **kwargs) + + +def closePipe(pipe): + pipe.stdout.close() + pipe.send_signal(signal.SIGTERM) + + +def findFfmpeg(): + if sys.platform == "win32": + bin = 'ffmpeg.exe' + else: + bin = 'ffmpeg' + + if getattr(sys, 'frozen', False): + # The application is frozen + bin = os.path.join(core.Core.wd, bin) + + with open(os.devnull, "w") as f: + try: + checkOutput( + [bin, '-version'], stderr=f + ) + except (subprocess.CalledProcessError, FileNotFoundError): + bin = "" + + return bin + + +def createFfmpegCommand(inputFile, outputFile, components, duration=-1): + ''' + Constructs the major ffmpeg command used to export the video + ''' + if duration == -1: + duration = getAudioDuration(inputFile) + safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters + duration = "{0:.3f}".format(duration + 0.1) # used by input sources + Core = core.Core + + # Test if user has libfdk_aac + encoders = checkOutput( + "%s -encoders -hide_banner" % Core.FFMPEG_BIN, shell=True + ) + encoders = encoders.decode("utf-8") + + acodec = Core.settings.value('outputAudioCodec') + + options = Core.encoderOptions + containerName = Core.settings.value('outputContainer') + vcodec = Core.settings.value('outputVideoCodec') + vbitrate = str(Core.settings.value('outputVideoBitrate'))+'k' + acodec = Core.settings.value('outputAudioCodec') + abitrate = str(Core.settings.value('outputAudioBitrate'))+'k' + + for cont in options['containers']: + if cont['name'] == containerName: + container = cont['container'] + break + + vencoders = options['video-codecs'][vcodec] + aencoders = options['audio-codecs'][acodec] + + def error(): + nonlocal encoders, encoder + log.critical("Selected encoder (%s) is not supported by Ffmpeg. The supported encoders are: %s", encoder, encoders) + return [] + + for encoder in vencoders: + if encoder in encoders: + vencoder = encoder + break + else: + return error() + + for encoder in aencoders: + if encoder in encoders: + aencoder = encoder + break + else: + return error() + + ffmpegCommand = [ + Core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-y', # overwrite the output file if it already exists. + + # INPUT VIDEO + '-f', 'rawvideo', + '-vcodec', 'rawvideo', + '-s', f'{Core.settings.value("outputWidth")}x{Core.settings.value("outputHeight")}', + '-pix_fmt', 'rgba', + '-r', str(Core.settings.value('outputFrameRate')), + '-t', duration, + '-an', # the video input has no sound + '-i', '-', # the video input comes from a pipe + + # INPUT SOUND + '-t', duration, + '-i', inputFile + ] + + extraAudio = [ + comp.audio for comp in components + if 'audio' in comp.properties() + ] + segment = createAudioFilterCommand(extraAudio, safeDuration) + ffmpegCommand.extend(segment) + # Map audio from the filters or the single audio input, and map video from the pipe + ffmpegCommand.extend([ + '-map', '0:v', + '-map', '[a]' if segment else '1:a', + ]) + + ffmpegCommand.extend([ + # OUTPUT + '-vcodec', vencoder, + '-acodec', aencoder, + '-b:v', vbitrate, + '-b:a', abitrate, + '-pix_fmt', Core.settings.value('outputVideoFormat'), + '-preset', Core.settings.value('outputPreset'), + '-f', container + ]) + + if acodec == 'aac': + ffmpegCommand.append('-strict') + ffmpegCommand.append('-2') + + ffmpegCommand.append(outputFile) + return ffmpegCommand + + +def createAudioFilterCommand(extraAudio, duration): + '''Add extra inputs and any needed filters to the main ffmpeg command.''' + # NOTE: Global filters are currently hard-coded here for debugging use + globalFilters = 0 # increase to add global filters + + if not extraAudio and not globalFilters: + return [] + + ffmpegCommand = [] + # Add -i options for extra input files + extraFilters = {} + for streamNo, params in enumerate(reversed(extraAudio)): + extraInputFile, params = params + ffmpegCommand.extend([ + '-t', duration, + # Tell ffmpeg about shorter clips (seemingly not needed) + # streamDuration = getAudioDuration(extraInputFile) + # if streamDuration and streamDuration > float(safeDuration) + # else "{0:.3f}".format(streamDuration), + '-i', extraInputFile + ]) + # Construct dataset of extra filters we'll need to add later + for ffmpegFilter in params: + if streamNo + 2 not in extraFilters: + extraFilters[streamNo + 2] = [] + extraFilters[streamNo + 2].append(( + ffmpegFilter, params[ffmpegFilter] + )) + + # Start creating avfilters! Popen-style, so don't use semicolons; + extraFilterCommand = [] + + if globalFilters <= 0: + # Dictionary of last-used tmp labels for a given stream number + tmpInputs = {streamNo: -1 for streamNo in extraFilters} + else: + # Insert blank entries for global filters into extraFilters + # so the per-stream filters know what input to source later + for streamNo in range(len(extraAudio), 0, -1): + if streamNo + 1 not in extraFilters: + extraFilters[streamNo + 1] = [] + # Also filter the primary audio track + extraFilters[1] = [] + tmpInputs = { + streamNo: globalFilters - 1 + for streamNo in extraFilters + } + + # Add the global filters! + # NOTE: list length must = globalFilters, currently hardcoded + if tmpInputs: + extraFilterCommand.extend([ + '[%s:a] ashowinfo [%stmp0]' % ( + str(streamNo), + str(streamNo) + ) + for streamNo in tmpInputs + ]) + + # Now add the per-stream filters! + for streamNo, paramList in extraFilters.items(): + for param in paramList: + source = '[%s:a]' % str(streamNo) \ + if tmpInputs[streamNo] == -1 else \ + '[%stmp%s]' % ( + str(streamNo), str(tmpInputs[streamNo]) + ) + tmpInputs[streamNo] = tmpInputs[streamNo] + 1 + extraFilterCommand.append( + '%s %s%s [%stmp%s]' % ( + source, param[0], param[1], str(streamNo), + str(tmpInputs[streamNo]) + ) + ) + + # Join all the filters together and combine into 1 stream + extraFilterCommand = "; ".join(extraFilterCommand) + '; ' \ + if tmpInputs else '' + ffmpegCommand.extend([ + '-filter_complex', + extraFilterCommand + + '%s amix=inputs=%s:duration=first [a]' + % ( + "".join([ + '[%stmp%s]' % (str(i), tmpInputs[i]) + if i in extraFilters else '[%s:a]' % str(i) + for i in range(1, len(extraAudio) + 2) + ]), + str(len(extraAudio) + 1) + ), + ]) + return ffmpegCommand + + +def testAudioStream(filename): + '''Test if an audio stream definitely exists''' + audioTestCommand = [ + core.Core.FFMPEG_BIN, + '-i', filename, + '-vn', '-f', 'null', '-' + ] + try: + checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + return False + else: + return True + + +def getAudioDuration(filename): + '''Try to get duration of audio file as float, or False if not possible''' + command = [core.Core.FFMPEG_BIN, '-i', filename] + + try: + fileInfo = checkOutput(command, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as ex: + fileInfo = ex.output + except FileNotFoundError: + # ffmpeg is possibly not installed + return False + + try: + info = fileInfo.decode("utf-8").split('\n') + except UnicodeDecodeError as e: + log.error('Unicode error:', str(e)) + return False + + for line in info: + if 'Duration' in line: + d = line.split(',')[0] + d = d.split(' ')[3] + d = d.split(':') + duration = float(d[0])*3600 + float(d[1])*60 + float(d[2]) + break + else: + # String not found in output + return False + return duration + + +def readAudioFile(filename, videoWorker): + ''' + Creates the completeAudioArray given to components + and used to draw the classic visualizer. + ''' + duration = getAudioDuration(filename) + if not duration: + log.error('Audio file doesn\'t exist or unreadable.') + return + + command = [ + core.Core.FFMPEG_BIN, + '-i', filename, + '-f', 's16le', + '-acodec', 'pcm_s16le', + '-ar', '44100', # ouput will have 44100 Hz + '-ac', '1', # mono (set to '2' for stereo) + '-'] + in_pipe = openPipe( + command, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8 + ) + + completeAudioArray = numpy.empty(0, dtype="int16") + + progress = 0 + lastPercent = None + while True: + if core.Core.canceled: + return + # read 2 seconds of audio + progress += 4 + raw_audio = in_pipe.stdout.read(88200*4) + if len(raw_audio) == 0: + break + audio_array = numpy.fromstring(raw_audio, dtype="int16") + completeAudioArray = numpy.append(completeAudioArray, audio_array) + + percent = int(100*(progress/duration)) + if percent >= 100: + percent = 100 + + if lastPercent != percent: + string = 'Loading audio file: '+str(percent)+'%' + videoWorker.progressBarSetText.emit(string) + videoWorker.progressBarUpdate.emit(percent) + + lastPercent = percent + + in_pipe.kill() + in_pipe.wait() + + # add 0s the end + completeAudioArrayCopy = numpy.zeros( + len(completeAudioArray) + 44100, dtype="int16") + completeAudioArrayCopy[:len(completeAudioArray)] = completeAudioArray + completeAudioArray = completeAudioArrayCopy + + return (completeAudioArray, duration) + + +def exampleSound( + style='white', extra='apulsator=offset_l=0.35:offset_r=0.67'): + '''Help generate an example sound for use in creating a preview''' + + if style == 'white': + src = '-2+random(0)' + elif style == 'freq': + src = 'sin(1000*t*PI*t)' + elif style == 'wave': + src = 'sin(random(0)*2*PI*t)*tan(random(0)*2*PI*t)' + elif style == 'stereo': + src = '0.1*sin(2*PI*(360-2.5/2)*t) : 0.1*sin(2*PI*(360+2.5/2)*t)' + + return "aevalsrc='%s', %s%s" % (src, extra, ', ' if extra else '') diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py new file mode 100644 index 0000000..520bd43 --- /dev/null +++ b/src/toolkit/frame.py @@ -0,0 +1,104 @@ +''' + Common tools for drawing compatible frames in a Component's frameRender() +''' +from PyQt5 import QtGui +from PIL import Image +from PIL.ImageQt import ImageQt +import sys +import os +import math +import logging + +from .. import core + + +log = logging.getLogger('AVP.Toolkit.Frame') + + +class FramePainter(QtGui.QPainter): + ''' + A QPainter for a blank frame, which can be converted into a + Pillow image with finalize() + ''' + def __init__(self, width, height): + image = BlankFrame(width, height) + log.debug("Creating QImage from PIL image object") + self.image = ImageQt(image) + super().__init__(self.image) + + def setPen(self, penStyle): + if type(penStyle) is tuple: + super().setPen(PaintColor(*penStyle)) + else: + super().setPen(penStyle) + + def finalize(self): + log.verbose("Finalizing FramePainter") + imBytes = self.image.bits().asstring(self.image.byteCount()) + frame = Image.frombytes( + 'RGBA', (self.image.width(), self.image.height()), imBytes + ) + self.end() + return frame + + +class PaintColor(QtGui.QColor): + '''Reverse the painter colour if the hardware stores RGB values backward''' + def __init__(self, r, g, b, a=255): + if sys.byteorder == 'big': + super().__init__(r, g, b, a) + else: + super().__init__(b, g, r, a) + + +def scale(scalePercent, width, height, returntype=None): + width = (float(width) / 100.0) * float(scalePercent) + height = (float(height) / 100.0) * float(scalePercent) + if returntype == str: + return (str(math.ceil(width)), str(math.ceil(height))) + elif returntype == int: + return (math.ceil(width), math.ceil(height)) + else: + return (width, height) + + +def defaultSize(framefunc): + '''Makes width/height arguments optional''' + def decorator(*args): + if len(args) < 2: + newArgs = list(args) + if len(args) == 0 or len(args) == 1: + height = int(core.Core.settings.value("outputHeight")) + newArgs.append(height) + if len(args) == 0: + width = int(core.Core.settings.value("outputWidth")) + newArgs.insert(0, width) + args = tuple(newArgs) + return framefunc(*args) + return decorator + + +def FloodFrame(width, height, RgbaTuple): + return Image.new("RGBA", (width, height), RgbaTuple) + + +@defaultSize +def BlankFrame(width, height): + '''The base frame used by each component to start drawing.''' + return FloodFrame(width, height, (0, 0, 0, 0)) + + +@defaultSize +def Checkerboard(width, height): + ''' + A checkerboard to represent transparency to the user. + TODO: Would be cool to generate this image with numpy instead. + ''' + log.debug('Creating new %s*%s checkerboard' % (width, height)) + image = FloodFrame(1920, 1080, (0, 0, 0, 0)) + image.paste(Image.open( + os.path.join(core.Core.wd, 'gui', "background.png")), + (0, 0) + ) + image = image.resize((width, height)) + return image diff --git a/src/video_thread.py b/src/video_thread.py new file mode 100644 index 0000000..5a28beb --- /dev/null +++ b/src/video_thread.py @@ -0,0 +1,419 @@ +''' + Thread created to export a video. It has a slot to begin export using + an input file, output path, and component list. During export multiple + threads are created to render the video as quickly as possible. Signals + are emitted to update MainWindow's progress bar, detail text, and preview. + Export can be cancelled with cancel() +''' +from PyQt5 import QtCore, QtGui +from PyQt5.QtCore import pyqtSignal, pyqtSlot +from PIL import Image +from PIL.ImageQt import ImageQt +import numpy +import subprocess as sp +import sys +import os +from queue import Queue, PriorityQueue +from threading import Thread, Event +import time +import signal +import logging + +from .component import ComponentError +from .toolkit.frame import Checkerboard +from .toolkit.ffmpeg import ( + openPipe, readAudioFile, + getAudioDuration, createFfmpegCommand +) + + +log = logging.getLogger("AVP.VideoThread") + + +class Worker(QtCore.QObject): + + imageCreated = pyqtSignal('QImage') + videoCreated = pyqtSignal() + progressBarUpdate = pyqtSignal(int) + progressBarSetText = pyqtSignal(str) + encoding = pyqtSignal(bool) + + def __init__(self, parent, inputFile, outputFile, components): + super().__init__() + self.core = parent.core + self.settings = parent.settings + self.modules = parent.core.modules + parent.createVideo.connect(self.createVideo) + + #self.parent = parent + self.components = components + self.outputFile = outputFile + self.inputFile = inputFile + + self.hertz = 44100 + self.sampleSize = 1470 # 44100 / 30 = 1470 + self.canceled = False + self.error = False + self.stopped = False + + def renderNode(self): + ''' + Grabs audio data indices at frames to export, from compositeQueue. + Sends it to the components' frameRender methods in layer order + to create subframes & composite them into the final frame. + The resulting frames are collected in the renderQueue + ''' + def err(): + self.closePipe() + self.cancelExport() + self.error = True + msg = 'A render node failed critically.' + log.critical(msg) + comp._error.emit(msg, str(e)) + + while not self.stopped: + audioI = self.compositeQueue.get() + bgI = int(audioI / self.sampleSize) + frame = None + for layerNo, comp in enumerate(reversed((self.components))): + try: + if layerNo in self.staticComponents: + if self.staticComponents[layerNo] is None: + # this layer was merged into a following layer + continue + # static component + if frame is None: # bottom-most layer + frame = self.staticComponents[layerNo] + else: + frame = Image.alpha_composite( + frame, self.staticComponents[layerNo] + ) + + else: + # animated component + if frame is None: # bottom-most layer + frame = comp.frameRender(bgI) + else: + frame = Image.alpha_composite( + frame, comp.frameRender(bgI) + ) + except Exception as e: + err() + + self.renderQueue.put([audioI, frame]) + self.compositeQueue.task_done() + + def renderDispatch(self): + ''' + Places audio data indices in the compositeQueue, to be used + by a renderNode later. All indices are multiples of self.sampleSize + sampleSize * frameNo = audioI, AKA audio data starting at frameNo + ''' + log.debug('Dispatching Frames for Compositing...') + + for audioI in range(0, self.audioArrayLen, self.sampleSize): + self.compositeQueue.put(audioI) + + def previewDispatch(self): + ''' + Grabs frames from the previewQueue, adds them to the checkerboard + and emits a final QImage to the MainWindow for the live preview + ''' + background = Checkerboard(self.width, self.height) + + while not self.stopped: + audioI, frame = self.previewQueue.get() + if time.time() - self.lastPreview >= 0.06 or audioI == 0: + image = Image.alpha_composite(background.copy(), frame) + self.imageCreated.emit(QtGui.QImage(ImageQt(image))) + self.lastPreview = time.time() + + self.previewQueue.task_done() + + @pyqtSlot() + def createVideo(self): + log.debug("Video worker received signal to createVideo") + numpy.seterr(divide='ignore') + self.encoding.emit(True) + self.extraAudio = [] + self.width = int(self.settings.value('outputWidth')) + self.height = int(self.settings.value('outputHeight')) + + self.compositeQueue = Queue() + self.compositeQueue.maxsize = 20 + self.renderQueue = PriorityQueue() + self.renderQueue.maxsize = 20 + self.previewQueue = PriorityQueue() + + self.reset() + progressBarValue = 0 + self.progressBarUpdate.emit(progressBarValue) + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # READ AUDIO, INITIALIZE COMPONENTS, OPEN A PIPE TO FFMPEG + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + log.debug("Determining length of audio...") + if any([ + True if 'pcm' in comp.properties() else False + for comp in self.components + ]): + self.progressBarSetText.emit("Loading audio file...") + audioFileTraits = readAudioFile( + self.inputFile, self + ) + if audioFileTraits is None: + self.cancelExport() + return + self.completeAudioArray, duration = audioFileTraits + self.audioArrayLen = len(self.completeAudioArray) + else: + duration = getAudioDuration(self.inputFile) + self.completeAudioArray = [] + self.audioArrayLen = int( + ((duration * self.hertz) + + self.hertz) - self.sampleSize) + + self.progressBarUpdate.emit(0) + self.progressBarSetText.emit("Starting components...") + canceledByComponent = False + initText = ", ".join([ + "%s) %s" % (num, str(component)) + for num, component in enumerate(reversed(self.components)) + ]) + print('Loaded Components:', initText) + log.info('Calling preFrameRender for %s', initText) + self.staticComponents = {} + for compNo, comp in enumerate(reversed(self.components)): + try: + comp.preFrameRender( + audioFile=self.inputFile, + completeAudioArray=self.completeAudioArray, + audioArrayLen=self.audioArrayLen, + sampleSize=self.sampleSize, + progressBarUpdate=self.progressBarUpdate, + progressBarSetText=self.progressBarSetText + ) + except ComponentError: + log.warning( + '#%s %s encountered an error in its preFrameRender method', + compNo, + comp + ) + + compProps = comp.properties() + if 'error' in compProps or comp._lockedError is not None: + self.cancel() + self.canceled = True + canceledByComponent = True + compError = comp.error() \ + if type(comp.error()) is tuple else (comp.error(), '') + errMsg = ( + "Component #%s (%s) encountered an error!" % ( + str(compNo), comp.name + ) + if comp.error() is None else + 'Export cancelled by component #%s (%s): %s' % ( + str(compNo), + comp.name, + compError[0] + ) + ) + log.error(errMsg) + comp._error.emit(errMsg, compError[1]) + break + if 'static' in compProps: + log.info('Saving static frame from #%s %s', compNo, comp) + self.staticComponents[compNo] = \ + comp.frameRender(0).copy() + + log.debug("Checking if a component wishes to cancel the export...") + if self.canceled: + if canceledByComponent: + log.error( + 'Export cancelled by component #%s (%s): %s', + compNo, + comp.name, + 'No message.' if comp.error() is None else ( + comp.error() if type(comp.error()) is str + else comp.error()[0] + ) + ) + self.cancelExport() + return + + log.info("Merging consecutive static component frames") + for compNo in range(len(self.components)): + if compNo not in self.staticComponents \ + or compNo + 1 not in self.staticComponents: + continue + self.staticComponents[compNo + 1] = Image.alpha_composite( + self.staticComponents.pop(compNo), + self.staticComponents[compNo + 1] + ) + self.staticComponents[compNo] = None + + try: + ffmpegCommand = createFfmpegCommand( + self.inputFile, self.outputFile, self.components, duration + ) + except sp.CalledProcessError as e: + #FIXME video_thread should own this error signal, not components + self.components[0]._error.emit("Ffmpeg could not be found. Is it installed?", str(e)) + self.error = True + return + + cmd = " ".join(ffmpegCommand) + print('###### FFMPEG COMMAND ######\n%s' % cmd) + print('############################') + if not cmd: + #FIXME video_thread should own this error signal, not components + self.components[0]._error.emit("The ffmpeg command could not be generated.", "") + log.critical("Cancelling render process due to failure while generating the ffmpeg command.") + self.failExport() + return + + log.info('Opening pipe to ffmpeg') + log.info(cmd) + try: + self.out_pipe = openPipe( + ffmpegCommand, + stdin=sp.PIPE, stdout=sys.stdout, stderr=sys.stdout + ) + except sp.CalledProcessError: + log.critical('Ffmpeg pipe couldn\'t be created!', exc_info=True) + raise + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # START CREATING THE VIDEO + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + # Make 2 or 3 renderNodes in new threads to create the frames + self.renderThreads = [] + try: + numCpus = len(os.sched_getaffinity(0)) + except Exception: + numCpus = os.cpu_count() + + for i in range(2 if numCpus <= 2 else 3): + self.renderThreads.append( + Thread(target=self.renderNode, name="Render Thread")) + self.renderThreads[i].daemon = True + self.renderThreads[i].start() + + self.dispatchThread = Thread( + target=self.renderDispatch, name="Render Dispatch Thread") + self.dispatchThread.daemon = True + self.dispatchThread.start() + + self.lastPreview = 0.0 + self.previewDispatch = Thread( + target=self.previewDispatch, name="Render Dispatch Thread" + ) + self.previewDispatch.daemon = True + self.previewDispatch.start() + + # Begin piping into ffmpeg! + frameBuffer = {} + progressBarValue = 0 + self.progressBarUpdate.emit(progressBarValue) + self.progressBarSetText.emit("Exporting video...") + if not self.canceled: + for audioI in range( + 0, self.audioArrayLen, self.sampleSize): + while True: + if audioI in frameBuffer or self.canceled: + # if frame's in buffer, pipe it to ffmpeg + break + # else fetch the next frame & add to the buffer + audioI_, frame = self.renderQueue.get() + frameBuffer[audioI_] = frame + self.renderQueue.task_done() + if self.canceled: + break + + try: + self.out_pipe.stdin.write(frameBuffer[audioI].tobytes()) + self.previewQueue.put([audioI, frameBuffer.pop(audioI)]) + except Exception: + break + + # increase progress bar value + completion = (audioI / self.audioArrayLen) * 100 + if progressBarValue + 1 <= completion: + progressBarValue = numpy.floor(completion).astype(int) + self.progressBarUpdate.emit(progressBarValue) + self.progressBarSetText.emit( + "Exporting video: %s%%" % str(int(progressBarValue)) + ) + + numpy.seterr(all='print') + + self.closePipe() + + for comp in reversed(self.components): + comp.postFrameRender() + + if self.canceled: + print("Export Canceled") + try: + os.remove(self.outputFile) + except Exception: + pass + self.progressBarUpdate.emit(0) + self.progressBarSetText.emit('Export Canceled') + else: + if self.error: + self.failExport() + else: + print("Export Complete") + self.progressBarUpdate.emit(100) + self.progressBarSetText.emit('Export Complete') + + self.error = False + self.canceled = False + self.stopped = True + self.encoding.emit(False) + self.videoCreated.emit() + + def closePipe(self): + try: + self.out_pipe.stdin.close() + except BrokenPipeError: + log.error('Broken pipe to ffmpeg!') + if self.out_pipe.stderr is not None: + log.error(self.out_pipe.stderr.read()) + self.out_pipe.stderr.close() + self.error = True + self.out_pipe.wait() + + def cancelExport(self, message='Export Canceled'): + self.progressBarUpdate.emit(0) + self.progressBarSetText.emit(message) + self.encoding.emit(False) + self.videoCreated.emit() + + def failExport(self): + self.cancelExport('Export Failed') + + def updateProgress(self, pStr, pVal): + self.progressBarValue.emit(pVal) + self.progressBarSetText.emit(pStr) + + def cancel(self): + self.canceled = True + self.stopped = True + self.core.cancel() + + for comp in self.components: + comp.cancel() + + try: + self.out_pipe.send_signal(signal.SIGTERM) + except Exception: + pass + + def reset(self): + self.core.reset() + self.canceled = False + for comp in self.components: + comp.reset() diff --git a/video_thread.py b/video_thread.py deleted file mode 100644 index 6f71d38..0000000 --- a/video_thread.py +++ /dev/null @@ -1,133 +0,0 @@ -from PyQt4 import QtCore, QtGui, uic -from PyQt4.QtCore import pyqtSignal, pyqtSlot -from PIL import Image, ImageDraw, ImageFont -from PIL.ImageQt import ImageQt -import core -import numpy -import subprocess as sp -import sys - -class Worker(QtCore.QObject): - - videoCreated = pyqtSignal() - progressBarUpdate = pyqtSignal(int) - progressBarSetText = pyqtSignal(str) - - def __init__(self, parent=None): - QtCore.QObject.__init__(self) - parent.videoTask.connect(self.createVideo) - self.core = core.Core() - - - @pyqtSlot(str, str, QtGui.QFont, int, int, int, int, tuple, tuple, str, str) - def createVideo(self, backgroundImage, titleText, titleFont, fontSize, alignment,\ - xOffset, yOffset, textColor, visColor, inputFile, outputFile): - # print('worker thread id: {}'.format(QtCore.QThread.currentThreadId())) - def getBackgroundAtIndex(i): - return self.core.drawBaseImage( - backgroundFrames[i], - titleText, - titleFont, - fontSize, - alignment, - xOffset, - yOffset, - textColor, - visColor) - - progressBarValue = 0 - self.progressBarUpdate.emit(progressBarValue) - self.progressBarSetText.emit('Loading background image…') - - backgroundFrames = self.core.parseBaseImage(backgroundImage) - if len(backgroundFrames) < 2: - # the base image is not a video so we can draw it now - imBackground = getBackgroundAtIndex(0) - else: - # base images will be drawn while drawing the audio bars - imBackground = None - - self.progressBarSetText.emit('Loading audio file…') - completeAudioArray = self.core.readAudioFile(inputFile) - - # test if user has libfdk_aac - encoders = sp.check_output(self.core.FFMPEG_BIN + " -encoders -hide_banner", shell=True) - if b'libfdk_aac' in encoders: - acodec = 'libfdk_aac' - else: - acodec = 'aac' - - ffmpegCommand = [ self.core.FFMPEG_BIN, - '-y', # (optional) means overwrite the output file if it already exists. - '-f', 'rawvideo', - '-vcodec', 'rawvideo', - '-s', '1280x720', # size of one frame - '-pix_fmt', 'rgb24', - '-r', '30', # frames per second - '-i', '-', # The input comes from a pipe - '-an', - '-i', inputFile, - '-acodec', acodec, # output audio codec - '-b:a', "192k", - '-vcodec', "libx264", - '-pix_fmt', "yuv420p", - '-preset', "medium", - '-f', "mp4"] - - if acodec == 'aac': - ffmpegCommand.append('-strict') - ffmpegCommand.append('-2') - - ffmpegCommand.append(outputFile) - - out_pipe = sp.Popen(ffmpegCommand, - stdin=sp.PIPE,stdout=sys.stdout, stderr=sys.stdout) - - smoothConstantDown = 0.08 - smoothConstantUp = 0.8 - lastSpectrum = None - sampleSize = 1470 - - numpy.seterr(divide='ignore') - bgI = 0 - for i in range(0, len(completeAudioArray), sampleSize): - # create video for output - lastSpectrum = self.core.transformData( - i, - completeAudioArray, - sampleSize, - smoothConstantDown, - smoothConstantUp, - lastSpectrum) - if imBackground != None: - im = self.core.drawBars(lastSpectrum, imBackground, visColor) - else: - im = self.core.drawBars(lastSpectrum, getBackgroundAtIndex(bgI), visColor) - if bgI < len(backgroundFrames)-1: - bgI += 1 - - # write to out_pipe - try: - out_pipe.stdin.write(im.tobytes()) - finally: - True - - # increase progress bar value - if progressBarValue + 1 <= (i / len(completeAudioArray)) * 100: - progressBarValue = numpy.floor((i / len(completeAudioArray)) * 100) - self.progressBarUpdate.emit(progressBarValue) - self.progressBarSetText.emit('%s%%' % str(int(progressBarValue))) - - numpy.seterr(all='print') - - out_pipe.stdin.close() - if out_pipe.stderr is not None: - print(out_pipe.stderr.read()) - out_pipe.stderr.close() - # out_pipe.terminate() # don't terminate ffmpeg too early - out_pipe.wait() - print("Video file created") - self.core.deleteTempDir() - self.progressBarUpdate.emit(100) - self.progressBarSetText.emit('100%') - self.videoCreated.emit() |
