From 86c6ac87627365e8ea30f84420c3ac97f0a597ea Mon Sep 17 00:00:00 2001 From: DH4 Date: Sat, 27 May 2017 05:40:22 -0500 Subject: Removed .vscode directory and updated .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to '.gitignore') diff --git a/.gitignore b/.gitignore index f4d88c1..24ed791 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ settings.ini -build/* \ No newline at end of file +build/* +.vscode/* \ No newline at end of file -- cgit v1.2.3 From 37fd68fd2bd2ad81bf1b08d923df2a0934bee6b8 Mon Sep 17 00:00:00 2001 From: tassaron Date: Mon, 29 May 2017 17:40:40 -0400 Subject: remove test video --- .gitignore | 4 +++- test.mkv | Bin 1207731 -> 0 bytes 2 files changed, 3 insertions(+), 1 deletion(-) delete mode 100644 test.mkv (limited to '.gitignore') diff --git a/.gitignore b/.gitignore index 24ed791..0316a98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ __pycache__ settings.ini build/* -.vscode/* \ No newline at end of file +.vscode/* +*.mkv +*.mp4 diff --git a/test.mkv b/test.mkv deleted file mode 100644 index 92780a7..0000000 Binary files a/test.mkv and /dev/null differ -- cgit v1.2.3 From 38557f29f91b8abc68ec3408ce466ee8a5da815e Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 2 Jul 2017 14:19:15 -0400 Subject: rm unneeded imports, work on freezing --- .gitignore | 7 ++++++- freeze.py | 35 +++++++++++++++++++++-------------- src/components/__base__.py | 2 +- src/components/text.py | 7 +++++-- src/core.py | 26 +++++++++++++++++--------- src/main.py | 2 +- 6 files changed, 51 insertions(+), 28 deletions(-) (limited to '.gitignore') diff --git a/.gitignore b/.gitignore index 0316a98..68dffc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ __pycache__ -settings.ini build/* +env/* .vscode/* *.mkv *.mp4 +*.zip +*.tar +*.tar.* +*.exe +ffmpeg diff --git a/freeze.py b/freeze.py index 48034dc..a81f325 100644 --- a/freeze.py +++ b/freeze.py @@ -1,11 +1,14 @@ from cx_Freeze import setup, Executable import sys +import os # Dependencies are automatically detected, but it might need # fine tuning. +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( - packages=[], excludes=[ "apport", "apt", @@ -17,17 +20,21 @@ buildOptions = dict( "xmlrpc", "nose" ], - include_files=[ - "mainwindow.ui", - "presetmanager.ui", - "background.png", - "encoder-options.json", - "components/" - ], includes=[ - 'numpy.core._methods', - 'numpy.lib.format' - ] + "encodings", + "json", + "filecmp", + "numpy.core._methods", + "numpy.lib.format", + "PyQt5.QtCore", + "PyQt5.QtGui", + "PyQt5.QtWidgets", + "PyQt5.uic", + "PIL.Image", + "PIL.ImageQt", + "PIL.ImageDraw", + ], + include_files=deps, ) @@ -35,16 +42,16 @@ base = 'Win32GUI' if sys.platform == 'win32' else None executables = [ Executable( - 'main.py', + 'src/main.py', base=base, targetName='audio-visualizer-python' - ) + ), ] setup( name='audio-visualizer-python', - version='1.0', + version='2.0', description='GUI tool to render visualization videos of audio files', options=dict(build_exe=buildOptions), executables=executables diff --git a/src/components/__base__.py b/src/components/__base__.py index 00601e7..b5e7d93 100644 --- a/src/components/__base__.py +++ b/src/components/__base__.py @@ -1,4 +1,4 @@ -from PyQt5 import uic, QtGui, QtCore, QtWidgets +from PyQt5 import uic, QtCore, QtWidgets from PIL import Image import os diff --git a/src/components/text.py b/src/components/text.py index 96421e6..6c5c4eb 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -3,7 +3,7 @@ from PyQt5.QtGui import QPainter, QColor, QFont from PyQt5 import QtGui, QtCore, QtWidgets from PIL.ImageQt import ImageQt import os -import io +import sys from . import __base__ @@ -136,7 +136,10 @@ class Component(__base__.Component): painter = QPainter(image) self.titleFont.setPixelSize(self.fontSize) painter.setFont(self.titleFont) - painter.setPen(QColor(*self.textColor[::-1])) + if sys.byteorder == 'big': + painter.setPen(QColor(*self.textColor)) + else: + painter.setPen(QColor(*self.textColor[::-1])) painter.drawText(x, y, self.title) painter.end() diff --git a/src/core.py b/src/core.py index b3c5640..3fa67db 100644 --- a/src/core.py +++ b/src/core.py @@ -17,7 +17,6 @@ import string class Core(): def __init__(self): - self.FFMPEG_BIN = self.findFfmpeg() self.dataDir = QStandardPaths.writableLocation( QStandardPaths.AppConfigLocation ) @@ -63,6 +62,7 @@ class Core(): '*.xpm', ]) + self.FFMPEG_BIN = self.findFfmpeg() self.findComponents() self.selectedComponents = [] # copies of named presets to detect modification @@ -437,15 +437,23 @@ class Core(): self.encoder_options = json.load(json_file) def findFfmpeg(self): - if sys.platform == "win32": - return "ffmpeg.exe" + if getattr(sys, 'frozen', False): + # The application is frozen + if sys.platform == "win32": + return os.path.join(self.wd, 'ffmpeg.exe') + else: + return os.path.join(self.wd, 'ffmpeg') + else: - try: - with open(os.devnull, "w") as f: - sp.check_call(['ffmpeg', '-version'], stdout=f, stderr=f) - return "ffmpeg" - except: - return "avconv" + 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 readAudioFile(self, filename, parent): command = [self.FFMPEG_BIN, '-i', filename] diff --git a/src/main.py b/src/main.py index fd32b13..bae9adf 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,4 @@ -from PyQt5 import QtGui, uic, QtWidgets +from PyQt5 import uic, QtWidgets import sys import os -- cgit v1.2.3 From 63daa3138284f1e928b362c09e391eed53d4527d Mon Sep 17 00:00:00 2001 From: tassaron Date: Tue, 4 Jul 2017 20:01:14 -0400 Subject: removed pyc --- .gitignore | 1 + src/core.pyc | Bin 15050 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 src/core.pyc (limited to '.gitignore') diff --git a/.gitignore b/.gitignore index 68dffc7..d44e3f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ +.py[cod] build/* env/* .vscode/* diff --git a/src/core.pyc b/src/core.pyc deleted file mode 100644 index ce68831..0000000 Binary files a/src/core.pyc and /dev/null differ -- cgit v1.2.3 From f027fd43537eb60f682b51a5018caee471bf33e2 Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 6 Jul 2017 19:52:46 -0400 Subject: more thorough installation directions --- .gitignore | 1 + MANIFEST | 2 ++ README.md | 9 +++++++-- freeze.py | 3 ++- 4 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 MANIFEST (limited to '.gitignore') diff --git a/.gitignore b/.gitignore index d44e3f2..1095610 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__ .py[cod] build/* +dist/* env/* .vscode/* *.mkv diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..a0c51f7 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,2 @@ +# file GENERATED by distutils, do NOT edit +freeze.py diff --git a/README.md b/README.md index acf36d0..b82f3b4 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,19 @@ Installation ------------ ### Manual installation on Ubuntu 16.04 * Install pip: `sudo apt-get install python3-pip` -* Install dependencies: `sudo pip3 install pyqt5 numpy pillow-simd` -* Install `ffmpeg` from the [website](http://ffmpeg.org/) or from a PPA (e.g. [https://launchpad.net/~jonathonf/+archive/ubuntu/ffmpeg-3](https://launchpad.net/~jonathonf/+archive/ubuntu/ffmpeg-3). NOTE: `ffmpeg` in the standard repos is too old (v2.8). Old versions and `avconv` may be used but full functionality is only guaranteed with `ffmpeg` 3.3 or higher. +* Install [prerequisites to compile Pillow](http://pillow.readthedocs.io/en/3.1.x/installation.html#building-on-linux):`sudo apt-get install python3-dev python3-setuptools libtiff5-dev libjpeg8-dev zlib1g-dev libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python-tk` +* Prerequisites on **Fedora**:`sudo dnf install python3-devel redhat-rpm-config libtiff-devel libjpeg-devel libzip-devel freetype-devel lcms2-devel libwebp-devel tcl-devel tk-devel` +* Install dependencies from PyPI: `sudo pip3 install pyqt5 numpy pillow-simd` +* Install `ffmpeg` from the [website](http://ffmpeg.org/) or from a PPA (e.g. [https://launchpad.net/~jonathonf/+archive/ubuntu/ffmpeg-3](https://launchpad.net/~jonathonf/+archive/ubuntu/ffmpeg-3)). NOTE: `ffmpeg` in the standard repos is too old (v2.8). Old versions and `avconv` may be used but full functionality is only guaranteed with `ffmpeg` 3.3 or higher. Download audio-visualizer-python from this repository and run it with `python3 main.py`. ### Manual installation on Windows +* **Not Recommended.** [Compiling Pillow is difficult on Windows](http://pillow.readthedocs.io/en/3.1.x/installation.html#building-on-windows) and required for a manual installation. * Download and install Python 3.6 from [https://www.python.org/downloads/windows/](https://www.python.org/downloads/windows/) * Add Python to your system PATH (it will ask during the installation process). +* Brave treacherous valley of getting prerequisites to [compile Pillow on Windows](https://www.pypkg.com/pypi/pillow-simd/f/winbuild/README.md). This is necessary because binary builds for Pillow-SIMD are not available. +* **Alternative:** install Pillow instead of Pillow-SIMD, for which binaries *are* available. However this will result in much slower video export times. * Open command prompt and run: `pip install pyqt5 numpy pillow-simd` * 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, too. [How to edit the PATH on Windows.](https://www.java.com/en/download/help/path.xml) diff --git a/freeze.py b/freeze.py index 3266f45..c9b7918 100644 --- a/freeze.py +++ b/freeze.py @@ -18,7 +18,8 @@ buildOptions = dict( "html", "http", "xmlrpc", - "nose" + "nose", + 'tkinter', ], includes=[ "encodings", -- cgit v1.2.3 From 03a36d429761c169e23ae21d816a383fa73c0277 Mon Sep 17 00:00:00 2001 From: tassaron Date: Tue, 25 Jul 2017 22:04:50 -0400 Subject: removed pyc --- .gitignore | 2 +- src/presetmanager.pyc | Bin 10936 -> 0 bytes 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 src/presetmanager.pyc (limited to '.gitignore') diff --git a/.gitignore b/.gitignore index 1095610..bfdd0e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ __pycache__ -.py[cod] +*.py[cod] build/* dist/* env/* diff --git a/src/presetmanager.pyc b/src/presetmanager.pyc deleted file mode 100644 index 97069d2..0000000 Binary files a/src/presetmanager.pyc and /dev/null differ -- cgit v1.2.3 From c1457b6dad4640b17679dd802e372bd46a13d2a5 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sat, 29 Jul 2017 13:08:28 -0400 Subject: starting work on Waveform component split Video class out of Video component for reuse in Waveform --- .gitignore | 2 + src/component.py | 7 +- src/components/video.py | 198 ++++++++----------------------- src/components/waveform.py | 139 ++++++++++++++++++++++ src/components/waveform.ui | 283 +++++++++++++++++++++++++++++++++++++++++++++ src/toolkit/common.py | 37 ++++-- src/toolkit/ffmpeg.py | 99 ++++++++++++++++ src/video_thread.py | 2 +- 8 files changed, 607 insertions(+), 160 deletions(-) create mode 100644 src/components/waveform.py create mode 100644 src/components/waveform.ui (limited to '.gitignore') diff --git a/.gitignore b/.gitignore index bfdd0e7..7cec615 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ env/* *.tar.* *.exe ffmpeg +*.bak +*~ diff --git a/src/component.py b/src/component.py index 03023e7..fc8fbd3 100644 --- a/src/component.py +++ b/src/component.py @@ -197,7 +197,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' Must call super() when subclassing Triggered only before a video is exported (video_thread.py) - self.worker = the video thread worker + 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 @@ -436,7 +436,7 @@ class ComponentError(RuntimeError): import sys if sys.exc_info()[0] is not None: string = ( - "%s component's %s encountered %s %s." % ( + "%s component's %s encountered %s %s: %s" % ( caller.__class__.name, name, 'an' if any([ @@ -444,12 +444,13 @@ class ComponentError(RuntimeError): for vowel in ('A', 'I') ]) else 'a', sys.exc_info()[0].__name__, + str(sys.exc_info()[1]) ) ) detail = formatTraceback(sys.exc_info()[2]) else: string = name - detail = "Methods:\n%s" % ( + detail = "Attributes:\n%s" % ( "\n".join( [m for m in dir(caller) if not m.startswith('_')] ) diff --git a/src/components/video.py b/src/components/video.py index b2487c1..d3460ff 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -1,103 +1,13 @@ -from PIL import Image, ImageDraw +from PIL import Image from PyQt5 import QtGui, QtCore, QtWidgets import os import math import subprocess -import signal -import threading -from queue import PriorityQueue from component import Component, ComponentError from toolkit.frame import BlankFrame -from toolkit.ffmpeg import testAudioStream -from toolkit import openPipe, checkOutput - - -class Video: - '''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 = [ - 'ffmpeg', # path to ffmpeg, usually self.core.FFMPEG_BIN - 'videoPath', - 'width', - 'height', - 'scale', # percentage scale - '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' - if 'loopVideo' in kwargs and kwargs['loopVideo']: - self.loopValue = '-1' - else: - self.loopValue = '0' - self.command = [ - self.ffmpeg, - '-thread_queue_size', '512', - '-r', str(self.frameRate), - '-stream_loop', self.loopValue, - '-i', self.videoPath, - '-f', 'image2pipe', - '-pix_fmt', 'rgba', - '-filter_complex', '[0:v] scale=%s:%s' % scale( - self.scale, self.width, self.height, str), - '-vcodec', 'rawvideo', '-', - ] - - self.frameBuffer = PriorityQueue() - self.frameBuffer.maxsize = self.frameRate - self.finishedFrames = {} - - self.thread = threading.Thread( - target=self.fillBuffer, - name='Video 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 finalizeFrame( - self.component, image, self.width, self.height) - - i, image = self.frameBuffer.get() - self.finishedFrames[i] = image - self.frameBuffer.task_done() - - def fillBuffer(self): - 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: - Video.threadError = ComponentError(self.component, 'video') - break - - self.currentFrame = self.pipe.stdout.read(self.chunkSize) - if len(self.currentFrame) != 0: - self.frameBuffer.put((self.frameNo, self.currentFrame)) - self.lastFrame = self.currentFrame +from toolkit.ffmpeg import testAudioStream, FfmpegVideo +from toolkit import openPipe, closePipe, checkOutput, scale class Component(Component): @@ -182,22 +92,21 @@ class Component(Component): def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) self.updateChunksize() - self.video = Video( - ffmpeg=self.core.FFMPEG_BIN, videoPath=self.videoPath, + 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, scale=self.scale + component=self ) if os.path.exists(self.videoPath) else None def frameRender(self, frameNo): - if Video.threadError is not None: - raise Video.threadError - return self.video.frame(frameNo) + if FfmpegVideo.threadError is not None: + raise FfmpegVideo.threadError + return self.finalizeFrame(self.video.frame(frameNo)) def postFrameRender(self): - self.video.pipe.stdout.close() - self.video.pipe.send_signal(signal.SIGINT) + closePipe(self.video.pipe) def pickVideo(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) @@ -220,23 +129,30 @@ class Component(Component): '-i', self.videoPath, '-f', 'image2pipe', '-pix_fmt', 'rgba', - '-filter_complex', '[0:v] scale=%s:%s' % scale( - self.scale, width, height, str), + ] + command.extend(self.makeFfmpegFilter()) + command.extend([ '-vcodec', 'rawvideo', '-', '-ss', '90', - '-vframes', '1', - ] + '-frames:v', '1', + ]) pipe = openPipe( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8 ) byteFrame = pipe.stdout.read(self.chunkSize) - pipe.stdout.close() - pipe.send_signal(signal.SIGINT) + closePipe(pipe) - frame = finalizeFrame(self, byteFrame, width, height) + 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) @@ -268,44 +184,32 @@ class Component(Component): 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) + + except ValueError: + print( + '### BAD VIDEO SELECTED ###\n' + 'Video will not export with these settings' + ) + self.badVideo = True + return BlankFrame(self.width, self.height) -def scale(scale, width, height, returntype=None): - width = (float(width) / 100.0) * float(scale) - height = (float(height) / 100.0) * float(scale) - 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 finalizeFrame(self, imageData, width, height): - try: - if self.distort: - image = Image.frombytes( - 'RGBA', - (width, height), - imageData) + 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: - image = Image.frombytes( - 'RGBA', - scale(self.scale, width, height, int), - imageData) - - except ValueError: - print( - '### BAD VIDEO SELECTED ###\n' - 'Video will not export with these settings' - ) - self.badVideo = True - return BlankFrame(width, height) - - if self.scale != 100 \ - or self.xPosition != 0 or self.yPosition != 0: - frame = BlankFrame(width, height) - frame.paste(image, box=(self.xPosition, self.yPosition)) - else: - frame = image - self.badVideo = False - return frame + frame = image + self.badVideo = False + return frame diff --git a/src/components/waveform.py b/src/components/waveform.py new file mode 100644 index 0000000..487a3bb --- /dev/null +++ b/src/components/waveform.py @@ -0,0 +1,139 @@ +from PIL import Image +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtGui import QColor +import os +import math +import subprocess + +from component import Component, ComponentError +from toolkit.frame import BlankFrame +from toolkit import openPipe, checkOutput, rgbFromString +from toolkit.ffmpeg import FfmpegVideo + + +class Component(Component): + name = 'Waveform' + version = '1.0.0' + + def widget(self, *args): + self.color = (255, 255, 255) + super().widget(*args) + + self.page.lineEdit_color.setText('%s,%s,%s' % self.color) + btnStyle = "QPushButton { background-color : %s; outline: none; }" \ + % QColor(*self.color1).name() + self.page.lineEdit_color.setStylesheet(btnStyle) + self.page.pushButton_color.clicked.connect(lambda: self.pickColor()) + + self.trackWidgets( + { + 'mode': self.page.comboBox_mode, + 'x': self.page.spinBox_x, + 'y': self.page.spinBox_y, + 'mirror': self.page.checkBox_mirror, + 'scale': self.page.spinBox_scale, + } + ) + + def update(self): + self.color = rgbFromString(self.page.lineEdit_color.text()) + btnStyle = "QPushButton { background-color : %s; outline: none; }" \ + % QColor(*self.color).name() + self.page.pushButton_color.setStyleSheet(btnStyle) + super().update() + + 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() + self.video = FfmpegVideo( + inputPath=self.audioFile, + filter_=makeFfmpegFilter(), + width=self.width, height=self.height, + 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 finalizeFrame(self.video.frame(frameNo)) + + def postFrameRender(self): + closePipe(self.video.pipe) + + def getPreviewFrame(self, width, height): + inputFile = self.parent.window.lineEdit_audioFile.text() + if not inputFile or not os.path.exists(inputFile): + return + + command = [ + self.core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-i', inputFile, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + command.extend(self.makeFfmpegFilter()) + command.extend([ + '-vcodec', 'rawvideo', '-', + '-ss', '90', + '-frames:v', '1', + ]) + pipe = openPipe( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=10**8 + ) + byteFrame = pipe.stdout.read(self.chunkSize) + closePipe(pipe) + + frame = finalizeFrame(self, byteFrame, width, height) + return frame + + def makeFfmpegFilter(self): + w, h = scale(self.scale, self.width, self.height, str) + return [ + '-filter_complex', + '[0:a] showwaves=s=%sx%s:mode=%s,format=rgba [v]' % ( + w, h, self.mode, + ), + '-map', '[v]', + '-map', '0:a', + ] + + def updateChunksize(self): + if self.scale != 100: + width, height = scale(self.scale, self.width, self.height, int) + else: + width, height = self.width, self.height + self.chunkSize = 4 * width * height + + +def scale(scale, width, height, returntype=None): + width = (float(width) / 100.0) * float(scale) + height = (float(height) / 100.0) * float(scale) + 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 finalizeFrame(self, imageData, width, height): + # frombytes goes here + if self.scale != 100 \ + or self.x != 0 or self.y != 0: + frame = BlankFrame(width, 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..5d62150 --- /dev/null +++ b/src/components/waveform.ui @@ -0,0 +1,283 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + + 0 + 0 + + + + + 0 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Mode + + + + + + + + Cline + + + + + Line + + + + + P2p + + + + + Point + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + + + + + + + + + + + + Wave Color + + + + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + false + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Mirror + + + + + + + Scale + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + % + + + 10 + + + 400 + + + 100 + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/toolkit/common.py b/src/toolkit/common.py index 251a2c1..128ed08 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -6,9 +6,22 @@ import string import os import sys import subprocess +import signal +import math from collections import OrderedDict +def scale(scale, width, height, returntype=None): + width = (float(width) / 100.0) * float(scale) + height = (float(height) / 100.0) * float(scale) + 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 badName(name): '''Returns whether a name contains non-alphanumeric chars''' return any([letter in string.punctuation for letter in name]) @@ -34,29 +47,35 @@ def appendUppercase(lst): lst.append(form.upper()) return lst - -def hideCmdWin(func): - ''' Stops CMD window from appearing on Windows. - Adapted from here: http://code.activestate.com/recipes/409002/ - ''' - def decorator(commandList, **kwargs): +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 decorator + return pipeWrapper -@hideCmdWin +@pipeWrapper def checkOutput(commandList, **kwargs): return subprocess.check_output(commandList, **kwargs) -@hideCmdWin +@pipeWrapper def openPipe(commandList, **kwargs): return subprocess.Popen(commandList, **kwargs) +def closePipe(pipe): + pipe.stdout.close() + pipe.send_signal(signal.SIGINT) def disableWhenEncoding(func): def decorator(self, *args, **kwargs): diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index b8bc679..fea9d4e 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -5,11 +5,110 @@ import numpy import sys import os import subprocess +import threading +from queue import PriorityQueue import core from toolkit.common import checkOutput, openPipe +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', self.loopValue, + '-i', self.inputPath, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + ] + if type(kwargs['filter_']) is list: + self.command.extend( + kwargs['filter_'] + ) + self.command.extend([ + '-vcodec', '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): + 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: + Video.threadError = ComponentError(self.component, 'video') + break + + self.currentFrame = self.pipe.stdout.read(self.chunkSize) + if len(self.currentFrame) != 0: + self.frameBuffer.put((self.frameNo, self.currentFrame)) + self.lastFrame = self.currentFrame + + def findFfmpeg(): if getattr(sys, 'frozen', False): # The application is frozen diff --git a/src/video_thread.py b/src/video_thread.py index 32e8a38..f27ec21 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -153,7 +153,7 @@ class Worker(QtCore.QObject): for compNo, comp in enumerate(reversed(self.components)): try: comp.preFrameRender( - worker=self, + audioFile=self.inputFile, completeAudioArray=self.completeAudioArray, sampleSize=self.sampleSize, progressBarUpdate=self.progressBarUpdate, -- cgit v1.2.3 From 9732f3bdebc7fbeb944d71f313d3c0797b3dbcd4 Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 10 Aug 2017 00:48:07 -0400 Subject: rm garbage file --- .gitignore | 1 + src/.goutputstream-67IS4Y | 976 ---------------------------------------------- 2 files changed, 1 insertion(+), 976 deletions(-) delete mode 100644 src/.goutputstream-67IS4Y (limited to '.gitignore') diff --git a/.gitignore b/.gitignore index 7cec615..916c6c1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ env/* ffmpeg *.bak *~ +*.goutput* \ No newline at end of file diff --git a/src/.goutputstream-67IS4Y b/src/.goutputstream-67IS4Y deleted file mode 100644 index 789a6e7..0000000 --- a/src/.goutputstream-67IS4Y +++ /dev/null @@ -1,976 +0,0 @@ -''' - When using GUI mode, this module's object (the main window) takes - user input to construct a program state (stored in the Core object). - This shows a preview of the video being created and allows for saving - projects and exporting the video at a later time. -''' -from PyQt5 import QtCore, QtGui, uic, QtWidgets -from PyQt5.QtWidgets import QMenu, QShortcut -from PIL import Image -from queue import Queue -import sys -import os -import signal -import filecmp -import time - -from core import Core -import preview_thread -from presetmanager import PresetManager -from toolkit import disableWhenEncoding, disableWhenOpeningProject, checkOutput - - -class PreviewWindow(QtWidgets.QLabel): - ''' - Paints the preview QLabel and maintains the aspect ratio when the - window is resized. - ''' - - def __init__(self, parent, img): - super(PreviewWindow, self).__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((size.width() - scaledPix.width())/2) - point.setY((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.window.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()) - component.previewClickEvent( - pos, size, event.button() - ) - self.parent.core.updateComponent(i) - - @QtCore.pyqtSlot(str) - def threadError(self, msg): - self.parent.showMessage( - msg=msg, - icon='Critical', - parent=self - ) - - -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, window, project): - QtWidgets.QMainWindow.__init__(self) - # print('main thread id: {}'.format(QtCore.QThread.currentThreadId())) - self.window = window - self.core = Core() - - # 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 - - # Create data directory, load/create settings - self.dataDir = Core.dataDir - self.presetDir = Core.presetDir - self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') - self.settings = Core.settings - self.presetManager = PresetManager( - uic.loadUi( - os.path.join(Core.wd, 'presetmanager.ui')), self) - - if not os.path.exists(self.dataDir): - os.makedirs(self.dataDir) - for neededDirectory in ( - self.presetDir, self.settings.value("projectDir")): - if not os.path.exists(neededDirectory): - os.mkdir(neededDirectory) - - # Create the preview window and its thread, queues, and timers - self.previewWindow = PreviewWindow(self, os.path.join( - Core.wd, "background.png")) - window.verticalLayout_previewWrapper.addWidget(self.previewWindow) - - self.previewQueue = Queue() - self.previewThread = QtCore.QThread(self) - self.previewWorker = preview_thread.Worker(self, self.previewQueue) - self.previewWorker.error.connect(self.previewWindow.threadError) - 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) - - # Begin decorating the window and connecting events - self.window.installEventFilter(self) - componentList = self.window.listWidget_componentList - - if sys.platform == 'darwin': - window.progressBar_createVideo.setTextVisible(False) - else: - window.progressLabel.setHidden(True) - - window.toolButton_selectAudioFile.clicked.connect( - self.openInputFileDialog) - - window.toolButton_selectOutputFile.clicked.connect( - self.openOutputFileDialog) - - def changedField(): - self.autosave() - self.updateWindowTitle() - - window.lineEdit_audioFile.textChanged.connect(changedField) - window.lineEdit_outputFile.textChanged.connect(changedField) - - window.progressBar_createVideo.setValue(0) - - window.pushButton_createVideo.clicked.connect( - self.createAudioVisualisation) - - window.pushButton_Cancel.clicked.connect(self.stopVideo) - - for i, container in enumerate(Core.encoderOptions['containers']): - window.comboBox_videoContainer.addItem(container['name']) - if container['name'] == self.settings.value('outputContainer'): - selectedContainer = i - - window.comboBox_videoContainer.setCurrentIndex(selectedContainer) - window.comboBox_videoContainer.currentIndexChanged.connect( - self.updateCodecs - ) - - self.updateCodecs() - - for i in range(window.comboBox_videoCodec.count()): - codec = window.comboBox_videoCodec.itemText(i) - if codec == self.settings.value('outputVideoCodec'): - window.comboBox_videoCodec.setCurrentIndex(i) - - for i in range(window.comboBox_audioCodec.count()): - codec = window.comboBox_audioCodec.itemText(i) - if codec == self.settings.value('outputAudioCodec'): - window.comboBox_audioCodec.setCurrentIndex(i) - - window.comboBox_videoCodec.currentIndexChanged.connect( - self.updateCodecSettings - ) - - window.comboBox_audioCodec.currentIndexChanged.connect( - self.updateCodecSettings - ) - - vBitrate = int(self.settings.value('outputVideoBitrate')) - aBitrate = int(self.settings.value('outputAudioBitrate')) - - window.spinBox_vBitrate.setValue(vBitrate) - window.spinBox_aBitrate.setValue(aBitrate) - window.spinBox_vBitrate.valueChanged.connect(self.updateCodecSettings) - window.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings) - - # Make component buttons - self.compMenu = QMenu() - for i, comp in enumerate(self.core.modules): - action = self.compMenu.addAction(comp.Component.name) - action.triggered.connect( - lambda _, item=i: self.core.insertComponent(0, item, self) - ) - - self.window.pushButton_addComponent.setMenu(self.compMenu) - - componentList.dropEvent = self.dragComponent - componentList.itemSelectionChanged.connect( - self.changeComponentWidget - ) - componentList.itemSelectionChanged.connect( - self.presetManager.clearPresetListSelection - ) - self.window.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): - window.comboBox_resolution.addItem(res) - if res == currentRes: - currentRes = i - window.comboBox_resolution.setCurrentIndex(currentRes) - window.comboBox_resolution.currentIndexChanged.connect( - self.updateResolution - ) - - self.window.pushButton_listMoveUp.clicked.connect( - lambda: self.moveComponent(-1) - ) - self.window.pushButton_listMoveDown.clicked.connect( - lambda: self.moveComponent(1) - ) - - # Configure the Projects Menu - self.projectMenu = QMenu() - self.window.menuButton_newProject = self.projectMenu.addAction( - "New Project" - ) - self.window.menuButton_newProject.triggered.connect( - lambda: self.createNewProject() - ) - self.window.menuButton_openProject = self.projectMenu.addAction( - "Open Project" - ) - self.window.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.window.pushButton_projects.setMenu(self.projectMenu) - - # Configure the Presets Button - self.window.pushButton_presets.clicked.connect( - self.openPresetManager - ) - - self.updateWindowTitle() - window.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) - - # verify Pillow version - if not self.settings.value("pilMsgShown") \ - and 'post' not in Image.PILLOW_VERSION: - self.showMessage( - msg="You are using the standard version of the " - "Python imaging library (Pillow %s). Upgrade " - "to the Pillow-SIMD fork to enable hardware accelerations " - "and export videos faster." % Image.PILLOW_VERSION - ) - self.settings.setValue("pilMsgShown", True) - - # verify Ffmpeg version - if not self.settings.value("ffmpegMsgShown"): - try: - with open(os.devnull, "w") as f: - ffmpegVers = checkOutput( - ['ffmpeg', '-version'], stderr=f - ) - goodVersion = str(ffmpegVers).split()[2].startswith('3') - except Exception: - goodVersion = False - else: - goodVersion = True - - if not goodVersion: - self.showMessage( - msg="You're using an old version of Ffmpeg. " - "Some features may not work as expected." - ) - self.settings.setValue("ffmpegMsgShown", True) - - # Hotkeys for projects - QtWidgets.QShortcut("Ctrl+S", self.window, self.saveCurrentProject) - QtWidgets.QShortcut("Ctrl+A", self.window, self.openSaveProjectDialog) - QtWidgets.QShortcut("Ctrl+O", self.window, self.openOpenProjectDialog) - QtWidgets.QShortcut("Ctrl+N", self.window, self.createNewProject) - - # Hotkeys for component list - for inskey in ("Ctrl+T", QtCore.Qt.Key_Insert): - QtWidgets.QShortcut( - inskey, self.window, - activated=lambda: self.window.pushButton_addComponent.click() - ) - for delkey in ("Ctrl+R", QtCore.Qt.Key_Delete): - QtWidgets.QShortcut( - delkey, self.window.listWidget_componentList, - self.removeComponent - ) - QtWidgets.QShortcut( - "Ctrl+Space", self.window, - activated=lambda: self.window.listWidget_componentList.setFocus() - ) - QtWidgets.QShortcut( - "Ctrl+Shift+S", self.window, - self.presetManager.openSavePresetDialog - ) - QtWidgets.QShortcut( - "Ctrl+Shift+C", self.window, self.presetManager.clearPreset - ) - - QtWidgets.QShortcut( - "Ctrl+Up", self.window.listWidget_componentList, - activated=lambda: self.moveComponent(-1) - ) - QtWidgets.QShortcut( - "Ctrl+Down", self.window.listWidget_componentList, - activated=lambda: self.moveComponent(1) - ) - QtWidgets.QShortcut( - "Ctrl+Home", self.window.listWidget_componentList, - activated=lambda: self.moveComponent('top') - ) - QtWidgets.QShortcut( - "Ctrl+End", self.window.listWidget_componentList, - activated=lambda: self.moveComponent('bottom') - ) - - # Debug Hotkeys - QtWidgets.QShortcut( - "Ctrl+Alt+Shift+R", self.window, self.drawPreview - ) - QtWidgets.QShortcut( - "Ctrl+Alt+Shift+F", self.window, self.showFfmpegCommand - ) - - @QtCore.pyqtSlot() - def cleanUp(self, *args): - self.timer.stop() - self.previewThread.quit() - self.previewThread.wait() - - @disableWhenOpeningProject - def updateWindowTitle(self): - 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 - self.window.setWindowTitle(appName) - - @QtCore.pyqtSlot(int, dict) - def updateComponentTitle(self, pos, presetStore=False): - if type(presetStore) == dict: - name = presetStore['preset'] - if name is None or name not in self.core.savedPresets: - modified = False - else: - modified = (presetStore != self.core.savedPresets[name]) - else: - modified = bool(presetStore) - if pos < 0: - pos = len(self.core.selectedComponents)-1 - title = str(self.core.selectedComponents[pos]) - if self.core.selectedComponents[pos].currentPreset: - title += ' - %s' % self.core.selectedComponents[pos].currentPreset - if modified: - title += '*' - self.window.listWidget_componentList.item(pos).setText(title) - - def updateCodecs(self): - containerWidget = self.window.comboBox_videoContainer - vCodecWidget = self.window.comboBox_videoCodec - aCodecWidget = self.window.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.window.comboBox_videoCodec - vBitrateWidget = self.window.spinBox_vBitrate - aBitrateWidget = self.window.spinBox_aBitrate - aCodecWidget = self.window.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.window) - 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) - - 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: - return True - except FileNotFoundError: - print('project file couldn\'t be located:', 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.window, "Open Audio File", - inputDir, "Audio Files (%s)" % " ".join(Core.audioFormats)) - - if fileName: - self.settings.setValue("inputDir", os.path.dirname(fileName)) - self.window.lineEdit_audioFile.setText(fileName) - - def openOutputFileDialog(self): - outputDir = self.settings.value("outputDir", os.path.expanduser("~")) - - fileName, _ = QtWidgets.QFileDialog.getSaveFileName( - self.window, "Set Output Video File", - outputDir, - "Video Files (%s);; All Files (*)" % " ".join( - Core.videoFormats)) - - if fileName: - self.settings.setValue("outputDir", os.path.dirname(fileName)) - self.window.lineEdit_outputFile.setText(fileName) - - def stopVideo(self): - print('stop') - self.videoWorker.cancel() - self.canceled = True - - def createAudioVisualisation(self): - # create output video if mandatory settings are filled in - audioFile = self.window.lineEdit_audioFile.text() - outputPath = self.window.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', - ) - - def changeEncodingStatus(self, status): - self.encoding = status - if status: - self.window.pushButton_createVideo.setEnabled(False) - self.window.pushButton_Cancel.setEnabled(True) - self.window.comboBox_resolution.setEnabled(False) - self.window.stackedWidget.setEnabled(False) - self.window.tab_encoderSettings.setEnabled(False) - self.window.label_audioFile.setEnabled(False) - self.window.toolButton_selectAudioFile.setEnabled(False) - self.window.label_outputFile.setEnabled(False) - self.window.toolButton_selectOutputFile.setEnabled(False) - self.window.lineEdit_audioFile.setEnabled(False) - self.window.lineEdit_outputFile.setEnabled(False) - self.window.pushButton_addComponent.setEnabled(False) - self.window.pushButton_removeComponent.setEnabled(False) - self.window.pushButton_listMoveDown.setEnabled(False) - self.window.pushButton_listMoveUp.setEnabled(False) - self.window.menuButton_newProject.setEnabled(False) - self.window.menuButton_openProject.setEnabled(False) - if sys.platform == 'darwin': - self.window.progressLabel.setHidden(False) - else: - self.window.listWidget_componentList.setEnabled(False) - else: - self.window.pushButton_createVideo.setEnabled(True) - self.window.pushButton_Cancel.setEnabled(False) - self.window.comboBox_resolution.setEnabled(True) - self.window.stackedWidget.setEnabled(True) - self.window.tab_encoderSettings.setEnabled(True) - self.window.label_audioFile.setEnabled(True) - self.window.toolButton_selectAudioFile.setEnabled(True) - self.window.lineEdit_audioFile.setEnabled(True) - self.window.label_outputFile.setEnabled(True) - self.window.toolButton_selectOutputFile.setEnabled(True) - self.window.lineEdit_outputFile.setEnabled(True) - self.window.pushButton_addComponent.setEnabled(True) - self.window.pushButton_removeComponent.setEnabled(True) - self.window.pushButton_listMoveDown.setEnabled(True) - self.window.pushButton_listMoveUp.setEnabled(True) - self.window.menuButton_newProject.setEnabled(True) - self.window.menuButton_openProject.setEnabled(True) - self.window.listWidget_componentList.setEnabled(True) - self.window.progressLabel.setHidden(True) - self.drawPreview(True) - - @QtCore.pyqtSlot(int) - def progressBarUpdated(self, value): - self.window.progressBar_createVideo.setValue(value) - - @QtCore.pyqtSlot(str) - def progressBarSetText(self, value): - if sys.platform == 'darwin': - self.window.progressLabel.setText(value) - else: - self.window.progressBar_createVideo.setFormat(value) - - def updateResolution(self): - resIndex = int(self.window.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 showFfmpegCommand(self): - from textwrap import wrap - from toolkit.ffmpeg import createFfmpegCommand - command = createFfmpegCommand( - self.window.lineEdit_audioFile.text(), - self.window.lineEdit_outputFile.text(), - self.core.selectedComponents - ) - lines = wrap(" ".join(command), 49) - self.showMessage( - msg="Current FFmpeg command:\n\n %s" % " ".join(lines) - ) - - def insertComponent(self, index): - componentList = self.window.listWidget_componentList - stackedWidget = self.window.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.window.listWidget_componentList - - for selected in componentList.selectedItems(): - index = componentList.row(selected) - self.window.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.window.listWidget_componentList - if change == 'top': - change = -componentList.currentRow() - elif change == 'bottom': - change = len(componentList)-componentList.currentRow()-1 - stackedWidget = self.window.stackedWidget - - row = componentList.currentRow() - newRow = row + change - if newRow > -1 and newRow < componentList.count(): - self.core.moveComponent(row, newRow) - - # update widgets - page = self.pages.pop(row) - self.pages.insert(newRow, page) - item = componentList.takeItem(row) - newItem = componentList.insertItem(newRow, item) - widget = stackedWidget.removeWidget(page) - stackedWidget.insertWidget(newRow, page) - componentList.setCurrentRow(newRow) - stackedWidget.setCurrentIndex(newRow) - self.drawPreview(True) - - def getComponentListMousePos(self, position): - ''' - Given a QPos, returns the component index under the mouse cursor - or -1 if no component is there. - ''' - componentList = self.window.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) - return mousePos - - @disableWhenEncoding - def dragComponent(self, event): - '''Used as Qt drop event for the component listwidget''' - componentList = self.window.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.window.listWidget_componentList.selectedItems() - if selected: - index = self.window.listWidget_componentList.row(selected[0]) - self.window.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.window.listWidget_componentList.clear() - for widget in self.pages: - self.window.stackedWidget.removeWidget(widget) - self.pages = [] - for field in ( - self.window.lineEdit_audioFile, - self.window.lineEdit_outputFile - ): - field.blockSignals(True) - field.setText('') - field.blockSignals(False) - self.progressBarUpdated(0) - self.progressBarSetText('') - - @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.window) - 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.window, "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.window) - self.updateWindowTitle() - - @disableWhenEncoding - def openOpenProjectDialog(self): - filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self.window, "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.window - 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.window.listWidget_componentList - self.menu = 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 = 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 = 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.core.insertComponent( - 0 if insertCompAtTop else index, item, self - ) - ) - - self.menu.move(parentPosition + QPos) - self.menu.show() - - def eventFilter(self, object, event): - if event.type() == QtCore.QEvent.WindowActivate \ - or event.type() == QtCore.QEvent.FocusIn: - Core.windowHasFocus = True - elif event.type() == QtCore.QEvent.WindowDeactivate \ - or event.type() == QtCore.QEvent.FocusOut: - Core.windowHasFocus = False - return False -- cgit v1.2.3 From 39d6a4e5af94a8aa612a009bbe235715b84e7abc Mon Sep 17 00:00:00 2001 From: tassaron Date: Mon, 14 Aug 2017 14:30:57 -0400 Subject: rm test.wav --- .gitignore | 6 +++++- test.wav | Bin 14348366 -> 0 bytes 2 files changed, 5 insertions(+), 1 deletion(-) delete mode 100644 test.wav (limited to '.gitignore') diff --git a/.gitignore b/.gitignore index 916c6c1..380168f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ env/* .vscode/* *.mkv *.mp4 +*.wav +*.mp3 +*.aif +*.ac3 *.zip *.tar *.tar.* @@ -13,4 +17,4 @@ env/* ffmpeg *.bak *~ -*.goutput* \ No newline at end of file +*.goutput* diff --git a/test.wav b/test.wav deleted file mode 100644 index 98afe5f..0000000 Binary files a/test.wav and /dev/null differ -- cgit v1.2.3 From a42ea1cd69fcf3f6c1b2ff79871cd00f24b95118 Mon Sep 17 00:00:00 2001 From: tassaron Date: Fri, 22 Apr 2022 17:10:35 -0400 Subject: add commandline option for tests. add first tests --- .gitignore | 10 ++++++---- src/command.py | 39 ++++++++++++++++++++++++++++++++------- src/tests/__init__.py | 32 ++++++++++++++++++++++++++++++++ src/tests/data/test.jpg | Bin 0 -> 48766 bytes src/tests/data/test.ogg | Bin 0 -> 30043 bytes src/tests/data/test.png | Bin 0 -> 220 bytes src/tests/test_core_init.py | 19 +++++++++++++++++++ src/tests/test_export_classic.py | 5 +++++ 8 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 src/tests/__init__.py create mode 100644 src/tests/data/test.jpg create mode 100644 src/tests/data/test.ogg create mode 100644 src/tests/data/test.png create mode 100644 src/tests/test_core_init.py create mode 100644 src/tests/test_export_classic.py (limited to '.gitignore') diff --git a/.gitignore b/.gitignore index 380168f..1595776 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ __pycache__ *.py[cod] -build/* -dist/* -env/* -.vscode/* +*.egg-info +.pytest_cache +build/ +dist/ +env/ +.vscode/ *.mkv *.mp4 *.wav diff --git a/src/command.py b/src/command.py index cd3c6c3..49026c6 100644 --- a/src/command.py +++ b/src/command.py @@ -9,21 +9,33 @@ import os import sys import time import signal +import logging -from core import Core +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): QtCore.QObject.__init__(self) - self.core = Core() - Core.mode = 'commandline' + 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 ' @@ -31,6 +43,10 @@ class Command(QtCore.QObject): '-c 0 image path=~/Pictures/thisWeeksPicture.jpg ' '-c 1 video "preset=My Logo" -c 2 vis layout=classic' ) + self.parser.add_argument( + '-t', '--test', action='store_true', + help='run tests and generate a logfile to report a bug' + ) self.parser.add_argument( '-i', '--input', metavar='SOUND', help='input audio file' @@ -55,7 +71,10 @@ class Command(QtCore.QObject): nargs='*', action='append') self.args = self.parser.parse_args() - self.settings = Core.settings + + if self.args.test: + self.runTests() + quit(0) if self.args.projpath: projPath = self.args.projpath @@ -92,9 +111,6 @@ class Command(QtCore.QObject): for arg in args: self.core.selectedComponents[i].command(arg) - # ctrl-c stops the export thread - signal.signal(signal.SIGINT, self.stopVideo) - if self.args.export and self.args.projpath: errcode, data = self.core.parseAvFile(projPath) for key, value in data['WindowFields']: @@ -188,3 +204,12 @@ class Command(QtCore.QObject): return return None + + def runTests(self): + core.FILE_LOGLVL = logging.DEBUG + from . import tests + test_report = os.path.join(core.Core.logDir, "test_report.log") + tests.run(test_report) + with open(test_report, "r") as f: + output = f.readlines() + print("".join(output)) diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..f2b2ff1 --- /dev/null +++ b/src/tests/__init__.py @@ -0,0 +1,32 @@ +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 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 new file mode 100644 index 0000000..86266d9 Binary files /dev/null and b/src/tests/data/test.jpg differ diff --git a/src/tests/data/test.ogg b/src/tests/data/test.ogg new file mode 100644 index 0000000..46af76c Binary files /dev/null and b/src/tests/data/test.ogg differ diff --git a/src/tests/data/test.png b/src/tests/data/test.png new file mode 100644 index 0000000..f1ffd4a Binary files /dev/null and b/src/tests/data/test.png differ 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/tests/test_export_classic.py b/src/tests/test_export_classic.py new file mode 100644 index 0000000..a6d3e8c --- /dev/null +++ b/src/tests/test_export_classic.py @@ -0,0 +1,5 @@ +from .__init__ import command + + +def test_export_classic_visualizer_default(command): + assert command -- cgit v1.2.3