From 8c9914850e9987d4f05e8b88dedb058ffbb4f53f Mon Sep 17 00:00:00 2001 From: DH4 Date: Fri, 23 Jun 2017 02:39:56 -0500 Subject: cx_freeze Path Updates --- setup.py | 63 ++++++++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 42 insertions(+), 21 deletions(-) (limited to 'setup.py') diff --git a/setup.py b/setup.py index 0d9cbc4..48034dc 100644 --- a/setup.py +++ b/setup.py @@ -1,30 +1,51 @@ from cx_Freeze import setup, Executable +import sys # 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 +buildOptions = dict( + packages=[], + excludes=[ + "apport", + "apt", + "curses", + "distutils", + "email", + "html", + "http", + "xmlrpc", + "nose" + ], + include_files=[ + "mainwindow.ui", + "presetmanager.ui", + "background.png", + "encoder-options.json", + "components/" + ], + includes=[ + 'numpy.core._methods', + 'numpy.lib.format' + ] +) + + +base = 'Win32GUI' if sys.platform == 'win32' else None executables = [ - Executable('main.py', base=base, targetName = 'audio-visualizer-python') + 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) + +setup( + name='audio-visualizer-python', + version='1.0', + description='GUI tool to render visualization videos of audio files', + options=dict(build_exe=buildOptions), + executables=executables +) -- cgit v1.2.3 From e92e9d79f95ad67e83074ef318278c3486601eac Mon Sep 17 00:00:00 2001 From: DH4 Date: Fri, 23 Jun 2017 17:38:05 -0500 Subject: QT5 Conversion + Directory Structure --- background.png | Bin 45367 -> 0 bytes command.py | 126 ------- components/__base__.py | 153 --------- components/__init__.py | 1 - components/color.py | 246 -------------- components/color.ui | 660 ------------------------------------ components/image.py | 111 ------- components/image.ui | 259 --------------- components/original.py | 204 ------------ components/original.ui | 108 ------ components/text.py | 176 ---------- components/text.ui | 316 ------------------ components/video.py | 273 --------------- components/video.ui | 266 --------------- core.py | 476 -------------------------- encoder-options.json | 130 -------- freeze.py | 51 +++ main.py | 88 ----- mainwindow.py | 721 ---------------------------------------- mainwindow.ui | 809 --------------------------------------------- presetmanager.py | 290 ---------------- presetmanager.ui | 150 --------- preview_thread.py | 59 ---- setup.py | 70 ++-- src/background.png | Bin 0 -> 45367 bytes src/command.py | 126 +++++++ src/components/__base__.py | 153 +++++++++ src/components/__init__.py | 1 + src/components/color.py | 246 ++++++++++++++ src/components/color.ui | 660 ++++++++++++++++++++++++++++++++++++ src/components/image.py | 111 +++++++ src/components/image.ui | 259 +++++++++++++++ src/components/original.py | 204 ++++++++++++ src/components/original.ui | 108 ++++++ src/components/text.py | 176 ++++++++++ src/components/text.ui | 316 ++++++++++++++++++ src/components/video.py | 273 +++++++++++++++ src/components/video.ui | 266 +++++++++++++++ src/core.py | 477 ++++++++++++++++++++++++++ src/encoder-options.json | 130 ++++++++ src/main.py | 88 +++++ src/mainwindow.py | 718 ++++++++++++++++++++++++++++++++++++++++ src/mainwindow.ui | 809 +++++++++++++++++++++++++++++++++++++++++++++ src/presetmanager.py | 290 ++++++++++++++++ src/presetmanager.ui | 150 +++++++++ src/preview_thread.py | 59 ++++ src/video_thread.py | 309 +++++++++++++++++ video_thread.py | 309 ----------------- 48 files changed, 5999 insertions(+), 5982 deletions(-) delete mode 100644 background.png delete mode 100644 command.py delete mode 100644 components/__base__.py delete mode 100644 components/__init__.py delete mode 100644 components/color.py delete mode 100644 components/color.ui delete mode 100644 components/image.py delete mode 100644 components/image.ui delete mode 100644 components/original.py delete mode 100644 components/original.ui delete mode 100644 components/text.py delete mode 100644 components/text.ui delete mode 100644 components/video.py delete mode 100644 components/video.ui delete mode 100644 core.py delete mode 100644 encoder-options.json create mode 100644 freeze.py delete mode 100644 main.py delete mode 100644 mainwindow.py delete mode 100644 mainwindow.ui delete mode 100644 presetmanager.py delete mode 100644 presetmanager.ui delete mode 100644 preview_thread.py create mode 100644 src/background.png create mode 100644 src/command.py create mode 100644 src/components/__base__.py create mode 100644 src/components/__init__.py create mode 100644 src/components/color.py create mode 100644 src/components/color.ui create mode 100644 src/components/image.py create mode 100644 src/components/image.ui create mode 100644 src/components/original.py create mode 100644 src/components/original.ui create mode 100644 src/components/text.py create mode 100644 src/components/text.ui create mode 100644 src/components/video.py create mode 100644 src/components/video.ui create mode 100644 src/core.py create mode 100644 src/encoder-options.json create mode 100644 src/main.py create mode 100644 src/mainwindow.py create mode 100644 src/mainwindow.ui create mode 100644 src/presetmanager.py create mode 100644 src/presetmanager.ui create mode 100644 src/preview_thread.py create mode 100644 src/video_thread.py delete mode 100644 video_thread.py (limited to 'setup.py') diff --git a/background.png b/background.png deleted file mode 100644 index fb58593..0000000 Binary files a/background.png and /dev/null differ diff --git a/command.py b/command.py deleted file mode 100644 index 1a1e810..0000000 --- a/command.py +++ /dev/null @@ -1,126 +0,0 @@ -from PyQt4 import QtCore -from PyQt4.QtCore import QSettings -import argparse -import os -import sys - -import core -import video_thread -from main import LoadDefaultSettings - - -class Command(QtCore.QObject): - - videoTask = QtCore.pyqtSignal(str, str, list) - - def __init__(self): - QtCore.QObject.__init__(self) - self.core = core.Core() - self.dataDir = self.core.dataDir - self.canceled = False - - 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') - - # 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() - self.settings = QSettings( - os.path.join(self.dataDir, 'settings.ini'), QSettings.IniFormat) - LoadDefaultSettings(self) - - if self.args.projpath: - self.core.openProject(self, self.args.projpath) - 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.input and self.args.output: - self.createAudioVisualisation() - elif 'help' not in sys.argv: - self.parser.print_help() - quit(1) - - def createAudioVisualisation(self): - 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.input, - self.args.output, - list(reversed(self.core.selectedComponents)) - ) - - def videoCreated(self): - self.videoThread.quit() - self.videoThread.wait() - quit(0) - - def showMessage(self, **kwargs): - print(kwargs['msg']) - if 'detail' in kwargs: - print(kwargs['detail']) - - 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 diff --git a/components/__base__.py b/components/__base__.py deleted file mode 100644 index bef7f0e..0000000 --- a/components/__base__.py +++ /dev/null @@ -1,153 +0,0 @@ -from PyQt4 import QtGui, QtCore -from PIL import Image -import os - - -class Component(QtCore.QObject): - '''A base class for components to inherit from''' - - # modified = QtCore.pyqtSignal(int, bool) - - def __init__(self, moduleIndex, compPos, core): - super().__init__() - self.currentPreset = None - self.moduleIndex = moduleIndex - self.compPos = compPos - self.core = core - - def __str__(self): - return self.__doc__ - - def version(self): - # change this number to identify new versions of a component - return 1 - - def cancel(self): - # please stop any lengthy process in response to this variable - self.canceled = True - - def reset(self): - self.canceled = False - - def update(self): - self.modified.emit(self.compPos, self.savePreset()) - # read your widget values, then call super().update() - - def loadPreset(self, presetDict, presetName): - '''Subclasses take (presetDict, presetName=None) as args. - Must use super().loadPreset(presetDict, presetName) first, - then update self.page widgets using the preset dict. - ''' - self.currentPreset = presetName \ - if presetName != None else presetDict['preset'] - - def preFrameRender(self, **kwargs): - '''Triggered only before a video is exported (video_thread.py) - self.worker = the video thread worker - 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 MainProgram if needed - for a long initialization procedure (i.e., for a visualizer) - ''' - for var, value in kwargs.items(): - exec('self.%s = value' % var) - - def command(self, arg): - '''Configure a component using argument from the commandline. - Use super().command(arg) at the end of a subclass's method, - if no arguments are found in that method first - ''' - 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) - else: - print( - self.__doc__, 'Usage:\n' - 'Open a preset for this component:\n' - ' "preset=Preset Name"') - self.commandHelp() - quit(0) - - def commandHelp(self): - '''Print help text for this Component's commandline arguments''' - - def blankFrame(self, width, height): - return Image.new("RGBA", (width, height), (0, 0, 0, 0)) - - def pickColor(self): - '''Use color picker to get color input from the user, - and return this as an RGB string and QPushButton stylesheet. - In a subclass apply stylesheet to any color selection widgets - ''' - dialog = QtGui.QColorDialog() - dialog.setOption(QtGui.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() - return RGBstring, btnStyle - else: - return None, None - - def RGBFromString(self, 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) - - ''' - ### Reference methods for creating a new component - ### (Inherit from this class and define these) - - def widget(self, parent): - self.parent = parent - page = uic.loadUi(os.path.join( - os.path.dirname(os.path.realpath(__file__)), 'example.ui')) - # --- connect widget signals here --- - self.page = page - return page - - def update(self): - super().update() - self.parent.drawPreview() - - def previewRender(self, previewWorker): - width = int(previewWorker.core.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) - image = Image.new("RGBA", (width, height), (0,0,0,0)) - return image - - def frameRender(self, moduleNo, frameNo): - width = int(self.worker.core.settings.value('outputWidth')) - height = int(self.worker.core.settings.value('outputHeight')) - image = Image.new("RGBA", (width, height), (0,0,0,0)) - return image - ''' - -class BadComponentInit(Exception): - def __init__(self, arg, name): - string = \ -'''################################ -Mandatory argument "%s" not specified - in %s instance initialization -###################################''' - print(string % (arg, name)) - quit() diff --git a/components/__init__.py b/components/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/components/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/components/color.py b/components/color.py deleted file mode 100644 index 5ffcdea..0000000 --- a/components/color.py +++ /dev/null @@ -1,246 +0,0 @@ -from PIL import Image, ImageDraw -from PyQt4 import uic, QtGui, QtCore -from PyQt4.QtGui import QColor -from PIL.ImageQt import ImageQt -import os -from . import __base__ - - -class Component(__base__.Component): - '''Color''' - - modified = QtCore.pyqtSignal(int, dict) - - def widget(self, parent): - self.parent = parent - page = uic.loadUi(os.path.join( - os.path.dirname(os.path.realpath(__file__)), 'color.ui')) - - self.color1 = (0, 0, 0) - self.color2 = (133, 133, 133) - self.x = 0 - self.y = 0 - - page.lineEdit_color1.setText('%s,%s,%s' % self.color1) - page.lineEdit_color2.setText('%s,%s,%s' % self.color2) - - btnStyle1 = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*self.color1).name() - - btnStyle2 = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*self.color2).name() - - page.pushButton_color1.setStyleSheet(btnStyle1) - page.pushButton_color2.setStyleSheet(btnStyle2) - page.pushButton_color1.clicked.connect(lambda: self.pickColor(1)) - page.pushButton_color2.clicked.connect(lambda: self.pickColor(2)) - - # disable color #2 until non-default 'fill' option gets changed - page.lineEdit_color2.setDisabled(True) - page.pushButton_color2.setDisabled(True) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - page.spinBox_width.setValue( - int(parent.settings.value("outputWidth"))) - page.spinBox_height.setValue( - int(parent.settings.value("outputHeight"))) - - page.lineEdit_color1.textChanged.connect(self.update) - page.lineEdit_color2.textChanged.connect(self.update) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - page.spinBox_width.valueChanged.connect(self.update) - page.spinBox_height.valueChanged.connect(self.update) - page.checkBox_trans.stateChanged.connect(self.update) - - self.fillLabels = [ \ - 'Solid', - 'Linear Gradient', - 'Radial Gradient', - ] - for label in self.fillLabels: - page.comboBox_fill.addItem(label) - page.comboBox_fill.setCurrentIndex(0) - page.comboBox_fill.currentIndexChanged.connect(self.update) - page.comboBox_spread.currentIndexChanged.connect(self.update) - page.spinBox_radialGradient_end.valueChanged.connect(self.update) - page.spinBox_radialGradient_start.valueChanged.connect(self.update) - page.spinBox_radialGradient_spread.valueChanged.connect(self.update) - page.spinBox_linearGradient_end.valueChanged.connect(self.update) - page.spinBox_linearGradient_start.valueChanged.connect(self.update) - page.checkBox_stretch.stateChanged.connect(self.update) - - self.page = page - return page - - def update(self): - self.color1 = self.RGBFromString(self.page.lineEdit_color1.text()) - self.color2 = self.RGBFromString(self.page.lineEdit_color2.text()) - self.x = self.page.spinBox_x.value() - self.y = self.page.spinBox_y.value() - self.sizeWidth = self.page.spinBox_width.value() - self.sizeHeight = self.page.spinBox_height.value() - self.trans = self.page.checkBox_trans.isChecked() - self.spread = self.page.comboBox_spread.currentIndex() - - self.RG_start = self.page.spinBox_radialGradient_start.value() - self.RG_end = self.page.spinBox_radialGradient_end.value() - self.RG_centre = self.page.spinBox_radialGradient_spread.value() - self.stretch = self.page.checkBox_stretch.isChecked() - self.LG_start = self.page.spinBox_linearGradient_start.value() - self.LG_end = self.page.spinBox_linearGradient_end.value() - - self.fillType = self.page.comboBox_fill.currentIndex() - if self.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) - self.page.fillWidget.setCurrentIndex(self.fillType) - - self.parent.drawPreview() - super().update() - - def previewRender(self, previewWorker): - width = int(previewWorker.core.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) - return self.drawFrame(width, height) - - def preFrameRender(self, **kwargs): - super().preFrameRender(**kwargs) - return ['static'] - - def frameRender(self, moduleNo, arrayNo, frameNo): - width = int(self.worker.core.settings.value('outputWidth')) - height = int(self.worker.core.settings.value('outputHeight')) - return self.drawFrame(width, 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 Image.new("RGBA", (width, height), (r, g, b, 255)) - - frame = self.blankFrame(width, height) - - # Return a solid image at x, y - if self.fillType == 0: - image = Image.new("RGBA", shapeSize, (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 = ImageQt(frame) - painter = QtGui.QPainter(image) - 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_start+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, QColor(*self.color1)) - if self.trans: - brush.setColorAt(1.0, QColor(0, 0, 0, 0)) - elif self.fillType == 1 and self.stretch: - brush.setColorAt(0.2, QColor(*self.color2)) - else: - brush.setColorAt(1.0, QColor(*self.color2)) - painter.setBrush(brush) - painter.drawRect(self.x, self.y, - self.sizeWidth, self.sizeHeight) - painter.end() - imBytes = image.bits().asstring(image.numBytes()) - return Image.frombytes('RGBA', (width, height), imBytes) - - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - - self.page.comboBox_fill.setCurrentIndex(pr['fillType']) - self.page.lineEdit_color1.setText('%s,%s,%s' % pr['color1']) - self.page.lineEdit_color2.setText('%s,%s,%s' % pr['color2']) - self.page.spinBox_x.setValue(pr['x']) - self.page.spinBox_y.setValue(pr['y']) - self.page.spinBox_width.setValue(pr['width']) - self.page.spinBox_height.setValue(pr['height']) - self.page.checkBox_trans.setChecked(pr['trans']) - - self.page.spinBox_radialGradient_start.setValue(pr['RG_start']) - self.page.spinBox_radialGradient_end.setValue(pr['RG_end']) - self.page.spinBox_radialGradient_spread.setValue(pr['RG_centre']) - self.page.spinBox_linearGradient_start.setValue(pr['LG_start']) - self.page.spinBox_linearGradient_end.setValue(pr['LG_end']) - self.page.checkBox_stretch.setChecked(pr['stretch']) - self.page.comboBox_spread.setCurrentIndex(pr['spread']) - - btnStyle1 = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*pr['color1']).name() - btnStyle2 = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*pr['color2']).name() - self.page.pushButton_color1.setStyleSheet(btnStyle1) - self.page.pushButton_color2.setStyleSheet(btnStyle2) - - def savePreset(self): - return { - 'preset': self.currentPreset, - 'color1': self.color1, - 'color2': self.color2, - 'x': self.x, - 'y': self.y, - 'fillType': self.fillType, - 'width': self.sizeWidth, - 'height': self.sizeHeight, - 'trans': self.trans, - 'stretch': self.stretch, - 'spread': self.spread, - 'RG_start': self.RG_start, - 'RG_end': self.RG_end, - 'RG_centre': self.RG_centre, - 'LG_start': self.LG_start, - 'LG_end': self.LG_end, - } - - def pickColor(self, num): - RGBstring, btnStyle = super().pickColor() - if not RGBstring: - return - if num == 1: - self.page.lineEdit_color1.setText(RGBstring) - self.page.pushButton_color1.setStyleSheet(btnStyle) - else: - self.page.lineEdit_color2.setText(RGBstring) - self.page.pushButton_color2.setStyleSheet(btnStyle) - - def commandHelp(self): - print('Specify a color:\n color=255,255,255') - - def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: - key, arg = arg.split('=', 1) - if key == 'color': - self.page.lineEdit_color1.setText(arg) - return - super().command(arg) diff --git a/components/color.ui b/components/color.ui deleted file mode 100644 index a9dacea..0000000 --- a/components/color.ui +++ /dev/null @@ -1,660 +0,0 @@ - - - Form - - - - 0 - 0 - 586 - 197 - - - - Form - - - - - - 4 - - - - - - - - 0 - 0 - - - - - 31 - 0 - - - - Color #1 - - - - - - - - 32 - 32 - - - - - - - - 32 - 32 - - - - - - - - - 0 - 0 - - - - - 1 - 0 - - - - 12 - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - - 31 - 0 - - - - Color #2 - - - - - - - - 32 - 32 - - - - - - - - 32 - 32 - - - - - - - - - 0 - 0 - - - - - 1 - 0 - - - - 12 - - - - - - - - - 0 - - - - - - 0 - 0 - - - - Width - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - - 0 - 0 - - - - 0 - - - 999999999 - - - 0 - - - - - - - - 0 - 0 - - - - Height - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - 999999999 - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - X - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - - 0 - 0 - - - - -10000 - - - 10000 - - - 0 - - - - - - - - 0 - 0 - - - - Y - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - -10000 - - - 10000 - - - - - - - - - 0 - - - - - - 0 - 0 - - - - Fill - - - - - - - - 0 - 0 - - - - -1 - - - QComboBox::AdjustToContentsOnFirstShow - - - - - - - - 0 - 0 - - - - Transparent - - - - - - - - 0 - 0 - - - - Stretch - - - - - - - - Pad - - - - - Reflect - - - - - Repeat - - - - - - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 40 - 20 - - - - - - - - - - - - - 0 - 0 - - - - 0 - - - 2 - - - - - - - -1 - 0 - 561 - 31 - - - - - - - - 0 - 0 - - - - Start - - - - - - - -10000 - - - 10000 - - - 10 - - - - - - - - 0 - 0 - - - - End - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - -10000 - - - 10000 - - - 10 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - -1 - -1 - 561 - 31 - - - - - - - - 0 - 0 - - - - Start - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - -10000 - - - 10000 - - - 10 - - - - - - - - 0 - 0 - - - - End - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - -10000 - - - 10000 - - - 10 - - - - - - - - 0 - 0 - - - - Centre - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - QAbstractSpinBox::PlusMinus - - - -10000 - - - 10000 - - - 3 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - - - - - diff --git a/components/image.py b/components/image.py deleted file mode 100644 index f8ae64e..0000000 --- a/components/image.py +++ /dev/null @@ -1,111 +0,0 @@ -from PIL import Image, ImageDraw -from PyQt4 import uic, QtGui, QtCore -import os -from . import __base__ - - -class Component(__base__.Component): - '''Image''' - - modified = QtCore.pyqtSignal(int, dict) - - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - page = uic.loadUi(os.path.join( - os.path.dirname(os.path.realpath(__file__)), 'image.ui')) - self.imagePath = '' - self.x = 0 - self.y = 0 - - page.lineEdit_image.textChanged.connect(self.update) - page.pushButton_image.clicked.connect(self.pickImage) - page.spinBox_scale.valueChanged.connect(self.update) - page.checkBox_stretch.stateChanged.connect(self.update) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - - self.page = page - return page - - def update(self): - self.imagePath = self.page.lineEdit_image.text() - self.scale = self.page.spinBox_scale.value() - self.xPosition = self.page.spinBox_x.value() - self.yPosition = self.page.spinBox_y.value() - self.stretched = self.page.checkBox_stretch.isChecked() - self.parent.drawPreview() - super().update() - - def previewRender(self, previewWorker): - self.imageFormats = previewWorker.core.imageFormats - width = int(previewWorker.core.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) - return self.drawFrame(width, height) - - def preFrameRender(self, **kwargs): - super().preFrameRender(**kwargs) - return ['static'] - - def frameRender(self, moduleNo, arrayNo, frameNo): - width = int(self.worker.core.settings.value('outputWidth')) - height = int(self.worker.core.settings.value('outputHeight')) - return self.drawFrame(width, height) - - def drawFrame(self, width, height): - frame = self.blankFrame(width, height) - if self.imagePath and os.path.exists(self.imagePath): - image = Image.open(self.imagePath) - if self.stretched and image.size != (width, height): - image = image.resize((width, height), Image.ANTIALIAS) - if self.scale != 100: - newHeight = int((image.height / 100) * self.scale) - newWidth = int((image.width / 100) * self.scale) - image = image.resize((newWidth, newHeight), Image.ANTIALIAS) - frame.paste(image, box=(self.xPosition, self.yPosition)) - return frame - - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - self.page.lineEdit_image.setText(pr['image']) - self.page.spinBox_scale.setValue(pr['scale']) - self.page.spinBox_x.setValue(pr['x']) - self.page.spinBox_y.setValue(pr['y']) - self.page.checkBox_stretch.setChecked(pr['stretched']) - - def savePreset(self): - return { - 'preset': self.currentPreset, - 'image': self.imagePath, - 'scale': self.scale, - 'stretched': self.stretched, - 'x': self.xPosition, - 'y': self.yPosition, - } - - def pickImage(self): - imgDir = self.settings.value("backgroundDir", os.path.expanduser("~")) - filename = QtGui.QFileDialog.getOpenFileName( - self.page, "Choose Image", imgDir, - "Image Files (%s)" % " ".join(self.imageFormats)) - if filename: - self.settings.setValue("backgroundDir", os.path.dirname(filename)) - self.page.lineEdit_image.setText(filename) - self.update() - - def command(self, arg): - if not arg.startswith('preset=') and '=' 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') diff --git a/components/image.ui b/components/image.ui deleted file mode 100644 index 6df03a5..0000000 --- a/components/image.ui +++ /dev/null @@ -1,259 +0,0 @@ - - - Form - - - - 0 - 0 - 586 - 197 - - - - Form - - - - - - 4 - - - - - - - - 0 - 0 - - - - - 31 - 0 - - - - Image - - - - - - - - 1 - 0 - - - - - - - - - 0 - 0 - - - - - 1 - 0 - - - - - 32 - 32 - - - - ... - - - - 32 - 32 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - X - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - -10000 - - - 10000 - - - - - - - - 0 - 0 - - - - Y - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - - 0 - 0 - - - - -1000 - - - 1000 - - - 0 - - - - - - - - - - - - - Stretch - - - false - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - Scale - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - QAbstractSpinBox::UpDownArrows - - - % - - - 10 - - - 400 - - - 100 - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - diff --git a/components/original.py b/components/original.py deleted file mode 100644 index 6222157..0000000 --- a/components/original.py +++ /dev/null @@ -1,204 +0,0 @@ -import numpy -from PIL import Image, ImageDraw -from PyQt4 import uic, QtGui, QtCore -from PyQt4.QtGui import QColor -import os -from . import __base__ -import time -from copy import copy - - -class Component(__base__.Component): - '''Original Audio Visualization''' - - modified = QtCore.pyqtSignal(int, dict) - - def widget(self, parent): - self.parent = parent - self.visColor = (255, 255, 255) - - page = uic.loadUi(os.path.join( - os.path.dirname(os.path.realpath(__file__)), 'original.ui')) - page.comboBox_visLayout.addItem("Classic") - page.comboBox_visLayout.addItem("Split") - page.comboBox_visLayout.addItem("Bottom") - page.comboBox_visLayout.setCurrentIndex(0) - page.comboBox_visLayout.currentIndexChanged.connect(self.update) - page.lineEdit_visColor.setText('%s,%s,%s' % self.visColor) - page.pushButton_visColor.clicked.connect(lambda: self.pickColor()) - btnStyle = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*self.visColor).name() - page.pushButton_visColor.setStyleSheet(btnStyle) - page.lineEdit_visColor.textChanged.connect(self.update) - self.page = page - self.canceled = False - return page - - def update(self): - self.layout = self.page.comboBox_visLayout.currentIndex() - self.visColor = self.RGBFromString(self.page.lineEdit_visColor.text()) - self.parent.drawPreview() - super().update() - - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - - self.page.lineEdit_visColor.setText('%s,%s,%s' % pr['visColor']) - btnStyle = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*pr['visColor']).name() - self.page.pushButton_visColor.setStyleSheet(btnStyle) - self.page.comboBox_visLayout.setCurrentIndex(pr['layout']) - - def savePreset(self): - return { - 'preset': self.currentPreset, - 'layout': self.layout, - 'visColor': self.visColor, - } - - def previewRender(self, previewWorker): - spectrum = numpy.fromfunction( - lambda x: 0.008*(x-128)**2, (255,), dtype="int16") - width = int(previewWorker.core.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) - return self.drawBars( - width, 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 = {} - self.width = int(self.worker.core.settings.value('outputWidth')) - self.height = int(self.worker.core.settings.value('outputHeight')) - - 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, moduleNo, arrayNo, frameNo): - return self.drawBars( - self.width, self.height, - self.spectrumArray[arrayNo], - self.visColor, self.layout) - - def pickColor(self): - RGBstring, btnStyle = super().pickColor() - if not RGBstring: - return - self.page.lineEdit_visColor.setText(RGBstring) - self.page.pushButton_visColor.setStyleSheet(btnStyle) - - 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 drawBars(self, width, height, spectrum, color, layout): - vH = height-height/8 - bF = width / 64 - bH = bF / 2 - bQ = bF / 4 - imTop = self.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 = self.blankFrame(width, height) - - if layout == 0: - y = 0 - int(height/100*43) - im.paste(imTop, (0, y), mask=imTop) - y = 0 + int(height/100*43) - im.paste(imBottom, (0, y), mask=imBottom) - - if layout == 1: - y = 0 + int(height/100*10) - im.paste(imTop, (0, y), mask=imTop) - y = 0 - int(height/100*10) - im.paste(imBottom, (0, y), mask=imBottom) - - if layout == 2: - y = 0 + int(height/100*10) - im.paste(imTop, (0, y), mask=imTop) - - return im - - def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: - key, arg = arg.split('=', 1) - 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) - return - super().command(arg) - - def commandHelp(self): - print('Give a layout name:\n layout=[classic/split/bottom]') - print('Specify a color:\n color=255,255,255') diff --git a/components/original.ui b/components/original.ui deleted file mode 100644 index 5808653..0000000 --- a/components/original.ui +++ /dev/null @@ -1,108 +0,0 @@ - - - Form - - - - 0 - 0 - 633 - 178 - - - - - 180 - 0 - - - - Form - - - - - - 4 - - - - - - 0 - 0 - - - - Visualizer Layout - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - Visualizer Color - - - - - - - - 32 - 32 - - - - - - - - 32 - 32 - - - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - diff --git a/components/text.py b/components/text.py deleted file mode 100644 index 2375dcd..0000000 --- a/components/text.py +++ /dev/null @@ -1,176 +0,0 @@ -from PIL import Image, ImageDraw -from PyQt4.QtGui import QPainter, QColor, QFont -from PyQt4 import uic, QtGui, QtCore -from PIL.ImageQt import ImageQt -import os -import io -from . import __base__ - - -class Component(__base__.Component): - '''Title Text''' - - modified = QtCore.pyqtSignal(int, dict) - - def __init__(self, *args): - super().__init__(*args) - self.titleFont = QFont() - - def widget(self, parent): - height = int(parent.settings.value('outputHeight')) - width = int(parent.settings.value('outputWidth')) - - self.parent = parent - self.textColor = (255, 255, 255) - self.title = 'Text' - self.alignment = 1 - self.fontSize = height / 13.5 - fm = QtGui.QFontMetrics(self.titleFont) - self.xPosition = width / 2 - fm.width(self.title)/2 - self.yPosition = height / 2 * 1.036 - - page = uic.loadUi(os.path.join( - os.path.dirname(os.path.realpath(__file__)), 'text.ui')) - page.comboBox_textAlign.addItem("Left") - page.comboBox_textAlign.addItem("Middle") - page.comboBox_textAlign.addItem("Right") - - page.lineEdit_textColor.setText('%s,%s,%s' % self.textColor) - page.pushButton_textColor.clicked.connect(self.pickColor) - btnStyle = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*self.textColor).name() - page.pushButton_textColor.setStyleSheet(btnStyle) - - page.lineEdit_title.setText(self.title) - page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) - page.spinBox_fontSize.setValue(int(self.fontSize)) - page.spinBox_xTextAlign.setValue(int(self.xPosition)) - page.spinBox_yTextAlign.setValue(int(self.yPosition)) - - page.fontComboBox_titleFont.currentFontChanged.connect(self.update) - page.lineEdit_title.textChanged.connect(self.update) - page.comboBox_textAlign.currentIndexChanged.connect(self.update) - page.spinBox_xTextAlign.valueChanged.connect(self.update) - page.spinBox_yTextAlign.valueChanged.connect(self.update) - page.spinBox_fontSize.valueChanged.connect(self.update) - page.lineEdit_textColor.textChanged.connect(self.update) - self.page = page - return page - - def update(self): - self.title = self.page.lineEdit_title.text() - self.alignment = self.page.comboBox_textAlign.currentIndex() - self.titleFont = self.page.fontComboBox_titleFont.currentFont() - self.fontSize = self.page.spinBox_fontSize.value() - self.xPosition = self.page.spinBox_xTextAlign.value() - self.yPosition = self.page.spinBox_yTextAlign.value() - self.textColor = self.RGBFromString( - self.page.lineEdit_textColor.text()) - self.parent.drawPreview() - super().update() - - def getXY(self): - '''Returns true x, y after considering alignment settings''' - fm = QtGui.QFontMetrics(self.titleFont) - if self.alignment == 0: # Left - x = self.xPosition - - if self.alignment == 1: # Middle - offset = fm.width(self.title)/2 - x = self.xPosition - offset - - if self.alignment == 2: # Right - offset = fm.width(self.title) - x = self.xPosition - offset - return x, self.yPosition - - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - - self.page.lineEdit_title.setText(pr['title']) - font = QFont() - font.fromString(pr['titleFont']) - self.page.fontComboBox_titleFont.setCurrentFont(font) - self.page.spinBox_fontSize.setValue(pr['fontSize']) - self.page.comboBox_textAlign.setCurrentIndex(pr['alignment']) - self.page.spinBox_xTextAlign.setValue(pr['xPosition']) - self.page.spinBox_yTextAlign.setValue(pr['yPosition']) - self.page.lineEdit_textColor.setText('%s,%s,%s' % pr['textColor']) - btnStyle = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*pr['textColor']).name() - self.page.pushButton_textColor.setStyleSheet(btnStyle) - - def savePreset(self): - return { - 'preset': self.currentPreset, - 'title': self.title, - 'titleFont': self.titleFont.toString(), - 'alignment': self.alignment, - 'fontSize': self.fontSize, - 'xPosition': self.xPosition, - 'yPosition': self.yPosition, - 'textColor': self.textColor - } - - def previewRender(self, previewWorker): - width = int(previewWorker.core.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) - return self.addText(width, height) - - def preFrameRender(self, **kwargs): - super().preFrameRender(**kwargs) - return ['static'] - - def frameRender(self, moduleNo, arrayNo, frameNo): - width = int(self.worker.core.settings.value('outputWidth')) - height = int(self.worker.core.settings.value('outputHeight')) - return self.addText(width, height) - - def addText(self, width, height): - x, y = self.getXY() - im = self.blankFrame(width, height) - image = ImageQt(im) - - painter = QPainter(image) - self.titleFont.setPixelSize(self.fontSize) - painter.setFont(self.titleFont) - painter.setPen(QColor(*self.textColor)) - painter.drawText(x, y, self.title) - painter.end() - - imBytes = image.bits().asstring(image.numBytes()) - - return Image.frombytes('RGBA', (width, height), imBytes) - - def pickColor(self): - RGBstring, btnStyle = super().pickColor() - if not RGBstring: - return - self.page.lineEdit_textColor.setText(RGBstring) - self.page.pushButton_textColor.setStyleSheet(btnStyle) - - 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 not arg.startswith('preset=') and '=' 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/components/text.ui b/components/text.ui deleted file mode 100644 index 05e7f8e..0000000 --- a/components/text.ui +++ /dev/null @@ -1,316 +0,0 @@ - - - Form - - - - 0 - 0 - 586 - 197 - - - - Form - - - - - - 4 - - - - - - - - 0 - 0 - - - - Font - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - Font Size - - - - - - - 500 - - - - - - - - - - - - 0 - 0 - - - - Text Layout - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - Text Color - - - - - - - - 32 - 32 - - - - - - - - 32 - 32 - - - - - - - - - - - - - 0 - - - - - Title - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - Testing New GUI - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - X - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - - 0 - 0 - - - - 0 - - - 999999999 - - - 0 - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - Y - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - 999999999 - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - diff --git a/components/video.py b/components/video.py deleted file mode 100644 index 1d250bd..0000000 --- a/components/video.py +++ /dev/null @@ -1,273 +0,0 @@ -from PIL import Image, ImageDraw -from PyQt4 import uic, QtGui, QtCore -import os -import subprocess -import threading -from queue import PriorityQueue -from . import __base__ - - -class Video: - '''Video Component Frame-Fetcher''' - def __init__(self, **kwargs): - mandatoryArgs = [ - 'ffmpeg', # path to ffmpeg, usually 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: - try: - exec('self.%s = kwargs[arg]' % arg) - except KeyError: - raise __base__.BadComponentInit(arg, self.__doc__) - - 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: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=self.__doc__ - ) - 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): - pipe = subprocess.Popen( - self.command, 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. - if len(self.currentFrame) == 0: - self.frameBuffer.put((self.frameNo-1, self.lastFrame)) - continue - - self.currentFrame = pipe.stdout.read(self.chunkSize) - if len(self.currentFrame) != 0: - self.frameBuffer.put((self.frameNo, self.currentFrame)) - self.lastFrame = self.currentFrame - - -class Component(__base__.Component): - '''Video''' - - modified = QtCore.pyqtSignal(int, dict) - - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - page = uic.loadUi(os.path.join( - os.path.dirname(os.path.realpath(__file__)), - 'video.ui' - )) - self.videoPath = '' - self.x = 0 - self.y = 0 - self.loopVideo = False - - page.lineEdit_video.textChanged.connect(self.update) - page.pushButton_video.clicked.connect(self.pickVideo) - page.checkBox_loop.stateChanged.connect(self.update) - page.checkBox_distort.stateChanged.connect(self.update) - page.spinBox_scale.valueChanged.connect(self.update) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - - self.page = page - return page - - def update(self): - self.videoPath = self.page.lineEdit_video.text() - self.loopVideo = self.page.checkBox_loop.isChecked() - self.distort = self.page.checkBox_distort.isChecked() - self.scale = self.page.spinBox_scale.value() - self.xPosition = self.page.spinBox_x.value() - self.yPosition = self.page.spinBox_y.value() - self.parent.drawPreview() - super().update() - - def previewRender(self, previewWorker): - self.videoFormats = previewWorker.core.videoFormats - width = int(previewWorker.core.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) - self.updateChunksize(width, height) - frame = self.getPreviewFrame(width, height) - if not frame: - return self.blankFrame(width, height) - else: - return frame - - def preFrameRender(self, **kwargs): - super().preFrameRender(**kwargs) - width = int(self.worker.core.settings.value('outputWidth')) - height = int(self.worker.core.settings.value('outputHeight')) - self.blankFrame_ = self.blankFrame(width, height) - self.updateChunksize(width, height) - self.video = Video( - ffmpeg=self.parent.core.FFMPEG_BIN, videoPath=self.videoPath, - width=width, height=height, chunkSize=self.chunkSize, - frameRate=int(self.settings.value("outputFrameRate")), - parent=self.parent, loopVideo=self.loopVideo, - component=self, scale=self.scale - ) if os.path.exists(self.videoPath) else None - - def frameRender(self, moduleNo, arrayNo, frameNo): - if self.video: - return self.video.frame(frameNo) - else: - return self.blankFrame_ - - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - self.page.lineEdit_video.setText(pr['video']) - self.page.checkBox_loop.setChecked(pr['loop']) - self.page.checkBox_distort.setChecked(pr['distort']) - self.page.spinBox_scale.setValue(pr['scale']) - self.page.spinBox_x.setValue(pr['x']) - self.page.spinBox_y.setValue(pr['y']) - - def savePreset(self): - return { - 'preset': self.currentPreset, - 'video': self.videoPath, - 'loop': self.loopVideo, - 'distort': self.distort, - 'scale': self.scale, - 'x': self.xPosition, - 'y': self.yPosition, - } - - def pickVideo(self): - imgDir = self.settings.value("backgroundDir", os.path.expanduser("~")) - filename = QtGui.QFileDialog.getOpenFileName( - self.page, "Choose Video", - imgDir, "Video Files (%s)" % " ".join(self.videoFormats) - ) - if filename: - self.settings.setValue("backgroundDir", os.path.dirname(filename)) - self.page.lineEdit_video.setText(filename) - self.update() - - def getPreviewFrame(self, width, height): - if not self.videoPath or not os.path.exists(self.videoPath): - return - - command = [ - self.parent.core.FFMPEG_BIN, - '-thread_queue_size', '512', - '-i', self.videoPath, - '-f', 'image2pipe', - '-pix_fmt', 'rgba', - '-filter:v', 'scale=%s:%s' % - scale(self.scale, width, height, str), - '-vcodec', 'rawvideo', '-', - '-ss', '90', - '-vframes', '1', - ] - pipe = subprocess.Popen( - command, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, bufsize=10**8 - ) - byteFrame = pipe.stdout.read(self.chunkSize) - frame = finalizeFrame(self, byteFrame, width, height) - pipe.stdout.close() - pipe.kill() - - return frame - - def updateChunksize(self, width, height): - if self.scale != 100 and not self.distort: - width, height = scale(self.scale, width, height, int) - self.chunkSize = 4*width*height - - def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: - key, arg = arg.split('=', 1) - if key == 'path' and os.path.exists(arg): - if 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) - super().command(arg) - - def commandHelp(self): - print('Load a video:\n path=/filepath/to/video.mp4') - -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(int(width)), str(int(height))) - elif returntype == int: - return (int(width), int(height)) - else: - return (width, height) - -def finalizeFrame(self, imageData, width, height): - if self.distort: - try: - image = Image.frombytes( - 'RGBA', - (width, height), - imageData) - except ValueError: - print('#### ignored invalid data caused by distortion ####') - image = self.blankFrame(width, height) - else: - image = Image.frombytes( - 'RGBA', - scale(self.scale, width, height, int), - imageData) - - if self.scale != 100 \ - or self.xPosition != 0 or self.yPosition != 0: - frame = self.blankFrame(width, height) - frame.paste(image, box=(self.xPosition, self.yPosition)) - else: - frame = image - return frame diff --git a/components/video.ui b/components/video.ui deleted file mode 100644 index f05e8a5..0000000 --- a/components/video.ui +++ /dev/null @@ -1,266 +0,0 @@ - - - Form - - - - 0 - 0 - 586 - 197 - - - - Form - - - - - - 4 - - - - - - - - 0 - 0 - - - - - 31 - 0 - - - - Video - - - - - - - - 1 - 0 - - - - - - - - - 0 - 0 - - - - - 1 - 0 - - - - - 32 - 32 - - - - ... - - - - 32 - 32 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - X - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - -10000 - - - 10000 - - - - - - - - 0 - 0 - - - - Y - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - - 0 - 0 - - - - -10000 - - - 10000 - - - 0 - - - - - - - - - - - - - Loop - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - Distort by scale - - - - - - - Scale - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - QAbstractSpinBox::UpDownArrows - - - % - - - 10 - - - 400 - - - 100 - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - diff --git a/core.py b/core.py deleted file mode 100644 index de6ed99..0000000 --- a/core.py +++ /dev/null @@ -1,476 +0,0 @@ -import sys -import io -import os -from PyQt4 import QtCore, QtGui, uic -from os.path import expanduser -import subprocess as sp -import numpy -from PIL import Image -from shutil import rmtree -import time -from collections import OrderedDict -import json -from importlib import import_module -from PyQt4.QtGui import QDesktopServices -import string - - -class Core(): - - def __init__(self): - self.FFMPEG_BIN = self.findFfmpeg() - self.dataDir = QDesktopServices.storageLocation( - QDesktopServices.DataLocation) - self.presetDir = os.path.join(self.dataDir, 'presets') - if getattr(sys, 'frozen', False): - # frozen - self.wd = os.path.dirname(sys.executable) - else: - # unfrozen - self.wd = os.path.dirname(os.path.realpath(__file__)) - - self.loadEncoderOptions() - self.videoFormats = Core.appendUppercase([ - '*.mp4', - '*.mov', - '*.mkv', - '*.avi', - '*.webm', - '*.flv', - ]) - self.audioFormats = Core.appendUppercase([ - '*.mp3', - '*.wav', - '*.ogg', - '*.fla', - '*.flac', - '*.aac', - ]) - self.imageFormats = Core.appendUppercase([ - '*.png', - '*.jpg', - '*.tif', - '*.tiff', - '*.gif', - '*.bmp', - '*.ico', - '*.xbm', - '*.xpm', - ]) - - self.findComponents() - self.selectedComponents = [] - # copies of named presets to detect modification - self.savedPresets = {} - - def findComponents(self): - def findComponents(): - srcPath = os.path.join(self.wd, 'components') - if os.path.exists(srcPath): - for f in sorted(os.listdir(srcPath)): - name, ext = os.path.splitext(f) - if name.startswith("__"): - continue - elif ext == '.py': - yield name - self.modules = [ - import_module('components.%s' % name) - for name in findComponents() - ] - self.moduleIndexes = [i for i in range(len(self.modules))] - self.compNames = [mod.Component.__doc__ for mod in self.modules] - - def componentListChanged(self): - for i, component in enumerate(self.selectedComponents): - component.compPos = i - - def insertComponent(self, compPos, moduleIndex, loader): - '''Creates a new component''' - if compPos < 0 or compPos > len(self.selectedComponents): - compPos = len(self.selectedComponents) - if len(self.selectedComponents) > 50: - return None - - component = self.modules[moduleIndex].Component( - moduleIndex, compPos, self) - self.selectedComponents.insert( - compPos, - component) - self.componentListChanged() - - # init component's widget for loading/saving presets - self.selectedComponents[compPos].widget(loader) - self.updateComponent(compPos) - - if hasattr(loader, 'insertComponent'): - loader.insertComponent(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): - # print('updating %s' % self.selectedComponents[i]) - self.selectedComponents[i].update() - - def moduleIndexFor(self, compName): - index = self.compNames.index(compName) - return self.moduleIndexes[index] - - 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 - try: - self.selectedComponents[compIndex].loadPreset( - saveValueStore, - presetName - ) - except KeyError as e: - print('preset missing value: %s' % e) - - self.savedPresets[presetName] = dict(saveValueStore) - return True - - def getPresetDir(self, comp): - return os.path.join( - self.presetDir, str(comp), str(comp.version())) - - 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 = Core.presetFromString(line.strip()) - break - return saveValueStore - - 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: - try: - for i, tup in enumerate(data['Components']): - name, vers, preset = tup - clearThis = False - - # add loaded named presets to savedPresets dict - if 'preset' in preset and preset['preset'] != None: - nam = preset['preset'] - filepath2 = os.path.join( - self.presetDir, name, str(vers), nam) - origSaveValueStore = self.getPreset(filepath2) - if origSaveValueStore: - self.savedPresets[nam] = dict(origSaveValueStore) - 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 == None: - loader.showMessage(msg="Too many components!") - break - - try: - if 'preset' in preset and preset['preset'] != None: - self.selectedComponents[i].loadPreset( - preset - ) - else: - self.selectedComponents[i].loadPreset( - preset, - preset['preset'] - ) - except KeyError as e: - print('%s missing value %s' % - (self.selectedComponents[i], e)) - - if clearThis: - self.clearPreset(i) - if hasattr(loader, 'updateComponentTitle'): - loader.updateComponentTitle(i) - except: - errcode = 1 - data = sys.exc_info() - - - if errcode == 1: - typ, value, _ = data - if typ.__name__ == KeyError: - # probably just an old version, still loadable - print('file missing value: %s' % value) - return - if hasattr(loader, 'createNewProject'): - loader.createNewProject() - msg = '%s: %s' % (typ.__name__, value) - loader.showMessage( - msg="Project file '%s' is corrupted." % filepath, - showCancel=False, - icon=QtGui.QMessageBox.Warning, - detail=msg) - - 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) - ''' - data = {} - try: - with open(filepath, 'r') as f: - def parseLine(line): - '''Decides if a file line is a section header''' - validSections = ('Components') - 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) - data[section] = [] - 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 = Core.presetFromString(line) - data[section].append( - (lastCompName, - lastCompVers, - lastCompPreset) - ) - i = 0 - return 0, data - except: - 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( - self.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(self.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 = Core.presetFromString(internalData[0].strip()) - self.createPresetFile( - compName, vers, - origName, saveValueStore, - exportPath - ) - return True - except: - 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(self.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(Core.presetToString(saveValueStore)) - - def createProjectFile(self, filepath): - '''Create a project file (.avp) using the current program state''' - try: - if not filepath.endswith(".avp"): - filepath += '.avp' - if os.path.exists(filepath): - os.remove(filepath) - with open(filepath, 'w') as f: - print('creating %s' % filepath) - f.write('[Components]\n') - for comp in self.selectedComponents: - saveValueStore = comp.savePreset() - f.write('%s\n' % str(comp)) - f.write('%s\n' % str(comp.version())) - f.write('%s\n' % Core.presetToString(saveValueStore)) - return True - except: - return False - - def loadEncoderOptions(self): - file_path = os.path.join(self.wd, 'encoder-options.json') - with open(file_path) as json_file: - self.encoder_options = json.load(json_file) - - 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 readAudioFile(self, filename, parent): - command = [self.FFMPEG_BIN, '-i', filename] - - try: - fileInfo = sp.check_output(command, stderr=sp.STDOUT, shell=False) - except sp.CalledProcessError as ex: - fileInfo = ex.output - pass - - info = fileInfo.decode("utf-8").split('\n') - 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]) - - 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") - - progress = 0 - lastPercent = None - while True: - if self.canceled: - break - # read 2 seconds of audio - progress = 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)+'%' - parent.progressBarSetText.emit(string) - parent.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 - - def cancel(self): - self.canceled = True - - def reset(self): - self.canceled = False - - @staticmethod - def badName(name): - '''Returns whether a name contains non-alphanumeric chars''' - return any([letter in string.punctuation for letter in name]) - - @staticmethod - def presetToString(dictionary): - '''Alphabetizes a dict into OrderedDict & returns string repr''' - return repr(OrderedDict(sorted(dictionary.items(), key=lambda t: t[0]))) - - @staticmethod - def presetFromString(string): - '''Turns a string repr of OrderedDict into a regular dict''' - return dict(eval(string)) - - @staticmethod - def appendUppercase(lst): - for form, i in zip(lst, range(len(lst))): - lst.append(form.upper()) - return lst diff --git a/encoder-options.json b/encoder-options.json deleted file mode 100644 index 78bc940..0000000 --- a/encoder-options.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "containers":[ - { - "name": "MP4", - "container": "mp4", - "default-vcodec": "H264", - "default-acodec": "AAC", - "video-codecs": [ - "H264", - "H264 (nvenc)", - "MPEG4" - ], - "audio-codecs": [ - "AAC", - "AC3", - "MP3" - ] - }, - { - "name": "MOV", - "container": "mov", - "default-vcodec": "H264", - "default-acodec": "AAC", - "video-codecs": [ - "H264", - "H264 (nvenc)", - "MPEG4", - "XVID" - ], - "audio-codecs": [ - "AAC", - "AC3", - "MP3", - "PCM s16 LE" - ] - }, - { - "name": "MKV", - "container": "matroska", - "default-vcodec": "H264", - "default-acodec": "AAC", - "video-codecs": [ - "H264", - "H264 (nvenc)", - "MPEG4", - "MPEG2", - "DV", - "WMV" - ], - "audio-codecs": [ - "AAC", - "AC3", - "MP3", - "PCM s16 LE", - "WMA" - ] - }, - { - "name": "AVI", - "container": "avi", - "default-vcodec": "H264", - "default-acodec": "AAC", - "video-codecs": [ - "H264", - "H264 (nvenc)", - "MPEG4", - "MPEG2", - "DV", - "WMV" - ], - "audio-codecs": [ - "AAC", - "AC3", - "MP3", - "PCM s16 LE", - "WMA" - ] - }, - { - "name": "WEBM", - "container": "webm", - "default-vcodec": "VP9", - "default-acodec": "Vorbis", - "video-codecs": [ - "VP9", - "VP8" - ], - "audio-codecs": [ - "Vorbis" - ] - }, - { - "name": "FLV", - "container": "flv", - "default-vcodec": "FLV", - "default-acodec": "Vorbis", - "video-codecs": [ - "Sorenson (flv)", - "H264", - "H264 (nvenc)", - "MPEG4" - ], - "audio-codecs": [ - "MP3", - "PCM s16 LE", - "Vorbis" - ] - } - ], - "video-codecs":{ - "H264": ["libx264"], - "H264 (nvenc)": ["h264_nvenc", "nvenc_h264"], - "MPEG4": ["mpeg4"], - "VP9": ["libvpx-vp9"], - "VP8": ["libvpx"], - "XVID": ["libxvid"], - "Sorenson (flv)": ["flv"], - "MPEG2": ["mp2video"], - "DV": ["dvvideo"], - "WMV": ["wmv2"] - }, - "audio-codecs": { - "AAC": ["libfdk_aac", "aac"], - "AC3": ["ac3"], - "MP3": ["libmp3lame"], - "PCM s16 LE": ["pcm_s16le"], - "WMA": ["wmav2"], - "Vorbis": ["libvorbis"] - } -} \ No newline at end of file diff --git a/freeze.py b/freeze.py new file mode 100644 index 0000000..48034dc --- /dev/null +++ b/freeze.py @@ -0,0 +1,51 @@ +from cx_Freeze import setup, Executable +import sys + +# Dependencies are automatically detected, but it might need +# fine tuning. + +buildOptions = dict( + packages=[], + excludes=[ + "apport", + "apt", + "curses", + "distutils", + "email", + "html", + "http", + "xmlrpc", + "nose" + ], + include_files=[ + "mainwindow.ui", + "presetmanager.ui", + "background.png", + "encoder-options.json", + "components/" + ], + includes=[ + 'numpy.core._methods', + 'numpy.lib.format' + ] +) + + +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='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 106bd29..0000000 --- a/main.py +++ /dev/null @@ -1,88 +0,0 @@ -from PyQt4 import QtGui, uic -import sys -import os - -import core -import preview_thread -import video_thread - - -def LoadDefaultSettings(self): - self.resolutions = [ - '1920x1080', - '1280x720', - '854x480' - ] - - default = { - "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(self.dataDir, 'projects'), - } - - for parm, value in default.items(): - #print(parm, self.settings.value(parm)) - if self.settings.value(parm) is None: - self.settings.setValue(parm, value) - -if __name__ == "__main__": - mode = 'gui' - if len(sys.argv) > 2: - mode = 'cmd' - - elif len(sys.argv) == 2: - if sys.argv[1].startswith('-'): - mode = 'cmd' - else: - # opening a project file with gui - proj = sys.argv[1] - else: - # normal gui launch - proj = None - - app = QtGui.QApplication(sys.argv) - app.setApplicationName("audio-visualizer") - app.setOrganizationName("audio-visualizer") - - if mode == 'cmd': - from command import * - - main = Command() - - elif mode == 'gui': - from mainwindow import * - import atexit - import signal - - if getattr(sys, 'frozen', False): - # frozen - wd = os.path.dirname(sys.executable) - else: - # unfrozen - wd = os.path.dirname(os.path.realpath(__file__)) - - window = uic.loadUi(os.path.join(wd, "mainwindow.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 = MainWindow(window, proj) - - signal.signal(signal.SIGINT, main.cleanUp) - atexit.register(main.cleanUp) - - # applicable to both modes - sys.exit(app.exec_()) diff --git a/mainwindow.py b/mainwindow.py deleted file mode 100644 index cdc2a51..0000000 --- a/mainwindow.py +++ /dev/null @@ -1,721 +0,0 @@ -from queue import Queue -from PyQt4 import QtCore, QtGui, uic -from PyQt4.QtCore import QSettings, Qt -from PyQt4.QtGui import QMenu, QShortcut -import sys -import os -import signal -import filecmp -import time - -import core -import preview_thread -import video_thread -from presetmanager import PresetManager -from main import LoadDefaultSettings - - -class PreviewWindow(QtGui.QLabel): - def __init__(self, parent, img): - super(PreviewWindow, self).__init__() - self.parent = parent - self.setFrameStyle(QtGui.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, Qt.KeepAspectRatio, transformMode=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() - - -class MainWindow(QtGui.QMainWindow): - - newTask = QtCore.pyqtSignal(list) - processTask = QtCore.pyqtSignal() - videoTask = QtCore.pyqtSignal(str, str, list) - - def __init__(self, window, project): - QtGui.QMainWindow.__init__(self) - - # print('main thread id: {}'.format(QtCore.QThread.currentThreadId())) - self.window = window - self.core = core.Core() - - self.pages = [] # widgets of component settings - self.lastAutosave = time.time() - - # Create data directory, load/create settings - self.dataDir = self.core.dataDir - self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') - self.settings = QSettings( - os.path.join(self.dataDir, 'settings.ini'), QSettings.IniFormat) - LoadDefaultSettings(self) - self.presetManager = PresetManager( - uic.loadUi( - os.path.join(self.core.wd, 'presetmanager.ui')), self) - - if not os.path.exists(self.dataDir): - os.makedirs(self.dataDir) - for neededDirectory in ( - self.core.presetDir, self.settings.value("projectDir")): - if not os.path.exists(neededDirectory): - os.mkdir(neededDirectory) - - # Make queues/timers for the preview thread - 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) - - # Begin decorating the window and connecting events - componentList = self.window.listWidget_componentList - - window.toolButton_selectAudioFile.clicked.connect( - self.openInputFileDialog) - - window.toolButton_selectOutputFile.clicked.connect( - self.openOutputFileDialog) - - window.progressBar_createVideo.setValue(0) - - window.pushButton_createVideo.clicked.connect( - self.createAudioVisualisation) - - window.pushButton_Cancel.clicked.connect(self.stopVideo) - - for i, container in enumerate(self.core.encoder_options['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) - #print(codec) - - 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) - - self.previewWindow = PreviewWindow(self, os.path.join( - self.core.wd, "background.png")) - window.verticalLayout_previewWrapper.addWidget(self.previewWindow) - - # Make component buttons - self.compMenu = QMenu() - for i, comp in enumerate(self.core.modules): - action = self.compMenu.addAction(comp.Component.__doc__) - 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) - - self.window.pushButton_removeComponent.clicked.connect( - lambda _: self.removeComponent()) - - componentList.setContextMenuPolicy( - QtCore.Qt.CustomContextMenu) - componentList.connect( - componentList, - QtCore.SIGNAL("customContextMenuRequested(QPoint)"), - self.componentContextMenu) - - currentRes = str(self.settings.value('outputWidth'))+'x' + \ - str(self.settings.value('outputHeight')) - for i, res in enumerate(self.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( - self.createNewProject) - - self.window.menuButton_openProject = self.projectMenu.addAction( - "Open Project") - self.window.menuButton_openProject.triggered[()].connect( - 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 - ) - - 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(os.path.expanduser('~'), 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) - - # Setup Hotkeys - QtGui.QShortcut("Ctrl+S", self.window, self.saveCurrentProject) - QtGui.QShortcut("Ctrl+A", self.window, self.openSaveProjectDialog) - QtGui.QShortcut("Ctrl+O", self.window, self.openOpenProjectDialog) - QtGui.QShortcut("Ctrl+N", self.window, self.createNewProject) - - QtGui.QShortcut("Ctrl+T", self.window, activated=lambda: - self.window.pushButton_addComponent.click()) - QtGui.QShortcut("Ctrl+Space", self.window, activated=lambda: - self.window.listWidget_componentList.setFocus()) - QtGui.QShortcut("Ctrl+Shift+S", self.window, - self.presetManager.openSavePresetDialog) - QtGui.QShortcut("Ctrl+Shift+C", self.window, - self.presetManager.clearPreset) - - QtGui.QShortcut("Ctrl+Up", self.window, - activated=lambda: self.moveComponent(-1)) - QtGui.QShortcut("Ctrl+Down", self.window, - activated=lambda: self.moveComponent(1)) - QtGui.QShortcut("Ctrl+Home", self.window, self.moveComponentTop) - QtGui.QShortcut("Ctrl+End", self.window, self.moveComponentBottom) - QtGui.QShortcut("Ctrl+r", self.window, self.removeComponent) - - def cleanUp(self): - self.timer.stop() - self.previewThread.quit() - self.previewThread.wait() - self.autosave() - - def updateWindowTitle(self): - appName = 'Audio Visualizer' - if self.currentProject: - appName += ' - %s' % \ - os.path.splitext( - os.path.basename(self.currentProject))[0] - self.window.setWindowTitle(appName) - - @QtCore.pyqtSlot(int, dict) - def updateComponentTitle(self, pos, presetStore=False): - if type(presetStore) == dict: - name = presetStore['preset'] - if name == 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 self.core.encoder_options['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): - 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) - - 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 >= 2.0: - self.core.createProjectFile(self.autosavePath) - self.lastAutosave = time.time() - - def autosaveExists(self, identical=True): - 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): - 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 = QtGui.QFileDialog.getOpenFileName( - self.window, "Open Audio File", - inputDir, "Audio Files (%s)" % " ".join(self.core.audioFormats)) - - if not 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 = QtGui.QFileDialog.getSaveFileName( - self.window, "Set Output Video File", - outputDir, - "Video Files (%s);; All Files (*)" % " ".join(self.core.videoFormats)) - - if not 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 - if self.window.lineEdit_audioFile.text() and \ - self.window.lineEdit_outputFile.text(): - self.canceled = False - self.progressBarUpdated(-1) - 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.videoWorker.imageCreated.connect(self.showPreviewImage) - self.videoWorker.encoding.connect(self.changeEncodingStatus) - self.videoThread.start() - outputPath = self.window.lineEdit_outputFile.text() - if not os.path.dirname(outputPath): - outputPath = os.path.join( - os.path.expanduser("~"), outputPath) - self.videoTask.emit( - self.window.lineEdit_audioFile.text(), - outputPath, - self.core.selectedComponents) - else: - self.showMessage( - msg="You must select an audio file and output filename.") - - def changeEncodingStatus(self, 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.listWidget_componentList.setEnabled(False) - self.window.menuButton_newProject.setEnabled(False) - self.window.menuButton_openProject.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.listWidget_componentList.setEnabled(True) - self.window.menuButton_newProject.setEnabled(True) - self.window.menuButton_openProject.setEnabled(True) - self.drawPreview(True) - - def progressBarUpdated(self, value): - self.window.progressBar_createVideo.setValue(value) - - def progressBarSetText(self, value): - self.window.progressBar_createVideo.setFormat(value) - - def videoCreated(self): - self.videoThread.quit() - self.videoThread.wait() - - def updateResolution(self): - resIndex = int(self.window.comboBox_resolution.currentIndex()) - res = self.resolutions[resIndex].split('x') - self.settings.setValue('outputWidth', res[0]) - self.settings.setValue('outputHeight', res[1]) - self.drawPreview() - - def drawPreview(self, force=False): - self.newTask.emit(self.core.selectedComponents) - # self.processTask.emit() - self.autosave(force) - - def showPreviewImage(self, image): - self.previewWindow.changePixmap(image) - - def insertComponent(self, index): - componentList = self.window.listWidget_componentList - stackedWidget = self.window.stackedWidget - - componentList.insertItem( - index, - self.core.selectedComponents[index].__doc__) - 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() - - def moveComponent(self, change): - '''Moves a component relatively from its current position''' - componentList = self.window.listWidget_componentList - 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() - - def moveComponentTop(self): - componentList = self.window.listWidget_componentList - row = -componentList.currentRow() - self.moveComponent(row) - - def moveComponentBottom(self): - componentList = self.window.listWidget_componentList - row = len(componentList)-componentList.currentRow()-1 - self.moveComponent(row) - - def dragComponent(self, event): - '''Drop event for the component listwidget''' - componentList = self.window.listWidget_componentList - - modelIndexes = [ \ - componentList.model().index(i) \ - for i in range(componentList.count()) \ - ] - rects = [ \ - componentList.visualRect(modelIndex) \ - for modelIndex in modelIndexes \ - ] - - rowPos = [rect.contains(event.pos()) for rect in rects] - if not any(rowPos): - return - - i = rowPos.index(True) - change = (componentList.currentRow() - i) * -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 = [] - - def createNewProject(self): - self.openSaveChangesDialog('starting a new project') - - self.clear() - self.currentProject = None - self.settings.setValue("currentProject", None) - self.drawPreview(True) - self.updateWindowTitle() - - def saveCurrentProject(self): - if self.currentProject: - self.core.createProjectFile(self.currentProject) - 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 = QtGui.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.updateWindowTitle() - self.core.createProjectFile(filename) - - def openOpenProjectDialog(self): - filename = QtGui.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'): - self.updateWindowTitle() - 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.updateWindowTitle() - 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) - if self.window.listWidget_componentList.count() == 0: - self.drawPreview() - self.autosave(True) - - def showMessage(self, **kwargs): - parent = kwargs['parent'] if 'parent' in kwargs else self.window - msg = QtGui.QMessageBox(parent) - msg.setModal(True) - msg.setText(kwargs['msg']) - msg.setIcon( - kwargs['icon'] if 'icon' in kwargs else QtGui.QMessageBox.Information) - msg.setDetailedText(kwargs['detail'] if 'detail' in kwargs else None) - if 'showCancel'in kwargs and kwargs['showCancel']: - msg.setStandardButtons( - QtGui.QMessageBox.Ok | QtGui.QMessageBox.Cancel) - else: - msg.setStandardButtons(QtGui.QMessageBox.Ok) - ch = msg.exec_() - if ch == 1024: - return True - return False - - def componentContextMenu(self, QPos): - '''Appears when right-clicking a component in the list''' - componentList = self.window.listWidget_componentList - if not componentList.selectedItems(): - return - - # don't show menu if clicking empty space - parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) - index = componentList.currentRow() - modelIndex = componentList.model().index(index) - if not componentList.visualRect(modelIndex).contains(QPos): - return - - self.presetManager.findPresets() - self.menu = QtGui.QMenu() - 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.submenu = QtGui.QMenu("Open Preset") - self.menu.addMenu(self.submenu) - - for version, presetName in presets: - menuItem = self.submenu.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.move(parentPosition + QPos) - self.menu.show() diff --git a/mainwindow.ui b/mainwindow.ui deleted file mode 100644 index 4a12fd5..0000000 --- a/mainwindow.ui +++ /dev/null @@ -1,809 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 1008 - 575 - - - - - 0 - 0 - - - - - 0 - 0 - - - - MainWindow - - - - - 0 - 0 - - - - false - - - - 9 - - - 0 - - - - - - - Qt::Vertical - - - QSizePolicy::MinimumExpanding - - - - 0 - 360 - - - - - - - - QLayout::SetDefaultConstraint - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 420 - 0 - - - - - - - - - - QLayout::SetMinimumSize - - - 3 - - - - - QLayout::SetMinimumSize - - - 3 - - - - - QLayout::SetMinimumSize - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 140 - 20 - - - - - - - - Projects - - - - - - - Presets - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 20 - 2 - - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - true - - - QFrame::StyledPanel - - - QFrame::Sunken - - - 1 - - - true - - - true - - - false - - - QAbstractItemView::InternalMove - - - Qt::MoveAction - - - - - - - - - Add - - - - - - - Remove - - - - - - - Up - - - - - - - Down - - - - - - - - - - - 4 - - - 2 - - - - - - - - - - - QLayout::SetFixedSize - - - 4 - - - 0 - - - - - - 0 - 0 - - - - - 500 - 0 - - - - - 16777215 - 180 - - - - QTabWidget::North - - - QTabWidget::Rounded - - - 0 - - - - Export Video - - - - 10 - - - - - 0 - - - - - - 0 - 0 - - - - - 85 - 0 - - - - - 80 - 16777215 - - - - - 80 - 0 - - - - Audio File - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 0 - 28 - - - - - 16777215 - 28 - - - - - 0 - 0 - - - - - - - - - 0 - 28 - - - - - 16777215 - 28 - - - - ... - - - - - - - - - - - - - - 0 - 0 - - - - - 85 - 0 - - - - - 0 - 0 - - - - Output File - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 0 - 28 - - - - - 16777215 - 28 - - - - - - - - - 0 - 28 - - - - - 16777215 - 28 - - - - ... - - - - - - - - - - - 0 - - - - - - 0 - 0 - - - - 24 - - - - - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 10 - 20 - - - - - - - - Create Video - - - - - - - false - - - Cancel - - - - - - - - - - - - Encoder Settings - - - - 10 - - - - - - - - 0 - 0 - - - - - 85 - 0 - - - - Container - - - - - - - - 150 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 5 - 5 - - - - - - - - - 0 - 0 - - - - Resolution - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - - - - - - - - - 0 - 0 - - - - - 85 - 0 - - - - Video Codec - - - - - - - - 150 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 5 - - - - - - - - - 0 - 0 - - - - Video Bitrate (Kbps) - - - - - - - 99999 - - - - - - - - - - - - 0 - 0 - - - - - 85 - 0 - - - - Audio Codec - - - - - - - - 150 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 10 - - - - - - - - - 0 - 0 - - - - Audio Bitrate (Kbps) - - - - - - - 9999 - - - - - - - - - - - - - QLayout::SetDefaultConstraint - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 500 - 0 - - - - - - - - - 0 - 0 - - - - - 0 - 180 - - - - - 16777215 - 180 - - - - -1 - - - - - - - - - - - - - - diff --git a/presetmanager.py b/presetmanager.py deleted file mode 100644 index 3b02714..0000000 --- a/presetmanager.py +++ /dev/null @@ -1,290 +0,0 @@ -from PyQt4 import QtGui, QtCore -import string -import os - -import core - - -class PresetManager(QtGui.QDialog): - def __init__(self, window, parent): - super().__init__(parent.window) - self.parent = parent - self.core = parent.core - self.settings = parent.settings - self.presetDir = self.core.presetDir - if not self.settings.value('presetDir'): - self.settings.setValue( - "presetDir", - os.path.join(self.core.dataDir, 'projects')) - - self.findPresets() - - # window - self.lastFilter = '*' - self.presetRows = [] # list of (comp, vers, name) tuples - self.window = window - self.window.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) - - # connect button signals - self.window.pushButton_delete.clicked.connect(self.openDeletePresetDialog) - self.window.pushButton_rename.clicked.connect(self.openRenamePresetDialog) - self.window.pushButton_import.clicked.connect(self.openImportDialog) - self.window.pushButton_export.clicked.connect(self.openExportDialog) - self.window.pushButton_close.clicked.connect(self.window.close) - - # create filter box and preset list - self.drawFilterList() - self.window.comboBox_filter.currentIndexChanged.connect( - lambda: self.drawPresetList( - self.window.comboBox_filter.currentText(), self.window.lineEdit_search.text() - ) - ) - - # make auto-completion for search bar - self.autocomplete = QtGui.QStringListModel() - completer = QtGui.QCompleter() - completer.setModel(self.autocomplete) - self.window.lineEdit_search.setCompleter(completer) - self.window.lineEdit_search.textChanged.connect( - lambda: self.drawPresetList( - self.window.comboBox_filter.currentText(), self.window.lineEdit_search.text() - ) - ) - self.drawPresetList('*') - - def show(self): - '''Open a new preset manager window from the mainwindow''' - self.findPresets() - self.drawFilterList() - self.drawPresetList('*') - self.window.show() - - def findPresets(self): - 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)) - 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.window.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.window.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.window.comboBox_filter.clear() - self.window.comboBox_filter.addItem('*') - for component in self.presets: - self.window.comboBox_filter.addItem(component) - - def clearPreset(self, compI=None): - '''Functions on mainwindow level from the context menu''' - compI = self.parent.window.listWidget_componentList.currentRow() - self.core.clearPreset(compI, self.parent) - - def openSavePresetDialog(self): - '''Functions on mainwindow level from the context menu''' - window = self.parent.window - selectedComponents = self.core.selectedComponents - componentList = self.parent.window.listWidget_componentList - - if componentList.currentRow() == -1: - return - while True: - index = componentList.currentRow() - currentPreset = selectedComponents[index].currentPreset - newName, OK = QtGui.QInputDialog.getText( - self.parent.window, - 'Audio Visualizer', - 'New Preset Name:', - QtGui.QLineEdit.Normal, - currentPreset - ) - if OK: - if core.Core.badName(newName): - self.warnMessage(self.parent.window) - continue - if newName: - if index != -1: - selectedComponents[index].currentPreset = newName - saveValueStore = \ - selectedComponents[index].savePreset() - componentName = str(selectedComponents[index]).strip() - vers = selectedComponents[index].version() - self.createNewPreset( - componentName, vers, newName, - saveValueStore, window=self.parent.window) - self.openPreset(newName) - 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 = self.window \ - if 'window' not in kwargs else kwargs['window'] - ch = self.parent.showMessage( - msg="%s already exists! Overwrite it?" % - os.path.basename(path), - showCancel=True, - icon=QtGui.QMessageBox.Warning, - parent=window) - if not ch: - # user clicked cancel - return True - - return False - - def openPreset(self, presetName): - componentList = self.parent.window.listWidget_componentList - selectedComponents = self.parent.core.selectedComponents - - index = componentList.currentRow() - if index == -1: - return - componentName = str(selectedComponents[index]).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): - selected = self.window.listWidget_presets.selectedItems() - if not selected: - return - row = self.window.listWidget_presets.row(selected[0]) - comp, vers, name = self.presetRows[row] - ch = self.parent.showMessage( - msg='Really delete %s?' % name, - showCancel=True, - icon=QtGui.QMessageBox.Warning, - parent=self.window - ) - if not ch: - return - self.deletePreset(comp, vers, name) - self.findPresets() - self.drawPresetList() - - def deletePreset(self, comp, vers, name): - filepath = os.path.join(self.presetDir, comp, str(vers), name) - os.remove(filepath) - - def warnMessage(self, window=None): - print(window) - self.parent.showMessage( - msg='Preset names must contain only letters, ' - 'numbers, and spaces.', - parent=window if window else self.window) - - def openRenamePresetDialog(self): - presetList = self.window.listWidget_presets - if presetList.currentRow() == -1: - return - - while True: - index = presetList.currentRow() - newName, OK = QtGui.QInputDialog.getText( - self.window, - 'Preset Manager', - 'Rename Preset:', - QtGui.QLineEdit.Normal, - self.presetRows[index][2] - ) - if OK: - if core.Core.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) - oldPath = os.path.join(path, oldName) - if self.presetExists(newPath): - return - if os.path.exists(newPath): - os.remove(newPath) - os.rename(oldPath, newPath) - self.findPresets() - self.drawPresetList() - break - - def openImportDialog(self): - filename = QtGui.QFileDialog.getOpenFileName( - self.window, "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): - if not self.window.listWidget_presets.selectedItems(): - return - filename = QtGui.QFileDialog.getSaveFileName( - self.window, "Export Preset", - self.settings.value("presetDir"), - "Preset Files (*.avl)") - if filename: - index = self.window.listWidget_presets.currentRow() - 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.window - ) - self.settings.setValue("presetDir", os.path.dirname(filename)) diff --git a/presetmanager.ui b/presetmanager.ui deleted file mode 100644 index 5257b1c..0000000 --- a/presetmanager.ui +++ /dev/null @@ -1,150 +0,0 @@ - - - presetmanager - - - Qt::NonModal - - - true - - - - 0 - 0 - 497 - 377 - - - - Preset Manager - - - - - - - - - - - Filter by name - - - - - - - - 200 - 0 - - - - - - - - - - - - - 0 - 0 - - - - true - - - - - - - - - QLayout::SetMinimumSize - - - - - Import - - - - - - - Export - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - true - - - Rename - - - - - - - Delete - - - - - - - - - - - <html><head/><body><p><span style=" font-size:10pt; font-style:italic;">Right-click components in the main window to create presets</span></p></body></html> - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Close - - - - - - - - - - diff --git a/preview_thread.py b/preview_thread.py deleted file mode 100644 index eabf715..0000000 --- a/preview_thread.py +++ /dev/null @@ -1,59 +0,0 @@ -from PyQt4 import QtCore, QtGui, uic -from PyQt4.QtCore import pyqtSignal, pyqtSlot -from PIL import Image -from PIL.ImageQt import ImageQt -import core -from queue import Queue, Empty -import os -from copy import copy - - -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.parent = parent - self.core = core.Core() - self.queue = queue - self.core.settings = parent.settings - self.stackedWidget = parent.window.stackedWidget - self.background = Image.new("RGBA", (1920, 1080), (0, 0, 0, 0)) - self.background.paste(Image.open(os.path.join( - self.core.wd, "background.png"))) - - @pyqtSlot(str, 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.core.settings.value('outputWidth')) - height = int(self.core.settings.value('outputHeight')) - frame = copy(self.background) - frame = frame.resize((width, height)) - - components = nextPreviewInformation["components"] - for component in reversed(components): - frame = Image.alpha_composite( - frame, component.previewRender(self)) - - self._image = ImageQt(frame) - self.imageCreated.emit(QtGui.QImage(self._image)) - - except Empty: - True diff --git a/setup.py b/setup.py index 48034dc..fde3461 100644 --- a/setup.py +++ b/setup.py @@ -1,51 +1,19 @@ -from cx_Freeze import setup, Executable -import sys - -# Dependencies are automatically detected, but it might need -# fine tuning. - -buildOptions = dict( - packages=[], - excludes=[ - "apport", - "apt", - "curses", - "distutils", - "email", - "html", - "http", - "xmlrpc", - "nose" - ], - include_files=[ - "mainwindow.ui", - "presetmanager.ui", - "background.png", - "encoder-options.json", - "components/" - ], - includes=[ - 'numpy.core._methods', - 'numpy.lib.format' - ] -) - - -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='GUI tool to render visualization videos of audio files', - options=dict(build_exe=buildOptions), - executables=executables -) ++from setuptools import setup, find_packages + + -# Dependencies are automatically detected, but it might need +setup(name='audio_visualizer_python', + -# fine tuning. + version='1.0', + -buildOptions = dict(packages = [], excludes = [ + description='a little GUI tool to render visualization \ + - "apport", + videos of audio files', + - "apt", + license='MIT', + - "ctypes", + url='https://github.com/djfun/audio-visualizer-python', + - "curses", + packages=find_packages(), + - "distutils", + package_data={ + - "email", + 'src': ['*'], + - "html", + }, + - "http", + install_requires=['pillow-simd', 'numpy', ''], + - "json", + entry_points={ + - "xmlrpc", + 'gui_scripts': [ + - "nose" + 'audio-visualizer-python = avpython.main:main' + - ], include_files = ["main.ui"]) + ] + - + } + -import sys + ) \ No newline at end of file diff --git a/src/background.png b/src/background.png new file mode 100644 index 0000000..fb58593 Binary files /dev/null and b/src/background.png differ diff --git a/src/command.py b/src/command.py new file mode 100644 index 0000000..1a1e810 --- /dev/null +++ b/src/command.py @@ -0,0 +1,126 @@ +from PyQt4 import QtCore +from PyQt4.QtCore import QSettings +import argparse +import os +import sys + +import core +import video_thread +from main import LoadDefaultSettings + + +class Command(QtCore.QObject): + + videoTask = QtCore.pyqtSignal(str, str, list) + + def __init__(self): + QtCore.QObject.__init__(self) + self.core = core.Core() + self.dataDir = self.core.dataDir + self.canceled = False + + 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') + + # 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() + self.settings = QSettings( + os.path.join(self.dataDir, 'settings.ini'), QSettings.IniFormat) + LoadDefaultSettings(self) + + if self.args.projpath: + self.core.openProject(self, self.args.projpath) + 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.input and self.args.output: + self.createAudioVisualisation() + elif 'help' not in sys.argv: + self.parser.print_help() + quit(1) + + def createAudioVisualisation(self): + 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.input, + self.args.output, + list(reversed(self.core.selectedComponents)) + ) + + def videoCreated(self): + self.videoThread.quit() + self.videoThread.wait() + quit(0) + + def showMessage(self, **kwargs): + print(kwargs['msg']) + if 'detail' in kwargs: + print(kwargs['detail']) + + 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 diff --git a/src/components/__base__.py b/src/components/__base__.py new file mode 100644 index 0000000..a4677b1 --- /dev/null +++ b/src/components/__base__.py @@ -0,0 +1,153 @@ +from PyQt5 import QtGui, QtCore, QtWidgets +from PIL import Image +import os + + +class Component(QtCore.QObject): + '''A base class for components to inherit from''' + + # modified = QtCore.pyqtSignal(int, bool) + + def __init__(self, moduleIndex, compPos, core): + super().__init__() + self.currentPreset = None + self.moduleIndex = moduleIndex + self.compPos = compPos + self.core = core + + def __str__(self): + return self.__doc__ + + def version(self): + # change this number to identify new versions of a component + return 1 + + def cancel(self): + # please stop any lengthy process in response to this variable + self.canceled = True + + def reset(self): + self.canceled = False + + def update(self): + self.modified.emit(self.compPos, self.savePreset()) + # read your widget values, then call super().update() + + def loadPreset(self, presetDict, presetName): + '''Subclasses take (presetDict, presetName=None) as args. + Must use super().loadPreset(presetDict, presetName) first, + then update self.page widgets using the preset dict. + ''' + self.currentPreset = presetName \ + if presetName != None else presetDict['preset'] + + def preFrameRender(self, **kwargs): + '''Triggered only before a video is exported (video_thread.py) + self.worker = the video thread worker + 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 MainProgram if needed + for a long initialization procedure (i.e., for a visualizer) + ''' + for var, value in kwargs.items(): + exec('self.%s = value' % var) + + def command(self, arg): + '''Configure a component using argument from the commandline. + Use super().command(arg) at the end of a subclass's method, + if no arguments are found in that method first + ''' + 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) + else: + print( + self.__doc__, 'Usage:\n' + 'Open a preset for this component:\n' + ' "preset=Preset Name"') + self.commandHelp() + quit(0) + + def commandHelp(self): + '''Print help text for this Component's commandline arguments''' + + def blankFrame(self, width, height): + return Image.new("RGBA", (width, height), (0, 0, 0, 0)) + + def pickColor(self): + '''Use color picker to get color input from the user, + and return this as an RGB string and QPushButton stylesheet. + In a subclass apply stylesheet to any color selection widgets + ''' + dialog = QtGui.QColorDialog() + dialog.setOption(QtGui.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() + return RGBstring, btnStyle + else: + return None, None + + def RGBFromString(self, 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) + + ''' + ### Reference methods for creating a new component + ### (Inherit from this class and define these) + + def widget(self, parent): + self.parent = parent + page = uic.loadUi(os.path.join( + os.path.dirname(os.path.realpath(__file__)), 'example.ui')) + # --- connect widget signals here --- + self.page = page + return page + + def update(self): + super().update() + self.parent.drawPreview() + + def previewRender(self, previewWorker): + width = int(previewWorker.core.settings.value('outputWidth')) + height = int(previewWorker.core.settings.value('outputHeight')) + image = Image.new("RGBA", (width, height), (0,0,0,0)) + return image + + def frameRender(self, moduleNo, frameNo): + width = int(self.worker.core.settings.value('outputWidth')) + height = int(self.worker.core.settings.value('outputHeight')) + image = Image.new("RGBA", (width, height), (0,0,0,0)) + return image + ''' + +class BadComponentInit(Exception): + def __init__(self, arg, name): + string = \ +'''################################ +Mandatory argument "%s" not specified + in %s instance initialization +###################################''' + print(string % (arg, name)) + quit() 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/color.py b/src/components/color.py new file mode 100644 index 0000000..8f9a1d1 --- /dev/null +++ b/src/components/color.py @@ -0,0 +1,246 @@ +from PIL import Image, ImageDraw +from PyQt5 import uic, QtGui, QtCore +from PyQt5.QtGui import QColor +from PIL.ImageQt import ImageQt +import os +from . import __base__ + + +class Component(__base__.Component): + '''Color''' + + modified = QtCore.pyqtSignal(int, dict) + + def widget(self, parent): + self.parent = parent + page = uic.loadUi(os.path.join( + os.path.dirname(os.path.realpath(__file__)), 'color.ui')) + + self.color1 = (0, 0, 0) + self.color2 = (133, 133, 133) + self.x = 0 + self.y = 0 + + page.lineEdit_color1.setText('%s,%s,%s' % self.color1) + page.lineEdit_color2.setText('%s,%s,%s' % self.color2) + + btnStyle1 = "QPushButton { background-color : %s; outline: none; }" \ + % QColor(*self.color1).name() + + btnStyle2 = "QPushButton { background-color : %s; outline: none; }" \ + % QColor(*self.color2).name() + + page.pushButton_color1.setStyleSheet(btnStyle1) + page.pushButton_color2.setStyleSheet(btnStyle2) + page.pushButton_color1.clicked.connect(lambda: self.pickColor(1)) + page.pushButton_color2.clicked.connect(lambda: self.pickColor(2)) + + # disable color #2 until non-default 'fill' option gets changed + page.lineEdit_color2.setDisabled(True) + page.pushButton_color2.setDisabled(True) + page.spinBox_x.valueChanged.connect(self.update) + page.spinBox_y.valueChanged.connect(self.update) + page.spinBox_width.setValue( + int(parent.settings.value("outputWidth"))) + page.spinBox_height.setValue( + int(parent.settings.value("outputHeight"))) + + page.lineEdit_color1.textChanged.connect(self.update) + page.lineEdit_color2.textChanged.connect(self.update) + page.spinBox_x.valueChanged.connect(self.update) + page.spinBox_y.valueChanged.connect(self.update) + page.spinBox_width.valueChanged.connect(self.update) + page.spinBox_height.valueChanged.connect(self.update) + page.checkBox_trans.stateChanged.connect(self.update) + + self.fillLabels = [ \ + 'Solid', + 'Linear Gradient', + 'Radial Gradient', + ] + for label in self.fillLabels: + page.comboBox_fill.addItem(label) + page.comboBox_fill.setCurrentIndex(0) + page.comboBox_fill.currentIndexChanged.connect(self.update) + page.comboBox_spread.currentIndexChanged.connect(self.update) + page.spinBox_radialGradient_end.valueChanged.connect(self.update) + page.spinBox_radialGradient_start.valueChanged.connect(self.update) + page.spinBox_radialGradient_spread.valueChanged.connect(self.update) + page.spinBox_linearGradient_end.valueChanged.connect(self.update) + page.spinBox_linearGradient_start.valueChanged.connect(self.update) + page.checkBox_stretch.stateChanged.connect(self.update) + + self.page = page + return page + + def update(self): + self.color1 = self.RGBFromString(self.page.lineEdit_color1.text()) + self.color2 = self.RGBFromString(self.page.lineEdit_color2.text()) + self.x = self.page.spinBox_x.value() + self.y = self.page.spinBox_y.value() + self.sizeWidth = self.page.spinBox_width.value() + self.sizeHeight = self.page.spinBox_height.value() + self.trans = self.page.checkBox_trans.isChecked() + self.spread = self.page.comboBox_spread.currentIndex() + + self.RG_start = self.page.spinBox_radialGradient_start.value() + self.RG_end = self.page.spinBox_radialGradient_end.value() + self.RG_centre = self.page.spinBox_radialGradient_spread.value() + self.stretch = self.page.checkBox_stretch.isChecked() + self.LG_start = self.page.spinBox_linearGradient_start.value() + self.LG_end = self.page.spinBox_linearGradient_end.value() + + self.fillType = self.page.comboBox_fill.currentIndex() + if self.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) + self.page.fillWidget.setCurrentIndex(self.fillType) + + self.parent.drawPreview() + super().update() + + def previewRender(self, previewWorker): + width = int(previewWorker.core.settings.value('outputWidth')) + height = int(previewWorker.core.settings.value('outputHeight')) + return self.drawFrame(width, height) + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + return ['static'] + + def frameRender(self, moduleNo, arrayNo, frameNo): + width = int(self.worker.core.settings.value('outputWidth')) + height = int(self.worker.core.settings.value('outputHeight')) + return self.drawFrame(width, 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 Image.new("RGBA", (width, height), (r, g, b, 255)) + + frame = self.blankFrame(width, height) + + # Return a solid image at x, y + if self.fillType == 0: + image = Image.new("RGBA", shapeSize, (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 = ImageQt(frame) + painter = QtGui.QPainter(image) + 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_start+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, QColor(*self.color1)) + if self.trans: + brush.setColorAt(1.0, QColor(0, 0, 0, 0)) + elif self.fillType == 1 and self.stretch: + brush.setColorAt(0.2, QColor(*self.color2)) + else: + brush.setColorAt(1.0, QColor(*self.color2)) + painter.setBrush(brush) + painter.drawRect(self.x, self.y, + self.sizeWidth, self.sizeHeight) + painter.end() + imBytes = image.bits().asstring(image.numBytes()) + return Image.frombytes('RGBA', (width, height), imBytes) + + def loadPreset(self, pr, presetName=None): + super().loadPreset(pr, presetName) + + self.page.comboBox_fill.setCurrentIndex(pr['fillType']) + self.page.lineEdit_color1.setText('%s,%s,%s' % pr['color1']) + self.page.lineEdit_color2.setText('%s,%s,%s' % pr['color2']) + self.page.spinBox_x.setValue(pr['x']) + self.page.spinBox_y.setValue(pr['y']) + self.page.spinBox_width.setValue(pr['width']) + self.page.spinBox_height.setValue(pr['height']) + self.page.checkBox_trans.setChecked(pr['trans']) + + self.page.spinBox_radialGradient_start.setValue(pr['RG_start']) + self.page.spinBox_radialGradient_end.setValue(pr['RG_end']) + self.page.spinBox_radialGradient_spread.setValue(pr['RG_centre']) + self.page.spinBox_linearGradient_start.setValue(pr['LG_start']) + self.page.spinBox_linearGradient_end.setValue(pr['LG_end']) + self.page.checkBox_stretch.setChecked(pr['stretch']) + self.page.comboBox_spread.setCurrentIndex(pr['spread']) + + btnStyle1 = "QPushButton { background-color : %s; outline: none; }" \ + % QColor(*pr['color1']).name() + btnStyle2 = "QPushButton { background-color : %s; outline: none; }" \ + % QColor(*pr['color2']).name() + self.page.pushButton_color1.setStyleSheet(btnStyle1) + self.page.pushButton_color2.setStyleSheet(btnStyle2) + + def savePreset(self): + return { + 'preset': self.currentPreset, + 'color1': self.color1, + 'color2': self.color2, + 'x': self.x, + 'y': self.y, + 'fillType': self.fillType, + 'width': self.sizeWidth, + 'height': self.sizeHeight, + 'trans': self.trans, + 'stretch': self.stretch, + 'spread': self.spread, + 'RG_start': self.RG_start, + 'RG_end': self.RG_end, + 'RG_centre': self.RG_centre, + 'LG_start': self.LG_start, + 'LG_end': self.LG_end, + } + + def pickColor(self, num): + RGBstring, btnStyle = super().pickColor() + if not RGBstring: + return + if num == 1: + self.page.lineEdit_color1.setText(RGBstring) + self.page.pushButton_color1.setStyleSheet(btnStyle) + else: + self.page.lineEdit_color2.setText(RGBstring) + self.page.pushButton_color2.setStyleSheet(btnStyle) + + def commandHelp(self): + print('Specify a color:\n color=255,255,255') + + def command(self, arg): + if not arg.startswith('preset=') and '=' 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..a9dacea --- /dev/null +++ b/src/components/color.ui @@ -0,0 +1,660 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Color #1 + + + + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + 12 + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Color #2 + + + + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + 12 + + + + + + + + + 0 + + + + + + 0 + 0 + + + + Width + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + 0 + + + 999999999 + + + 0 + + + + + + + + 0 + 0 + + + + Height + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + 999999999 + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + + 0 + + + + + + 0 + 0 + + + + Fill + + + + + + + + 0 + 0 + + + + -1 + + + QComboBox::AdjustToContentsOnFirstShow + + + + + + + + 0 + 0 + + + + Transparent + + + + + + + + 0 + 0 + + + + Stretch + + + + + + + + Pad + + + + + Reflect + + + + + Repeat + + + + + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 40 + 20 + + + + + + + + + + + + + 0 + 0 + + + + 0 + + + 2 + + + + + + + -1 + 0 + 561 + 31 + + + + + + + + 0 + 0 + + + + Start + + + + + + + -10000 + + + 10000 + + + 10 + + + + + + + + 0 + 0 + + + + End + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + -10000 + + + 10000 + + + 10 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + -1 + -1 + 561 + 31 + + + + + + + + 0 + 0 + + + + Start + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + -10000 + + + 10000 + + + 10 + + + + + + + + 0 + 0 + + + + End + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + -10000 + + + 10000 + + + 10 + + + + + + + + 0 + 0 + + + + Centre + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::PlusMinus + + + -10000 + + + 10000 + + + 3 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + diff --git a/src/components/image.py b/src/components/image.py new file mode 100644 index 0000000..8ca88d3 --- /dev/null +++ b/src/components/image.py @@ -0,0 +1,111 @@ +from PIL import Image, ImageDraw +from PyQt5 import uic, QtGui, QtCore, QtWidgets +import os +from . import __base__ + + +class Component(__base__.Component): + '''Image''' + + modified = QtCore.pyqtSignal(int, dict) + + def widget(self, parent): + self.parent = parent + self.settings = parent.settings + page = uic.loadUi(os.path.join( + os.path.dirname(os.path.realpath(__file__)), 'image.ui')) + self.imagePath = '' + self.x = 0 + self.y = 0 + + page.lineEdit_image.textChanged.connect(self.update) + page.pushButton_image.clicked.connect(self.pickImage) + page.spinBox_scale.valueChanged.connect(self.update) + page.checkBox_stretch.stateChanged.connect(self.update) + page.spinBox_x.valueChanged.connect(self.update) + page.spinBox_y.valueChanged.connect(self.update) + + self.page = page + return page + + def update(self): + self.imagePath = self.page.lineEdit_image.text() + self.scale = self.page.spinBox_scale.value() + self.xPosition = self.page.spinBox_x.value() + self.yPosition = self.page.spinBox_y.value() + self.stretched = self.page.checkBox_stretch.isChecked() + self.parent.drawPreview() + super().update() + + def previewRender(self, previewWorker): + self.imageFormats = previewWorker.core.imageFormats + width = int(previewWorker.core.settings.value('outputWidth')) + height = int(previewWorker.core.settings.value('outputHeight')) + return self.drawFrame(width, height) + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + return ['static'] + + def frameRender(self, moduleNo, arrayNo, frameNo): + width = int(self.worker.core.settings.value('outputWidth')) + height = int(self.worker.core.settings.value('outputHeight')) + return self.drawFrame(width, height) + + def drawFrame(self, width, height): + frame = self.blankFrame(width, height) + if self.imagePath and os.path.exists(self.imagePath): + image = Image.open(self.imagePath) + if self.stretched and image.size != (width, height): + image = image.resize((width, height), Image.ANTIALIAS) + if self.scale != 100: + newHeight = int((image.height / 100) * self.scale) + newWidth = int((image.width / 100) * self.scale) + image = image.resize((newWidth, newHeight), Image.ANTIALIAS) + frame.paste(image, box=(self.xPosition, self.yPosition)) + return frame + + def loadPreset(self, pr, presetName=None): + super().loadPreset(pr, presetName) + self.page.lineEdit_image.setText(pr['image']) + self.page.spinBox_scale.setValue(pr['scale']) + self.page.spinBox_x.setValue(pr['x']) + self.page.spinBox_y.setValue(pr['y']) + self.page.checkBox_stretch.setChecked(pr['stretched']) + + def savePreset(self): + return { + 'preset': self.currentPreset, + 'image': self.imagePath, + 'scale': self.scale, + 'stretched': self.stretched, + 'x': self.xPosition, + 'y': self.yPosition, + } + + def pickImage(self): + imgDir = self.settings.value("backgroundDir", os.path.expanduser("~")) + filename = QtGui.QFileDialog.getOpenFileName( + self.page, "Choose Image", imgDir, + "Image Files (%s)" % " ".join(self.imageFormats)) + if filename: + self.settings.setValue("backgroundDir", os.path.dirname(filename)) + self.page.lineEdit_image.setText(filename) + self.update() + + def command(self, arg): + if not arg.startswith('preset=') and '=' 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') diff --git a/src/components/image.ui b/src/components/image.ui new file mode 100644 index 0000000..6df03a5 --- /dev/null +++ b/src/components/image.ui @@ -0,0 +1,259 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Image + + + + + + + + 1 + 0 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + + 32 + 32 + + + + ... + + + + 32 + 32 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -1000 + + + 1000 + + + 0 + + + + + + + + + + + + + Stretch + + + false + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + Scale + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + % + + + 10 + + + 400 + + + 100 + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/components/original.py b/src/components/original.py new file mode 100644 index 0000000..61f463d --- /dev/null +++ b/src/components/original.py @@ -0,0 +1,204 @@ +import numpy +from PIL import Image, ImageDraw +from PyQt5 import uic, QtGui, QtCore +from PyQt5.QtGui import QColor +import os +from . import __base__ +import time +from copy import copy + + +class Component(__base__.Component): + '''Original Audio Visualization''' + + modified = QtCore.pyqtSignal(int, dict) + + def widget(self, parent): + self.parent = parent + self.visColor = (255, 255, 255) + + page = uic.loadUi(os.path.join( + os.path.dirname(os.path.realpath(__file__)), 'original.ui')) + page.comboBox_visLayout.addItem("Classic") + page.comboBox_visLayout.addItem("Split") + page.comboBox_visLayout.addItem("Bottom") + page.comboBox_visLayout.setCurrentIndex(0) + page.comboBox_visLayout.currentIndexChanged.connect(self.update) + page.lineEdit_visColor.setText('%s,%s,%s' % self.visColor) + page.pushButton_visColor.clicked.connect(lambda: self.pickColor()) + btnStyle = "QPushButton { background-color : %s; outline: none; }" \ + % QColor(*self.visColor).name() + page.pushButton_visColor.setStyleSheet(btnStyle) + page.lineEdit_visColor.textChanged.connect(self.update) + self.page = page + self.canceled = False + return page + + def update(self): + self.layout = self.page.comboBox_visLayout.currentIndex() + self.visColor = self.RGBFromString(self.page.lineEdit_visColor.text()) + self.parent.drawPreview() + super().update() + + def loadPreset(self, pr, presetName=None): + super().loadPreset(pr, presetName) + + self.page.lineEdit_visColor.setText('%s,%s,%s' % pr['visColor']) + btnStyle = "QPushButton { background-color : %s; outline: none; }" \ + % QColor(*pr['visColor']).name() + self.page.pushButton_visColor.setStyleSheet(btnStyle) + self.page.comboBox_visLayout.setCurrentIndex(pr['layout']) + + def savePreset(self): + return { + 'preset': self.currentPreset, + 'layout': self.layout, + 'visColor': self.visColor, + } + + def previewRender(self, previewWorker): + spectrum = numpy.fromfunction( + lambda x: 0.008*(x-128)**2, (255,), dtype="int16") + width = int(previewWorker.core.settings.value('outputWidth')) + height = int(previewWorker.core.settings.value('outputHeight')) + return self.drawBars( + width, 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 = {} + self.width = int(self.worker.core.settings.value('outputWidth')) + self.height = int(self.worker.core.settings.value('outputHeight')) + + 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, moduleNo, arrayNo, frameNo): + return self.drawBars( + self.width, self.height, + self.spectrumArray[arrayNo], + self.visColor, self.layout) + + def pickColor(self): + RGBstring, btnStyle = super().pickColor() + if not RGBstring: + return + self.page.lineEdit_visColor.setText(RGBstring) + self.page.pushButton_visColor.setStyleSheet(btnStyle) + + 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 drawBars(self, width, height, spectrum, color, layout): + vH = height-height/8 + bF = width / 64 + bH = bF / 2 + bQ = bF / 4 + imTop = self.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 = self.blankFrame(width, height) + + if layout == 0: + y = 0 - int(height/100*43) + im.paste(imTop, (0, y), mask=imTop) + y = 0 + int(height/100*43) + im.paste(imBottom, (0, y), mask=imBottom) + + if layout == 1: + y = 0 + int(height/100*10) + im.paste(imTop, (0, y), mask=imTop) + y = 0 - int(height/100*10) + im.paste(imBottom, (0, y), mask=imBottom) + + if layout == 2: + y = 0 + int(height/100*10) + im.paste(imTop, (0, y), mask=imTop) + + return im + + def command(self, arg): + if not arg.startswith('preset=') and '=' in arg: + key, arg = arg.split('=', 1) + 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) + return + super().command(arg) + + def commandHelp(self): + print('Give a layout name:\n layout=[classic/split/bottom]') + print('Specify a color:\n color=255,255,255') diff --git a/src/components/original.ui b/src/components/original.ui new file mode 100644 index 0000000..5808653 --- /dev/null +++ b/src/components/original.ui @@ -0,0 +1,108 @@ + + + Form + + + + 0 + 0 + 633 + 178 + + + + + 180 + 0 + + + + Form + + + + + + 4 + + + + + + 0 + 0 + + + + Visualizer Layout + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + Visualizer Color + + + + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/components/text.py b/src/components/text.py new file mode 100644 index 0000000..0f599ed --- /dev/null +++ b/src/components/text.py @@ -0,0 +1,176 @@ +from PIL import Image, ImageDraw +from PyQt5.QtGui import QPainter, QColor, QFont +from PyQt5 import uic, QtGui, QtCore +from PIL.ImageQt import ImageQt +import os +import io +from . import __base__ + + +class Component(__base__.Component): + '''Title Text''' + + modified = QtCore.pyqtSignal(int, dict) + + def __init__(self, *args): + super().__init__(*args) + self.titleFont = QFont() + + def widget(self, parent): + height = int(parent.settings.value('outputHeight')) + width = int(parent.settings.value('outputWidth')) + + self.parent = parent + self.textColor = (255, 255, 255) + self.title = 'Text' + self.alignment = 1 + self.fontSize = height / 13.5 + fm = QtGui.QFontMetrics(self.titleFont) + self.xPosition = width / 2 - fm.width(self.title)/2 + self.yPosition = height / 2 * 1.036 + + page = uic.loadUi(os.path.join( + os.path.dirname(os.path.realpath(__file__)), 'text.ui')) + page.comboBox_textAlign.addItem("Left") + page.comboBox_textAlign.addItem("Middle") + page.comboBox_textAlign.addItem("Right") + + page.lineEdit_textColor.setText('%s,%s,%s' % self.textColor) + page.pushButton_textColor.clicked.connect(self.pickColor) + btnStyle = "QPushButton { background-color : %s; outline: none; }" \ + % QColor(*self.textColor).name() + page.pushButton_textColor.setStyleSheet(btnStyle) + + page.lineEdit_title.setText(self.title) + page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) + page.spinBox_fontSize.setValue(int(self.fontSize)) + page.spinBox_xTextAlign.setValue(int(self.xPosition)) + page.spinBox_yTextAlign.setValue(int(self.yPosition)) + + page.fontComboBox_titleFont.currentFontChanged.connect(self.update) + page.lineEdit_title.textChanged.connect(self.update) + page.comboBox_textAlign.currentIndexChanged.connect(self.update) + page.spinBox_xTextAlign.valueChanged.connect(self.update) + page.spinBox_yTextAlign.valueChanged.connect(self.update) + page.spinBox_fontSize.valueChanged.connect(self.update) + page.lineEdit_textColor.textChanged.connect(self.update) + self.page = page + return page + + def update(self): + self.title = self.page.lineEdit_title.text() + self.alignment = self.page.comboBox_textAlign.currentIndex() + self.titleFont = self.page.fontComboBox_titleFont.currentFont() + self.fontSize = self.page.spinBox_fontSize.value() + self.xPosition = self.page.spinBox_xTextAlign.value() + self.yPosition = self.page.spinBox_yTextAlign.value() + self.textColor = self.RGBFromString( + self.page.lineEdit_textColor.text()) + self.parent.drawPreview() + super().update() + + def getXY(self): + '''Returns true x, y after considering alignment settings''' + fm = QtGui.QFontMetrics(self.titleFont) + if self.alignment == 0: # Left + x = self.xPosition + + if self.alignment == 1: # Middle + offset = fm.width(self.title)/2 + x = self.xPosition - offset + + if self.alignment == 2: # Right + offset = fm.width(self.title) + x = self.xPosition - offset + return x, self.yPosition + + def loadPreset(self, pr, presetName=None): + super().loadPreset(pr, presetName) + + self.page.lineEdit_title.setText(pr['title']) + font = QFont() + font.fromString(pr['titleFont']) + self.page.fontComboBox_titleFont.setCurrentFont(font) + self.page.spinBox_fontSize.setValue(pr['fontSize']) + self.page.comboBox_textAlign.setCurrentIndex(pr['alignment']) + self.page.spinBox_xTextAlign.setValue(pr['xPosition']) + self.page.spinBox_yTextAlign.setValue(pr['yPosition']) + self.page.lineEdit_textColor.setText('%s,%s,%s' % pr['textColor']) + btnStyle = "QPushButton { background-color : %s; outline: none; }" \ + % QColor(*pr['textColor']).name() + self.page.pushButton_textColor.setStyleSheet(btnStyle) + + def savePreset(self): + return { + 'preset': self.currentPreset, + 'title': self.title, + 'titleFont': self.titleFont.toString(), + 'alignment': self.alignment, + 'fontSize': self.fontSize, + 'xPosition': self.xPosition, + 'yPosition': self.yPosition, + 'textColor': self.textColor + } + + def previewRender(self, previewWorker): + width = int(previewWorker.core.settings.value('outputWidth')) + height = int(previewWorker.core.settings.value('outputHeight')) + return self.addText(width, height) + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + return ['static'] + + def frameRender(self, moduleNo, arrayNo, frameNo): + width = int(self.worker.core.settings.value('outputWidth')) + height = int(self.worker.core.settings.value('outputHeight')) + return self.addText(width, height) + + def addText(self, width, height): + x, y = self.getXY() + im = self.blankFrame(width, height) + image = ImageQt(im) + + painter = QPainter(image) + self.titleFont.setPixelSize(self.fontSize) + painter.setFont(self.titleFont) + painter.setPen(QColor(*self.textColor)) + painter.drawText(x, y, self.title) + painter.end() + + imBytes = image.bits().asstring(image.numBytes()) + + return Image.frombytes('RGBA', (width, height), imBytes) + + def pickColor(self): + RGBstring, btnStyle = super().pickColor() + if not RGBstring: + return + self.page.lineEdit_textColor.setText(RGBstring) + self.page.pushButton_textColor.setStyleSheet(btnStyle) + + 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 not arg.startswith('preset=') and '=' 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..05e7f8e --- /dev/null +++ b/src/components/text.ui @@ -0,0 +1,316 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + Font + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Font Size + + + + + + + 500 + + + + + + + + + + + + 0 + 0 + + + + Text Layout + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + Text Color + + + + + + + + 32 + 32 + + + + + + + + 32 + 32 + + + + + + + + + + + + + 0 + + + + + Title + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Testing New GUI + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + 0 + + + 999999999 + + + 0 + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + 999999999 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/src/components/video.py b/src/components/video.py new file mode 100644 index 0000000..58ce7a3 --- /dev/null +++ b/src/components/video.py @@ -0,0 +1,273 @@ +from PIL import Image, ImageDraw +from PyQt5 import uic, QtGui, QtCore +import os +import subprocess +import threading +from queue import PriorityQueue +from . import __base__ + + +class Video: + '''Video Component Frame-Fetcher''' + def __init__(self, **kwargs): + mandatoryArgs = [ + 'ffmpeg', # path to ffmpeg, usually 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: + try: + exec('self.%s = kwargs[arg]' % arg) + except KeyError: + raise __base__.BadComponentInit(arg, self.__doc__) + + 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: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=self.__doc__ + ) + 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): + pipe = subprocess.Popen( + self.command, 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. + if len(self.currentFrame) == 0: + self.frameBuffer.put((self.frameNo-1, self.lastFrame)) + continue + + self.currentFrame = pipe.stdout.read(self.chunkSize) + if len(self.currentFrame) != 0: + self.frameBuffer.put((self.frameNo, self.currentFrame)) + self.lastFrame = self.currentFrame + + +class Component(__base__.Component): + '''Video''' + + modified = QtCore.pyqtSignal(int, dict) + + def widget(self, parent): + self.parent = parent + self.settings = parent.settings + page = uic.loadUi(os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'video.ui' + )) + self.videoPath = '' + self.x = 0 + self.y = 0 + self.loopVideo = False + + page.lineEdit_video.textChanged.connect(self.update) + page.pushButton_video.clicked.connect(self.pickVideo) + page.checkBox_loop.stateChanged.connect(self.update) + page.checkBox_distort.stateChanged.connect(self.update) + page.spinBox_scale.valueChanged.connect(self.update) + page.spinBox_x.valueChanged.connect(self.update) + page.spinBox_y.valueChanged.connect(self.update) + + self.page = page + return page + + def update(self): + self.videoPath = self.page.lineEdit_video.text() + self.loopVideo = self.page.checkBox_loop.isChecked() + self.distort = self.page.checkBox_distort.isChecked() + self.scale = self.page.spinBox_scale.value() + self.xPosition = self.page.spinBox_x.value() + self.yPosition = self.page.spinBox_y.value() + self.parent.drawPreview() + super().update() + + def previewRender(self, previewWorker): + self.videoFormats = previewWorker.core.videoFormats + width = int(previewWorker.core.settings.value('outputWidth')) + height = int(previewWorker.core.settings.value('outputHeight')) + self.updateChunksize(width, height) + frame = self.getPreviewFrame(width, height) + if not frame: + return self.blankFrame(width, height) + else: + return frame + + def preFrameRender(self, **kwargs): + super().preFrameRender(**kwargs) + width = int(self.worker.core.settings.value('outputWidth')) + height = int(self.worker.core.settings.value('outputHeight')) + self.blankFrame_ = self.blankFrame(width, height) + self.updateChunksize(width, height) + self.video = Video( + ffmpeg=self.parent.core.FFMPEG_BIN, videoPath=self.videoPath, + width=width, height=height, chunkSize=self.chunkSize, + frameRate=int(self.settings.value("outputFrameRate")), + parent=self.parent, loopVideo=self.loopVideo, + component=self, scale=self.scale + ) if os.path.exists(self.videoPath) else None + + def frameRender(self, moduleNo, arrayNo, frameNo): + if self.video: + return self.video.frame(frameNo) + else: + return self.blankFrame_ + + def loadPreset(self, pr, presetName=None): + super().loadPreset(pr, presetName) + self.page.lineEdit_video.setText(pr['video']) + self.page.checkBox_loop.setChecked(pr['loop']) + self.page.checkBox_distort.setChecked(pr['distort']) + self.page.spinBox_scale.setValue(pr['scale']) + self.page.spinBox_x.setValue(pr['x']) + self.page.spinBox_y.setValue(pr['y']) + + def savePreset(self): + return { + 'preset': self.currentPreset, + 'video': self.videoPath, + 'loop': self.loopVideo, + 'distort': self.distort, + 'scale': self.scale, + 'x': self.xPosition, + 'y': self.yPosition, + } + + def pickVideo(self): + imgDir = self.settings.value("backgroundDir", os.path.expanduser("~")) + filename = QtGui.QFileDialog.getOpenFileName( + self.page, "Choose Video", + imgDir, "Video Files (%s)" % " ".join(self.videoFormats) + ) + if filename: + self.settings.setValue("backgroundDir", os.path.dirname(filename)) + self.page.lineEdit_video.setText(filename) + self.update() + + def getPreviewFrame(self, width, height): + if not self.videoPath or not os.path.exists(self.videoPath): + return + + command = [ + self.parent.core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-i', self.videoPath, + '-f', 'image2pipe', + '-pix_fmt', 'rgba', + '-filter:v', 'scale=%s:%s' % + scale(self.scale, width, height, str), + '-vcodec', 'rawvideo', '-', + '-ss', '90', + '-vframes', '1', + ] + pipe = subprocess.Popen( + command, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=10**8 + ) + byteFrame = pipe.stdout.read(self.chunkSize) + frame = finalizeFrame(self, byteFrame, width, height) + pipe.stdout.close() + pipe.kill() + + return frame + + def updateChunksize(self, width, height): + if self.scale != 100 and not self.distort: + width, height = scale(self.scale, width, height, int) + self.chunkSize = 4*width*height + + def command(self, arg): + if not arg.startswith('preset=') and '=' in arg: + key, arg = arg.split('=', 1) + if key == 'path' and os.path.exists(arg): + if 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) + super().command(arg) + + def commandHelp(self): + print('Load a video:\n path=/filepath/to/video.mp4') + +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(int(width)), str(int(height))) + elif returntype == int: + return (int(width), int(height)) + else: + return (width, height) + +def finalizeFrame(self, imageData, width, height): + if self.distort: + try: + image = Image.frombytes( + 'RGBA', + (width, height), + imageData) + except ValueError: + print('#### ignored invalid data caused by distortion ####') + image = self.blankFrame(width, height) + else: + image = Image.frombytes( + 'RGBA', + scale(self.scale, width, height, int), + imageData) + + if self.scale != 100 \ + or self.xPosition != 0 or self.yPosition != 0: + frame = self.blankFrame(width, 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..f05e8a5 --- /dev/null +++ b/src/components/video.ui @@ -0,0 +1,266 @@ + + + Form + + + + 0 + 0 + 586 + 197 + + + + Form + + + + + + 4 + + + + + + + + 0 + 0 + + + + + 31 + 0 + + + + Video + + + + + + + + 1 + 0 + + + + + + + + + 0 + 0 + + + + + 1 + 0 + + + + + 32 + 32 + + + + ... + + + + 32 + 32 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 0 + + + + X + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + -10000 + + + 10000 + + + + + + + + 0 + 0 + + + + Y + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + 0 + 0 + + + + -10000 + + + 10000 + + + 0 + + + + + + + + + + + + + Loop + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + Distort by scale + + + + + + + Scale + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QAbstractSpinBox::UpDownArrows + + + % + + + 10 + + + 400 + + + 100 + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + diff --git a/src/core.py b/src/core.py new file mode 100644 index 0000000..bb5d351 --- /dev/null +++ b/src/core.py @@ -0,0 +1,477 @@ +import sys +import io +import os +from PyQt5 import QtCore, QtGui, uic +from os.path import expanduser +import subprocess as sp +import numpy +from PIL import Image +from shutil import rmtree +import time +from collections import OrderedDict +import json +from importlib import import_module +from PyQt5.QtCore import QStandardPaths +import string + + +class Core(): + + def __init__(self): + self.FFMPEG_BIN = self.findFfmpeg() + self.dataDir = QStandardPaths.writableLocation( + QStandardPaths.AppConfigLocation + ) + self.presetDir = os.path.join(self.dataDir, 'presets') + if getattr(sys, 'frozen', False): + # frozen + self.wd = os.path.dirname(sys.executable) + else: + # unfrozen + self.wd = os.path.dirname(os.path.realpath(__file__)) + + self.loadEncoderOptions() + self.videoFormats = Core.appendUppercase([ + '*.mp4', + '*.mov', + '*.mkv', + '*.avi', + '*.webm', + '*.flv', + ]) + self.audioFormats = Core.appendUppercase([ + '*.mp3', + '*.wav', + '*.ogg', + '*.fla', + '*.flac', + '*.aac', + ]) + self.imageFormats = Core.appendUppercase([ + '*.png', + '*.jpg', + '*.tif', + '*.tiff', + '*.gif', + '*.bmp', + '*.ico', + '*.xbm', + '*.xpm', + ]) + + self.findComponents() + self.selectedComponents = [] + # copies of named presets to detect modification + self.savedPresets = {} + + def findComponents(self): + def findComponents(): + srcPath = os.path.join(self.wd, 'components') + if os.path.exists(srcPath): + for f in sorted(os.listdir(srcPath)): + name, ext = os.path.splitext(f) + if name.startswith("__"): + continue + elif ext == '.py': + yield name + self.modules = [ + import_module('components.%s' % name) + for name in findComponents() + ] + self.moduleIndexes = [i for i in range(len(self.modules))] + self.compNames = [mod.Component.__doc__ for mod in self.modules] + + def componentListChanged(self): + for i, component in enumerate(self.selectedComponents): + component.compPos = i + + def insertComponent(self, compPos, moduleIndex, loader): + '''Creates a new component''' + if compPos < 0 or compPos > len(self.selectedComponents): + compPos = len(self.selectedComponents) + if len(self.selectedComponents) > 50: + return None + + component = self.modules[moduleIndex].Component( + moduleIndex, compPos, self) + self.selectedComponents.insert( + compPos, + component) + self.componentListChanged() + + # init component's widget for loading/saving presets + self.selectedComponents[compPos].widget(loader) + self.updateComponent(compPos) + + if hasattr(loader, 'insertComponent'): + loader.insertComponent(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): + # print('updating %s' % self.selectedComponents[i]) + self.selectedComponents[i].update() + + def moduleIndexFor(self, compName): + index = self.compNames.index(compName) + return self.moduleIndexes[index] + + 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 + try: + self.selectedComponents[compIndex].loadPreset( + saveValueStore, + presetName + ) + except KeyError as e: + print('preset missing value: %s' % e) + + self.savedPresets[presetName] = dict(saveValueStore) + return True + + def getPresetDir(self, comp): + return os.path.join( + self.presetDir, str(comp), str(comp.version())) + + 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 = Core.presetFromString(line.strip()) + break + return saveValueStore + + 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: + try: + for i, tup in enumerate(data['Components']): + name, vers, preset = tup + clearThis = False + + # add loaded named presets to savedPresets dict + if 'preset' in preset and preset['preset'] != None: + nam = preset['preset'] + filepath2 = os.path.join( + self.presetDir, name, str(vers), nam) + origSaveValueStore = self.getPreset(filepath2) + if origSaveValueStore: + self.savedPresets[nam] = dict(origSaveValueStore) + 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 == None: + loader.showMessage(msg="Too many components!") + break + + try: + if 'preset' in preset and preset['preset'] != None: + self.selectedComponents[i].loadPreset( + preset + ) + else: + self.selectedComponents[i].loadPreset( + preset, + preset['preset'] + ) + except KeyError as e: + print('%s missing value %s' % + (self.selectedComponents[i], e)) + + if clearThis: + self.clearPreset(i) + if hasattr(loader, 'updateComponentTitle'): + loader.updateComponentTitle(i) + except: + errcode = 1 + data = sys.exc_info() + + + if errcode == 1: + typ, value, _ = data + if typ.__name__ == KeyError: + # probably just an old version, still loadable + print('file missing value: %s' % value) + return + if hasattr(loader, 'createNewProject'): + loader.createNewProject() + msg = '%s: %s' % (typ.__name__, value) + loader.showMessage( + msg="Project file '%s' is corrupted." % filepath, + showCancel=False, + icon=QtGui.QMessageBox.Warning, + detail=msg) + + 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) + ''' + data = {} + try: + with open(filepath, 'r') as f: + def parseLine(line): + '''Decides if a file line is a section header''' + validSections = ('Components') + 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) + data[section] = [] + 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 = Core.presetFromString(line) + data[section].append( + (lastCompName, + lastCompVers, + lastCompPreset) + ) + i = 0 + return 0, data + except: + 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( + self.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(self.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 = Core.presetFromString(internalData[0].strip()) + self.createPresetFile( + compName, vers, + origName, saveValueStore, + exportPath + ) + return True + except: + 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(self.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(Core.presetToString(saveValueStore)) + + def createProjectFile(self, filepath): + '''Create a project file (.avp) using the current program state''' + try: + if not filepath.endswith(".avp"): + filepath += '.avp' + if os.path.exists(filepath): + os.remove(filepath) + with open(filepath, 'w') as f: + print('creating %s' % filepath) + f.write('[Components]\n') + for comp in self.selectedComponents: + saveValueStore = comp.savePreset() + f.write('%s\n' % str(comp)) + f.write('%s\n' % str(comp.version())) + f.write('%s\n' % Core.presetToString(saveValueStore)) + return True + except: + return False + + def loadEncoderOptions(self): + file_path = os.path.join(self.wd, 'encoder-options.json') + with open(file_path) as json_file: + self.encoder_options = json.load(json_file) + + 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 readAudioFile(self, filename, parent): + command = [self.FFMPEG_BIN, '-i', filename] + + try: + fileInfo = sp.check_output(command, stderr=sp.STDOUT, shell=False) + except sp.CalledProcessError as ex: + fileInfo = ex.output + pass + + info = fileInfo.decode("utf-8").split('\n') + 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]) + + 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") + + progress = 0 + lastPercent = None + while True: + if self.canceled: + break + # read 2 seconds of audio + progress = 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)+'%' + parent.progressBarSetText.emit(string) + parent.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 + + def cancel(self): + self.canceled = True + + def reset(self): + self.canceled = False + + @staticmethod + def badName(name): + '''Returns whether a name contains non-alphanumeric chars''' + return any([letter in string.punctuation for letter in name]) + + @staticmethod + def presetToString(dictionary): + '''Alphabetizes a dict into OrderedDict & returns string repr''' + return repr(OrderedDict(sorted(dictionary.items(), key=lambda t: t[0]))) + + @staticmethod + def presetFromString(string): + '''Turns a string repr of OrderedDict into a regular dict''' + return dict(eval(string)) + + @staticmethod + def appendUppercase(lst): + for form, i in zip(lst, range(len(lst))): + lst.append(form.upper()) + return lst 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/main.py b/src/main.py new file mode 100644 index 0000000..4bf26db --- /dev/null +++ b/src/main.py @@ -0,0 +1,88 @@ +from PyQt5 import QtGui, uic, QtWidgets +import sys +import os + +import core +import preview_thread +import video_thread + + +def LoadDefaultSettings(self): + self.resolutions = [ + '1920x1080', + '1280x720', + '854x480' + ] + + default = { + "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(self.dataDir, 'projects'), + } + + for parm, value in default.items(): + #print(parm, self.settings.value(parm)) + if self.settings.value(parm) is None: + self.settings.setValue(parm, value) + +if __name__ == "__main__": + mode = 'gui' + if len(sys.argv) > 2: + mode = 'cmd' + + elif len(sys.argv) == 2: + if sys.argv[1].startswith('-'): + mode = 'cmd' + else: + # opening a project file with gui + proj = sys.argv[1] + else: + # normal gui launch + proj = None + + app = QtWidgets.QApplication(sys.argv) + app.setApplicationName("audio-visualizer") + app.setOrganizationName("audio-visualizer") + + if mode == 'cmd': + from command import * + + main = Command() + + elif mode == 'gui': + from mainwindow import * + import atexit + import signal + + if getattr(sys, 'frozen', False): + # frozen + wd = os.path.dirname(sys.executable) + else: + # unfrozen + wd = os.path.dirname(os.path.realpath(__file__)) + + window = uic.loadUi(os.path.join(wd, "mainwindow.ui")) + # window.adjustSize() + desc = QtWidgets.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 = MainWindow(window, proj) + + signal.signal(signal.SIGINT, main.cleanUp) + atexit.register(main.cleanUp) + + # applicable to both modes + sys.exit(app.exec_()) diff --git a/src/mainwindow.py b/src/mainwindow.py new file mode 100644 index 0000000..a52a0f4 --- /dev/null +++ b/src/mainwindow.py @@ -0,0 +1,718 @@ +from queue import Queue +from PyQt5 import QtCore, QtGui, uic, QtWidgets +from PyQt5.QtCore import QSettings, Qt +from PyQt5.QtWidgets import QMenu, QShortcut +import sys +import os +import signal +import filecmp +import time + +import core +import preview_thread +import video_thread +from presetmanager import PresetManager +from main import LoadDefaultSettings + + +class PreviewWindow(QtWidgets.QLabel): + 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, Qt.KeepAspectRatio, transformMode=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() + + +class MainWindow(QtWidgets.QMainWindow): + + newTask = QtCore.pyqtSignal(list) + processTask = QtCore.pyqtSignal() + videoTask = QtCore.pyqtSignal(str, str, list) + + def __init__(self, window, project): + QtWidgets.QMainWindow.__init__(self) + + # print('main thread id: {}'.format(QtCore.QThread.currentThreadId())) + self.window = window + self.core = core.Core() + + self.pages = [] # widgets of component settings + self.lastAutosave = time.time() + + # Create data directory, load/create settings + self.dataDir = self.core.dataDir + self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') + self.settings = QSettings( + os.path.join(self.dataDir, 'settings.ini'), QSettings.IniFormat) + LoadDefaultSettings(self) + self.presetManager = PresetManager( + uic.loadUi( + os.path.join(self.core.wd, 'presetmanager.ui')), self) + + if not os.path.exists(self.dataDir): + os.makedirs(self.dataDir) + for neededDirectory in ( + self.core.presetDir, self.settings.value("projectDir")): + if not os.path.exists(neededDirectory): + os.mkdir(neededDirectory) + + # Make queues/timers for the preview thread + 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) + + # Begin decorating the window and connecting events + componentList = self.window.listWidget_componentList + + window.toolButton_selectAudioFile.clicked.connect( + self.openInputFileDialog) + + window.toolButton_selectOutputFile.clicked.connect( + self.openOutputFileDialog) + + window.progressBar_createVideo.setValue(0) + + window.pushButton_createVideo.clicked.connect( + self.createAudioVisualisation) + + window.pushButton_Cancel.clicked.connect(self.stopVideo) + + for i, container in enumerate(self.core.encoder_options['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) + #print(codec) + + 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) + + self.previewWindow = PreviewWindow(self, os.path.join( + self.core.wd, "background.png")) + window.verticalLayout_previewWrapper.addWidget(self.previewWindow) + + # Make component buttons + self.compMenu = QMenu() + for i, comp in enumerate(self.core.modules): + action = self.compMenu.addAction(comp.Component.__doc__) + 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) + + 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(self.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( + self.createNewProject) + + self.window.menuButton_openProject = self.projectMenu.addAction( + "Open Project") + self.window.menuButton_openProject.triggered.connect( + 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 + ) + + 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(os.path.expanduser('~'), 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) + + # Setup Hotkeys + 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) + + QtWidgets.QShortcut("Ctrl+T", self.window, activated=lambda: + self.window.pushButton_addComponent.click()) + 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, + activated=lambda: self.moveComponent(-1)) + QtWidgets.QShortcut("Ctrl+Down", self.window, + activated=lambda: self.moveComponent(1)) + QtWidgets.QShortcut("Ctrl+Home", self.window, self.moveComponentTop) + QtWidgets.QShortcut("Ctrl+End", self.window, self.moveComponentBottom) + QtWidgets.QShortcut("Ctrl+r", self.window, self.removeComponent) + + def cleanUp(self): + self.timer.stop() + self.previewThread.quit() + self.previewThread.wait() + self.autosave() + + def updateWindowTitle(self): + appName = 'Audio Visualizer' + if self.currentProject: + appName += ' - %s' % \ + os.path.splitext( + os.path.basename(self.currentProject))[0] + self.window.setWindowTitle(appName) + + @QtCore.pyqtSlot(int, dict) + def updateComponentTitle(self, pos, presetStore=False): + if type(presetStore) == dict: + name = presetStore['preset'] + if name == 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 self.core.encoder_options['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): + 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) + + 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 >= 2.0: + self.core.createProjectFile(self.autosavePath) + self.lastAutosave = time.time() + + def autosaveExists(self, identical=True): + 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): + 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 = QtGui.QFileDialog.getOpenFileName( + self.window, "Open Audio File", + inputDir, "Audio Files (%s)" % " ".join(self.core.audioFormats)) + + if not 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 = QtGui.QFileDialog.getSaveFileName( + self.window, "Set Output Video File", + outputDir, + "Video Files (%s);; All Files (*)" % " ".join(self.core.videoFormats)) + + if not 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 + if self.window.lineEdit_audioFile.text() and \ + self.window.lineEdit_outputFile.text(): + self.canceled = False + self.progressBarUpdated(-1) + 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.videoWorker.imageCreated.connect(self.showPreviewImage) + self.videoWorker.encoding.connect(self.changeEncodingStatus) + self.videoThread.start() + outputPath = self.window.lineEdit_outputFile.text() + if not os.path.dirname(outputPath): + outputPath = os.path.join( + os.path.expanduser("~"), outputPath) + self.videoTask.emit( + self.window.lineEdit_audioFile.text(), + outputPath, + self.core.selectedComponents) + else: + self.showMessage( + msg="You must select an audio file and output filename.") + + def changeEncodingStatus(self, 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.listWidget_componentList.setEnabled(False) + self.window.menuButton_newProject.setEnabled(False) + self.window.menuButton_openProject.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.listWidget_componentList.setEnabled(True) + self.window.menuButton_newProject.setEnabled(True) + self.window.menuButton_openProject.setEnabled(True) + self.drawPreview(True) + + def progressBarUpdated(self, value): + self.window.progressBar_createVideo.setValue(value) + + def progressBarSetText(self, value): + self.window.progressBar_createVideo.setFormat(value) + + def videoCreated(self): + self.videoThread.quit() + self.videoThread.wait() + + def updateResolution(self): + resIndex = int(self.window.comboBox_resolution.currentIndex()) + res = self.resolutions[resIndex].split('x') + self.settings.setValue('outputWidth', res[0]) + self.settings.setValue('outputHeight', res[1]) + self.drawPreview() + + def drawPreview(self, force=False): + self.newTask.emit(self.core.selectedComponents) + # self.processTask.emit() + self.autosave(force) + + def showPreviewImage(self, image): + self.previewWindow.changePixmap(image) + + def insertComponent(self, index): + componentList = self.window.listWidget_componentList + stackedWidget = self.window.stackedWidget + + componentList.insertItem( + index, + self.core.selectedComponents[index].__doc__) + 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() + + def moveComponent(self, change): + '''Moves a component relatively from its current position''' + componentList = self.window.listWidget_componentList + 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() + + def moveComponentTop(self): + componentList = self.window.listWidget_componentList + row = -componentList.currentRow() + self.moveComponent(row) + + def moveComponentBottom(self): + componentList = self.window.listWidget_componentList + row = len(componentList)-componentList.currentRow()-1 + self.moveComponent(row) + + def dragComponent(self, event): + '''Drop event for the component listwidget''' + componentList = self.window.listWidget_componentList + + modelIndexes = [ \ + componentList.model().index(i) \ + for i in range(componentList.count()) \ + ] + rects = [ \ + componentList.visualRect(modelIndex) \ + for modelIndex in modelIndexes \ + ] + + rowPos = [rect.contains(event.pos()) for rect in rects] + if not any(rowPos): + return + + i = rowPos.index(True) + change = (componentList.currentRow() - i) * -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 = [] + + def createNewProject(self): + self.openSaveChangesDialog('starting a new project') + + self.clear() + self.currentProject = None + self.settings.setValue("currentProject", None) + self.drawPreview(True) + self.updateWindowTitle() + + def saveCurrentProject(self): + if self.currentProject: + self.core.createProjectFile(self.currentProject) + 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 = QtGui.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.updateWindowTitle() + self.core.createProjectFile(filename) + + def openOpenProjectDialog(self): + filename = QtGui.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'): + self.updateWindowTitle() + 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.updateWindowTitle() + 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) + if self.window.listWidget_componentList.count() == 0: + self.drawPreview() + self.autosave(True) + + def showMessage(self, **kwargs): + parent = kwargs['parent'] if 'parent' in kwargs else self.window + msg = QtGui.QMessageBox(parent) + msg.setModal(True) + msg.setText(kwargs['msg']) + msg.setIcon( + kwargs['icon'] if 'icon' in kwargs else QtGui.QMessageBox.Information) + msg.setDetailedText(kwargs['detail'] if 'detail' in kwargs else None) + if 'showCancel'in kwargs and kwargs['showCancel']: + msg.setStandardButtons( + QtGui.QMessageBox.Ok | QtGui.QMessageBox.Cancel) + else: + msg.setStandardButtons(QtGui.QMessageBox.Ok) + ch = msg.exec_() + if ch == 1024: + return True + return False + + def componentContextMenu(self, QPos): + '''Appears when right-clicking a component in the list''' + componentList = self.window.listWidget_componentList + if not componentList.selectedItems(): + return + + # don't show menu if clicking empty space + parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) + index = componentList.currentRow() + modelIndex = componentList.model().index(index) + if not componentList.visualRect(modelIndex).contains(QPos): + return + + self.presetManager.findPresets() + self.menu = QtGui.QMenu() + 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.submenu = QtGui.QMenu("Open Preset") + self.menu.addMenu(self.submenu) + + for version, presetName in presets: + menuItem = self.submenu.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.move(parentPosition + QPos) + self.menu.show() diff --git a/src/mainwindow.ui b/src/mainwindow.ui new file mode 100644 index 0000000..4a12fd5 --- /dev/null +++ b/src/mainwindow.ui @@ -0,0 +1,809 @@ + + + MainWindow + + + + 0 + 0 + 1008 + 575 + + + + + 0 + 0 + + + + + 0 + 0 + + + + MainWindow + + + + + 0 + 0 + + + + false + + + + 9 + + + 0 + + + + + + + Qt::Vertical + + + QSizePolicy::MinimumExpanding + + + + 0 + 360 + + + + + + + + QLayout::SetDefaultConstraint + + + 0 + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 420 + 0 + + + + + + + + + + QLayout::SetMinimumSize + + + 3 + + + + + QLayout::SetMinimumSize + + + 3 + + + + + QLayout::SetMinimumSize + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 140 + 20 + + + + + + + + Projects + + + + + + + Presets + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 20 + 2 + + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + true + + + QFrame::StyledPanel + + + QFrame::Sunken + + + 1 + + + true + + + true + + + false + + + QAbstractItemView::InternalMove + + + Qt::MoveAction + + + + + + + + + Add + + + + + + + Remove + + + + + + + Up + + + + + + + Down + + + + + + + + + + + 4 + + + 2 + + + + + + + + + + + QLayout::SetFixedSize + + + 4 + + + 0 + + + + + + 0 + 0 + + + + + 500 + 0 + + + + + 16777215 + 180 + + + + QTabWidget::North + + + QTabWidget::Rounded + + + 0 + + + + Export Video + + + + 10 + + + + + 0 + + + + + + 0 + 0 + + + + + 85 + 0 + + + + + 80 + 16777215 + + + + + 80 + 0 + + + + Audio File + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 0 + 28 + + + + + 16777215 + 28 + + + + + 0 + 0 + + + + + + + + + 0 + 28 + + + + + 16777215 + 28 + + + + ... + + + + + + + + + + + + + + 0 + 0 + + + + + 85 + 0 + + + + + 0 + 0 + + + + Output File + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 0 + 28 + + + + + 16777215 + 28 + + + + + + + + + 0 + 28 + + + + + 16777215 + 28 + + + + ... + + + + + + + + + + + 0 + + + + + + 0 + 0 + + + + 24 + + + + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 10 + 20 + + + + + + + + Create Video + + + + + + + false + + + Cancel + + + + + + + + + + + + Encoder Settings + + + + 10 + + + + + + + + 0 + 0 + + + + + 85 + 0 + + + + Container + + + + + + + + 150 + 0 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 5 + 5 + + + + + + + + + 0 + 0 + + + + Resolution + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + + + + + + + + + 0 + 0 + + + + + 85 + 0 + + + + Video Codec + + + + + + + + 150 + 0 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 5 + + + + + + + + + 0 + 0 + + + + Video Bitrate (Kbps) + + + + + + + 99999 + + + + + + + + + + + + 0 + 0 + + + + + 85 + 0 + + + + Audio Codec + + + + + + + + 150 + 0 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 10 + + + + + + + + + 0 + 0 + + + + Audio Bitrate (Kbps) + + + + + + + 9999 + + + + + + + + + + + + + QLayout::SetDefaultConstraint + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 500 + 0 + + + + + + + + + 0 + 0 + + + + + 0 + 180 + + + + + 16777215 + 180 + + + + -1 + + + + + + + + + + + + + + diff --git a/src/presetmanager.py b/src/presetmanager.py new file mode 100644 index 0000000..ec3f5cd --- /dev/null +++ b/src/presetmanager.py @@ -0,0 +1,290 @@ +from PyQt5 import QtGui, QtCore, QtWidgets +import string +import os + +import core + + +class PresetManager(QtWidgets.QDialog): + def __init__(self, window, parent): + super().__init__(parent.window) + self.parent = parent + self.core = parent.core + self.settings = parent.settings + self.presetDir = self.core.presetDir + if not self.settings.value('presetDir'): + self.settings.setValue( + "presetDir", + os.path.join(self.core.dataDir, 'projects')) + + self.findPresets() + + # window + self.lastFilter = '*' + self.presetRows = [] # list of (comp, vers, name) tuples + self.window = window + self.window.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) + + # connect button signals + self.window.pushButton_delete.clicked.connect(self.openDeletePresetDialog) + self.window.pushButton_rename.clicked.connect(self.openRenamePresetDialog) + self.window.pushButton_import.clicked.connect(self.openImportDialog) + self.window.pushButton_export.clicked.connect(self.openExportDialog) + self.window.pushButton_close.clicked.connect(self.window.close) + + # create filter box and preset list + self.drawFilterList() + self.window.comboBox_filter.currentIndexChanged.connect( + lambda: self.drawPresetList( + self.window.comboBox_filter.currentText(), self.window.lineEdit_search.text() + ) + ) + + # make auto-completion for search bar + self.autocomplete = QtCore.QStringListModel() + completer = QtWidgets.QCompleter() + completer.setModel(self.autocomplete) + self.window.lineEdit_search.setCompleter(completer) + self.window.lineEdit_search.textChanged.connect( + lambda: self.drawPresetList( + self.window.comboBox_filter.currentText(), self.window.lineEdit_search.text() + ) + ) + self.drawPresetList('*') + + def show(self): + '''Open a new preset manager window from the mainwindow''' + self.findPresets() + self.drawFilterList() + self.drawPresetList('*') + self.window.show() + + def findPresets(self): + 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)) + 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.window.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.window.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.window.comboBox_filter.clear() + self.window.comboBox_filter.addItem('*') + for component in self.presets: + self.window.comboBox_filter.addItem(component) + + def clearPreset(self, compI=None): + '''Functions on mainwindow level from the context menu''' + compI = self.parent.window.listWidget_componentList.currentRow() + self.core.clearPreset(compI, self.parent) + + def openSavePresetDialog(self): + '''Functions on mainwindow level from the context menu''' + window = self.parent.window + selectedComponents = self.core.selectedComponents + componentList = self.parent.window.listWidget_componentList + + if componentList.currentRow() == -1: + return + while True: + index = componentList.currentRow() + currentPreset = selectedComponents[index].currentPreset + newName, OK = QtGui.QInputDialog.getText( + self.parent.window, + 'Audio Visualizer', + 'New Preset Name:', + QtGui.QLineEdit.Normal, + currentPreset + ) + if OK: + if core.Core.badName(newName): + self.warnMessage(self.parent.window) + continue + if newName: + if index != -1: + selectedComponents[index].currentPreset = newName + saveValueStore = \ + selectedComponents[index].savePreset() + componentName = str(selectedComponents[index]).strip() + vers = selectedComponents[index].version() + self.createNewPreset( + componentName, vers, newName, + saveValueStore, window=self.parent.window) + self.openPreset(newName) + 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 = self.window \ + if 'window' not in kwargs else kwargs['window'] + ch = self.parent.showMessage( + msg="%s already exists! Overwrite it?" % + os.path.basename(path), + showCancel=True, + icon=QtGui.QMessageBox.Warning, + parent=window) + if not ch: + # user clicked cancel + return True + + return False + + def openPreset(self, presetName): + componentList = self.parent.window.listWidget_componentList + selectedComponents = self.parent.core.selectedComponents + + index = componentList.currentRow() + if index == -1: + return + componentName = str(selectedComponents[index]).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): + selected = self.window.listWidget_presets.selectedItems() + if not selected: + return + row = self.window.listWidget_presets.row(selected[0]) + comp, vers, name = self.presetRows[row] + ch = self.parent.showMessage( + msg='Really delete %s?' % name, + showCancel=True, + icon=QtGui.QMessageBox.Warning, + parent=self.window + ) + if not ch: + return + self.deletePreset(comp, vers, name) + self.findPresets() + self.drawPresetList() + + def deletePreset(self, comp, vers, name): + filepath = os.path.join(self.presetDir, comp, str(vers), name) + os.remove(filepath) + + def warnMessage(self, window=None): + print(window) + self.parent.showMessage( + msg='Preset names must contain only letters, ' + 'numbers, and spaces.', + parent=window if window else self.window) + + def openRenamePresetDialog(self): + presetList = self.window.listWidget_presets + if presetList.currentRow() == -1: + return + + while True: + index = presetList.currentRow() + newName, OK = QtGui.QInputDialog.getText( + self.window, + 'Preset Manager', + 'Rename Preset:', + QtGui.QLineEdit.Normal, + self.presetRows[index][2] + ) + if OK: + if core.Core.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) + oldPath = os.path.join(path, oldName) + if self.presetExists(newPath): + return + if os.path.exists(newPath): + os.remove(newPath) + os.rename(oldPath, newPath) + self.findPresets() + self.drawPresetList() + break + + def openImportDialog(self): + filename = QtGui.QFileDialog.getOpenFileName( + self.window, "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): + if not self.window.listWidget_presets.selectedItems(): + return + filename = QtGui.QFileDialog.getSaveFileName( + self.window, "Export Preset", + self.settings.value("presetDir"), + "Preset Files (*.avl)") + if filename: + index = self.window.listWidget_presets.currentRow() + 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.window + ) + self.settings.setValue("presetDir", os.path.dirname(filename)) diff --git a/src/presetmanager.ui b/src/presetmanager.ui new file mode 100644 index 0000000..5257b1c --- /dev/null +++ b/src/presetmanager.ui @@ -0,0 +1,150 @@ + + + presetmanager + + + Qt::NonModal + + + true + + + + 0 + 0 + 497 + 377 + + + + Preset Manager + + + + + + + + + + + Filter by name + + + + + + + + 200 + 0 + + + + + + + + + + + + + 0 + 0 + + + + true + + + + + + + + + QLayout::SetMinimumSize + + + + + Import + + + + + + + Export + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + true + + + Rename + + + + + + + Delete + + + + + + + + + + + <html><head/><body><p><span style=" font-size:10pt; font-style:italic;">Right-click components in the main window to create presets</span></p></body></html> + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + + + + + + + + diff --git a/src/preview_thread.py b/src/preview_thread.py new file mode 100644 index 0000000..4a46d51 --- /dev/null +++ b/src/preview_thread.py @@ -0,0 +1,59 @@ +from PyQt5 import QtCore, QtGui, uic +from PyQt5.QtCore import pyqtSignal, pyqtSlot +from PIL import Image +from PIL.ImageQt import ImageQt +import core +from queue import Queue, Empty +import os +from copy import copy + + +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.parent = parent + self.core = core.Core() + self.queue = queue + self.core.settings = parent.settings + self.stackedWidget = parent.window.stackedWidget + self.background = Image.new("RGBA", (1920, 1080), (0, 0, 0, 0)) + self.background.paste(Image.open(os.path.join( + self.core.wd, "background.png"))) + + @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.core.settings.value('outputWidth')) + height = int(self.core.settings.value('outputHeight')) + frame = copy(self.background) + frame = frame.resize((width, height)) + + components = nextPreviewInformation["components"] + for component in reversed(components): + frame = Image.alpha_composite( + frame, component.previewRender(self)) + + self._image = ImageQt(frame) + self.imageCreated.emit(QtGui.QImage(self._image)) + + except Empty: + True diff --git a/src/video_thread.py b/src/video_thread.py new file mode 100644 index 0000000..5ea6d21 --- /dev/null +++ b/src/video_thread.py @@ -0,0 +1,309 @@ +from PyQt5 import QtCore, QtGui, uic +from PyQt5.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 +import os +from queue import Queue, PriorityQueue +from threading import Thread, Event +import time +from copy import copy +import signal + + +class Worker(QtCore.QObject): + + imageCreated = pyqtSignal(['QImage']) + videoCreated = pyqtSignal() + progressBarUpdate = pyqtSignal(int) + progressBarSetText = pyqtSignal(str) + encoding = pyqtSignal(bool) + + def __init__(self, parent=None): + QtCore.QObject.__init__(self) + self.core = core.Core() + self.core.settings = parent.settings + self.modules = parent.core.modules + self.parent = parent + parent.videoTask.connect(self.createVideo) + self.sampleSize = 1470 # 44100 / 30 = 1470 + self.canceled = False + self.error = False + self.stopped = False + + def renderNode(self): + while not self.stopped: + i = self.compositeQueue.get() + frame = None + + for compNo, comp in reversed(list(enumerate(self.components))): + if compNo in self.staticComponents and \ + self.staticComponents[compNo] is not None: + if frame is None: + frame = self.staticComponents[compNo] + else: + frame = Image.alpha_composite( + frame, self.staticComponents[compNo]) + else: + if frame is None: + frame = comp.frameRender(compNo, i[0], i[1]) + else: + frame = Image.alpha_composite( + frame, comp.frameRender(compNo, i[0], i[1])) + + self.renderQueue.put([i[0], frame]) + self.compositeQueue.task_done() + + def renderDispatch(self): + print('Dispatching Frames for Compositing...') + + for i in range(0, len(self.completeAudioArray), self.sampleSize): + self.compositeQueue.put([i, self.bgI]) + # increment tracked video frame for next iteration + self.bgI += 1 + + def previewDispatch(self): + background = Image.new("RGBA", (1920, 1080), (0, 0, 0, 0)) + background.paste(Image.open(os.path.join( + self.core.wd, "background.png"))) + background = background.resize((self.width, self.height)) + + while not self.stopped: + i = self.previewQueue.get() + if time.time() - self.lastPreview >= 0.06 or i[0] == 0: + image = copy(background) + image = Image.alpha_composite(image, i[1]) + self._image = ImageQt(image) + self.imageCreated.emit(QtGui.QImage(self._image)) + self.lastPreview = time.time() + + self.previewQueue.task_done() + + @pyqtSlot(str, str, list) + def createVideo(self, inputFile, outputFile, components): + self.encoding.emit(True) + self.components = components + self.outputFile = outputFile + self.bgI = 0 # tracked video frame + self.reset() + self.width = int(self.core.settings.value('outputWidth')) + self.height = int(self.core.settings.value('outputHeight')) + progressBarValue = 0 + self.progressBarUpdate.emit(progressBarValue) + + self.progressBarSetText.emit('Loading audio file...') + self.completeAudioArray = self.core.readAudioFile(inputFile, self) + + # test if user has libfdk_aac + encoders = sp.check_output( + self.core.FFMPEG_BIN + " -encoders -hide_banner", + shell=True) + + encoders = encoders.decode("utf-8") + + acodec = self.core.settings.value('outputAudioCodec') + + options = self.core.encoder_options + containerName = self.core.settings.value('outputContainer') + vcodec = self.core.settings.value('outputVideoCodec') + vbitrate = str(self.core.settings.value('outputVideoBitrate'))+'k' + acodec = self.core.settings.value('outputAudioCodec') + abitrate = str(self.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] + + #print(encoders) + for encoder in vencoders: + #print(encoder) + if encoder in encoders: + vencoder = encoder + break + + for encoder in aencoders: + #print(encoder) + if encoder in encoders: + aencoder = encoder + break + + ffmpegCommand = [ + self.core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-y', # overwrite the output file if it already exists. + '-f', 'rawvideo', + '-vcodec', 'rawvideo', + '-s', str(self.width)+'x'+str(self.height), # size of one frame + '-pix_fmt', 'rgba', + + # frames per second + '-r', self.core.settings.value('outputFrameRate'), + '-i', '-', # The input comes from a pipe + '-an', + '-i', inputFile, + '-vcodec', vencoder, + '-acodec', aencoder, # output audio codec + '-b:v', vbitrate, + '-b:a', abitrate, + '-pix_fmt', self.core.settings.value('outputVideoFormat'), + '-preset', self.core.settings.value('outputPreset'), + '-f', container + ] + + if acodec == 'aac': + ffmpegCommand.append('-strict') + ffmpegCommand.append('-2') + + ffmpegCommand.append(outputFile) + + # ### Now start creating video for output ### + numpy.seterr(divide='ignore') + + # Call preFrameRender on all components + print('Loaded Components:', ", ".join( + ["%s) %s" % (num, str(component)) \ + for num, component in enumerate(reversed(self.components)) + ])) + self.staticComponents = {} + numComps = len(self.components) + for compNo, comp in enumerate(self.components): + pStr = "Analyzing audio..." + self.progressBarSetText.emit(pStr) + properties = None + properties = comp.preFrameRender( + worker=self, + completeAudioArray=self.completeAudioArray, + sampleSize=self.sampleSize, + progressBarUpdate=self.progressBarUpdate, + progressBarSetText=self.progressBarSetText + ) + + if properties and 'static' in properties: + self.staticComponents[compNo] = copy( + comp.frameRender(compNo, 0, 0)) + self.progressBarUpdate.emit(100) + + # Create ffmpeg pipe and queues for frames + self.out_pipe = sp.Popen( + ffmpegCommand, stdin=sp.PIPE, stdout=sys.stdout, stderr=sys.stdout) + self.compositeQueue = Queue() + self.compositeQueue.maxsize = 20 + self.renderQueue = PriorityQueue() + self.renderQueue.maxsize = 20 + self.previewQueue = PriorityQueue() + + # Threads to render frames and send them back here for piping out + self.renderThreads = [] + for i in range(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.previewDispatch = Thread( + target=self.previewDispatch, name="Render Dispatch Thread") + self.previewDispatch.daemon = True + self.previewDispatch.start() + + frameBuffer = {} + self.lastPreview = 0.0 + self.progressBarUpdate.emit(0) + pStr = "Exporting video..." + self.progressBarSetText.emit(pStr) + if not self.canceled: + for i in range(0, len(self.completeAudioArray), self.sampleSize): + while True: + if i in frameBuffer or self.canceled: + # if frame's in buffer, pipe it to ffmpeg + break + # else fetch the next frame & add to the buffer + data = self.renderQueue.get() + frameBuffer[data[0]] = data[1] + self.renderQueue.task_done() + if self.canceled: + break + + try: + self.out_pipe.stdin.write(frameBuffer[i].tobytes()) + self.previewQueue.put([i, frameBuffer[i]]) + del frameBuffer[i] + except: + break + + # increase progress bar value + if progressBarValue + 1 <= (i / len(self.completeAudioArray)) \ + * 100: + progressBarValue = numpy.floor( + (i / len(self.completeAudioArray)) * 100) + self.progressBarUpdate.emit(progressBarValue) + pStr = "Exporting video: " + str(int(progressBarValue)) \ + + "%" + self.progressBarSetText.emit(pStr) + + numpy.seterr(all='print') + + self.out_pipe.stdin.close() + if self.out_pipe.stderr is not None: + print(self.out_pipe.stderr.read()) + self.out_pipe.stderr.close() + self.error = True + # out_pipe.terminate() # don't terminate ffmpeg too early + self.out_pipe.wait() + if self.canceled: + print("Export Canceled") + try: + os.remove(self.outputFile) + except: + pass + self.progressBarUpdate.emit(0) + self.progressBarSetText.emit('Export Canceled') + + else: + if self.error: + print("Export Failed") + self.progressBarUpdate.emit(0) + self.progressBarSetText.emit('Export Failed') + 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 updateProgress(self, pStr, pVal): + self.progressBarValue.emit(pVal) + self.progressBarSetText.emit(pStr) + + def cancel(self): + self.canceled = True + self.core.cancel() + + for comp in self.components: + comp.cancel() + + try: + self.out_pipe.send_signal(signal.SIGINT) + except: + 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 265feee..0000000 --- a/video_thread.py +++ /dev/null @@ -1,309 +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 -import os -from queue import Queue, PriorityQueue -from threading import Thread, Event -import time -from copy import copy -import signal - - -class Worker(QtCore.QObject): - - imageCreated = pyqtSignal(['QImage']) - videoCreated = pyqtSignal() - progressBarUpdate = pyqtSignal(int) - progressBarSetText = pyqtSignal(str) - encoding = pyqtSignal(bool) - - def __init__(self, parent=None): - QtCore.QObject.__init__(self) - self.core = core.Core() - self.core.settings = parent.settings - self.modules = parent.core.modules - self.parent = parent - parent.videoTask.connect(self.createVideo) - self.sampleSize = 1470 # 44100 / 30 = 1470 - self.canceled = False - self.error = False - self.stopped = False - - def renderNode(self): - while not self.stopped: - i = self.compositeQueue.get() - frame = None - - for compNo, comp in reversed(list(enumerate(self.components))): - if compNo in self.staticComponents and \ - self.staticComponents[compNo] is not None: - if frame is None: - frame = self.staticComponents[compNo] - else: - frame = Image.alpha_composite( - frame, self.staticComponents[compNo]) - else: - if frame is None: - frame = comp.frameRender(compNo, i[0], i[1]) - else: - frame = Image.alpha_composite( - frame, comp.frameRender(compNo, i[0], i[1])) - - self.renderQueue.put([i[0], frame]) - self.compositeQueue.task_done() - - def renderDispatch(self): - print('Dispatching Frames for Compositing...') - - for i in range(0, len(self.completeAudioArray), self.sampleSize): - self.compositeQueue.put([i, self.bgI]) - # increment tracked video frame for next iteration - self.bgI += 1 - - def previewDispatch(self): - background = Image.new("RGBA", (1920, 1080), (0, 0, 0, 0)) - background.paste(Image.open(os.path.join( - self.core.wd, "background.png"))) - background = background.resize((self.width, self.height)) - - while not self.stopped: - i = self.previewQueue.get() - if time.time() - self.lastPreview >= 0.06 or i[0] == 0: - image = copy(background) - image = Image.alpha_composite(image, i[1]) - self._image = ImageQt(image) - self.imageCreated.emit(QtGui.QImage(self._image)) - self.lastPreview = time.time() - - self.previewQueue.task_done() - - @pyqtSlot(str, str, list) - def createVideo(self, inputFile, outputFile, components): - self.encoding.emit(True) - self.components = components - self.outputFile = outputFile - self.bgI = 0 # tracked video frame - self.reset() - self.width = int(self.core.settings.value('outputWidth')) - self.height = int(self.core.settings.value('outputHeight')) - progressBarValue = 0 - self.progressBarUpdate.emit(progressBarValue) - - self.progressBarSetText.emit('Loading audio file...') - self.completeAudioArray = self.core.readAudioFile(inputFile, self) - - # test if user has libfdk_aac - encoders = sp.check_output( - self.core.FFMPEG_BIN + " -encoders -hide_banner", - shell=True) - - encoders = encoders.decode("utf-8") - - acodec = self.core.settings.value('outputAudioCodec') - - options = self.core.encoder_options - containerName = self.core.settings.value('outputContainer') - vcodec = self.core.settings.value('outputVideoCodec') - vbitrate = str(self.core.settings.value('outputVideoBitrate'))+'k' - acodec = self.core.settings.value('outputAudioCodec') - abitrate = str(self.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] - - #print(encoders) - for encoder in vencoders: - #print(encoder) - if encoder in encoders: - vencoder = encoder - break - - for encoder in aencoders: - #print(encoder) - if encoder in encoders: - aencoder = encoder - break - - ffmpegCommand = [ - self.core.FFMPEG_BIN, - '-thread_queue_size', '512', - '-y', # overwrite the output file if it already exists. - '-f', 'rawvideo', - '-vcodec', 'rawvideo', - '-s', str(self.width)+'x'+str(self.height), # size of one frame - '-pix_fmt', 'rgba', - - # frames per second - '-r', self.core.settings.value('outputFrameRate'), - '-i', '-', # The input comes from a pipe - '-an', - '-i', inputFile, - '-vcodec', vencoder, - '-acodec', aencoder, # output audio codec - '-b:v', vbitrate, - '-b:a', abitrate, - '-pix_fmt', self.core.settings.value('outputVideoFormat'), - '-preset', self.core.settings.value('outputPreset'), - '-f', container - ] - - if acodec == 'aac': - ffmpegCommand.append('-strict') - ffmpegCommand.append('-2') - - ffmpegCommand.append(outputFile) - - # ### Now start creating video for output ### - numpy.seterr(divide='ignore') - - # Call preFrameRender on all components - print('Loaded Components:', ", ".join( - ["%s) %s" % (num, str(component)) \ - for num, component in enumerate(reversed(self.components)) - ])) - self.staticComponents = {} - numComps = len(self.components) - for compNo, comp in enumerate(self.components): - pStr = "Analyzing audio..." - self.progressBarSetText.emit(pStr) - properties = None - properties = comp.preFrameRender( - worker=self, - completeAudioArray=self.completeAudioArray, - sampleSize=self.sampleSize, - progressBarUpdate=self.progressBarUpdate, - progressBarSetText=self.progressBarSetText - ) - - if properties and 'static' in properties: - self.staticComponents[compNo] = copy( - comp.frameRender(compNo, 0, 0)) - self.progressBarUpdate.emit(100) - - # Create ffmpeg pipe and queues for frames - self.out_pipe = sp.Popen( - ffmpegCommand, stdin=sp.PIPE, stdout=sys.stdout, stderr=sys.stdout) - self.compositeQueue = Queue() - self.compositeQueue.maxsize = 20 - self.renderQueue = PriorityQueue() - self.renderQueue.maxsize = 20 - self.previewQueue = PriorityQueue() - - # Threads to render frames and send them back here for piping out - self.renderThreads = [] - for i in range(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.previewDispatch = Thread( - target=self.previewDispatch, name="Render Dispatch Thread") - self.previewDispatch.daemon = True - self.previewDispatch.start() - - frameBuffer = {} - self.lastPreview = 0.0 - self.progressBarUpdate.emit(0) - pStr = "Exporting video..." - self.progressBarSetText.emit(pStr) - if not self.canceled: - for i in range(0, len(self.completeAudioArray), self.sampleSize): - while True: - if i in frameBuffer or self.canceled: - # if frame's in buffer, pipe it to ffmpeg - break - # else fetch the next frame & add to the buffer - data = self.renderQueue.get() - frameBuffer[data[0]] = data[1] - self.renderQueue.task_done() - if self.canceled: - break - - try: - self.out_pipe.stdin.write(frameBuffer[i].tobytes()) - self.previewQueue.put([i, frameBuffer[i]]) - del frameBuffer[i] - except: - break - - # increase progress bar value - if progressBarValue + 1 <= (i / len(self.completeAudioArray)) \ - * 100: - progressBarValue = numpy.floor( - (i / len(self.completeAudioArray)) * 100) - self.progressBarUpdate.emit(progressBarValue) - pStr = "Exporting video: " + str(int(progressBarValue)) \ - + "%" - self.progressBarSetText.emit(pStr) - - numpy.seterr(all='print') - - self.out_pipe.stdin.close() - if self.out_pipe.stderr is not None: - print(self.out_pipe.stderr.read()) - self.out_pipe.stderr.close() - self.error = True - # out_pipe.terminate() # don't terminate ffmpeg too early - self.out_pipe.wait() - if self.canceled: - print("Export Canceled") - try: - os.remove(self.outputFile) - except: - pass - self.progressBarUpdate.emit(0) - self.progressBarSetText.emit('Export Canceled') - - else: - if self.error: - print("Export Failed") - self.progressBarUpdate.emit(0) - self.progressBarSetText.emit('Export Failed') - 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 updateProgress(self, pStr, pVal): - self.progressBarValue.emit(pVal) - self.progressBarSetText.emit(pStr) - - def cancel(self): - self.canceled = True - self.core.cancel() - - for comp in self.components: - comp.cancel() - - try: - self.out_pipe.send_signal(signal.SIGINT) - except: - pass - - def reset(self): - self.core.reset() - self.canceled = False - for comp in self.components: - comp.reset() -- cgit v1.2.3 From 17c8a6703a8093d31c6772ba3b8d9ee01adaa0da Mon Sep 17 00:00:00 2001 From: tassaron Date: Sat, 15 Jul 2017 18:59:22 -0400 Subject: trying to make setup.py work --- setup.py | 53 +++++++++++++++++++++++++++++++++------------------ src/__init__.py | 0 src/__main__.py | 3 +++ src/main.py | 29 +++++++++++++++------------- src/presetmanager.py | 1 - src/preview_thread.py | 1 - src/video_thread.py | 1 - 7 files changed, 53 insertions(+), 35 deletions(-) create mode 100644 src/__init__.py create mode 100644 src/__main__.py (limited to 'setup.py') diff --git a/setup.py b/setup.py index fde3461..4ef6077 100644 --- a/setup.py +++ b/setup.py @@ -1,19 +1,34 @@ -+from setuptools import setup, find_packages - - -# Dependencies are automatically detected, but it might need +setup(name='audio_visualizer_python', - -# fine tuning. + version='1.0', - -buildOptions = dict(packages = [], excludes = [ + description='a little GUI tool to render visualization \ - - "apport", + videos of audio files', - - "apt", + license='MIT', - - "ctypes", + url='https://github.com/djfun/audio-visualizer-python', - - "curses", + packages=find_packages(), - - "distutils", + package_data={ - - "email", + 'src': ['*'], - - "html", + }, - - "http", + install_requires=['pillow-simd', 'numpy', ''], - - "json", + entry_points={ - - "xmlrpc", + 'gui_scripts': [ - - "nose" + 'audio-visualizer-python = avpython.main:main' - - ], include_files = ["main.ui"]) + ] - - + } - -import sys + ) \ No newline at end of file +from setuptools import setup +import os + + +def package_files(directory): + paths = [] + for (path, directories, filenames) in os.walk(directory): + for filename in filenames: + paths.append(os.path.join('..', path, filename)) + return paths + + +setup( + name='audio_visualizer_python', + version='2.0.0', + description='A little GUI tool to create audio visualization " \ + "videos out of audio files', + license='MIT', + url='https://github.com/djfun/audio-visualizer-python', + packages=[ + 'avpython', + 'avpython.components' + ], + package_dir={'avpython': 'src'}, + package_data={ + 'avpython': package_files('src'), + }, + install_requires=['olefile', 'Pillow-SIMD', 'PyQt5', 'numpy'], + entry_points={ + 'gui_scripts': [ + 'avp = avpython.main:main' + ], + } +) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..a68739e --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,3 @@ +from avpython.main import main + +main() \ No newline at end of file diff --git a/src/main.py b/src/main.py index 2216d2a..317237c 100644 --- a/src/main.py +++ b/src/main.py @@ -2,12 +2,18 @@ from PyQt5 import uic, QtWidgets import sys import os -import core -import preview_thread -import video_thread +def main(): + if getattr(sys, 'frozen', False): + # frozen + wd = os.path.dirname(sys.executable) + else: + # unfrozen + wd = os.path.dirname(os.path.realpath(__file__)) + + # make local imports work everywhere + sys.path.append(wd) -if __name__ == "__main__": mode = 'GUI' if len(sys.argv) > 2: mode = 'commandline' @@ -28,22 +34,15 @@ if __name__ == "__main__": # app.setOrganizationName("audio-visualizer") if mode == 'commandline': - from command import * + from command import Command main = Command() elif mode == 'GUI': - from mainwindow import * + from mainwindow import MainWindow import atexit import signal - if getattr(sys, 'frozen', False): - # frozen - wd = os.path.dirname(sys.executable) - else: - # unfrozen - wd = os.path.dirname(os.path.realpath(__file__)) - window = uic.loadUi(os.path.join(wd, "mainwindow.ui")) # window.adjustSize() desc = QtWidgets.QDesktopWidget() @@ -64,3 +63,7 @@ if __name__ == "__main__": # applicable to both modes sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/src/presetmanager.py b/src/presetmanager.py index 0028203..6e003a1 100644 --- a/src/presetmanager.py +++ b/src/presetmanager.py @@ -6,7 +6,6 @@ from PyQt5 import QtCore, QtWidgets import string import os -import core import toolkit diff --git a/src/preview_thread.py b/src/preview_thread.py index 4ffb7f6..6c33aff 100644 --- a/src/preview_thread.py +++ b/src/preview_thread.py @@ -6,7 +6,6 @@ from PyQt5 import QtCore, QtGui, uic from PyQt5.QtCore import pyqtSignal, pyqtSlot from PIL import Image from PIL.ImageQt import ImageQt -import core from queue import Queue, Empty import os diff --git a/src/video_thread.py b/src/video_thread.py index 674765a..60db99f 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -18,7 +18,6 @@ from threading import Thread, Event import time import signal -import core from toolkit import openPipe from frame import Checkerboard -- cgit v1.2.3 From ec0abd190273b7b636c7085d7caed8220ab09172 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 16 Jul 2017 14:06:11 -0400 Subject: apply complex filters to audio streams from components tons of sound options could be given now, + installation using setup.py --- README.md | 21 +++++----- setup.py | 24 ++++++++--- src/component.py | 5 ++- src/components/sound.py | 23 ++++++++++- src/components/sound.ui | 50 +++++++++++++++++++++++ src/components/video.py | 16 +++++++- src/components/video.ui | 75 +++++++++++++++++++++++++++++++---- src/core.py | 103 ++++++++++++++++++++++++++++++++++++++++-------- src/main.py | 2 +- src/toolkit.py | 11 ++++-- 10 files changed, 283 insertions(+), 47 deletions(-) (limited to 'setup.py') diff --git a/README.md b/README.md index 658a22d..9149b4f 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,31 @@ 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 file. Different components can be added and layered to change the resulting video and add images, videos, gradients, text, etc. The component setup can be saved as a Project and exporting can be automated using commandline options. +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. -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 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. Create a template project named `template` with your typical visualizers and watermarks, and add text to the top layer from commandline: +`avp template -c 99 text "title=Episode 371" -i /this/weeks/audio.ogg -o out` -I also need a good name that is not as generic as "audio-visualizer-python"! +For more information use `avp --help` or for help with a particular component use `avp -c 0 componentName help`. + +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. Dependencies ------------ -Python 3, PyQt5, pillow-simd, numpy, and ffmpeg 3.3 +Python 3.4, FFmpeg 3.3, PyQt5, Pillow-SIMD, NumPy -**Note:** Pillow may be used as a drop-in replacement for Pillow-SIMD if problems are encountered installing. However this will result in much slower video export times. +**Note:** Pillow may be used as a drop-in replacement for Pillow-SIMD if problems are encountered installing. However this will result in much slower video export times. For help troubleshooting installation problems, the * For any problems with installing Pillow-SIMD, see the [Pillow installation guide](http://pillow.readthedocs.io/en/3.1.x/installation.html). Installation ------------ ### Manual installation on Ubuntu 16.04 * Install pip: `sudo apt-get install python3-pip` -* 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` +* If Pillow is installed, it must be removed. Nothing should break because Pillow-SIMD is simply a drop-in replacement with better performance. +* Download audio-visualizer-python from this repository and run `sudo pip3 install .` in this directory * 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`. +Run the program with `avp` or `python3 -m avpython` ### Manual installation on Windows * **Warning:** [Compiling Pillow is difficult on Windows](http://pillow.readthedocs.io/en/3.1.x/installation.html#building-on-windows) and required for the best experience. diff --git a/setup.py b/setup.py index 4ef6077..71dc51f 100644 --- a/setup.py +++ b/setup.py @@ -12,11 +12,25 @@ def package_files(directory): setup( name='audio_visualizer_python', - version='2.0.0', - description='A little GUI tool to create audio visualization " \ - "videos out of audio files', + version='2.0.0rc1', + url='https://github.com/djfun/audio-visualizer-python/tree/feature-newgui', license='MIT', - url='https://github.com/djfun/audio-visualizer-python', + description='Create audio visualization videos from a GUI or commandline', + long_description="Create customized audio visualization videos and save " + "them as Projects to continue editing later. Different components can " + "be added and layered to add visualizers, images, videos, gradients, " + "text, etc. Use Projects created in the GUI with commandline mode to " + "automate your video production workflow without learning any complex " + "syntax.", + 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=[ 'avpython', 'avpython.components' @@ -25,7 +39,7 @@ setup( package_data={ 'avpython': package_files('src'), }, - install_requires=['olefile', 'Pillow-SIMD', 'PyQt5', 'numpy'], + install_requires=['Pillow-SIMD', 'PyQt5', 'numpy'], entry_points={ 'gui_scripts': [ 'avp = avpython.main:main' diff --git a/src/component.py b/src/component.py index 2b297d1..adb170e 100644 --- a/src/component.py +++ b/src/component.py @@ -178,8 +178,9 @@ class Component(QtCore.QObject): 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 parameters - to apply to the input stream. + 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 \''' @classmethod diff --git a/src/components/sound.py b/src/components/sound.py index 4a5714b..bd7d002 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -17,12 +17,18 @@ class Component(Component): page.lineEdit_sound.textChanged.connect(self.update) page.pushButton_sound.clicked.connect(self.pickSound) + page.checkBox_chorus.stateChanged.connect(self.update) + page.spinBox_delay.valueChanged.connect(self.update) + page.spinBox_volume.valueChanged.connect(self.update) self.page = page return page def update(self): self.sound = self.page.lineEdit_sound.text() + self.delay = self.page.spinBox_delay.value() + self.volume = self.page.spinBox_volume.value() + self.chorus = self.page.checkBox_chorus.isChecked() super().update() def previewRender(self, previewWorker): @@ -46,7 +52,16 @@ class Component(Component): return "The audio file selected no longer exists!" def audio(self): - return (self.sound, {}) + 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("~")) @@ -66,10 +81,16 @@ class Component(Component): def loadPreset(self, pr, presetName=None): super().loadPreset(pr, presetName) self.page.lineEdit_sound.setText(pr['sound']) + self.page.checkBox_chorus.setChecked(pr['chorus']) + self.page.spinBox_delay.setValue(pr['delay']) + self.page.spinBox_volume.setValue(pr['volume']) def savePreset(self): return { 'sound': self.sound, + 'chorus': self.chorus, + 'delay': self.delay, + 'volume': self.volume, } def commandHelp(self): diff --git a/src/components/sound.ui b/src/components/sound.ui index 5fc00c1..4c11332 100644 --- a/src/components/sound.ui +++ b/src/components/sound.ui @@ -87,6 +87,29 @@ + + + + Volume + + + + + + + x + + + 10.000000000000000 + + + 0.100000000000000 + + + 1.000000000000000 + + + @@ -100,6 +123,33 @@ + + + + Delay + + + + + + + s + + + 9999999.990000000223517 + + + 0.500000000000000 + + + + + + + Chorus + + + diff --git a/src/components/video.py b/src/components/video.py index 0b93293..e1f182c 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -127,6 +127,7 @@ class Component(Component): page.checkBox_distort.stateChanged.connect(self.update) page.checkBox_useAudio.stateChanged.connect(self.update) page.spinBox_scale.valueChanged.connect(self.update) + page.spinBox_volume.valueChanged.connect(self.update) page.spinBox_x.valueChanged.connect(self.update) page.spinBox_y.valueChanged.connect(self.update) @@ -139,9 +140,17 @@ class Component(Component): self.useAudio = self.page.checkBox_useAudio.isChecked() self.distort = self.page.checkBox_distort.isChecked() self.scale = self.page.spinBox_scale.value() + self.volume = self.page.spinBox_volume.value() self.xPosition = self.page.spinBox_x.value() self.yPosition = self.page.spinBox_y.value() + if self.useAudio: + 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) + super().update() def previewRender(self, previewWorker): @@ -193,7 +202,10 @@ class Component(Component): self.badAudio = False def audio(self): - return (self.videoPath, {'map': '-v'}) + 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) @@ -222,6 +234,7 @@ class Component(Component): self.page.checkBox_useAudio.setChecked(pr['useAudio']) self.page.checkBox_distort.setChecked(pr['distort']) self.page.spinBox_scale.setValue(pr['scale']) + self.page.spinBox_volume.setValue(pr['volume']) self.page.spinBox_x.setValue(pr['x']) self.page.spinBox_y.setValue(pr['y']) @@ -233,6 +246,7 @@ class Component(Component): 'useAudio': self.useAudio, 'distort': self.distort, 'scale': self.scale, + 'volume': self.volume, 'x': self.xPosition, 'y': self.yPosition, } diff --git a/src/components/video.ui b/src/components/video.ui index 97b7d6f..08d15d3 100644 --- a/src/components/video.ui +++ b/src/components/video.ui @@ -10,6 +10,18 @@ 197 + + + 0 + 0 + + + + + 0 + 197 + + Form @@ -189,13 +201,6 @@ - - - - Use Audio - - - @@ -247,6 +252,62 @@ + + + + + + Use Audio + + + + + + + Volume + + + + + + + + 0 + 0 + + + + x + + + 0.000000000000000 + + + 10.000000000000000 + + + 0.100000000000000 + + + 1.000000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + diff --git a/src/core.py b/src/core.py index 4c12209..324b04f 100644 --- a/src/core.py +++ b/src/core.py @@ -468,7 +468,8 @@ class Core: ''' Constructs the major ffmpeg command used to export the video ''' - duration = str(duration) + safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters + duration = "{0:.3f}".format(duration + 0.1) # used by input sources # Test if user has libfdk_aac encoders = toolkit.checkOutput( @@ -526,35 +527,99 @@ class Core: '-i', inputFile ] + # Add extra audio inputs and any needed avfilters + # NOTE: Global filters are currently hard-coded here for debugging use + globalFilters = 0 # increase to add global filters extraAudio = [ comp.audio() for comp in self.selectedComponents if 'audio' in comp.properties() ] - if extraAudio: - unwantedVideoStreams = [] - for streamNo, params in enumerate(extraAudio): + if extraAudio or globalFilters > 0: + # Add -i options for extra input files + extraFilters = {} + for streamNo, params in enumerate(reversed(extraAudio)): extraInputFile, params = params ffmpegCommand.extend([ - '-t', duration, + '-t', safeDuration, '-i', extraInputFile ]) - if 'map' in params and params['map'] == '-v': - # a video stream to remove - unwantedVideoStreams.append(streamNo + 1) + # 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! + 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]) + ) + ) - if unwantedVideoStreams: - ffmpegCommand.extend(['-map', '0']) - for streamNo in unwantedVideoStreams: - ffmpegCommand.extend([ - '-map', '-%s:v' % str(streamNo) - ]) + # Join all the filters together and combine into 1 stream + extraFilterCommand = "; ".join(extraFilterCommand) + '; ' \ + if tmpInputs else '' ffmpegCommand.extend([ '-filter_complex', - 'amix=inputs=%s:duration=first:dropout_transition=3' % str( - len(extraAudio) + 1 + 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) ), ]) + # Only map audio from the filters, and video from the pipe + ffmpegCommand.extend([ + '-map', '0:v', + '-map', '[a]', + ]) + ffmpegCommand.extend([ # OUTPUT '-vcodec', vencoder, @@ -573,7 +638,7 @@ class Core: ffmpegCommand.append(outputFile) return ffmpegCommand - def readAudioFile(self, filename, parent): + def getAudioDuration(self, filename): command = [self.FFMPEG_BIN, '-i', filename] try: @@ -588,6 +653,10 @@ class Core: d = d.split(' ')[3] d = d.split(':') duration = float(d[0])*3600 + float(d[1])*60 + float(d[2]) + return duration + + def readAudioFile(self, filename, parent): + duration = self.getAudioDuration(filename) command = [ self.FFMPEG_BIN, diff --git a/src/main.py b/src/main.py index 317237c..6a9a25e 100644 --- a/src/main.py +++ b/src/main.py @@ -12,7 +12,7 @@ def main(): wd = os.path.dirname(os.path.realpath(__file__)) # make local imports work everywhere - sys.path.append(wd) + sys.path.insert(0, wd) mode = 'GUI' if len(sys.argv) > 2: diff --git a/src/toolkit.py b/src/toolkit.py index 589d8e6..5493f37 100644 --- a/src/toolkit.py +++ b/src/toolkit.py @@ -13,11 +13,14 @@ def badName(name): 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): - '''Alphabetizes a dict into OrderedDict & returns string repr''' - return repr( - OrderedDict(sorted(dictionary.items(), key=lambda t: t[0])) - ) + '''Returns string repr of a preset''' + return repr(alphabetizeDict(dictionary)) def presetFromString(string): -- cgit v1.2.3 From aa464632c64725201dc7584ebf6bf2c3d06b47b6 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 16 Jul 2017 23:13:00 -0400 Subject: new hotkey to preview the ffmpeg command --- setup.py | 2 +- src/components/video.py | 4 ++-- src/core.py | 6 +++++- src/mainwindow.py | 19 ++++++++++++++++++- 4 files changed, 26 insertions(+), 5 deletions(-) (limited to 'setup.py') diff --git a/setup.py b/setup.py index 71dc51f..6ef688a 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ setup( 'Topic :: Multimedia :: Video :: Non-Linear Editor', ], keywords=['visualizer', 'visualization', 'commandline video', - 'video editor', 'ffmpeg', 'podcast'] + 'video editor', 'ffmpeg', 'podcast'], packages=[ 'avpython', 'avpython.components' diff --git a/src/components/video.py b/src/components/video.py index e1f182c..9e3db30 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -45,7 +45,7 @@ class Video: '-i', self.videoPath, '-f', 'image2pipe', '-pix_fmt', 'rgba', - '-filter:v', 'scale=%s:%s' % scale( + '-filter_complex', '[0:v] scale=%s:%s' % scale( self.scale, self.width, self.height, str), '-vcodec', 'rawvideo', '-', ] @@ -272,7 +272,7 @@ class Component(Component): '-i', self.videoPath, '-f', 'image2pipe', '-pix_fmt', 'rgba', - '-filter:v', 'scale=%s:%s' % scale( + '-filter_complex', '[0:v] scale=%s:%s' % scale( self.scale, width, height, str), '-vcodec', 'rawvideo', '-', '-ss', '90', diff --git a/src/core.py b/src/core.py index 324b04f..a0a028b 100644 --- a/src/core.py +++ b/src/core.py @@ -541,6 +541,10 @@ class Core: extraInputFile, params = params ffmpegCommand.extend([ '-t', safeDuration, + # Tell ffmpeg about shorter clips (seemingly not needed) + # streamDuration = self.getAudioDuration(extraInputFile) + # if streamDuration > float(safeDuration) + # else "{0:.3f}".format(streamDuration), '-i', extraInputFile ]) # Construct dataset of extra filters we'll need to add later @@ -551,7 +555,7 @@ class Core: ffmpegFilter, params[ffmpegFilter] )) - # Start creating avfilters! + # Start creating avfilters! Popen-style, so don't use semicolons; extraFilterCommand = [] if globalFilters <= 0: diff --git a/src/mainwindow.py b/src/mainwindow.py index 76ed179..ca8e697 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -305,7 +305,12 @@ class MainWindow(QtWidgets.QMainWindow): QtWidgets.QShortcut("Ctrl+A", self.window, self.openSaveProjectDialog) QtWidgets.QShortcut("Ctrl+O", self.window, self.openOpenProjectDialog) QtWidgets.QShortcut("Ctrl+N", self.window, self.createNewProject) - QtWidgets.QShortcut("Ctrl+Alt+Shift+R", self.window, self.drawPreview) + QtWidgets.QShortcut( + "Ctrl+Alt+Shift+R", self.window, self.drawPreview + ) + QtWidgets.QShortcut( + "Ctrl+Alt+Shift+F", self.window, self.showFfmpegCommand + ) QtWidgets.QShortcut( "Ctrl+T", self.window, @@ -580,6 +585,18 @@ class MainWindow(QtWidgets.QMainWindow): def showPreviewImage(self, image): self.previewWindow.changePixmap(image) + def showFfmpegCommand(self): + from textwrap import wrap + command = self.core.createFfmpegCommand( + self.window.lineEdit_audioFile.text(), + self.window.lineEdit_outputFile.text(), + self.core.getAudioDuration(self.window.lineEdit_audioFile.text()) + ) + 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 -- cgit v1.2.3 From f454814867443ceeeca2a3a2c2a676947184503c Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 20 Jul 2017 20:31:38 -0400 Subject: ffmpeg functions moved to toolkit, component format simplified component methods are auto-decorated & settings are now class variables --- freeze.py | 7 +- setup.py | 15 +- src/command.py | 10 +- src/component.py | 167 +++++++++++++------- src/components/color.py | 8 +- src/components/image.py | 11 +- src/components/original.py | 11 +- src/components/sound.py | 14 +- src/components/text.py | 8 +- src/components/video.py | 23 ++- src/core.py | 379 ++++++++------------------------------------- src/mainwindow.py | 81 ++++++---- src/presetmanager.py | 20 +-- src/preview_thread.py | 4 +- src/toolkit/common.py | 12 +- src/toolkit/core.py | 18 +++ src/toolkit/ffmpeg.py | 284 +++++++++++++++++++++++++++++++++ src/toolkit/frame.py | 6 +- src/video_thread.py | 45 ++++-- 19 files changed, 628 insertions(+), 495 deletions(-) create mode 100644 src/toolkit/core.py create mode 100644 src/toolkit/ffmpeg.py (limited to 'setup.py') diff --git a/freeze.py b/freeze.py index c9b7918..3281cad 100644 --- a/freeze.py +++ b/freeze.py @@ -2,8 +2,8 @@ from cx_Freeze import setup, Executable import sys import os -# Dependencies are automatically detected, but it might need -# fine tuning. +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') @@ -39,7 +39,6 @@ buildOptions = dict( include_files=deps, ) - base = 'Win32GUI' if sys.platform == 'win32' else None executables = [ @@ -53,7 +52,7 @@ executables = [ setup( name='audio-visualizer-python', - version='2.0', + version=VERSION, description='GUI tool to render visualization videos of audio files', options=dict(build_exe=buildOptions), executables=executables diff --git a/setup.py b/setup.py index 6ef688a..5abb976 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,9 @@ from setuptools import setup import os +VERSION = '2.0.0.rc1' + + def package_files(directory): paths = [] for (path, directories, filenames) in os.walk(directory): @@ -12,7 +15,7 @@ def package_files(directory): setup( name='audio_visualizer_python', - version='2.0.0rc1', + version=VERSION, url='https://github.com/djfun/audio-visualizer-python/tree/feature-newgui', license='MIT', description='Create audio visualization videos from a GUI or commandline', @@ -20,8 +23,7 @@ setup( "them as Projects to continue editing later. Different components can " "be added and layered to add visualizers, images, videos, gradients, " "text, etc. Use Projects created in the GUI with commandline mode to " - "automate your video production workflow without learning any complex " - "syntax.", + "automate your video production workflow without any complex syntax.", classifiers=[ 'Development Status :: 4 - Beta', 'License :: OSI Approved :: MIT License', @@ -29,10 +31,13 @@ setup( 'Intended Audience :: End Users/Desktop', 'Topic :: Multimedia :: Video :: Non-Linear Editor', ], - keywords=['visualizer', 'visualization', 'commandline video', - 'video editor', 'ffmpeg', 'podcast'], + keywords=[ + 'visualizer', 'visualization', 'commandline video', + 'video editor', 'ffmpeg', 'podcast' + ], packages=[ 'avpython', + 'avpython.toolkit', 'avpython.components' ], package_dir={'avpython': 'src'}, diff --git a/src/command.py b/src/command.py index 84d798d..046a1bf 100644 --- a/src/command.py +++ b/src/command.py @@ -9,8 +9,8 @@ import os import sys import time -import core -from toolkit import LoadDefaultSettings +from core import Core +from toolkit import loadDefaultSettings class Command(QtCore.QObject): @@ -19,7 +19,7 @@ class Command(QtCore.QObject): def __init__(self): QtCore.QObject.__init__(self) - self.core = core.Core() + self.core = Core() self.dataDir = self.core.dataDir self.canceled = False @@ -54,8 +54,8 @@ class Command(QtCore.QObject): nargs='*', action='append') self.args = self.parser.parse_args() - self.settings = self.core.settings - LoadDefaultSettings(self) + self.settings = Core.settings + loadDefaultSettings(self) if self.args.projpath: projPath = self.args.projpath diff --git a/src/component.py b/src/component.py index 7842bd6..92cc65c 100644 --- a/src/component.py +++ b/src/component.py @@ -1,33 +1,87 @@ ''' - Base classes for components to import. + Base classes for components to import. Read comments for some documentation + on making a valid component. ''' from PyQt5 import uic, QtCore, QtWidgets import os +from core import Core +from toolkit.common import getPresetDir -class Component(QtCore.QObject): + +class ComponentMetaclass(type(QtCore.QObject)): + ''' + Checks the validity of each Component class imported, and + mutates some attributes for easier use by the core program. + E.g., takes only major version from version string & decorates methods + ''' + def __new__(cls, name, parents, attrs): + # print('Creating %s component' % attrs['name']) + + # Turn certain class methods into properties and classmethods + for key in ('error', 'properties', 'audio', 'commandHelp'): + if key not in attrs: + continue + attrs[key] = property(attrs[key]) + + for key in ('names'): + if key not in attrs: + continue + attrs[key] = classmethod(key) + + # Turn version string into a number + try: + if 'version' not in attrs: + print( + 'No version attribute in %s. Defaulting to 1' % + attrs['name']) + attrs['version'] = 1 + else: + attrs['version'] = int(attrs['version'].split('.')[0]) + except ValueError: + print('%s component has an invalid version string:\n%s' % ( + attrs['name'], str(attrs['version']))) + except KeyError: + print('%s component has no version string.' % attrs['name']) + else: + return super().__new__(cls, name, parents, attrs) + quit(1) + + +class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' - A class for components to inherit. Read comments for documentation - on making a valid component. All subclasses must implement this signal: - modified = QtCore.pyqtSignal(int, bool) + The base class for components to inherit. ''' - def __init__(self, moduleIndex, compPos, core): + name = 'Component' + version = '1.0.0' + # The 1st number (before dot, aka the major version) is used to determine + # preset compatibility; the rest is ignored so it can be non-numeric. + + modified = QtCore.pyqtSignal(int, dict) + # ^ Signal used to tell core program that the component state changed, + # you shouldn't need to use this directly, it is used by self.update() + + def __init__(self, moduleIndex, compPos): super().__init__() self.currentPreset = None - self.canceled = False self.moduleIndex = moduleIndex self.compPos = compPos - self.core = core + + # Stop lengthy processes in response to this variable + self.canceled = False def __str__(self): - return self.__doc__ + return self.__class__.name - def version(self): - ''' - Change this number to identify new versions of a component - ''' - return 1 + def __repr__(self): + return '%s\n%s\n%s' % ( + self.__class__.name, str(self.__class__.version), self.savePreset() + ) + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Properties + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ def properties(self): ''' @@ -43,19 +97,32 @@ class Component(QtCore.QObject): ''' return - def cancel(self): + def audio(self): ''' - Stop any lengthy process in response to this variable + 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 ''' - self.canceled = True - def reset(self): - self.canceled = False - - def update(self): + def names(): ''' - Read your widget values from self.page, then call super().update() + Alternative names for renaming a component between project files. ''' + return [] + + def commandHelp(self): + '''Help text as string for this component's commandline arguments''' + + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + # Methods + # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + def update(self): + '''Read widget values from self.page, then call super().update()''' self.parent.drawPreview() saveValueStore = self.savePreset() saveValueStore['preset'] = self.currentPreset @@ -92,7 +159,7 @@ class Component(QtCore.QObject): ''' if arg.startswith('preset='): _, preset = arg.split('=', 1) - path = os.path.join(self.core.getPresetDir(self), preset) + path = os.path.join(getPresetDir(self), preset) if not os.path.exists(path): print('Couldn\'t locate preset "%s"' % preset) quit(1) @@ -106,14 +173,19 @@ class Component(QtCore.QObject): self.__doc__, 'Usage:\n' 'Open a preset for this component:\n' ' "preset=Preset Name"') - self.commandHelp() + print(self.commandHelp) quit(0) - def commandHelp(self): - '''Print help text for this Component's commandline arguments''' - def loadUi(self, filename): - return uic.loadUi(os.path.join(self.core.componentsPath, filename)) + '''Load a Qt Designer ui file to use for this component's widget''' + return uic.loadUi(os.path.join(Core.componentsPath, filename)) + + def cancel(self): + '''Stop any lengthy process in response to this variable.''' + self.canceled = True + + def reset(self): + self.canceled = False ''' ### Reference methods for creating a new component @@ -121,47 +193,34 @@ class Component(QtCore.QObject): def widget(self, parent): self.parent = parent - page = self.loadUi('example.ui') + self.settings = parent.settings + self.page = self.loadUi('example.ui') # --- connect widget signals here --- - self.page = page - return page + return self.page def previewRender(self, previewWorker): - width = int(previewWorker.core.settings.value('outputWidth')) + width = int(self.settings.value('outputWidth')) height = int(previewWorker.core.settings.value('outputHeight')) - from frame import BlankFrame + from toolkit.frame import BlankFrame image = BlankFrame(width, height) return image def frameRender(self, layerNo, frameNo): audioArrayIndex = frameNo * self.sampleSize - width = int(self.worker.core.settings.value('outputWidth')) - height = int(self.worker.core.settings.value('outputHeight')) - from frame import BlankFrame + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) + from toolkit.frame import BlankFrame image = BlankFrame(width, height) return image - - 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 - \''' - - @classmethod - def names(cls): - \''' - Alternative names for renaming a component between project files. - \''' - return [] ''' class BadComponentInit(Exception): + ''' + General purpose exception components can raise to indicate + a Python issue with e.g., dynamic creation of instances or something. + Decorative for now, may have future use for logging. + ''' def __init__(self, arg, name): string = '''################################ Mandatory argument "%s" not specified diff --git a/src/components/color.py b/src/components/color.py index 8d2526d..03371e7 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -10,13 +10,12 @@ from toolkit import rgbFromString, pickColor class Component(Component): - '''Color''' - - modified = QtCore.pyqtSignal(int, dict) + name = 'Color' + version = '1.0.0' def widget(self, parent): self.parent = parent - self.settings = self.parent.core.settings + self.settings = parent.settings page = self.loadUi('color.ui') self.color1 = (0, 0, 0) @@ -211,7 +210,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'color1': self.color1, 'color2': self.color2, 'x': self.x, diff --git a/src/components/image.py b/src/components/image.py index 7f3f610..591e03e 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -2,18 +2,18 @@ from PIL import Image, ImageDraw, ImageEnhance from PyQt5 import QtGui, QtCore, QtWidgets import os +from core import Core from component import Component from toolkit.frame import BlankFrame class Component(Component): - '''Image''' - - modified = QtCore.pyqtSignal(int, dict) + name = 'Image' + version = '1.0.0' def widget(self, parent): self.parent = parent - self.settings = self.parent.core.settings + self.settings = parent.settings page = self.loadUi('image.ui') page.lineEdit_image.textChanged.connect(self.update) @@ -102,7 +102,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'image': self.imagePath, 'scale': self.scale, 'color': self.color, @@ -117,7 +116,7 @@ class Component(Component): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Image", imgDir, - "Image Files (%s)" % " ".join(self.core.imageFormats)) + "Image Files (%s)" % " ".join(Core.imageFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_image.setText(filename) diff --git a/src/components/original.py b/src/components/original.py index 586204a..ae40df3 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -12,17 +12,15 @@ from toolkit import rgbFromString, pickColor class Component(Component): - '''Classic Visualizer''' + name = 'Classic Visualizer' + version = '1.0.0' - modified = QtCore.pyqtSignal(int, dict) - - @classmethod - def names(cls): + def names(): return ['Original Audio Visualization'] def widget(self, parent): self.parent = parent - self.settings = self.parent.core.settings + self.settings = parent.settings self.visColor = (255, 255, 255) self.scale = 20 self.y = 0 @@ -68,7 +66,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'layout': self.layout, 'visColor': self.visColor, 'scale': self.scale, diff --git a/src/components/sound.py b/src/components/sound.py index 5b06405..677a22f 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -1,14 +1,14 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os +from core import Core from component import Component from toolkit.frame import BlankFrame class Component(Component): - '''Sound''' - - modified = QtCore.pyqtSignal(int, dict) + name = 'Sound' + version = '1.0.0' def widget(self, parent): self.parent = parent @@ -32,8 +32,8 @@ class Component(Component): super().update() def previewRender(self, previewWorker): - width = int(previewWorker.core.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) return BlankFrame(width, height) def preFrameRender(self, **kwargs): @@ -67,7 +67,7 @@ class Component(Component): sndDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Sound", sndDir, - "Audio Files (%s)" % " ".join(self.core.audioFormats)) + "Audio Files (%s)" % " ".join(Core.audioFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_sound.setText(filename) @@ -101,7 +101,7 @@ class Component(Component): key, arg = arg.split('=', 1) if key == 'path': if '*%s' % os.path.splitext(arg)[1] \ - not in self.core.audioFormats: + not in Core.audioFormats: print("Not a supported audio format") quit(1) self.page.lineEdit_sound.setText(arg) diff --git a/src/components/text.py b/src/components/text.py index fc3ef5f..d511f22 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -9,9 +9,8 @@ from toolkit import rgbFromString, pickColor class Component(Component): - '''Title Text''' - - modified = QtCore.pyqtSignal(int, dict) + name = 'Title Text' + version = '1.0.0' def __init__(self, *args): super().__init__(*args) @@ -19,7 +18,7 @@ class Component(Component): def widget(self, parent): self.parent = parent - self.settings = self.parent.core.settings + self.settings = parent.settings height = int(self.settings.value('outputHeight')) width = int(self.settings.value('outputWidth')) @@ -106,7 +105,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'title': self.title, 'titleFont': self.titleFont.toString(), 'alignment': self.alignment, diff --git a/src/components/video.py b/src/components/video.py index a9f334e..b35c2e5 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -6,6 +6,7 @@ import subprocess import threading from queue import PriorityQueue +from core import Core from component import Component, BadComponentInit from toolkit.frame import BlankFrame from toolkit import openPipe, checkOutput @@ -106,9 +107,8 @@ class Video: class Component(Component): - '''Video''' - - modified = QtCore.pyqtSignal(int, dict) + name = 'Video' + version = '1.0.0' def widget(self, parent): self.parent = parent @@ -154,8 +154,8 @@ class Component(Component): super().update() def previewRender(self, previewWorker): - width = int(previewWorker.core.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) self.updateChunksize(width, height) frame = self.getPreviewFrame(width, height) if not frame: @@ -190,7 +190,7 @@ class Component(Component): def testAudioStream(self): # test if an audio stream really exists audioTestCommand = [ - self.core.FFMPEG_BIN, + Core.FFMPEG_BIN, '-i', self.videoPath, '-vn', '-f', 'null', '-' ] @@ -209,12 +209,12 @@ class Component(Component): def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) - width = int(self.worker.core.settings.value('outputWidth')) - height = int(self.worker.core.settings.value('outputHeight')) + width = int(self.settings.value('outputWidth')) + height = int(self.settings.value('outputHeight')) self.blankFrame_ = BlankFrame(width, height) self.updateChunksize(width, height) self.video = Video( - ffmpeg=self.core.FFMPEG_BIN, videoPath=self.videoPath, + ffmpeg=Core.FFMPEG_BIN, videoPath=self.videoPath, width=width, height=height, chunkSize=self.chunkSize, frameRate=int(self.settings.value("outputFrameRate")), parent=self.parent, loopVideo=self.loopVideo, @@ -240,7 +240,6 @@ class Component(Component): def savePreset(self): return { - 'preset': self.currentPreset, 'video': self.videoPath, 'loop': self.loopVideo, 'useAudio': self.useAudio, @@ -255,7 +254,7 @@ class Component(Component): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Video", - imgDir, "Video Files (%s)" % " ".join(self.core.videoFormats) + imgDir, "Video Files (%s)" % " ".join(Core.videoFormats) ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) @@ -298,7 +297,7 @@ class Component(Component): if not arg.startswith('preset=') and '=' 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: + if '*%s' % os.path.splitext(arg)[1] in Core.videoFormats: self.page.lineEdit_video.setText(arg) self.page.spinBox_scale.setValue(100) self.page.checkBox_loop.setChecked(True) diff --git a/src/core.py b/src/core.py index 07c1f71..dd2ef18 100644 --- a/src/core.py +++ b/src/core.py @@ -1,46 +1,56 @@ ''' Home to the Core class which tracks program state. Used by GUI & commandline ''' +from PyQt5 import QtCore, QtGui, uic import sys import os -from PyQt5 import QtCore, QtGui, uic -import subprocess as sp -import numpy import json from importlib import import_module -from PyQt5.QtCore import QStandardPaths import toolkit -from toolkit.frame import Frame +from toolkit.ffmpeg import findFfmpeg import video_thread class Core: ''' MainWindow and Command module both use an instance of this class - to store the program state. This object tracks the components, - opens projects and presets, and stores settings/paths to data. + to store the main program state. This object tracks the components + as an instance, has methods for managing the components and for + opening/creating project files and presets. ''' - def __init__(self): - Frame.core = self - self.dataDir = QStandardPaths.writableLocation( - QStandardPaths.AppConfigLocation - ) - self.presetDir = os.path.join(self.dataDir, 'presets') + + @classmethod + def storeSettings(cls): + ''' + Stores settings/paths to directories as class variables + ''' if getattr(sys, 'frozen', False): # frozen - self.wd = os.path.dirname(sys.executable) + wd = os.path.dirname(sys.executable) else: - # unfrozen - self.wd = os.path.dirname(os.path.realpath(__file__)) - self.componentsPath = os.path.join(self.wd, 'components') - self.settings = QtCore.QSettings( - os.path.join(self.dataDir, 'settings.ini'), - QtCore.QSettings.IniFormat - ) + wd = os.path.dirname(os.path.realpath(__file__)) - self.loadEncoderOptions() - self.videoFormats = toolkit.appendUppercase([ + dataDir = QtCore.QStandardPaths.writableLocation( + QtCore.QStandardPaths.AppConfigLocation + ) + with open(os.path.join(wd, 'encoder-options.json')) as json_file: + encoderOptions = json.load(json_file) + + settings = { + 'wd': wd, + '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'), + 'encoderOptions': encoderOptions, + 'FFMPEG_BIN': findFfmpeg(), + 'canceled': False, + } + + settings['videoFormats'] = toolkit.appendUppercase([ '*.mp4', '*.mov', '*.mkv', @@ -48,7 +58,7 @@ class Core: '*.webm', '*.flv', ]) - self.audioFormats = toolkit.appendUppercase([ + settings['audioFormats'] = toolkit.appendUppercase([ '*.mp3', '*.wav', '*.ogg', @@ -56,7 +66,7 @@ class Core: '*.flac', '*.aac', ]) - self.imageFormats = toolkit.appendUppercase([ + settings['imageFormats'] = toolkit.appendUppercase([ '*.png', '*.jpg', '*.tif', @@ -68,15 +78,22 @@ class Core: '*.xpm', ]) - self.FFMPEG_BIN = self.findFfmpeg() + # Register all settings as class variables + for classvar, val in settings.items(): + setattr(cls, classvar, val) + # Make settings accessible to the toolkit package + toolkit.init(settings) + + def __init__(self): + Core.storeSettings() + self.findComponents() self.selectedComponents = [] - # copies of named presets to detect modification - self.savedPresets = {} + self.savedPresets = {} # copies of presets to detect modification def findComponents(self): def findComponents(): - for f in sorted(os.listdir(self.componentsPath)): + for f in sorted(os.listdir(Core.componentsPath)): name, ext = os.path.splitext(f) if name.startswith("__"): continue @@ -88,7 +105,7 @@ class Core: ] # store canonical module names and indexes self.moduleIndexes = [i for i in range(len(self.modules))] - self.compNames = [mod.Component.__doc__ for mod in self.modules] + self.compNames = [mod.Component.name for mod in self.modules] self.altCompNames = [] # store alternative names for modules for i, mod in enumerate(self.modules): @@ -108,7 +125,7 @@ class Core: return None component = self.modules[moduleIndex].Component( - moduleIndex, compPos, self + moduleIndex, compPos ) self.selectedComponents.insert( compPos, @@ -171,10 +188,6 @@ class Core: self.savedPresets[presetName] = dict(saveValueStore) return True - def getPresetDir(self, comp): - return os.path.join( - self.presetDir, str(comp), str(comp.version())) - def getPreset(self, filepath): '''Returns the preset dict stored at this filepath''' if not os.path.exists(filepath): @@ -204,7 +217,7 @@ class Core: widget.blockSignals(False) for key, value in data['Settings']: - self.settings.setValue(key, value) + Core.settings.setValue(key, value) for tup in data['Components']: name, vers, preset = tup @@ -215,7 +228,7 @@ class Core: if 'preset' in preset and preset['preset'] is not None: nam = preset['preset'] filepath2 = os.path.join( - self.presetDir, name, str(vers), nam) + Core.presetDir, name, str(vers), nam) origSaveValueStore = self.getPreset(filepath2) if origSaveValueStore: self.savedPresets[nam] = dict(origSaveValueStore) @@ -336,7 +349,7 @@ class Core: presetName = preset['preset'] \ if preset['preset'] else os.path.basename(filepath)[:-4] newPath = os.path.join( - self.presetDir, + Core.presetDir, name, vers, presetName @@ -354,7 +367,7 @@ class Core: def exportPreset(self, exportPath, compName, vers, origName): internalPath = os.path.join( - self.presetDir, compName, str(vers), origName + Core.presetDir, compName, str(vers), origName ) if not os.path.exists(internalPath): return @@ -378,7 +391,7 @@ class Core: '''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(self.presetDir, compName, str(vers)) + dirname = os.path.join(Core.presetDir, compName, str(vers)) if not os.path.exists(dirname): os.makedirs(dirname) filepath = os.path.join(dirname, presetName) @@ -417,13 +430,13 @@ class Core: saveValueStore = comp.savePreset() saveValueStore['preset'] = comp.currentPreset f.write('%s\n' % str(comp)) - f.write('%s\n' % str(comp.version())) + f.write('%s\n' % str(comp.version)) f.write('%s\n' % toolkit.presetToString(saveValueStore)) f.write('\n[Settings]\n') - for key in self.settings.allKeys(): + for key in Core.settings.allKeys(): if key in settingsKeys: - f.write('%s=%s\n' % (key, self.settings.value(key))) + f.write('%s=%s\n' % (key, Core.settings.value(key))) if window: f.write('\n[WindowFields]\n') @@ -438,280 +451,8 @@ class Core: except: return False - def loadEncoderOptions(self): - file_path = os.path.join(self.wd, 'encoder-options.json') - with open(file_path) as json_file: - self.encoder_options = json.load(json_file) - - def findFfmpeg(self): - 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: - if sys.platform == "win32": - return "ffmpeg" - else: - try: - with open(os.devnull, "w") as f: - toolkit.checkOutput( - ['ffmpeg', '-version'], stderr=f - ) - return "ffmpeg" - except sp.CalledProcessError: - return "avconv" - - def createFfmpegCommand(self, inputFile, outputFile, duration): - ''' - Constructs the major ffmpeg command used to export the video - ''' - safeDuration = "{0:.3f}".format(duration - 0.05) # used by filters - duration = "{0:.3f}".format(duration + 0.1) # used by input sources - - # Test if user has libfdk_aac - encoders = toolkit.checkOutput( - "%s -encoders -hide_banner" % self.FFMPEG_BIN, shell=True - ) - encoders = encoders.decode("utf-8") - - acodec = self.settings.value('outputAudioCodec') - - options = self.encoder_options - containerName = self.settings.value('outputContainer') - vcodec = self.settings.value('outputVideoCodec') - vbitrate = str(self.settings.value('outputVideoBitrate'))+'k' - acodec = self.settings.value('outputAudioCodec') - abitrate = str(self.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] - - for encoder in vencoders: - if encoder in encoders: - vencoder = encoder - break - - for encoder in aencoders: - if encoder in encoders: - aencoder = encoder - break - - ffmpegCommand = [ - self.FFMPEG_BIN, - '-thread_queue_size', '512', - '-y', # overwrite the output file if it already exists. - - # INPUT VIDEO - '-f', 'rawvideo', - '-vcodec', 'rawvideo', - '-s', '%sx%s' % ( - self.settings.value('outputWidth'), - self.settings.value('outputHeight'), - ), - '-pix_fmt', 'rgba', - '-r', self.settings.value('outputFrameRate'), - '-t', duration, - '-i', '-', # the video input comes from a pipe - '-an', # the video input has no sound - - # INPUT SOUND - '-t', duration, - '-i', inputFile - ] - - # Add extra audio inputs and any needed avfilters - # NOTE: Global filters are currently hard-coded here for debugging use - globalFilters = 0 # increase to add global filters - extraAudio = [ - comp.audio() for comp in self.selectedComponents - if 'audio' in comp.properties() - ] - if extraAudio or globalFilters > 0: - # Add -i options for extra input files - extraFilters = {} - for streamNo, params in enumerate(reversed(extraAudio)): - extraInputFile, params = params - ffmpegCommand.extend([ - '-t', safeDuration, - # Tell ffmpeg about shorter clips (seemingly not needed) - # streamDuration = self.getAudioDuration(extraInputFile) - # if 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) - ), - ]) - - # Only map audio from the filters, and video from the pipe - ffmpegCommand.extend([ - '-map', '0:v', - '-map', '[a]', - ]) - - ffmpegCommand.extend([ - # OUTPUT - '-vcodec', vencoder, - '-acodec', aencoder, - '-b:v', vbitrate, - '-b:a', abitrate, - '-pix_fmt', self.settings.value('outputVideoFormat'), - '-preset', self.settings.value('outputPreset'), - '-f', container - ]) - - if acodec == 'aac': - ffmpegCommand.append('-strict') - ffmpegCommand.append('-2') - - ffmpegCommand.append(outputFile) - return ffmpegCommand - - def getAudioDuration(self, filename): - command = [self.FFMPEG_BIN, '-i', filename] - - try: - fileInfo = toolkit.checkOutput(command, stderr=sp.STDOUT) - except sp.CalledProcessError as ex: - fileInfo = ex.output - - info = fileInfo.decode("utf-8").split('\n') - 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]) - return duration - - def readAudioFile(self, filename, parent): - duration = self.getAudioDuration(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 = toolkit.openPipe( - command, stdout=sp.PIPE, stderr=sp.DEVNULL, bufsize=10**8 - ) - - completeAudioArray = numpy.empty(0, dtype="int16") - - progress = 0 - lastPercent = None - while True: - if self.canceled: - break - # 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)+'%' - parent.progressBarSetText.emit(string) - parent.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 newVideoWorker(self, loader, audioFile, outputPath): + '''loader is MainWindow or Command object which must own the thread''' self.videoThread = QtCore.QThread(loader) videoWorker = video_thread.Worker( loader, audioFile, outputPath, self.selectedComponents @@ -727,7 +468,9 @@ class Core: self.videoThread.wait() def cancel(self): - self.canceled = True + Core.canceled = True + toolkit.cancel() def reset(self): - self.canceled = False + Core.canceled = False + toolkit.reset() diff --git a/src/mainwindow.py b/src/mainwindow.py index ca8e697..9944d1a 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -14,13 +14,17 @@ import signal import filecmp import time -import core +from core import Core import preview_thread from presetmanager import PresetManager -from toolkit import LoadDefaultSettings, disableWhenEncoding, checkOutput +from toolkit import loadDefaultSettings, disableWhenEncoding, 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 @@ -47,6 +51,14 @@ class PreviewWindow(QtWidgets.QLabel): 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 @@ -57,25 +69,26 @@ class MainWindow(QtWidgets.QMainWindow): # print('main thread id: {}'.format(QtCore.QThread.currentThreadId())) self.window = window - self.core = core.Core() + self.core = Core() self.pages = [] # widgets of component settings self.lastAutosave = time.time() self.encoding = False # Create data directory, load/create settings - self.dataDir = self.core.dataDir + self.dataDir = Core.dataDir + self.presetDir = Core.presetDir self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') - self.settings = self.core.settings - LoadDefaultSettings(self) + self.settings = Core.settings + loadDefaultSettings(self) self.presetManager = PresetManager( uic.loadUi( - os.path.join(self.core.wd, 'presetmanager.ui')), self) + os.path.join(Core.wd, 'presetmanager.ui')), self) if not os.path.exists(self.dataDir): os.makedirs(self.dataDir) for neededDirectory in ( - self.core.presetDir, self.settings.value("projectDir")): + self.presetDir, self.settings.value("projectDir")): if not os.path.exists(neededDirectory): os.mkdir(neededDirectory) @@ -120,7 +133,7 @@ class MainWindow(QtWidgets.QMainWindow): window.pushButton_Cancel.clicked.connect(self.stopVideo) - for i, container in enumerate(self.core.encoder_options['containers']): + for i, container in enumerate(Core.encoderOptions['containers']): window.comboBox_videoContainer.addItem(container['name']) if container['name'] == self.settings.value('outputContainer'): selectedContainer = i @@ -160,14 +173,14 @@ class MainWindow(QtWidgets.QMainWindow): window.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings) self.previewWindow = PreviewWindow(self, os.path.join( - self.core.wd, "background.png")) + Core.wd, "background.png")) window.verticalLayout_previewWrapper.addWidget(self.previewWindow) # Make component buttons self.compMenu = QMenu() self.compActions = [] for i, comp in enumerate(self.core.modules): - action = self.compMenu.addAction(comp.Component.__doc__) + action = self.compMenu.addAction(comp.Component.name) action.triggered.connect( lambda _, item=i: self.core.insertComponent(0, item, self) ) @@ -336,8 +349,14 @@ class MainWindow(QtWidgets.QMainWindow): "Ctrl+Down", self.window, activated=lambda: self.moveComponent(1) ) - QtWidgets.QShortcut("Ctrl+Home", self.window, self.moveComponentTop) - QtWidgets.QShortcut("Ctrl+End", self.window, self.moveComponentBottom) + QtWidgets.QShortcut( + "Ctrl+Home", self.window, + activated=lambda: self.moveComponent('top') + ) + QtWidgets.QShortcut( + "Ctrl+End", self.window, + activated=lambda: self.moveComponent('bottom') + ) QtWidgets.QShortcut("Ctrl+r", self.window, self.removeComponent) @QtCore.pyqtSlot() @@ -389,7 +408,7 @@ class MainWindow(QtWidgets.QMainWindow): vCodecWidget.clear() aCodecWidget.clear() - for container in self.core.encoder_options['containers']: + for container in Core.encoderOptions['containers']: if container['name'] == name: for vCodec in container['video-codecs']: vCodecWidget.addItem(vCodec) @@ -397,6 +416,7 @@ class MainWindow(QtWidgets.QMainWindow): 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 @@ -416,11 +436,12 @@ class MainWindow(QtWidgets.QMainWindow): if not self.currentProject: if os.path.exists(self.autosavePath): os.remove(self.autosavePath) - elif force or time.time() - self.lastAutosave >= 0.1: + elif force or time.time() - self.lastAutosave >= 0.2: self.core.createProjectFile(self.autosavePath, self.window) self.lastAutosave = time.time() 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( @@ -432,6 +453,7 @@ class MainWindow(QtWidgets.QMainWindow): return False def saveProjectChanges(self): + '''Overwrites project file with autosave file''' try: os.remove(self.currentProject) os.rename(self.autosavePath, self.currentProject) @@ -447,7 +469,7 @@ class MainWindow(QtWidgets.QMainWindow): fileName, _ = QtWidgets.QFileDialog.getOpenFileName( self.window, "Open Audio File", - inputDir, "Audio Files (%s)" % " ".join(self.core.audioFormats)) + inputDir, "Audio Files (%s)" % " ".join(Core.audioFormats)) if fileName: self.settings.setValue("inputDir", os.path.dirname(fileName)) @@ -460,7 +482,7 @@ class MainWindow(QtWidgets.QMainWindow): self.window, "Set Output Video File", outputDir, "Video Files (%s);; All Files (*)" % " ".join( - self.core.videoFormats)) + Core.videoFormats)) if fileName: self.settings.setValue("outputDir", os.path.dirname(fileName)) @@ -587,10 +609,11 @@ class MainWindow(QtWidgets.QMainWindow): def showFfmpegCommand(self): from textwrap import wrap - command = self.core.createFfmpegCommand( + from toolkit.ffmpeg import createFfmpegCommand + command = createFfmpegCommand( self.window.lineEdit_audioFile.text(), self.window.lineEdit_outputFile.text(), - self.core.getAudioDuration(self.window.lineEdit_audioFile.text()) + self.core.selectedComponents ) lines = wrap(" ".join(command), 49) self.showMessage( @@ -603,7 +626,7 @@ class MainWindow(QtWidgets.QMainWindow): componentList.insertItem( index, - self.core.selectedComponents[index].__doc__) + self.core.selectedComponents[index].name) componentList.setCurrentRow(index) # connect to signal that adds an asterisk when modified @@ -632,6 +655,10 @@ class MainWindow(QtWidgets.QMainWindow): 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() @@ -650,21 +677,9 @@ class MainWindow(QtWidgets.QMainWindow): stackedWidget.setCurrentIndex(newRow) self.drawPreview() - @disableWhenEncoding - def moveComponentTop(self): - componentList = self.window.listWidget_componentList - row = -componentList.currentRow() - self.moveComponent(row) - - @disableWhenEncoding - def moveComponentBottom(self): - componentList = self.window.listWidget_componentList - row = len(componentList)-componentList.currentRow()-1 - self.moveComponent(row) - @disableWhenEncoding def dragComponent(self, event): - '''Drop event for the component listwidget''' + '''Used as Qt drop event for the component listwidget''' componentList = self.window.listWidget_componentList modelIndexes = [ diff --git a/src/presetmanager.py b/src/presetmanager.py index 6e003a1..825fdee 100644 --- a/src/presetmanager.py +++ b/src/presetmanager.py @@ -15,11 +15,11 @@ class PresetManager(QtWidgets.QDialog): self.parent = parent self.core = parent.core self.settings = parent.settings - self.presetDir = self.core.presetDir + self.presetDir = parent.presetDir if not self.settings.value('presetDir'): self.settings.setValue( "presetDir", - os.path.join(self.core.dataDir, 'projects')) + os.path.join(parent.dataDir, 'projects')) self.findPresets() @@ -161,7 +161,7 @@ class PresetManager(QtWidgets.QDialog): selectedComponents[index].savePreset() saveValueStore['preset'] = newName componentName = str(selectedComponents[index]).strip() - vers = selectedComponents[index].version() + vers = selectedComponents[index].version self.createNewPreset( componentName, vers, newName, saveValueStore, window=self.parent.window) @@ -195,13 +195,13 @@ class PresetManager(QtWidgets.QDialog): def openPreset(self, presetName, compPos=None): componentList = self.parent.window.listWidget_componentList - selectedComponents = self.parent.core.selectedComponents + selectedComponents = self.core.selectedComponents index = compPos if compPos is not None else componentList.currentRow() if index == -1: return componentName = str(selectedComponents[index]).strip() - version = selectedComponents[index].version() + 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) @@ -243,6 +243,7 @@ class PresetManager(QtWidgets.QDialog): parent=window if window else self.window) def openRenamePresetDialog(self): + # TODO: maintain consistency by changing this to call createNewPreset() presetList = self.window.listWidget_presets if presetList.currentRow() == -1: return @@ -273,11 +274,12 @@ class PresetManager(QtWidgets.QDialog): os.rename(oldPath, newPath) self.findPresets() self.drawPresetList() - for i, comp in enumerate(self.core.selectedComponents): - if comp.currentPreset == oldName: - comp.currentPreset = newName - self.parent.updateComponentTitle(i, True) + if toolkit.getPresetDir(comp) == path \ + and comp.currentPreset == oldName: + self.core.openPreset(newPath, i, newName) + self.parent.updateComponentTitle(i, False) + self.parent.drawPreview() break def openImportDialog(self): diff --git a/src/preview_thread.py b/src/preview_thread.py index c28e048..3fc73b3 100644 --- a/src/preview_thread.py +++ b/src/preview_thread.py @@ -22,8 +22,8 @@ class Worker(QtCore.QObject): parent.newTask.connect(self.createPreviewImage) parent.processTask.connect(self.process) self.parent = parent - self.core = self.parent.core - self.settings = self.parent.core.settings + self.core = parent.core + self.settings = parent.settings self.queue = queue width = int(self.settings.value('outputWidth')) diff --git a/src/toolkit/common.py b/src/toolkit/common.py index e3a1649..763d582 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -8,6 +8,13 @@ import sys import subprocess from collections import OrderedDict +from toolkit.core import * + + +def getPresetDir(comp): + '''Get the preset subdirectory for a particular version of a component''' + return os.path.join(Core.presetDir, str(comp), str(comp.version)) + def badName(name): '''Returns whether a name contains non-alphanumeric chars''' @@ -103,8 +110,9 @@ def rgbFromString(string): return (255, 255, 255) -def LoadDefaultSettings(self): - ''' Runs once at each program start-up. Fills in default settings +def loadDefaultSettings(self): + ''' + Runs once at each program start-up. Fills in default settings for any settings not found in settings.ini ''' self.resolutions = [ diff --git a/src/toolkit/core.py b/src/toolkit/core.py new file mode 100644 index 0000000..a96a684 --- /dev/null +++ b/src/toolkit/core.py @@ -0,0 +1,18 @@ +class Core: + '''A very complicated class for tracking settings''' + + +def init(settings): + global Core + for classvar, val in settings.items(): + setattr(Core, classvar, val) + + +def cancel(): + global Core + Core.canceled = True + + +def reset(): + global Core + Core.canceled = False diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py new file mode 100644 index 0000000..89d4e9d --- /dev/null +++ b/src/toolkit/ffmpeg.py @@ -0,0 +1,284 @@ +''' + Tools for using ffmpeg +''' +import numpy +import sys +import os +import subprocess as sp + +from toolkit.common import Core, checkOutput, openPipe + + +def findFfmpeg(): + if getattr(sys, 'frozen', False): + # The application is frozen + if sys.platform == "win32": + return os.path.join(Core.wd, 'ffmpeg.exe') + else: + return os.path.join(Core.wd, 'ffmpeg') + + else: + if sys.platform == "win32": + return "ffmpeg" + else: + try: + with open(os.devnull, "w") as f: + checkOutput( + ['ffmpeg', '-version'], stderr=f + ) + return "ffmpeg" + except sp.CalledProcessError: + return "avconv" + + +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 + + # 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] + + for encoder in vencoders: + if encoder in encoders: + vencoder = encoder + break + + for encoder in aencoders: + if encoder in encoders: + aencoder = encoder + break + + ffmpegCommand = [ + Core.FFMPEG_BIN, + '-thread_queue_size', '512', + '-y', # overwrite the output file if it already exists. + + # INPUT VIDEO + '-f', 'rawvideo', + '-vcodec', 'rawvideo', + '-s', '%sx%s' % ( + Core.settings.value('outputWidth'), + Core.settings.value('outputHeight'), + ), + '-pix_fmt', 'rgba', + '-r', Core.settings.value('outputFrameRate'), + '-t', duration, + '-i', '-', # the video input comes from a pipe + '-an', # the video input has no sound + + # INPUT SOUND + '-t', duration, + '-i', inputFile + ] + + # Add extra audio inputs and any needed avfilters + # NOTE: Global filters are currently hard-coded here for debugging use + globalFilters = 0 # increase to add global filters + extraAudio = [ + comp.audio for comp in components + if 'audio' in comp.properties + ] + if extraAudio or globalFilters > 0: + # Add -i options for extra input files + extraFilters = {} + for streamNo, params in enumerate(reversed(extraAudio)): + extraInputFile, params = params + ffmpegCommand.extend([ + '-t', safeDuration, + # Tell ffmpeg about shorter clips (seemingly not needed) + # streamDuration = getAudioDuration(extraInputFile) + # if 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) + ), + ]) + + # Only map audio from the filters, and video from the pipe + ffmpegCommand.extend([ + '-map', '0:v', + '-map', '[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 getAudioDuration(filename): + command = [Core.FFMPEG_BIN, '-i', filename] + + try: + fileInfo = checkOutput(command, stderr=sp.STDOUT) + except sp.CalledProcessError as ex: + fileInfo = ex.output + + info = fileInfo.decode("utf-8").split('\n') + 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]) + return duration + + +def readAudioFile(filename, parent): + duration = getAudioDuration(filename) + + command = [ + 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=sp.PIPE, stderr=sp.DEVNULL, bufsize=10**8 + ) + + completeAudioArray = numpy.empty(0, dtype="int16") + + progress = 0 + lastPercent = None + while True: + if 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)+'%' + parent.progressBarSetText.emit(string) + parent.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) diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index cddb611..83fd59e 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -7,9 +7,7 @@ from PIL.ImageQt import ImageQt import sys import os - -class Frame: - '''Controller class for all frames.''' +from toolkit.common import Core class FramePainter(QtGui.QPainter): @@ -59,7 +57,7 @@ def Checkerboard(width, height): ''' image = FloodFrame(1920, 1080, (0, 0, 0, 0)) image.paste(Image.open( - os.path.join(Frame.core.wd, "background.png")), + os.path.join(Core.wd, "background.png")), (0, 0) ) image = image.resize((width, height)) diff --git a/src/video_thread.py b/src/video_thread.py index 1f2eaf5..8517b92 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -5,9 +5,9 @@ are emitted to update MainWindow's progress bar, detail text, and preview. Export can be cancelled with cancel() ''' -from PyQt5 import QtCore, QtGui, uic +from PyQt5 import QtCore, QtGui from PyQt5.QtCore import pyqtSignal, pyqtSlot -from PIL import Image, ImageDraw, ImageFont +from PIL import Image from PIL.ImageQt import ImageQt import numpy import subprocess as sp @@ -19,6 +19,7 @@ import time import signal from toolkit import openPipe +from toolkit.ffmpeg import readAudioFile, createFfmpegCommand from toolkit.frame import Checkerboard @@ -33,7 +34,7 @@ class Worker(QtCore.QObject): def __init__(self, parent, inputFile, outputFile, components): QtCore.QObject.__init__(self) self.core = parent.core - self.settings = parent.core.settings + self.settings = parent.settings self.modules = parent.core.modules parent.createVideo.connect(self.createVideo) @@ -133,12 +134,17 @@ class Worker(QtCore.QObject): # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ self.progressBarSetText.emit("Loading audio file...") - self.completeAudioArray, duration = self.core.readAudioFile( + audioFileTraits = readAudioFile( self.inputFile, self ) + if audioFileTraits is None: + self.cancelExport() + return + self.completeAudioArray, duration = audioFileTraits self.progressBarUpdate.emit(0) self.progressBarSetText.emit("Starting components...") + canceledByComponent = False print('Loaded Components:', ", ".join([ "%s) %s" % (num, str(component)) for num, component in enumerate(reversed(self.components)) @@ -153,14 +159,15 @@ class Worker(QtCore.QObject): progressBarSetText=self.progressBarSetText ) - if 'error' in comp.properties(): + if 'error' in comp.properties: self.cancel() self.canceled = True + canceledByComponent = True errMsg = "Component #%s encountered an error!" % compNo \ - if comp.error() is None else 'Component #%s (%s): %s' % ( + if comp.error is None else 'Component #%s (%s): %s' % ( str(compNo), str(comp), - comp.error() + comp.error ) self.parent.showMessage( msg=errMsg, @@ -168,17 +175,16 @@ class Worker(QtCore.QObject): parent=None # MainWindow is in a different thread ) break - if 'static' in comp.properties(): + if 'static' in comp.properties: self.staticComponents[compNo] = \ comp.frameRender(compNo, 0).copy() if self.canceled: - print('Export cancelled by component #%s (%s): %s' % ( - compNo, str(comp), comp.error() - )) - self.progressBarSetText.emit('Export Canceled') - self.encoding.emit(False) - self.videoCreated.emit() + if canceledByComponent: + print('Export cancelled by component #%s (%s): %s' % ( + compNo, str(comp), comp.error + )) + self.cancelExport() return # Merge consecutive static component frames together @@ -192,8 +198,8 @@ class Worker(QtCore.QObject): ) self.staticComponents[compNo] = None - ffmpegCommand = self.core.createFfmpegCommand( - self.inputFile, self.outputFile, duration + ffmpegCommand = createFfmpegCommand( + self.inputFile, self.outputFile, self.components, duration ) print('###### FFMPEG COMMAND ######\n%s' % " ".join(ffmpegCommand)) print('############################') @@ -280,7 +286,6 @@ class Worker(QtCore.QObject): pass self.progressBarUpdate.emit(0) self.progressBarSetText.emit('Export Canceled') - else: if self.error: print("Export Failed") @@ -297,6 +302,12 @@ class Worker(QtCore.QObject): self.encoding.emit(False) self.videoCreated.emit() + def cancelExport(self): + self.progressBarUpdate.emit(0) + self.progressBarSetText.emit('Export Canceled') + self.encoding.emit(False) + self.videoCreated.emit() + def updateProgress(self, pStr, pVal): self.progressBarValue.emit(pVal) self.progressBarSetText.emit(pStr) -- cgit v1.2.3 From 450b944b87487aa60a935bbeee3908e2a62cd45b Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 20 Jul 2017 22:37:15 -0400 Subject: add component in context menu, del/ins hotkeys + preset manager uses mainwindow component list --- freeze.py | 4 +- setup.py | 4 +- src/__init__.py | 1 + src/components/video.py | 4 +- src/core.py | 10 ++-- src/mainwindow.py | 135 +++++++++++++++++++++++++++++------------------- src/presetmanager.py | 23 +++++++-- src/toolkit/ffmpeg.py | 9 +++- 8 files changed, 122 insertions(+), 68 deletions(-) (limited to 'setup.py') diff --git a/freeze.py b/freeze.py index 3281cad..520b445 100644 --- a/freeze.py +++ b/freeze.py @@ -2,7 +2,7 @@ from cx_Freeze import setup, Executable import sys import os -from setup import VERSION +from setup import __version__ deps = [os.path.join('src', p) for p in os.listdir('src') if p] @@ -52,7 +52,7 @@ executables = [ setup( name='audio-visualizer-python', - version=VERSION, + version=__version__, description='GUI tool to render visualization videos of audio files', options=dict(build_exe=buildOptions), executables=executables diff --git a/setup.py b/setup.py index 5abb976..a2d8495 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup import os -VERSION = '2.0.0.rc1' +__version__ = '2.0.0.rc1' def package_files(directory): @@ -15,7 +15,7 @@ def package_files(directory): setup( name='audio_visualizer_python', - version=VERSION, + version=__version__, url='https://github.com/djfun/audio-visualizer-python/tree/feature-newgui', license='MIT', description='Create audio visualization videos from a GUI or commandline', diff --git a/src/__init__.py b/src/__init__.py index e69de29..8b13789 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1 @@ + diff --git a/src/components/video.py b/src/components/video.py index b35c2e5..8758b12 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -16,7 +16,7 @@ class Video: '''Video Component Frame-Fetcher''' def __init__(self, **kwargs): mandatoryArgs = [ - 'ffmpeg', # path to ffmpeg, usually core.FFMPEG_BIN + 'ffmpeg', # path to ffmpeg, usually Core.FFMPEG_BIN 'videoPath', 'width', 'height', @@ -28,7 +28,7 @@ class Video: ] for arg in mandatoryArgs: try: - exec('self.%s = kwargs[arg]' % arg) + setattr(self, arg, kwargs[arg]) except KeyError: raise BadComponentInit(arg, self.__doc__) diff --git a/src/core.py b/src/core.py index dd2ef18..f6cf5eb 100644 --- a/src/core.py +++ b/src/core.py @@ -15,16 +15,14 @@ import video_thread class Core: ''' MainWindow and Command module both use an instance of this class - to store the main program state. This object tracks the components - as an instance, has methods for managing the components and for - opening/creating project files and presets. + to store the core program state. This object tracks the components, + talks to the components and handles opening/creating project files + and presets. The class also stores constants as class variables. ''' @classmethod def storeSettings(cls): - ''' - Stores settings/paths to directories as class variables - ''' + '''Store settings/paths to directories as class variables.''' if getattr(sys, 'frozen', False): # frozen wd = os.path.dirname(sys.executable) diff --git a/src/mainwindow.py b/src/mainwindow.py index 9944d1a..2d598ae 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -178,7 +178,6 @@ class MainWindow(QtWidgets.QMainWindow): # Make component buttons self.compMenu = QMenu() - self.compActions = [] for i, comp in enumerate(self.core.modules): action = self.compMenu.addAction(comp.Component.name) action.triggered.connect( @@ -191,6 +190,9 @@ class MainWindow(QtWidgets.QMainWindow): componentList.itemSelectionChanged.connect( self.changeComponentWidget ) + componentList.itemSelectionChanged.connect( + self.presetManager.clearPresetListSelection + ) self.window.pushButton_removeComponent.clicked.connect( lambda: self.removeComponent() ) @@ -313,22 +315,23 @@ class MainWindow(QtWidgets.QMainWindow): ) self.settings.setValue("ffmpegMsgShown", True) - # Setup Hotkeys + # 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) - QtWidgets.QShortcut( - "Ctrl+Alt+Shift+R", self.window, self.drawPreview - ) - QtWidgets.QShortcut( - "Ctrl+Alt+Shift+F", self.window, self.showFfmpegCommand - ) - QtWidgets.QShortcut( - "Ctrl+T", self.window, - activated=lambda: self.window.pushButton_addComponent.click() - ) + # 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() @@ -342,22 +345,29 @@ class MainWindow(QtWidgets.QMainWindow): ) QtWidgets.QShortcut( - "Ctrl+Up", self.window, + "Ctrl+Up", self.window.listWidget_componentList, activated=lambda: self.moveComponent(-1) ) QtWidgets.QShortcut( - "Ctrl+Down", self.window, + "Ctrl+Down", self.window.listWidget_componentList, activated=lambda: self.moveComponent(1) ) QtWidgets.QShortcut( - "Ctrl+Home", self.window, + "Ctrl+Home", self.window.listWidget_componentList, activated=lambda: self.moveComponent('top') ) QtWidgets.QShortcut( - "Ctrl+End", self.window, + "Ctrl+End", self.window.listWidget_componentList, activated=lambda: self.moveComponent('bottom') ) - QtWidgets.QShortcut("Ctrl+r", self.window, self.removeComponent) + + # 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): @@ -677,9 +687,7 @@ class MainWindow(QtWidgets.QMainWindow): stackedWidget.setCurrentIndex(newRow) self.drawPreview() - @disableWhenEncoding - def dragComponent(self, event): - '''Used as Qt drop event for the component listwidget''' + def getComponentListRects(self): componentList = self.window.listWidget_componentList modelIndexes = [ @@ -690,6 +698,13 @@ class MainWindow(QtWidgets.QMainWindow): componentList.visualRect(modelIndex) for modelIndex in modelIndexes ] + return rects + + @disableWhenEncoding + def dragComponent(self, event): + '''Used as Qt drop event for the component listwidget''' + componentList = self.window.listWidget_componentList + rects = self.getComponentListRects() rowPos = [rect.contains(event.pos()) for rect in rects] if not any(rowPos): @@ -826,47 +841,63 @@ class MainWindow(QtWidgets.QMainWindow): @disableWhenEncoding def componentContextMenu(self, QPos): - '''Appears when right-clicking a component in the list''' + '''Appears when right-clicking the component list''' componentList = self.window.listWidget_componentList - if not componentList.selectedItems(): - return - - # don't show menu if clicking empty space - parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) index = componentList.currentRow() - modelIndex = componentList.model().index(index) - if not componentList.visualRect(modelIndex).contains(QPos): - return - self.presetManager.findPresets() self.menu = QMenu() - menuItem = self.menu.addAction("Save Preset") - menuItem.triggered.connect( - self.presetManager.openSavePresetDialog - ) + parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) - # submenu for opening presets - try: - presets = self.presetManager.presets[ - str(self.core.selectedComponents[index]) - ] - self.submenu = QMenu("Open Preset") - self.menu.addMenu(self.submenu) - - for version, presetName in presets: - menuItem = self.submenu.addAction(presetName) + rects = self.getComponentListRects() + rowPos = [rect.contains(QPos) for rect in rects] + if not any(rowPos): + # Insert components at the top if clicking nothing + rowPos = 0 + else: + rowPos = rowPos.index(True) + + if index == rowPos: + # 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( - lambda _, presetName=presetName: - self.presetManager.openPreset(presetName) + self.presetManager.clearPreset ) - except KeyError: - pass + self.menu.addSeparator() - if self.core.selectedComponents[index].currentPreset: - menuItem = self.menu.addAction("Clear Preset") + # "Add Component" submenu + self.submenu = QMenu("Add") + self.menu.addMenu(self.submenu) + for i, comp in enumerate(self.core.modules): + menuItem = self.submenu.addAction(comp.Component.name) menuItem.triggered.connect( - self.presetManager.clearPreset - ) + lambda _, item=i: self.core.insertComponent( + rowPos, item, self + ) + ) self.menu.move(parentPosition + QPos) self.menu.show() diff --git a/src/presetmanager.py b/src/presetmanager.py index 825fdee..64e2203 100644 --- a/src/presetmanager.py +++ b/src/presetmanager.py @@ -245,11 +245,25 @@ class PresetManager(QtWidgets.QDialog): def openRenamePresetDialog(self): # TODO: maintain consistency by changing this to call createNewPreset() presetList = self.window.listWidget_presets - if presetList.currentRow() == -1: - return + index = presetList.currentRow() + if index == -1: + # check if component selected in MainWindow has preset loaded + componentList = self.parent.window.listWidget_componentList + compIndex = componentList.currentRow() + if compIndex == -1: + return + preset = self.core.selectedComponents[compIndex].currentPreset + if not preset: + return + else: + for i, tup in enumerate(self.presetRows): + if preset == tup[2]: + index = i + break + else: + return while True: - index = presetList.currentRow() newName, OK = QtWidgets.QInputDialog.getText( self.window, 'Preset Manager', @@ -321,3 +335,6 @@ class PresetManager(QtWidgets.QDialog): parent=self.window ) self.settings.setValue("presetDir", os.path.dirname(filename)) + + def clearPresetListSelection(self): + self.window.listWidget_presets.setCurrentRow(-1) diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index 89d4e9d..cc59a6c 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -113,7 +113,7 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1): '-t', safeDuration, # Tell ffmpeg about shorter clips (seemingly not needed) # streamDuration = getAudioDuration(extraInputFile) - # if streamDuration > float(safeDuration) + # if streamDuration and streamDuration > float(safeDuration) # else "{0:.3f}".format(streamDuration), '-i', extraInputFile ]) @@ -228,11 +228,18 @@ def getAudioDuration(filename): 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, parent): duration = getAudioDuration(filename) + if not duration: + print('Audio file doesn\'t exist or unreadable.') + return command = [ Core.FFMPEG_BIN, -- cgit v1.2.3 From bf0890e7c87c730b8970c1a20c5b6a9a1a55d203 Mon Sep 17 00:00:00 2001 From: tassaron Date: Sun, 23 Jul 2017 01:53:54 -0400 Subject: components auto-connect & track widgets, less autosave spam importing toolkit from live interpreter now works --- setup.py | 2 +- src/__init__.py | 12 +++ src/command.py | 2 - src/component.py | 196 +++++++++++++++++++++++++++++++++------------ src/components/color.py | 137 +++++++++++-------------------- src/components/image.py | 77 +++++------------- src/components/original.py | 59 ++++++-------- src/components/sound.py | 50 +++--------- src/components/text.py | 81 ++++++++----------- src/components/video.py | 98 +++++++---------------- src/core.py | 196 ++++++++++++++++++++++++++++----------------- src/main.py | 23 ++---- src/mainwindow.py | 125 +++++++++++++++++++---------- src/mainwindow.ui | 3 + src/presetmanager.py | 15 ++-- src/preview_thread.py | 17 ++-- src/toolkit/common.py | 56 +++---------- src/toolkit/core.py | 18 ----- src/toolkit/ffmpeg.py | 46 ++++++++--- src/toolkit/frame.py | 4 +- src/video_thread.py | 7 +- 21 files changed, 604 insertions(+), 620 deletions(-) delete mode 100644 src/toolkit/core.py (limited to 'setup.py') diff --git a/setup.py b/setup.py index a2d8495..d4f226b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup import os -__version__ = '2.0.0.rc1' +__version__ = '2.0.0.rc2' def package_files(directory): diff --git a/src/__init__.py b/src/__init__.py index 8b13789..2f4cffa 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1 +1,13 @@ +import sys +import os + +if getattr(sys, 'frozen', False): + # frozen + wd = os.path.dirname(sys.executable) +else: + # unfrozen + wd = os.path.dirname(os.path.realpath(__file__)) + +# make relative imports work when using /src as a package +sys.path.insert(0, wd) diff --git a/src/command.py b/src/command.py index 046a1bf..ca186e5 100644 --- a/src/command.py +++ b/src/command.py @@ -10,7 +10,6 @@ import sys import time from core import Core -from toolkit import loadDefaultSettings class Command(QtCore.QObject): @@ -55,7 +54,6 @@ class Command(QtCore.QObject): self.args = self.parser.parse_args() self.settings = Core.settings - loadDefaultSettings(self) if self.args.projpath: projPath = self.args.projpath diff --git a/src/component.py b/src/component.py index 92cc65c..bec2df5 100644 --- a/src/component.py +++ b/src/component.py @@ -5,8 +5,28 @@ from PyQt5 import uic, QtCore, QtWidgets import os -from core import Core -from toolkit.common import getPresetDir +from presetmanager import getPresetDir + + +def commandWrapper(func): + '''Intercepts each component's command() method to check for global args''' + def decorator(self, arg): + if arg.startswith('preset='): + _, preset = arg.split('=', 1) + path = os.path.join(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 decorator class ComponentMetaclass(type(QtCore.QObject)): @@ -16,10 +36,14 @@ class ComponentMetaclass(type(QtCore.QObject)): E.g., takes only major version from version string & decorates methods ''' def __new__(cls, name, parents, attrs): - # print('Creating %s component' % attrs['name']) + 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] # Turn certain class methods into properties and classmethods - for key in ('error', 'properties', 'audio', 'commandHelp'): + for key in ('error', 'properties', 'audio'): if key not in attrs: continue attrs[key] = property(attrs[key]) @@ -29,6 +53,10 @@ class ComponentMetaclass(type(QtCore.QObject)): continue attrs[key] = classmethod(key) + # Do not apply these mutations to the base class + if parents[0] != QtCore.QObject: + attrs['command'] = commandWrapper(attrs['command']) + # Turn version string into a number try: if 'version' not in attrs: @@ -54,19 +82,24 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' name = 'Component' + # ui = 'nameOfNonDefaultUiFile' version = '1.0.0' - # The 1st number (before dot, aka the major version) is used to determine + # 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) # ^ Signal used to tell core program that the component state changed, # you shouldn't need to use this directly, it is used by self.update() - def __init__(self, moduleIndex, compPos): + def __init__(self, moduleIndex, compPos, core): super().__init__() - self.currentPreset = None self.moduleIndex = moduleIndex self.compPos = compPos + self.core = core + self.currentPreset = None + + self._trackedWidgets = {} + self._presetNames = {} # Stop lengthy processes in response to this variable self.canceled = False @@ -114,28 +147,103 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' return [] - def commandHelp(self): - '''Help text as string for this component's commandline arguments''' - # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - def update(self): - '''Read widget values from self.page, then call super().update()''' - self.parent.drawPreview() - saveValueStore = self.savePreset() - saveValueStore['preset'] = self.currentPreset - self.modified.emit(self.compPos, saveValueStore) + 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/colour) and initialize + ''' + self.parent = parent + self.settings = parent.settings + self.page = self.loadUi(self.__class__.ui) + + # Connect widget signals + widgets = { + 'lineEdit': self.page.findChildren(QtWidgets.QLineEdit), + 'checkBox': self.page.findChildren(QtWidgets.QCheckBox), + 'spinBox': self.page.findChildren(QtWidgets.QSpinBox), + 'comboBox': self.page.findChildren(QtWidgets.QComboBox), + } + widgets['spinBox'].extend( + self.page.findChildren(QtWidgets.QDoubleSpinBox) + ) + for widget in widgets['lineEdit']: + widget.textChanged.connect(self.update) + for widget in widgets['checkBox']: + widget.stateChanged.connect(self.update) + for widget in widgets['spinBox']: + widget.valueChanged.connect(self.update) + for widget in widgets['comboBox']: + widget.currentIndexChanged.connect(self.update) + + def trackWidgets(self, trackDict, presetNames=None): + ''' + Name widgets to track in update(), savePreset(), and loadPreset() + Accepts a dict with attribute names as keys and widgets as values. + Optional: a dict of attribute names to map to preset variable names + ''' + self._trackedWidgets = trackDict + if type(presetNames) is dict: + self._presetNames = presetNames - def loadPreset(self, presetDict, presetName): + def update(self): ''' - Subclasses take (presetDict, presetName=None) as args. - Must use super().loadPreset(presetDict, presetName) first, + Reads all tracked widget values into instance attributes + and tells the MainWindow that the component was modified. + Call at the END of your method if you need to subclass this. + ''' + for attr, widget in self._trackedWidgets.items(): + if type(widget) == QtWidgets.QLineEdit: + setattr(self, attr, widget.text()) + elif type(widget) == QtWidgets.QSpinBox \ + or type(widget) == QtWidgets.QDoubleSpinBox: + setattr(self, attr, widget.value()) + elif type(widget) == QtWidgets.QCheckBox: + setattr(self, attr, widget.isChecked()) + elif type(widget) == QtWidgets.QComboBox: + setattr(self, attr, widget.currentIndex()) + if not self.core.openingProject: + self.parent.drawPreview() + saveValueStore = self.savePreset() + saveValueStore['preset'] = self.currentPreset + self.modified.emit(self.compPos, saveValueStore) + + 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(): + val = presetDict[ + attr if attr not in self._presetNames + else self._presetNames[attr] + ] + 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) + + def savePreset(self): + saveValueStore = {} + for attr, widget in self._trackedWidgets.items(): + saveValueStore[ + attr if attr not in self._presetNames + else self._presetNames[attr] + ] = getattr(self, attr) + return saveValueStore def preFrameRender(self, **kwargs): ''' @@ -151,34 +259,27 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): for key, value in kwargs.items(): setattr(self, key, value) - def command(self, arg): + def commandHelp(self): + '''Help text as string for this component's commandline arguments''' + + def command(self, arg=''): ''' - Configure a component using argument from the commandline. - Use super().command(arg) at the end of a subclass's method, - if no arguments are found in that method first + 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. ''' - if arg.startswith('preset='): - _, preset = arg.split('=', 1) - path = os.path.join(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) - else: - print( - self.__doc__, 'Usage:\n' - 'Open a preset for this component:\n' - ' "preset=Preset Name"') - print(self.commandHelp) - quit(0) + print( + self.__class__.name, 'Usage:\n' + 'Open a preset for this component:\n' + ' "preset=Preset Name"' + ) + self.commandHelp() + quit(0) def loadUi(self, filename): '''Load a Qt Designer ui file to use for this component's widget''' - return uic.loadUi(os.path.join(Core.componentsPath, filename)) + return uic.loadUi(os.path.join(self.core.componentsPath, filename)) def cancel(self): '''Stop any lengthy process in response to this variable.''' @@ -191,16 +292,9 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ### Reference methods for creating a new component ### (Inherit from this class and define these) - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - self.page = self.loadUi('example.ui') - # --- connect widget signals here --- - return self.page - def previewRender(self, previewWorker): width = int(self.settings.value('outputWidth')) - height = int(previewWorker.core.settings.value('outputHeight')) + height = int(self.settings.value('outputHeight')) from toolkit.frame import BlankFrame image = BlankFrame(width, height) return image @@ -217,7 +311,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): class BadComponentInit(Exception): ''' - General purpose exception components can raise to indicate + General purpose exception that components can raise to indicate a Python issue with e.g., dynamic creation of instances or something. Decorative for now, may have future use for logging. ''' diff --git a/src/components/color.py b/src/components/color.py index 03371e7..8257ed9 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -13,18 +13,15 @@ class Component(Component): name = 'Color' version = '1.0.0' - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - page = self.loadUi('color.ui') - + def widget(self, *args): self.color1 = (0, 0, 0) self.color2 = (133, 133, 133) self.x = 0 self.y = 0 + super().widget(*args) - page.lineEdit_color1.setText('%s,%s,%s' % self.color1) - page.lineEdit_color2.setText('%s,%s,%s' % self.color2) + self.page.lineEdit_color1.setText('%s,%s,%s' % self.color1) + self.page.lineEdit_color2.setText('%s,%s,%s' % self.color2) btnStyle1 = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*self.color1).name() @@ -32,68 +29,55 @@ class Component(Component): btnStyle2 = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*self.color2).name() - page.pushButton_color1.setStyleSheet(btnStyle1) - page.pushButton_color2.setStyleSheet(btnStyle2) - page.pushButton_color1.clicked.connect(lambda: self.pickColor(1)) - page.pushButton_color2.clicked.connect(lambda: self.pickColor(2)) + self.page.pushButton_color1.setStyleSheet(btnStyle1) + self.page.pushButton_color2.setStyleSheet(btnStyle2) + self.page.pushButton_color1.clicked.connect(lambda: self.pickColor(1)) + self.page.pushButton_color2.clicked.connect(lambda: self.pickColor(2)) # disable color #2 until non-default 'fill' option gets changed - page.lineEdit_color2.setDisabled(True) - page.pushButton_color2.setDisabled(True) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - page.spinBox_width.setValue( + self.page.lineEdit_color2.setDisabled(True) + self.page.pushButton_color2.setDisabled(True) + self.page.spinBox_width.setValue( int(self.settings.value("outputWidth"))) - page.spinBox_height.setValue( + self.page.spinBox_height.setValue( int(self.settings.value("outputHeight"))) - page.lineEdit_color1.textChanged.connect(self.update) - page.lineEdit_color2.textChanged.connect(self.update) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - page.spinBox_width.valueChanged.connect(self.update) - page.spinBox_height.valueChanged.connect(self.update) - page.checkBox_trans.stateChanged.connect(self.update) - self.fillLabels = [ 'Solid', 'Linear Gradient', 'Radial Gradient', ] for label in self.fillLabels: - page.comboBox_fill.addItem(label) - page.comboBox_fill.setCurrentIndex(0) - page.comboBox_fill.currentIndexChanged.connect(self.update) - page.comboBox_spread.currentIndexChanged.connect(self.update) - page.spinBox_radialGradient_end.valueChanged.connect(self.update) - page.spinBox_radialGradient_start.valueChanged.connect(self.update) - page.spinBox_radialGradient_spread.valueChanged.connect(self.update) - page.spinBox_linearGradient_end.valueChanged.connect(self.update) - page.spinBox_linearGradient_start.valueChanged.connect(self.update) - page.checkBox_stretch.stateChanged.connect(self.update) - - self.page = page - return page + 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, + }, presetNames={ + 'sizeWidth': 'width', + 'sizeHeight': 'height', + } + ) def update(self): self.color1 = rgbFromString(self.page.lineEdit_color1.text()) self.color2 = rgbFromString(self.page.lineEdit_color2.text()) - self.x = self.page.spinBox_x.value() - self.y = self.page.spinBox_y.value() - self.sizeWidth = self.page.spinBox_width.value() - self.sizeHeight = self.page.spinBox_height.value() - self.trans = self.page.checkBox_trans.isChecked() - self.spread = self.page.comboBox_spread.currentIndex() - - self.RG_start = self.page.spinBox_radialGradient_start.value() - self.RG_end = self.page.spinBox_radialGradient_end.value() - self.RG_centre = self.page.spinBox_radialGradient_spread.value() - self.stretch = self.page.checkBox_stretch.isChecked() - self.LG_start = self.page.spinBox_linearGradient_start.value() - self.LG_end = self.page.spinBox_linearGradient_end.value() - - self.fillType = self.page.comboBox_fill.currentIndex() - if self.fillType == 0: + + 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) @@ -105,10 +89,10 @@ class Component(Component): self.page.checkBox_trans.setEnabled(True) self.page.checkBox_stretch.setEnabled(True) self.page.comboBox_spread.setEnabled(True) - if self.trans: + if self.page.checkBox_trans.isChecked(): self.page.lineEdit_color2.setEnabled(False) self.page.pushButton_color2.setEnabled(False) - self.page.fillWidget.setCurrentIndex(self.fillType) + self.page.fillWidget.setCurrentIndex(fillType) super().update() @@ -181,25 +165,11 @@ class Component(Component): return image.finalize() - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) + def loadPreset(self, pr, *args): + super().loadPreset(pr, *args) - self.page.comboBox_fill.setCurrentIndex(pr['fillType']) self.page.lineEdit_color1.setText('%s,%s,%s' % pr['color1']) self.page.lineEdit_color2.setText('%s,%s,%s' % pr['color2']) - self.page.spinBox_x.setValue(pr['x']) - self.page.spinBox_y.setValue(pr['y']) - self.page.spinBox_width.setValue(pr['width']) - self.page.spinBox_height.setValue(pr['height']) - self.page.checkBox_trans.setChecked(pr['trans']) - - self.page.spinBox_radialGradient_start.setValue(pr['RG_start']) - self.page.spinBox_radialGradient_end.setValue(pr['RG_end']) - self.page.spinBox_radialGradient_spread.setValue(pr['RG_centre']) - self.page.spinBox_linearGradient_start.setValue(pr['LG_start']) - self.page.spinBox_linearGradient_end.setValue(pr['LG_end']) - self.page.checkBox_stretch.setChecked(pr['stretch']) - self.page.comboBox_spread.setCurrentIndex(pr['spread']) btnStyle1 = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*pr['color1']).name() @@ -209,23 +179,10 @@ class Component(Component): self.page.pushButton_color2.setStyleSheet(btnStyle2) def savePreset(self): - return { - 'color1': self.color1, - 'color2': self.color2, - 'x': self.x, - 'y': self.y, - 'fillType': self.fillType, - 'width': self.sizeWidth, - 'height': self.sizeHeight, - 'trans': self.trans, - 'stretch': self.stretch, - 'spread': self.spread, - 'RG_start': self.RG_start, - 'RG_end': self.RG_end, - 'RG_centre': self.RG_centre, - 'LG_start': self.LG_start, - 'LG_end': self.LG_end, - } + saveValueStore = super().savePreset() + saveValueStore['color1'] = self.color1 + saveValueStore['color2'] = self.color2 + return saveValueStore def pickColor(self, num): RGBstring, btnStyle = pickColor() @@ -242,7 +199,7 @@ class Component(Component): print('Specify a color:\n color=255,255,255') def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) if key == 'color': self.page.lineEdit_color1.setText(arg) diff --git a/src/components/image.py b/src/components/image.py index 591e03e..a705904 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -2,7 +2,6 @@ from PIL import Image, ImageDraw, ImageEnhance from PyQt5 import QtGui, QtCore, QtWidgets import os -from core import Core from component import Component from toolkit.frame import BlankFrame @@ -11,35 +10,26 @@ class Component(Component): name = 'Image' version = '1.0.0' - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - page = self.loadUi('image.ui') - - page.lineEdit_image.textChanged.connect(self.update) - page.pushButton_image.clicked.connect(self.pickImage) - page.spinBox_scale.valueChanged.connect(self.update) - page.spinBox_rotate.valueChanged.connect(self.update) - page.spinBox_color.valueChanged.connect(self.update) - page.checkBox_stretch.stateChanged.connect(self.update) - page.checkBox_mirror.stateChanged.connect(self.update) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - - self.page = page - return page - - def update(self): - self.imagePath = self.page.lineEdit_image.text() - self.scale = self.page.spinBox_scale.value() - self.rotate = self.page.spinBox_rotate.value() - self.color = self.page.spinBox_color.value() - self.xPosition = self.page.spinBox_x.value() - self.yPosition = self.page.spinBox_y.value() - self.stretched = self.page.checkBox_stretch.isChecked() - self.mirror = self.page.checkBox_mirror.isChecked() - - super().update() + 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, + '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', + }, + ) def previewRender(self, previewWorker): width = int(self.settings.value('outputWidth')) @@ -89,41 +79,18 @@ class Component(Component): return frame - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - self.page.lineEdit_image.setText(pr['image']) - self.page.spinBox_scale.setValue(pr['scale']) - self.page.spinBox_color.setValue(pr['color']) - self.page.spinBox_rotate.setValue(pr['rotate']) - self.page.spinBox_x.setValue(pr['x']) - self.page.spinBox_y.setValue(pr['y']) - self.page.checkBox_stretch.setChecked(pr['stretched']) - self.page.checkBox_mirror.setChecked(pr['mirror']) - - def savePreset(self): - return { - 'image': self.imagePath, - 'scale': self.scale, - 'color': self.color, - 'rotate': self.rotate, - 'stretched': self.stretched, - 'mirror': self.mirror, - 'x': self.xPosition, - 'y': self.yPosition, - } - def pickImage(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Image", imgDir, - "Image Files (%s)" % " ".join(Core.imageFormats)) + "Image Files (%s)" % " ".join(self.core.imageFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_image.setText(filename) self.update() def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) if key == 'path' and os.path.exists(arg): try: diff --git a/src/components/original.py b/src/components/original.py index ae40df3..2bda878 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -18,59 +18,46 @@ class Component(Component): def names(): return ['Original Audio Visualization'] - def widget(self, parent): - self.parent = parent - self.settings = parent.settings + def widget(self, *args): self.visColor = (255, 255, 255) self.scale = 20 self.y = 0 - self.canceled = False - - page = self.loadUi('original.ui') - page.comboBox_visLayout.addItem("Classic") - page.comboBox_visLayout.addItem("Split") - page.comboBox_visLayout.addItem("Bottom") - page.comboBox_visLayout.addItem("Top") - page.comboBox_visLayout.setCurrentIndex(0) - page.comboBox_visLayout.currentIndexChanged.connect(self.update) - page.lineEdit_visColor.setText('%s,%s,%s' % self.visColor) - page.pushButton_visColor.clicked.connect(lambda: self.pickColor()) + 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('%s,%s,%s' % self.visColor) + self.page.pushButton_visColor.clicked.connect(lambda: self.pickColor()) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*self.visColor).name() - page.pushButton_visColor.setStyleSheet(btnStyle) - page.lineEdit_visColor.textChanged.connect(self.update) - page.spinBox_scale.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) + self.page.pushButton_visColor.setStyleSheet(btnStyle) - self.page = page - return page + self.trackWidgets({ + 'layout': self.page.comboBox_visLayout, + 'scale': self.page.spinBox_scale, + 'y': self.page.spinBox_y, + }) def update(self): - self.layout = self.page.comboBox_visLayout.currentIndex() self.visColor = rgbFromString(self.page.lineEdit_visColor.text()) - self.scale = self.page.spinBox_scale.value() - self.y = self.page.spinBox_y.value() - super().update() - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) + def loadPreset(self, pr, *args): + super().loadPreset(pr, *args) self.page.lineEdit_visColor.setText('%s,%s,%s' % pr['visColor']) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*pr['visColor']).name() self.page.pushButton_visColor.setStyleSheet(btnStyle) - self.page.comboBox_visLayout.setCurrentIndex(pr['layout']) - self.page.spinBox_scale.setValue(pr['scale']) - self.page.spinBox_y.setValue(pr['y']) def savePreset(self): - return { - 'layout': self.layout, - 'visColor': self.visColor, - 'scale': self.scale, - 'y': self.y, - } + saveValueStore = super().savePreset() + saveValueStore['visColor'] = self.visColor + return saveValueStore def previewRender(self, previewWorker): spectrum = numpy.fromfunction( @@ -206,7 +193,7 @@ class Component(Component): return im def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) try: if key == 'color': diff --git a/src/components/sound.py b/src/components/sound.py index 677a22f..dd3cbab 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -10,26 +10,15 @@ class Component(Component): name = 'Sound' version = '1.0.0' - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - page = self.loadUi('sound.ui') - - page.lineEdit_sound.textChanged.connect(self.update) - page.pushButton_sound.clicked.connect(self.pickSound) - page.checkBox_chorus.stateChanged.connect(self.update) - page.spinBox_delay.valueChanged.connect(self.update) - page.spinBox_volume.valueChanged.connect(self.update) - - self.page = page - return page - - def update(self): - self.sound = self.page.lineEdit_sound.text() - self.delay = self.page.spinBox_delay.value() - self.volume = self.page.spinBox_volume.value() - self.chorus = self.page.checkBox_chorus.isChecked() - super().update() + 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, + }) def previewRender(self, previewWorker): width = int(self.settings.value('outputWidth')) @@ -67,7 +56,7 @@ class Component(Component): sndDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Sound", sndDir, - "Audio Files (%s)" % " ".join(Core.audioFormats)) + "Audio Files (%s)" % " ".join(self.core.audioFormats)) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) self.page.lineEdit_sound.setText(filename) @@ -78,30 +67,15 @@ class Component(Component): height = int(self.settings.value('outputHeight')) return BlankFrame(width, height) - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - self.page.lineEdit_sound.setText(pr['sound']) - self.page.checkBox_chorus.setChecked(pr['chorus']) - self.page.spinBox_delay.setValue(pr['delay']) - self.page.spinBox_volume.setValue(pr['volume']) - - def savePreset(self): - return { - 'sound': self.sound, - 'chorus': self.chorus, - 'delay': self.delay, - 'volume': self.volume, - } - def commandHelp(self): print('Path to audio file:\n path=/filepath/to/sound.ogg') def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) if key == 'path': if '*%s' % os.path.splitext(arg)[1] \ - not in Core.audioFormats: + not in self.core.audioFormats: print("Not a supported audio format") quit(1) self.page.lineEdit_sound.setText(arg) diff --git a/src/components/text.py b/src/components/text.py index d511f22..1d64617 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -16,12 +16,10 @@ class Component(Component): super().__init__(*args) self.titleFont = QFont() - def widget(self, parent): - self.parent = parent - self.settings = parent.settings + def widget(self, *args): + super().widget(*args) height = int(self.settings.value('outputHeight')) width = int(self.settings.value('outputWidth')) - self.textColor = (255, 255, 255) self.title = 'Text' self.alignment = 1 @@ -30,40 +28,35 @@ class Component(Component): self.xPosition = width / 2 - fm.width(self.title)/2 self.yPosition = height / 2 * 1.036 - page = self.loadUi('text.ui') - page.comboBox_textAlign.addItem("Left") - page.comboBox_textAlign.addItem("Middle") - page.comboBox_textAlign.addItem("Right") + self.page.comboBox_textAlign.addItem("Left") + self.page.comboBox_textAlign.addItem("Middle") + self.page.comboBox_textAlign.addItem("Right") - page.lineEdit_textColor.setText('%s,%s,%s' % self.textColor) - page.pushButton_textColor.clicked.connect(self.pickColor) + self.page.lineEdit_textColor.setText('%s,%s,%s' % self.textColor) + self.page.pushButton_textColor.clicked.connect(self.pickColor) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*self.textColor).name() - page.pushButton_textColor.setStyleSheet(btnStyle) - - page.lineEdit_title.setText(self.title) - page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) - page.spinBox_fontSize.setValue(int(self.fontSize)) - page.spinBox_xTextAlign.setValue(int(self.xPosition)) - page.spinBox_yTextAlign.setValue(int(self.yPosition)) - - page.fontComboBox_titleFont.currentFontChanged.connect(self.update) - page.lineEdit_title.textChanged.connect(self.update) - page.comboBox_textAlign.currentIndexChanged.connect(self.update) - page.spinBox_xTextAlign.valueChanged.connect(self.update) - page.spinBox_yTextAlign.valueChanged.connect(self.update) - page.spinBox_fontSize.valueChanged.connect(self.update) - page.lineEdit_textColor.textChanged.connect(self.update) - self.page = page - return page + self.page.pushButton_textColor.setStyleSheet(btnStyle) + + self.page.lineEdit_title.setText(self.title) + self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) + self.page.spinBox_fontSize.setValue(int(self.fontSize)) + self.page.spinBox_xTextAlign.setValue(int(self.xPosition)) + self.page.spinBox_yTextAlign.setValue(int(self.yPosition)) + + self.page.fontComboBox_titleFont.currentFontChanged.connect( + self.update + ) + self.trackWidgets({ + '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, + }) def update(self): - self.title = self.page.lineEdit_title.text() - self.alignment = self.page.comboBox_textAlign.currentIndex() self.titleFont = self.page.fontComboBox_titleFont.currentFont() - self.fontSize = self.page.spinBox_fontSize.value() - self.xPosition = self.page.spinBox_xTextAlign.value() - self.yPosition = self.page.spinBox_yTextAlign.value() self.textColor = rgbFromString( self.page.lineEdit_textColor.text()) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ @@ -87,32 +80,22 @@ class Component(Component): x = self.xPosition - offset return x, self.yPosition - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) + def loadPreset(self, pr, *args): + super().loadPreset(pr, *args) - self.page.lineEdit_title.setText(pr['title']) font = QFont() font.fromString(pr['titleFont']) self.page.fontComboBox_titleFont.setCurrentFont(font) - self.page.spinBox_fontSize.setValue(pr['fontSize']) - self.page.comboBox_textAlign.setCurrentIndex(pr['alignment']) - self.page.spinBox_xTextAlign.setValue(pr['xPosition']) - self.page.spinBox_yTextAlign.setValue(pr['yPosition']) self.page.lineEdit_textColor.setText('%s,%s,%s' % pr['textColor']) btnStyle = "QPushButton { background-color : %s; outline: none; }" \ % QColor(*pr['textColor']).name() self.page.pushButton_textColor.setStyleSheet(btnStyle) def savePreset(self): - return { - 'title': self.title, - 'titleFont': self.titleFont.toString(), - 'alignment': self.alignment, - 'fontSize': self.fontSize, - 'xPosition': self.xPosition, - 'yPosition': self.yPosition, - 'textColor': self.textColor - } + saveValueStore = super().savePreset() + saveValueStore['titleFont'] = self.titleFont.toString() + saveValueStore['textColor'] = self.textColor + return saveValueStore def previewRender(self, previewWorker): width = int(self.settings.value('outputWidth')) @@ -158,7 +141,7 @@ class Component(Component): print('Set custom x, y position:\n x=500 y=500') def command(self, arg): - if not arg.startswith('preset=') and '=' in arg: + if '=' in arg: key, arg = arg.split('=', 1) if key == 'color': self.page.lineEdit_textColor.setText(arg) diff --git a/src/components/video.py b/src/components/video.py index 8758b12..677e3ee 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -9,6 +9,7 @@ from queue import PriorityQueue from core import Core from component import Component, BadComponentInit from toolkit.frame import BlankFrame +from toolkit.ffmpeg import testAudioStream from toolkit import openPipe, checkOutput @@ -16,7 +17,7 @@ class Video: '''Video Component Frame-Fetcher''' def __init__(self, **kwargs): mandatoryArgs = [ - 'ffmpeg', # path to ffmpeg, usually Core.FFMPEG_BIN + 'ffmpeg', # path to ffmpeg, usually self.core.FFMPEG_BIN 'videoPath', 'width', 'height', @@ -110,47 +111,40 @@ class Component(Component): name = 'Video' version = '1.0.0' - def widget(self, parent): - self.parent = parent - self.settings = parent.settings - page = self.loadUi('video.ui') + def widget(self, *args): self.videoPath = '' self.badVideo = False self.badAudio = False self.x = 0 self.y = 0 self.loopVideo = False - - page.lineEdit_video.textChanged.connect(self.update) - page.pushButton_video.clicked.connect(self.pickVideo) - page.checkBox_loop.stateChanged.connect(self.update) - page.checkBox_distort.stateChanged.connect(self.update) - page.checkBox_useAudio.stateChanged.connect(self.update) - page.spinBox_scale.valueChanged.connect(self.update) - page.spinBox_volume.valueChanged.connect(self.update) - page.spinBox_x.valueChanged.connect(self.update) - page.spinBox_y.valueChanged.connect(self.update) - - self.page = page - return page + super().widget(*args) + 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', + } + ) def update(self): - self.videoPath = self.page.lineEdit_video.text() - self.loopVideo = self.page.checkBox_loop.isChecked() - self.useAudio = self.page.checkBox_useAudio.isChecked() - self.distort = self.page.checkBox_distort.isChecked() - self.scale = self.page.spinBox_scale.value() - self.volume = self.page.spinBox_volume.value() - self.xPosition = self.page.spinBox_x.value() - self.yPosition = self.page.spinBox_y.value() - - if self.useAudio: + 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) - super().update() def previewRender(self, previewWorker): @@ -188,18 +182,7 @@ class Component(Component): return "The video selected is corrupt!" def testAudioStream(self): - # test if an audio stream really exists - audioTestCommand = [ - Core.FFMPEG_BIN, - '-i', self.videoPath, - '-vn', '-f', 'null', '-' - ] - try: - checkOutput(audioTestCommand, stderr=subprocess.DEVNULL) - except subprocess.CalledProcessError: - self.badAudio = True - else: - self.badAudio = False + self.badAudio = testAudioStream(self.videoPath) def audio(self): params = {} @@ -214,7 +197,7 @@ class Component(Component): self.blankFrame_ = BlankFrame(width, height) self.updateChunksize(width, height) self.video = Video( - ffmpeg=Core.FFMPEG_BIN, videoPath=self.videoPath, + ffmpeg=self.core.FFMPEG_BIN, videoPath=self.videoPath, width=width, height=height, chunkSize=self.chunkSize, frameRate=int(self.settings.value("outputFrameRate")), parent=self.parent, loopVideo=self.loopVideo, @@ -227,34 +210,11 @@ class Component(Component): else: return self.blankFrame_ - def loadPreset(self, pr, presetName=None): - super().loadPreset(pr, presetName) - self.page.lineEdit_video.setText(pr['video']) - self.page.checkBox_loop.setChecked(pr['loop']) - self.page.checkBox_useAudio.setChecked(pr['useAudio']) - self.page.checkBox_distort.setChecked(pr['distort']) - self.page.spinBox_scale.setValue(pr['scale']) - self.page.spinBox_volume.setValue(pr['volume']) - self.page.spinBox_x.setValue(pr['x']) - self.page.spinBox_y.setValue(pr['y']) - - def savePreset(self): - return { - 'video': self.videoPath, - 'loop': self.loopVideo, - 'useAudio': self.useAudio, - 'distort': self.distort, - 'scale': self.scale, - 'volume': self.volume, - 'x': self.xPosition, - 'y': self.yPosition, - } - def pickVideo(self): imgDir = self.settings.value("componentDir", os.path.expanduser("~")) filename, _ = QtWidgets.QFileDialog.getOpenFileName( self.page, "Choose Video", - imgDir, "Video Files (%s)" % " ".join(Core.videoFormats) + imgDir, "Video Files (%s)" % " ".join(self.core.videoFormats) ) if filename: self.settings.setValue("componentDir", os.path.dirname(filename)) @@ -266,7 +226,7 @@ class Component(Component): return command = [ - self.parent.core.FFMPEG_BIN, + self.core.FFMPEG_BIN, '-thread_queue_size', '512', '-i', self.videoPath, '-f', 'image2pipe', @@ -294,10 +254,10 @@ class Component(Component): self.chunkSize = 4*width*height def command(self, arg): - if not arg.startswith('preset=') and '=' in 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 Core.videoFormats: + 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) diff --git a/src/core.py b/src/core.py index f6cf5eb..eb6398b 100644 --- a/src/core.py +++ b/src/core.py @@ -1,5 +1,6 @@ ''' 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 @@ -8,7 +9,6 @@ import json from importlib import import_module import toolkit -from toolkit.ffmpeg import findFfmpeg import video_thread @@ -16,82 +16,21 @@ 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 and handles opening/creating project files - and presets. The class also stores constants as class variables. + 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. ''' - @classmethod - def storeSettings(cls): - '''Store settings/paths to directories as class variables.''' - if getattr(sys, 'frozen', False): - # frozen - wd = os.path.dirname(sys.executable) - else: - wd = os.path.dirname(os.path.realpath(__file__)) - - dataDir = QtCore.QStandardPaths.writableLocation( - QtCore.QStandardPaths.AppConfigLocation - ) - with open(os.path.join(wd, 'encoder-options.json')) as json_file: - encoderOptions = json.load(json_file) - - settings = { - 'wd': wd, - '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'), - 'encoderOptions': encoderOptions, - 'FFMPEG_BIN': findFfmpeg(), - 'canceled': 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) - # Make settings accessible to the toolkit package - toolkit.init(settings) - def __init__(self): - Core.storeSettings() - self.findComponents() self.selectedComponents = [] self.savedPresets = {} # copies of presets to detect modification + self.openingProject = False def findComponents(self): + '''Imports all the component modules''' def findComponents(): - for f in sorted(os.listdir(Core.componentsPath)): + for f in os.listdir(Core.componentsPath): name, ext = os.path.splitext(f) if name.startswith("__"): continue @@ -104,8 +43,13 @@ class Core: # 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] - self.altCompNames = [] + # 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(): @@ -116,14 +60,17 @@ class Core: component.compPos = i def insertComponent(self, compPos, moduleIndex, loader): - '''Creates a new component''' + ''' + Creates a new component using these args: + (compPos, moduleIndex in self.modules, MWindow/Command/Core obj) + ''' if compPos < 0 or compPos > len(self.selectedComponents): compPos = len(self.selectedComponents) if len(self.selectedComponents) > 50: return None component = self.modules[moduleIndex].Component( - moduleIndex, compPos + moduleIndex, compPos, self ) self.selectedComponents.insert( compPos, @@ -206,6 +153,7 @@ class Core: errcode, data = self.parseAvFile(filepath) if errcode == 0: + self.openingProject = True try: if hasattr(loader, 'window'): for widget, value in data['WindowFields']: @@ -239,7 +187,8 @@ class Core: i = self.insertComponent( -1, self.moduleIndexFor(name), - loader) + loader + ) if i is None: loader.showMessage(msg="Too many components!") break @@ -284,6 +233,7 @@ class Core: showCancel=False, icon='Warning', detail=msg) + self.openingProject = False def parseAvFile(self, filepath): '''Parses an avp (project) or avl (preset package) file. @@ -467,8 +417,106 @@ class Core: def cancel(self): Core.canceled = True - toolkit.cancel() def reset(self): Core.canceled = False - toolkit.reset() + + @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 + ) + with open(os.path.join(wd, 'encoder-options.json')) as json_file: + encoderOptions = json.load(json_file) + + settings = { + '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'), + 'encoderOptions': encoderOptions, + 'resolutions': [ + '1920x1080', + '1280x720', + '854x480', + ], + 'windowHasFocus': False, + 'FFMPEG_BIN': findFfmpeg(), + 'canceled': 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() + + @classmethod + def loadDefaultSettings(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, + } + + for parm, value in 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) + if val in ('true', 'false'): + cls.settings.setValue(key, True if val == 'true' else False) + + +# always store settings in class variables even if a Core object is not created +Core.storeSettings() diff --git a/src/main.py b/src/main.py index 6a9a25e..977da3b 100644 --- a/src/main.py +++ b/src/main.py @@ -2,22 +2,17 @@ from PyQt5 import uic, QtWidgets import sys import os +from __init__ import wd -def main(): - if getattr(sys, 'frozen', False): - # frozen - wd = os.path.dirname(sys.executable) - else: - # unfrozen - wd = os.path.dirname(os.path.realpath(__file__)) - # make local imports work everywhere - sys.path.insert(0, wd) +def main(): + app = QtWidgets.QApplication(sys.argv) + app.setApplicationName("audio-visualizer") + # Determine mode mode = 'GUI' if len(sys.argv) > 2: mode = 'commandline' - elif len(sys.argv) == 2: if sys.argv[1].startswith('-'): mode = 'commandline' @@ -28,11 +23,7 @@ def main(): # normal gui launch proj = None - print('Starting Audio Visualizer in %s mode' % mode) - app = QtWidgets.QApplication(sys.argv) - app.setApplicationName("audio-visualizer") - # app.setOrganizationName("audio-visualizer") - + # Launch program if mode == 'commandline': from command import Command @@ -61,9 +52,7 @@ def main(): signal.signal(signal.SIGINT, main.cleanUp) atexit.register(main.cleanUp) - # applicable to both modes sys.exit(app.exec_()) - if __name__ == "__main__": main() diff --git a/src/mainwindow.py b/src/mainwindow.py index 2d598ae..f333513 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -17,7 +17,7 @@ import time from core import Core import preview_thread from presetmanager import PresetManager -from toolkit import loadDefaultSettings, disableWhenEncoding, checkOutput +from toolkit import disableWhenEncoding, disableWhenOpeningProject, checkOutput class PreviewWindow(QtWidgets.QLabel): @@ -25,6 +25,7 @@ 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 @@ -49,6 +50,14 @@ class PreviewWindow(QtWidgets.QLabel): self.pixmap = QtGui.QPixmap(img) self.repaint() + @QtCore.pyqtSlot(str) + def threadError(self, msg): + self.parent.showMessage( + msg=msg, + icon='Warning', + parent=self + ) + class MainWindow(QtWidgets.QMainWindow): ''' @@ -66,13 +75,16 @@ class MainWindow(QtWidgets.QMainWindow): def __init__(self, window, project): QtWidgets.QMainWindow.__init__(self) - # print('main thread id: {}'.format(QtCore.QThread.currentThreadId())) self.window = window self.core = Core() - self.pages = [] # widgets of component settings + # 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 @@ -80,7 +92,6 @@ class MainWindow(QtWidgets.QMainWindow): self.presetDir = Core.presetDir self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') self.settings = Core.settings - loadDefaultSettings(self) self.presetManager = PresetManager( uic.loadUi( os.path.join(Core.wd, 'presetmanager.ui')), self) @@ -92,13 +103,17 @@ class MainWindow(QtWidgets.QMainWindow): if not os.path.exists(neededDirectory): os.mkdir(neededDirectory) - # Make queues/timers for the preview thread + # 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.previewWorker.error.connect(self.cleanUp) self.previewThread.start() self.timer = QtCore.QTimer(self) @@ -106,6 +121,7 @@ class MainWindow(QtWidgets.QMainWindow): self.timer.start(500) # Begin decorating the window and connecting events + self.window.installEventFilter(self) componentList = self.window.listWidget_componentList if sys.platform == 'darwin': @@ -168,14 +184,9 @@ class MainWindow(QtWidgets.QMainWindow): window.spinBox_vBitrate.setValue(vBitrate) window.spinBox_aBitrate.setValue(aBitrate) - window.spinBox_vBitrate.valueChanged.connect(self.updateCodecSettings) window.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings) - self.previewWindow = PreviewWindow(self, os.path.join( - Core.wd, "background.png")) - window.verticalLayout_previewWrapper.addWidget(self.previewWindow) - # Make component buttons self.compMenu = QMenu() for i, comp in enumerate(self.core.modules): @@ -204,7 +215,7 @@ class MainWindow(QtWidgets.QMainWindow): currentRes = str(self.settings.value('outputWidth'))+'x' + \ str(self.settings.value('outputHeight')) - for i, res in enumerate(self.resolutions): + for i, res in enumerate(Core.resolutions): window.comboBox_resolution.addItem(res) if res == currentRes: currentRes = i @@ -375,6 +386,7 @@ class MainWindow(QtWidgets.QMainWindow): self.previewThread.quit() self.previewThread.wait() + @disableWhenOpeningProject def updateWindowTitle(self): appName = 'Audio Visualizer' try: @@ -442,13 +454,29 @@ class MainWindow(QtWidgets.QMainWindow): 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 >= 0.2: + 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.''' @@ -602,15 +630,20 @@ class MainWindow(QtWidgets.QMainWindow): def updateResolution(self): resIndex = int(self.window.comboBox_resolution.currentIndex()) - res = self.resolutions[resIndex].split('x') + res = Core.resolutions[resIndex].split('x') self.settings.setValue('outputWidth', res[0]) self.settings.setValue('outputHeight', res[1]) self.drawPreview() - def drawPreview(self, force=False): + 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() - self.autosave(force) + if force or 'autosave' in kwargs: + if force or kwargs['autosave']: + self.autosave(True) + else: + self.autosave() self.updateWindowTitle() @QtCore.pyqtSlot(QtGui.QImage) @@ -685,9 +718,13 @@ class MainWindow(QtWidgets.QMainWindow): stackedWidget.insertWidget(newRow, page) componentList.setCurrentRow(newRow) stackedWidget.setCurrentIndex(newRow) - self.drawPreview() + self.drawPreview(True) - def getComponentListRects(self): + 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 = [ @@ -698,20 +735,23 @@ class MainWindow(QtWidgets.QMainWindow): componentList.visualRect(modelIndex) for modelIndex in modelIndexes ] - return rects + 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 - rects = self.getComponentListRects() - - rowPos = [rect.contains(event.pos()) for rect in rects] - if not any(rowPos): - return - - i = rowPos.index(True) - change = (componentList.currentRow() - i) * -1 + 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): @@ -814,9 +854,7 @@ class MainWindow(QtWidgets.QMainWindow): self.settings.setValue("projectDir", os.path.dirname(filepath)) # actually load the project using core method self.core.openProject(self, filepath) - if self.window.listWidget_componentList.count() == 0: - self.drawPreview() - self.autosave(True) + self.drawPreview(autosave=False) self.updateWindowTitle() def showMessage(self, **kwargs): @@ -843,20 +881,11 @@ class MainWindow(QtWidgets.QMainWindow): def componentContextMenu(self, QPos): '''Appears when right-clicking the component list''' componentList = self.window.listWidget_componentList - index = componentList.currentRow() - self.menu = QMenu() parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) - rects = self.getComponentListRects() - rowPos = [rect.contains(QPos) for rect in rects] - if not any(rowPos): - # Insert components at the top if clicking nothing - rowPos = 0 - else: - rowPos = rowPos.index(True) - - if index == rowPos: + index = self.getComponentListMousePos(QPos) + if index > -1: # Show preset menu if clicking a component self.presetManager.findPresets() menuItem = self.menu.addAction("Save Preset") @@ -891,13 +920,23 @@ class MainWindow(QtWidgets.QMainWindow): # "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( - rowPos, item, self + 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 diff --git a/src/mainwindow.ui b/src/mainwindow.ui index b491323..b43d375 100644 --- a/src/mainwindow.ui +++ b/src/mainwindow.ui @@ -22,6 +22,9 @@ 0 + + Qt::StrongFocus + MainWindow diff --git a/src/presetmanager.py b/src/presetmanager.py index 64e2203..643e180 100644 --- a/src/presetmanager.py +++ b/src/presetmanager.py @@ -6,7 +6,8 @@ from PyQt5 import QtCore, QtWidgets import string import os -import toolkit +from toolkit import badName +from core import Core class PresetManager(QtWidgets.QDialog): @@ -151,7 +152,7 @@ class PresetManager(QtWidgets.QDialog): currentPreset ) if OK: - if toolkit.badName(newName): + if badName(newName): self.warnMessage(self.parent.window) continue if newName: @@ -236,7 +237,6 @@ class PresetManager(QtWidgets.QDialog): os.remove(filepath) def warnMessage(self, window=None): - print(window) self.parent.showMessage( msg='Preset names must contain only letters, ' 'numbers, and spaces.', @@ -272,7 +272,7 @@ class PresetManager(QtWidgets.QDialog): self.presetRows[index][2] ) if OK: - if toolkit.badName(newName): + if badName(newName): self.warnMessage() continue if newName: @@ -289,7 +289,7 @@ class PresetManager(QtWidgets.QDialog): self.findPresets() self.drawPresetList() for i, comp in enumerate(self.core.selectedComponents): - if toolkit.getPresetDir(comp) == path \ + if getPresetDir(comp) == path \ and comp.currentPreset == oldName: self.core.openPreset(newPath, i, newName) self.parent.updateComponentTitle(i, False) @@ -338,3 +338,8 @@ class PresetManager(QtWidgets.QDialog): def clearPresetListSelection(self): self.window.listWidget_presets.setCurrentRow(-1) + + +def getPresetDir(comp): + '''Get the preset subdir for a particular version of a component''' + return os.path.join(Core.presetDir, str(comp), str(comp.version)) diff --git a/src/preview_thread.py b/src/preview_thread.py index 3fc73b3..9917e4b 100644 --- a/src/preview_thread.py +++ b/src/preview_thread.py @@ -10,12 +10,13 @@ from queue import Queue, Empty import os from toolkit.frame import Checkerboard +from toolkit import disableWhenOpeningProject class Worker(QtCore.QObject): imageCreated = pyqtSignal(QtGui.QImage) - error = pyqtSignal() + error = pyqtSignal(str) def __init__(self, parent=None, queue=None): QtCore.QObject.__init__(self) @@ -30,6 +31,7 @@ class Worker(QtCore.QObject): height = int(self.settings.value('outputHeight')) self.background = Checkerboard(width, height) + @disableWhenOpeningProject @pyqtSlot(list) def createPreviewImage(self, components): dic = { @@ -48,7 +50,6 @@ class Worker(QtCore.QObject): self.queue.get(block=False) except Empty: continue - if self.background.width != width \ or self.background.height != height: self.background = Checkerboard(width, height) @@ -65,20 +66,12 @@ class Worker(QtCore.QObject): except ValueError as e: errMsg = "Bad frame returned by %s's preview renderer. " \ - "%s. New frame size was %s*%s; should be %s*%s. " \ - "This is a fatal error." % ( + "%s. New frame size was %s*%s; should be %s*%s." % ( str(component), str(e).capitalize(), newFrame.width, newFrame.height, width, height ) - print(errMsg) - self.parent.showMessage( - msg=errMsg, - detail=str(e), - icon='Warning', - parent=None # MainWindow is in a different thread - ) - self.error.emit() + self.error.emit(errMsg) break except RuntimeError as e: print(e) diff --git a/src/toolkit/common.py b/src/toolkit/common.py index 763d582..5fe601f 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -8,13 +8,6 @@ import sys import subprocess from collections import OrderedDict -from toolkit.core import * - - -def getPresetDir(comp): - '''Get the preset subdirectory for a particular version of a component''' - return os.path.join(Core.presetDir, str(comp), str(comp.version)) - def badName(name): '''Returns whether a name contains non-alphanumeric chars''' @@ -66,14 +59,20 @@ def openPipe(commandList, **kwargs): def disableWhenEncoding(func): - ''' Blocks calls to a function while the video is being exported - in MainWindow. - ''' - def decorator(*args, **kwargs): - if args[0].encoding: + def decorator(self, *args, **kwargs): + if self.encoding: return else: - return func(*args, **kwargs) + 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 @@ -108,34 +107,3 @@ def rgbFromString(string): return tup except: return (255, 255, 255) - - -def loadDefaultSettings(self): - ''' - Runs once at each program start-up. Fills in default settings - for any settings not found in settings.ini - ''' - self.resolutions = [ - '1920x1080', - '1280x720', - '854x480' - ] - - default = { - "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(self.dataDir, 'projects'), - } - - for parm, value in default.items(): - if self.settings.value(parm) is None: - self.settings.setValue(parm, value) diff --git a/src/toolkit/core.py b/src/toolkit/core.py deleted file mode 100644 index a96a684..0000000 --- a/src/toolkit/core.py +++ /dev/null @@ -1,18 +0,0 @@ -class Core: - '''A very complicated class for tracking settings''' - - -def init(settings): - global Core - for classvar, val in settings.items(): - setattr(Core, classvar, val) - - -def cancel(): - global Core - Core.canceled = True - - -def reset(): - global Core - Core.canceled = False diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index cc59a6c..30dc0b3 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -4,18 +4,19 @@ import numpy import sys import os -import subprocess as sp +import subprocess -from toolkit.common import Core, checkOutput, openPipe +import core +from toolkit.common import checkOutput, openPipe def findFfmpeg(): if getattr(sys, 'frozen', False): # The application is frozen if sys.platform == "win32": - return os.path.join(Core.wd, 'ffmpeg.exe') + return os.path.join(core.Core.wd, 'ffmpeg.exe') else: - return os.path.join(Core.wd, 'ffmpeg') + return os.path.join(core.Core.wd, 'ffmpeg') else: if sys.platform == "win32": @@ -27,7 +28,7 @@ def findFfmpeg(): ['ffmpeg', '-version'], stderr=f ) return "ffmpeg" - except sp.CalledProcessError: + except subprocess.CalledProcessError: return "avconv" @@ -37,9 +38,9 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1): ''' 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( @@ -213,12 +214,28 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-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 True + else: + return False + + def getAudioDuration(filename): - command = [Core.FFMPEG_BIN, '-i', 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=sp.STDOUT) - except sp.CalledProcessError as ex: + fileInfo = checkOutput(command, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as ex: fileInfo = ex.output info = fileInfo.decode("utf-8").split('\n') @@ -236,13 +253,17 @@ def getAudioDuration(filename): def readAudioFile(filename, parent): + ''' + Creates the completeAudioArray given to components + and used to draw the classic visualizer. + ''' duration = getAudioDuration(filename) if not duration: print('Audio file doesn\'t exist or unreadable.') return command = [ - Core.FFMPEG_BIN, + core.Core.FFMPEG_BIN, '-i', filename, '-f', 's16le', '-acodec', 'pcm_s16le', @@ -250,7 +271,8 @@ def readAudioFile(filename, parent): '-ac', '1', # mono (set to '2' for stereo) '-'] in_pipe = openPipe( - command, stdout=sp.PIPE, stderr=sp.DEVNULL, bufsize=10**8 + command, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=10**8 ) completeAudioArray = numpy.empty(0, dtype="int16") @@ -258,7 +280,7 @@ def readAudioFile(filename, parent): progress = 0 lastPercent = None while True: - if Core.canceled: + if core.Core.canceled: return # read 2 seconds of audio progress += 4 diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index 83fd59e..ca2a054 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -7,7 +7,7 @@ from PIL.ImageQt import ImageQt import sys import os -from toolkit.common import Core +import core class FramePainter(QtGui.QPainter): @@ -57,7 +57,7 @@ def Checkerboard(width, height): ''' image = FloodFrame(1920, 1080, (0, 0, 0, 0)) image.paste(Image.open( - os.path.join(Core.wd, "background.png")), + os.path.join(core.Core.wd, "background.png")), (0, 0) ) image = image.resize((width, height)) diff --git a/src/video_thread.py b/src/video_thread.py index 8517b92..7fe3e02 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -18,6 +18,7 @@ from threading import Thread, Event import time import signal +import core from toolkit import openPipe from toolkit.ffmpeg import readAudioFile, createFfmpegCommand from toolkit.frame import Checkerboard @@ -104,7 +105,8 @@ class Worker(QtCore.QObject): while not self.stopped: audioI, frame = self.previewQueue.get() - if time.time() - self.lastPreview >= 0.06 or audioI == 0: + if core.Core.windowHasFocus \ + and 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() @@ -231,7 +233,8 @@ class Worker(QtCore.QObject): self.lastPreview = 0.0 self.previewDispatch = Thread( - target=self.previewDispatch, name="Render Dispatch Thread") + target=self.previewDispatch, name="Render Dispatch Thread" + ) self.previewDispatch.daemon = True self.previewDispatch.start() -- cgit v1.2.3 From 3c1b52205f183e9a2c943c5f666ed2c01db3aaf5 Mon Sep 17 00:00:00 2001 From: tassaron Date: Tue, 1 Aug 2017 17:57:39 -0400 Subject: component class now tracks colorwidgets so adding new color-selection widgets is now simple --- setup.py | 2 +- src/component.py | 73 +++++++++++++++++++++++++++++++++++++++++----- src/components/color.py | 58 +++++------------------------------- src/components/original.py | 35 +++------------------- src/components/text.py | 27 ++--------------- src/components/waveform.py | 40 ++++--------------------- src/toolkit/common.py | 19 ------------ src/toolkit/frame.py | 6 ++-- 8 files changed, 90 insertions(+), 170 deletions(-) (limited to 'setup.py') diff --git a/setup.py b/setup.py index d4f226b..4a4511f 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup import os -__version__ = '2.0.0.rc2' +__version__ = '2.0.0.rc3' def package_files(directory): diff --git a/src/component.py b/src/component.py index 36ad9d3..d47aeae 100644 --- a/src/component.py +++ b/src/component.py @@ -3,18 +3,20 @@ on making a valid component. ''' from PyQt5 import uic, QtCore, QtWidgets +from PyQt5.QtGui import QColor import os import sys import time from toolkit.frame import BlankFrame -from toolkit import getWidgetValue, setWidgetValue, connectWidget +from toolkit import ( + getWidgetValue, setWidgetValue, connectWidget, rgbFromString +) class ComponentMetaclass(type(QtCore.QObject)): ''' - Checks the validity of each Component class imported, and - mutates some attributes for easier use by the core program. + Checks the validity of each Component class and mutates some attrs. E.g., takes only major version from version string & decorates methods ''' @@ -173,6 +175,8 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self._trackedWidgets = {} self._presetNames = {} self._commandArgs = {} + self._colorWidgets = {} + self._relativeWidgets = {} self._lockedProperties = None self._lockedError = None @@ -188,7 +192,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ) # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ - # Critical Methods + # Render Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ def previewRender(self): @@ -286,7 +290,17 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): Call super() at the END if you need to subclass this. ''' for attr, widget in self._trackedWidgets.items(): - setattr(self, attr, getWidgetValue(widget)) + if attr in self._colorWidgets: + rgbTuple = rgbFromString(widget.text()) + setattr(self, attr, rgbTuple) + btnStyle = ( + "QPushButton { background-color : %s; outline: none; }" + % QColor(*rgbTuple).name() + ) + self._colorWidgets[attr].setStyleSheet(btnStyle) + else: + setattr(self, attr, getWidgetValue(widget)) + if not self.core.openingProject: self.parent.drawPreview() saveValueStore = self.savePreset() @@ -305,7 +319,16 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): key = attr if attr not in self._presetNames \ else self._presetNames[attr] val = presetDict[key] - setWidgetValue(widget, val) + + 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) + else: + setWidgetValue(widget, val) def savePreset(self): saveValueStore = {} @@ -352,7 +375,12 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self._trackedWidgets = trackDict for kwarg in kwargs: try: - if kwarg in ('presetNames', 'commandArgs'): + if kwarg in ( + 'presetNames', + 'commandArgs', + 'colorWidgets', + 'relativeWidgets', + ): setattr(self, '_%s' % kwarg, kwargs[kwarg]) else: raise ComponentError( @@ -360,6 +388,37 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): except ComponentError: continue + if kwarg == 'colorWidgets': + def makeColorFunc(attr): + def pickColor_(): + self.pickColor( + self._trackedWidgets[attr], + self._colorWidgets[attr] + ) + 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; }" + ) + + 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 diff --git a/src/components/color.py b/src/components/color.py index 2abd79a..d6fffc6 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -6,7 +6,6 @@ import os from component import Component from toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor -from toolkit import rgbFromString, pickColor class Component(Component): @@ -14,25 +13,12 @@ class Component(Component): version = '1.0.0' def widget(self, *args): - self.color1 = (0, 0, 0) - self.color2 = (133, 133, 133) self.x = 0 self.y = 0 super().widget(*args) - self.page.lineEdit_color1.setText('%s,%s,%s' % self.color1) - self.page.lineEdit_color2.setText('%s,%s,%s' % self.color2) - - btnStyle1 = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*self.color1).name() - - btnStyle2 = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*self.color2).name() - - self.page.pushButton_color1.setStyleSheet(btnStyle1) - self.page.pushButton_color2.setStyleSheet(btnStyle2) - self.page.pushButton_color1.clicked.connect(lambda: self.pickColor(1)) - self.page.pushButton_color2.clicked.connect(lambda: self.pickColor(2)) + self.page.lineEdit_color1.setText('0,0,0') + self.page.lineEdit_color2.setText('133,133,133') # disable color #2 until non-default 'fill' option gets changed self.page.lineEdit_color2.setDisabled(True) @@ -66,16 +52,18 @@ class Component(Component): '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, + }, ) def update(self): - self.color1 = rgbFromString(self.page.lineEdit_color1.text()) - self.color2 = rgbFromString(self.page.lineEdit_color2.text()) - fillType = self.page.comboBox_fill.currentIndex() if fillType == 0: self.page.lineEdit_color2.setEnabled(False) @@ -161,36 +149,6 @@ class Component(Component): return image.finalize() - def loadPreset(self, pr, *args): - super().loadPreset(pr, *args) - - self.page.lineEdit_color1.setText('%s,%s,%s' % pr['color1']) - self.page.lineEdit_color2.setText('%s,%s,%s' % pr['color2']) - - btnStyle1 = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*pr['color1']).name() - btnStyle2 = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*pr['color2']).name() - self.page.pushButton_color1.setStyleSheet(btnStyle1) - self.page.pushButton_color2.setStyleSheet(btnStyle2) - - def savePreset(self): - saveValueStore = super().savePreset() - saveValueStore['color1'] = self.color1 - saveValueStore['color2'] = self.color2 - return saveValueStore - - def pickColor(self, num): - RGBstring, btnStyle = pickColor() - if not RGBstring: - return - if num == 1: - self.page.lineEdit_color1.setText(RGBstring) - self.page.pushButton_color1.setStyleSheet(btnStyle) - else: - self.page.lineEdit_color2.setText(RGBstring) - self.page.pushButton_color2.setStyleSheet(btnStyle) - def commandHelp(self): print('Specify a color:\n color=255,255,255') diff --git a/src/components/original.py b/src/components/original.py index 621af6f..950ac7b 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -8,7 +8,6 @@ from copy import copy from component import Component from toolkit.frame import BlankFrame -from toolkit import rgbFromString, pickColor class Component(Component): @@ -22,7 +21,6 @@ class Component(Component): return ['pcm'] def widget(self, *args): - self.visColor = (255, 255, 255) self.scale = 20 self.y = 0 super().widget(*args) @@ -33,35 +31,17 @@ class Component(Component): self.page.comboBox_visLayout.addItem("Top") self.page.comboBox_visLayout.setCurrentIndex(0) - self.page.lineEdit_visColor.setText('%s,%s,%s' % self.visColor) - self.page.pushButton_visColor.clicked.connect(lambda: self.pickColor()) - btnStyle = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*self.visColor).name() - self.page.pushButton_visColor.setStyleSheet(btnStyle) + 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, }) - def update(self): - self.visColor = rgbFromString(self.page.lineEdit_visColor.text()) - super().update() - - def loadPreset(self, pr, *args): - super().loadPreset(pr, *args) - - self.page.lineEdit_visColor.setText('%s,%s,%s' % pr['visColor']) - btnStyle = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*pr['visColor']).name() - self.page.pushButton_visColor.setStyleSheet(btnStyle) - - def savePreset(self): - saveValueStore = super().savePreset() - saveValueStore['visColor'] = self.visColor - return saveValueStore - def previewRender(self): spectrum = numpy.fromfunction( lambda x: float(self.scale)/2500*(x-128)**2, (255,), dtype="int16") @@ -99,13 +79,6 @@ class Component(Component): self.spectrumArray[arrayNo], self.visColor, self.layout) - def pickColor(self): - RGBstring, btnStyle = pickColor() - if not RGBstring: - return - self.page.lineEdit_visColor.setText(RGBstring) - self.page.pushButton_visColor.setStyleSheet(btnStyle) - def transformData( self, i, completeAudioArray, sampleSize, smoothConstantDown, smoothConstantUp, lastSpectrum): diff --git a/src/components/text.py b/src/components/text.py index 8a302ff..1fe3467 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -5,7 +5,6 @@ import os from component import Component from toolkit.frame import FramePainter -from toolkit import rgbFromString, pickColor class Component(Component): @@ -33,11 +32,6 @@ class Component(Component): self.page.comboBox_textAlign.addItem("Right") self.page.lineEdit_textColor.setText('%s,%s,%s' % self.textColor) - self.page.pushButton_textColor.clicked.connect(self.pickColor) - btnStyle = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*self.textColor).name() - self.page.pushButton_textColor.setStyleSheet(btnStyle) - self.page.lineEdit_title.setText(self.title) self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) self.page.spinBox_fontSize.setValue(int(self.fontSize)) @@ -48,21 +42,18 @@ class Component(Component): 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, + }, colorWidgets={ + 'textColor': self.page.pushButton_textColor, }) def update(self): self.titleFont = self.page.fontComboBox_titleFont.currentFont() - self.textColor = rgbFromString( - self.page.lineEdit_textColor.text()) - btnStyle = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*self.textColor).name() - self.page.pushButton_textColor.setStyleSheet(btnStyle) - super().update() def getXY(self): @@ -86,15 +77,10 @@ class Component(Component): font = QFont() font.fromString(pr['titleFont']) self.page.fontComboBox_titleFont.setCurrentFont(font) - self.page.lineEdit_textColor.setText('%s,%s,%s' % pr['textColor']) - btnStyle = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*pr['textColor']).name() - self.page.pushButton_textColor.setStyleSheet(btnStyle) def savePreset(self): saveValueStore = super().savePreset() saveValueStore['titleFont'] = self.titleFont.toString() - saveValueStore['textColor'] = self.textColor return saveValueStore def previewRender(self): @@ -122,13 +108,6 @@ class Component(Component): return image.finalize() - def pickColor(self): - RGBstring, btnStyle = pickColor() - if not RGBstring: - return - self.page.lineEdit_textColor.setText(RGBstring) - self.page.pushButton_textColor.setStyleSheet(btnStyle) - def commandHelp(self): print('Enter a string to use as centred white text:') print(' "title=User Error"') diff --git a/src/components/waveform.py b/src/components/waveform.py index 6c5133d..9c3cf86 100644 --- a/src/components/waveform.py +++ b/src/components/waveform.py @@ -7,7 +7,7 @@ import subprocess from component import Component from toolkit.frame import BlankFrame, scale -from toolkit import checkOutput, rgbFromString, pickColor +from toolkit import checkOutput from toolkit.ffmpeg import ( openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound ) @@ -18,15 +18,9 @@ class Component(Component): 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.color).name() - self.page.pushButton_color.setStyleSheet(btnStyle) - self.page.pushButton_color.clicked.connect(lambda: self.pickColor()) - self.page.spinBox_scale.valueChanged.connect(self.updateChunksize) + self.page.lineEdit_color.setText('255,255,255') if hasattr(self.parent, 'window'): self.parent.window.lineEdit_audioFile.textChanged.connect( @@ -35,6 +29,7 @@ class Component(Component): self.trackWidgets( { + 'color': self.page.lineEdit_color, 'mode': self.page.comboBox_mode, 'amplitude': self.page.comboBox_amplitude, 'x': self.page.spinBox_x, @@ -44,36 +39,11 @@ class Component(Component): 'opacity': self.page.spinBox_opacity, 'compress': self.page.checkBox_compress, 'mono': self.page.checkBox_mono, + }, colorWidgets={ + 'color': self.page.pushButton_color, } ) - 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 loadPreset(self, pr, *args): - super().loadPreset(pr, *args) - - self.page.lineEdit_color.setText('%s,%s,%s' % pr['color']) - btnStyle = "QPushButton { background-color : %s; outline: none; }" \ - % QColor(*pr['color']).name() - self.page.pushButton_color.setStyleSheet(btnStyle) - - def savePreset(self): - saveValueStore = super().savePreset() - saveValueStore['color'] = self.color - return saveValueStore - - def pickColor(self): - RGBstring, btnStyle = pickColor() - if not RGBstring: - return - self.page.lineEdit_color.setText(RGBstring) - self.page.pushButton_color.setStyleSheet(btnStyle) - def previewRender(self): self.updateChunksize() frame = self.getPreviewFrame(self.width, self.height) diff --git a/src/toolkit/common.py b/src/toolkit/common.py index db278c0..eba57d9 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -74,25 +74,6 @@ def disableWhenOpeningProject(func): return decorator -def pickColor(): - ''' - Use color picker to get color input from the user, - and return this as an RGB string and QPushButton stylesheet. - In a subclass apply stylesheet to any color selection widgets - ''' - 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() - return RGBstring, btnStyle - else: - return None, None - - def rgbFromString(string): '''Turns an RGB string like "255, 255, 255" into a tuple''' try: diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index f42d4c9..c007188 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -42,9 +42,9 @@ class PaintColor(QtGui.QColor): super().__init__(b, g, r, a) -def scale(scale, width, height, returntype=None): - width = (float(width) / 100.0) * float(scale) - height = (float(height) / 100.0) * float(scale) +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: -- cgit v1.2.3 From bdb006f25d2237ad69ee88d7f054cefaa0c5a3d8 Mon Sep 17 00:00:00 2001 From: tassaron Date: Thu, 10 Aug 2017 17:27:59 -0400 Subject: fixed relative image scale bug & Life preset bug dicts must be alphabetized in AV files --- setup.py | 2 +- src/components/image.py | 32 ++++++++++++++++++++++++++++---- src/components/image.ui | 16 ++++++++++++++++ src/components/life.py | 7 ++++--- 4 files changed, 49 insertions(+), 8 deletions(-) (limited to 'setup.py') diff --git a/setup.py b/setup.py index 4a4511f..dd546e2 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup import os -__version__ = '2.0.0.rc3' +__version__ = '2.0.0.rc4' def package_files(directory): diff --git a/src/components/image.py b/src/components/image.py index 1555541..63bee1a 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -8,7 +8,7 @@ from toolkit.frame import BlankFrame class Component(Component): name = 'Image' - version = '1.0.0' + version = '1.0.1' def widget(self, *args): super().widget(*args) @@ -16,6 +16,7 @@ class Component(Component): 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, @@ -51,6 +52,7 @@ class Component(Component): 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 @@ -62,9 +64,9 @@ class Component(Component): image = image.transpose(Image.FLIP_LEFT_RIGHT) if self.stretched and image.size != (width, height): image = image.resize((width, height), Image.ANTIALIAS) - if self.scale != 100: - newHeight = int((image.height / 100) * self.scale) - newWidth = int((image.width / 100) * self.scale) + 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 @@ -100,3 +102,25 @@ class Component(Component): 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) + super().update() diff --git a/src/components/image.ui b/src/components/image.ui index 1837b64..2dad127 100644 --- a/src/components/image.ui +++ b/src/components/image.ui @@ -293,6 +293,22 @@ + + + + % + + + 10 + + + 400 + + + 100 + + + diff --git a/src/components/life.py b/src/components/life.py index 08360a2..147d4d5 100644 --- a/src/components/life.py +++ b/src/components/life.py @@ -4,12 +4,13 @@ import os import math from component import Component +from toolkit import alphabetizeDict from toolkit.frame import BlankFrame, scale class Component(Component): name = 'Conway\'s Game of Life' - version = '1.0.0a' + version = '1.0.0' def widget(self, *args): super().widget(*args) @@ -329,12 +330,12 @@ class Component(Component): def savePreset(self): pr = super().savePreset() - pr['GRID'] = self.startingGrid + pr['GRID'] = alphabetizeDict(self.startingGrid) return pr def loadPreset(self, pr, *args): super().loadPreset(pr, *args) - self.startingGrid = pr['GRID'] + self.startingGrid = dict(pr['GRID']) def nearbyCoords(x, y): -- cgit v1.2.3 From 8411857030d92e448d5c64682f396e677161afbe Mon Sep 17 00:00:00 2001 From: tassaron Date: Mon, 28 Aug 2017 18:54:54 -0400 Subject: ctrl-c ends commandline mode properly --- setup.py | 2 +- src/command.py | 9 +++++++++ src/components/spectrum.py | 3 ++- src/core.py | 10 ++++------ src/toolkit/frame.py | 1 + src/video_thread.py | 11 ++++++++--- 6 files changed, 25 insertions(+), 11 deletions(-) (limited to 'setup.py') diff --git a/setup.py b/setup.py index dd546e2..cdf4c4a 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup import os -__version__ = '2.0.0.rc4' +__version__ = '2.0.0rc5' def package_files(directory): diff --git a/src/command.py b/src/command.py index 4116c5a..cd3c6c3 100644 --- a/src/command.py +++ b/src/command.py @@ -8,6 +8,7 @@ import argparse import os import sys import time +import signal from core import Core @@ -91,6 +92,9 @@ 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']: @@ -124,6 +128,11 @@ class Command(QtCore.QObject): 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: diff --git a/src/components/spectrum.py b/src/components/spectrum.py index 77cb086..6675f5b 100644 --- a/src/components/spectrum.py +++ b/src/components/spectrum.py @@ -98,7 +98,8 @@ class Component(Component): def preFrameRender(self, **kwargs): super().preFrameRender(**kwargs) - self.previewPipe.wait() + if self.previewPipe is not None: + self.previewPipe.wait() self.updateChunksize() w, h = scale(self.scale, self.width, self.height, str) self.video = FfmpegVideo( diff --git a/src/core.py b/src/core.py index 1a90296..d7445c9 100644 --- a/src/core.py +++ b/src/core.py @@ -13,8 +13,8 @@ import toolkit log = logging.getLogger('AVP.Core') -STDOUT_LOGLVL = logging.INFO -FILE_LOGLVL = logging.VERBOSE +STDOUT_LOGLVL = logging.WARNING +FILE_LOGLVL = None class Core: @@ -77,8 +77,7 @@ class Core: if compPos < 0 or compPos > len(self.selectedComponents): compPos = len(self.selectedComponents) if len(self.selectedComponents) > 50: - return None - + return -1 if type(component) is int: # create component using module index in self.modules moduleIndex = int(component) @@ -188,7 +187,6 @@ class Core: for key, value in data['Settings']: Core.settings.setValue(key, value) - for tup in data['Components']: name, vers, preset = tup clearThis = False @@ -213,7 +211,7 @@ class Core: self.moduleIndexFor(name), loader ) - if i is None: + if i == -1: loader.showMessage(msg="Too many components!") break diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index aefb55f..0e200b5 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -32,6 +32,7 @@ class FramePainter(QtGui.QPainter): 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 diff --git a/src/video_thread.py b/src/video_thread.py index 823ac73..91ebe93 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -252,9 +252,14 @@ class Worker(QtCore.QObject): print('############################') log.info('Opening pipe to ffmpeg') log.info(cmd) - self.out_pipe = openPipe( - ffmpegCommand, stdin=sp.PIPE, stdout=sys.stdout, stderr=sys.stdout - ) + 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!') + raise # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # START CREATING THE VIDEO -- cgit v1.2.3 From 05d2ebc3c69f5a876d602004f69202c5ba8b09f7 Mon Sep 17 00:00:00 2001 From: tassaron Date: Fri, 22 Apr 2022 17:09:50 -0400 Subject: make pip-installable as a package --- MANIFEST.in | 7 ++++++ setup.py | 61 ++++++++++++++++++++++++++-------------------- src/__init__.py | 6 ++--- src/__main__.py | 4 +-- src/component.py | 4 +-- src/components/color.py | 4 +-- src/components/image.py | 4 +-- src/components/life.py | 4 +-- src/components/original.py | 4 +-- src/components/sound.py | 4 +-- src/components/spectrum.py | 8 +++--- src/components/text.py | 4 +-- src/components/video.py | 8 +++--- src/components/waveform.py | 8 +++--- src/core.py | 12 ++++----- src/gui/actions.py | 2 +- src/gui/mainwindow.py | 13 +++++----- src/gui/presetmanager.py | 6 ++--- src/gui/preview_thread.py | 4 +-- src/main.py | 11 ++++----- src/toolkit/__init__.py | 2 +- src/toolkit/ffmpeg.py | 8 +++--- src/toolkit/frame.py | 2 +- src/video_thread.py | 8 +++--- 24 files changed, 106 insertions(+), 92 deletions(-) create mode 100644 MANIFEST.in (limited to 'setup.py') 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] diff --git a/setup.py b/setup.py index cdf4c4a..5e01229 100644 --- a/setup.py +++ b/setup.py @@ -1,29 +1,39 @@ -from setuptools import setup -import os +from setuptools import setup, find_packages +from importlib import import_module +from os import path +import re -__version__ = '2.0.0rc5' +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 -def package_files(directory): - paths = [] - for (path, directories, filenames) in os.walk(directory): - for filename in filenames: - paths.append(os.path.join('..', path, filename)) - return paths +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=__version__, + version=avp.__version__, url='https://github.com/djfun/audio-visualizer-python/tree/feature-newgui', license='MIT', - description='Create audio visualization videos from a GUI or commandline', - long_description="Create customized audio visualization videos and save " - "them as Projects to continue editing later. Different components can " - "be added and layered to add visualizers, images, videos, gradients, " - "text, etc. Use Projects created in the GUI with commandline mode to " - "automate your video production workflow without any complex syntax.", + 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', @@ -35,19 +45,18 @@ setup( 'visualizer', 'visualization', 'commandline video', 'video editor', 'ffmpeg', 'podcast' ], - packages=[ - 'avpython', - 'avpython.toolkit', - 'avpython.components' + packages=proj_packages, + package_dir={PACKAGE_NAME: SOURCE_DIRECTORY}, + include_package_data=True, + install_requires=[ + 'Pillow-SIMD', + 'PyQt5', + 'numpy', + 'pytest' ], - package_dir={'avpython': 'src'}, - package_data={ - 'avpython': package_files('src'), - }, - install_requires=['Pillow-SIMD', 'PyQt5', 'numpy'], entry_points={ 'gui_scripts': [ - 'avp = avpython.main:main' + f'avp = {PACKAGE_NAME}.main:main' ], } ) diff --git a/src/__init__.py b/src/__init__.py index 73f174a..08131ce 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,6 +3,9 @@ import os import logging +__version__ = '2.0.0rc6' + + class Logger(logging.getLoggerClass()): ''' Custom Logger class to handle custom VERBOSE log level. @@ -31,6 +34,3 @@ if getattr(sys, 'frozen', False): else: # unfrozen wd = os.path.dirname(os.path.realpath(__file__)) - -# make relative imports work when using /src as a package -sys.path.insert(0, wd) diff --git a/src/__main__.py b/src/__main__.py index 3babeae..3206bc8 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,5 +1,5 @@ -# Allows for launching with python3 -m avpython +# Allows for launching with python3 -m avp -from avpython.main import main +from .main import main main() diff --git a/src/component.py b/src/component.py index f3ee188..33c7657 100644 --- a/src/component.py +++ b/src/component.py @@ -11,8 +11,8 @@ import time import logging from copy import copy -from toolkit.frame import BlankFrame -from toolkit import ( +from .toolkit.frame import BlankFrame +from .toolkit import ( getWidgetValue, setWidgetValue, connectWidget, rgbFromString, blockSignals ) diff --git a/src/components/color.py b/src/components/color.py index 7d4f86d..6336194 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -4,8 +4,8 @@ from PyQt5.QtGui import QColor from PIL.ImageQt import ImageQt import os -from component import Component -from toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor +from ..component import Component +from ..toolkit.frame import BlankFrame, FloodFrame, FramePainter, PaintColor class Component(Component): diff --git a/src/components/image.py b/src/components/image.py index dd363bf..42f9564 100644 --- a/src/components/image.py +++ b/src/components/image.py @@ -2,8 +2,8 @@ from PIL import Image, ImageDraw, ImageEnhance from PyQt5 import QtGui, QtCore, QtWidgets import os -from component import Component -from toolkit.frame import BlankFrame +from ..component import Component +from ..toolkit.frame import BlankFrame class Component(Component): diff --git a/src/components/life.py b/src/components/life.py index 7a610eb..94704bc 100644 --- a/src/components/life.py +++ b/src/components/life.py @@ -4,8 +4,8 @@ from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter import os import math -from component import Component -from toolkit.frame import BlankFrame, scale +from ..component import Component +from ..toolkit.frame import BlankFrame, scale class Component(Component): diff --git a/src/components/original.py b/src/components/original.py index f886374..80228fe 100644 --- a/src/components/original.py +++ b/src/components/original.py @@ -6,8 +6,8 @@ import os import time from copy import copy -from component import Component -from toolkit.frame import BlankFrame +from ..component import Component +from ..toolkit.frame import BlankFrame class Component(Component): diff --git a/src/components/sound.py b/src/components/sound.py index 18d2a65..118ea23 100644 --- a/src/components/sound.py +++ b/src/components/sound.py @@ -1,8 +1,8 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os -from component import Component -from toolkit.frame import BlankFrame +from ..component import Component +from ..toolkit.frame import BlankFrame class Component(Component): diff --git a/src/components/spectrum.py b/src/components/spectrum.py index 6675f5b..d1f8fb6 100644 --- a/src/components/spectrum.py +++ b/src/components/spectrum.py @@ -6,10 +6,10 @@ 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 ( +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit import checkOutput, connectWidget +from ..toolkit.ffmpeg import ( openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound ) diff --git a/src/components/text.py b/src/components/text.py index 32a108e..e8c5a9c 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -4,8 +4,8 @@ from PyQt5 import QtGui, QtCore, QtWidgets import os import logging -from component import Component -from toolkit.frame import FramePainter, PaintColor +from ..component import Component +from ..toolkit.frame import FramePainter, PaintColor log = logging.getLogger('AVP.Components.Text') diff --git a/src/components/video.py b/src/components/video.py index 8ad21b5..070940d 100644 --- a/src/components/video.py +++ b/src/components/video.py @@ -5,10 +5,10 @@ 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 +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') diff --git a/src/components/waveform.py b/src/components/waveform.py index cbfc47f..1a6035f 100644 --- a/src/components/waveform.py +++ b/src/components/waveform.py @@ -6,10 +6,10 @@ import math import subprocess import logging -from component import Component -from toolkit.frame import BlankFrame, scale -from toolkit import checkOutput -from toolkit.ffmpeg import ( +from ..component import Component +from ..toolkit.frame import BlankFrame, scale +from ..toolkit import checkOutput +from ..toolkit.ffmpeg import ( openPipe, closePipe, getAudioDuration, FfmpegVideo, exampleSound ) diff --git a/src/core.py b/src/core.py index d7445c9..bc6f9b4 100644 --- a/src/core.py +++ b/src/core.py @@ -9,12 +9,12 @@ import json from importlib import import_module import logging -import toolkit +from . import toolkit log = logging.getLogger('AVP.Core') STDOUT_LOGLVL = logging.WARNING -FILE_LOGLVL = None +FILE_LOGLVL = logging.ERROR class Core: @@ -47,7 +47,7 @@ class Core: yield name log.debug('Importing component modules') self.modules = [ - import_module('components.%s' % name) + import_module('.components.%s' % name, __package__) for name in findComponents() ] # store canonical module names and indexes @@ -426,7 +426,7 @@ class Core: def newVideoWorker(self, loader, audioFile, outputPath): '''loader is MainWindow or Command object which must own the thread''' - import video_thread + from . import video_thread self.videoThread = QtCore.QThread(loader) videoWorker = video_thread.Worker( loader, audioFile, outputPath, self.selectedComponents @@ -450,8 +450,8 @@ class Core: @classmethod def storeSettings(cls): '''Store settings/paths to directories as class variables''' - from __init__ import wd - from toolkit.ffmpeg import findFfmpeg + from .__init__ import wd + from .toolkit.ffmpeg import findFfmpeg cls.wd = wd dataDir = QtCore.QStandardPaths.writableLocation( diff --git a/src/gui/actions.py b/src/gui/actions.py index 8e867b9..eb7b953 100644 --- a/src/gui/actions.py +++ b/src/gui/actions.py @@ -5,7 +5,7 @@ from PyQt5.QtWidgets import QUndoCommand import os from copy import copy -from core import Core +from ..core import Core # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ diff --git a/src/gui/mainwindow.py b/src/gui/mainwindow.py index 75534c2..da8370d 100644 --- a/src/gui/mainwindow.py +++ b/src/gui/mainwindow.py @@ -16,12 +16,12 @@ import filecmp import time import logging -from core import Core -import gui.preview_thread as preview_thread -from gui.preview_win import PreviewWindow -from gui.presetmanager import PresetManager -from gui.actions import * -from toolkit import ( +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 ) @@ -65,7 +65,6 @@ class MainWindow(QtWidgets.QMainWindow): self.settings = Core.settings # Register clean-up functions - signal.signal(signal.SIGINT, self.terminate) atexit.register(self.cleanUp) # Create stack of undoable user actions diff --git a/src/gui/presetmanager.py b/src/gui/presetmanager.py index 2445760..1e47a7f 100644 --- a/src/gui/presetmanager.py +++ b/src/gui/presetmanager.py @@ -7,9 +7,9 @@ import string import os import logging -from toolkit import badName -from core import Core -from gui.actions import * +from ..toolkit import badName +from ..core import Core +from .actions import * log = logging.getLogger('AVP.Gui.PresetManager') diff --git a/src/gui/preview_thread.py b/src/gui/preview_thread.py index d3e0581..7829476 100644 --- a/src/gui/preview_thread.py +++ b/src/gui/preview_thread.py @@ -10,8 +10,8 @@ from queue import Queue, Empty import os import logging -from toolkit.frame import Checkerboard -from toolkit import disableWhenOpeningProject +from ..toolkit.frame import Checkerboard +from ..toolkit import disableWhenOpeningProject log = logging.getLogger("AVP.Gui.PreviewThread") diff --git a/src/main.py b/src/main.py index 126e4a8..5fabda3 100644 --- a/src/main.py +++ b/src/main.py @@ -3,7 +3,7 @@ import sys import os import logging -from __init__ import wd +from .__init__ import wd log = logging.getLogger('AVP.Main') @@ -12,6 +12,7 @@ log = logging.getLogger('AVP.Main') def main(): app = QtWidgets.QApplication(sys.argv) app.setApplicationName("audio-visualizer") + proj = None # Determine mode mode = 'GUI' @@ -23,19 +24,17 @@ def main(): else: # opening a project file with gui proj = sys.argv[1] - else: - # normal gui launch - proj = None # Launch program if mode == 'commandline': - from command import Command + from .command import Command main = Command() + main.parseArgs() log.debug("Finished creating command object") elif mode == 'GUI': - from gui.mainwindow import MainWindow + from .gui.mainwindow import MainWindow window = uic.loadUi(os.path.join(wd, "gui", "mainwindow.ui")) # window.adjustSize() diff --git a/src/toolkit/__init__.py b/src/toolkit/__init__.py index 3fca275..55e5f84 100644 --- a/src/toolkit/__init__.py +++ b/src/toolkit/__init__.py @@ -1 +1 @@ -from toolkit.common import * +from .common import * diff --git a/src/toolkit/ffmpeg.py b/src/toolkit/ffmpeg.py index 419d491..3298c04 100644 --- a/src/toolkit/ffmpeg.py +++ b/src/toolkit/ffmpeg.py @@ -10,8 +10,8 @@ import signal from queue import PriorityQueue import logging -import core -from toolkit.common import checkOutput, pipeWrapper +from .. import core +from .common import checkOutput, pipeWrapper log = logging.getLogger('AVP.Toolkit.Ffmpeg') @@ -90,7 +90,7 @@ class FfmpegVideo: self.frameBuffer.task_done() def fillBuffer(self): - from component import ComponentError + from ..component import ComponentError if core.Core.logEnabled: logFilename = os.path.join( core.Core.logDir, 'render_%s.log' % str(self.component.compPos) @@ -144,7 +144,7 @@ def openPipe(commandList, **kwargs): def closePipe(pipe): pipe.stdout.close() - pipe.send_signal(signal.SIGINT) + pipe.send_signal(signal.SIGTERM) def findFfmpeg(): diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index 0e200b5..f2511fe 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -9,7 +9,7 @@ import os import math import logging -import core +from .. import core log = logging.getLogger('AVP.Toolkit.Frame') diff --git a/src/video_thread.py b/src/video_thread.py index 0a39f28..31331a3 100644 --- a/src/video_thread.py +++ b/src/video_thread.py @@ -19,9 +19,9 @@ import time import signal import logging -from component import ComponentError -from toolkit.frame import Checkerboard -from toolkit.ffmpeg import ( +from .component import ComponentError +from .toolkit.frame import Checkerboard +from .toolkit.ffmpeg import ( openPipe, readAudioFile, getAudioDuration, createFfmpegCommand ) @@ -400,7 +400,7 @@ class Worker(QtCore.QObject): comp.cancel() try: - self.out_pipe.send_signal(signal.SIGINT) + self.out_pipe.send_signal(signal.SIGTERM) except Exception: pass -- cgit v1.2.3 From 67c6fa43ac5ed85719179485b0fff4a8ad071a9f Mon Sep 17 00:00:00 2001 From: tassaron Date: Fri, 29 Apr 2022 23:19:47 -0400 Subject: switch Pillow-SIMD for Pillow It is easier for people to install with pip. We can always go back to SIMD in the future when we have a better install script. Packaged versions can still use Pillow-SIMD --- setup.py | 4 ++-- src/gui/mainwindow.py | 14 ++------------ 2 files changed, 4 insertions(+), 14 deletions(-) (limited to 'setup.py') diff --git a/setup.py b/setup.py index 5e01229..3709e7b 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ proj_packages = [SOURCE_PACKAGE_REGEX.sub(PACKAGE_NAME, name) for name in source setup( name='audio_visualizer_python', version=avp.__version__, - url='https://github.com/djfun/audio-visualizer-python/tree/feature-newgui', + url='https://github.com/djfun/audio-visualizer-python', license='MIT', description=PACKAGE_DESCRIPTION, author=getTextFromFile('AUTHORS', 'djfun, tassaron'), @@ -49,7 +49,7 @@ setup( package_dir={PACKAGE_NAME: SOURCE_DIRECTORY}, include_package_data=True, install_requires=[ - 'Pillow-SIMD', + 'Pillow', 'PyQt5', 'numpy', 'pytest' diff --git a/src/gui/mainwindow.py b/src/gui/mainwindow.py index c31eec9..1b28b7e 100644 --- a/src/gui/mainwindow.py +++ b/src/gui/mainwindow.py @@ -333,16 +333,7 @@ class MainWindow(QtWidgets.QMainWindow): self.openProject(self.currentProject, prompt=False) self.drawPreview(True) - # verify Pillow version - if not self.settings.value("pilMsgShown") \ - and 'post' not in Image.__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.__version__ - ) - self.settings.setValue("pilMsgShown", True) + log.info("Pillow version %s", Image.__version__) # verify Ffmpeg version if not self.settings.value("ffmpegMsgShown"): @@ -351,8 +342,7 @@ class MainWindow(QtWidgets.QMainWindow): ffmpegVers = checkOutput( ['ffmpeg', '-version'], stderr=f ) - goodVersion = (str(ffmpegVers).split()[2].startswith('3') or - str(ffmpegVers).split()[2].startswith('4')) + goodVersion = str(ffmpegVers).split()[2].startswith('4') except Exception: goodVersion = False else: -- cgit v1.2.3