diff options
| author | martin | 2022-05-01 22:41:20 +0200 |
|---|---|---|
| committer | GitHub | 2022-05-01 22:41:20 +0200 |
| commit | 4c5aa37aa6f41d909153a2b7d522db6d7582659a (patch) | |
| tree | 326aa67921439defcb8c25ea5f770feb63e878a4 /src/core.py | |
| parent | 4a3ff8bfce622de0e5affa312d50557b5d336371 (diff) | |
| parent | 820358a79a87b214139eb7693ce80e96be79e3d8 (diff) | |
Merge pull request #69 from djfun/feature-newgui
GUI Redesign with Component System
Diffstat (limited to 'src/core.py')
| -rw-r--r-- | src/core.py | 614 |
1 files changed, 614 insertions, 0 deletions
diff --git a/src/core.py b/src/core.py new file mode 100644 index 0000000..77b0894 --- /dev/null +++ b/src/core.py @@ -0,0 +1,614 @@ +''' + Home to the Core class which tracks program state. Used by GUI & commandline + to create a list of components and create a video thread to export. +''' +from PyQt5 import QtCore, QtGui, uic +import sys +import os +import json +from importlib import import_module +import logging + +from . import toolkit + + +log = logging.getLogger('AVP.Core') +STDOUT_LOGLVL = logging.WARNING +FILE_LIBLOGLVL = logging.WARNING +FILE_LOGLVL = logging.INFO + + +class Core: + ''' + MainWindow and Command module both use an instance of this class + to store the core program state. This object tracks the components, + talks to the components, handles opening/creating project files + and presets, and creates the video thread to export. + This class also stores constants as class variables. + ''' + + def __init__(self): + self.importComponents() + self.selectedComponents = [] + self.savedPresets = {} # copies of presets to detect modification + self.openingProject = False + + def __repr__(self): + return "\n=~=~=~=\n".join( + [repr(comp) for comp in self.selectedComponents] + ) + + def importComponents(self): + def findComponents(): + for f in os.listdir(Core.componentsPath): + name, ext = os.path.splitext(f) + if name.startswith("__"): + continue + elif ext == '.py': + yield name + log.debug('Importing component modules') + self.modules = [ + import_module('.components.%s' % name, __package__) + for name in findComponents() + ] + # store canonical module names and indexes + self.moduleIndexes = [i for i in range(len(self.modules))] + self.compNames = [mod.Component.name for mod in self.modules] + # alphabetize modules by Component name + sortedModules = sorted(zip(self.compNames, self.modules)) + self.compNames = [y[0] for y in sortedModules] + self.modules = [y[1] for y in sortedModules] + + # store alternative names for modules + self.altCompNames = [] + for i, mod in enumerate(self.modules): + if hasattr(mod.Component, 'names'): + for name in mod.Component.names(): + self.altCompNames.append((name, i)) + + def componentListChanged(self): + for i, component in enumerate(self.selectedComponents): + component.compPos = i + + def insertComponent(self, compPos, component, loader): + ''' + Creates a new component using these args: + (compPos, component obj or moduleIndex, MWindow/Command/Core obj) + ''' + if compPos < 0 or compPos > len(self.selectedComponents): + compPos = len(self.selectedComponents) + if len(self.selectedComponents) > 50: + return -1 + if type(component) is int: + # create component using module index in self.modules + moduleIndex = int(component) + log.debug( + 'Creating new component from module #%s', str(moduleIndex)) + component = self.modules[moduleIndex].Component( + moduleIndex, compPos, self + ) + component.widget(loader) + else: + moduleIndex = -1 + log.debug( + 'Inserting previously-created %s component', component.name) + + component._error.connect( + loader.videoThreadError + ) + self.selectedComponents.insert( + compPos, + component + ) + if hasattr(loader, 'insertComponent'): + loader.insertComponent(compPos) + + self.componentListChanged() + self.updateComponent(compPos) + return compPos + + def moveComponent(self, startI, endI): + comp = self.selectedComponents.pop(startI) + self.selectedComponents.insert(endI, comp) + + self.componentListChanged() + return endI + + def removeComponent(self, i): + self.selectedComponents.pop(i) + self.componentListChanged() + + def clearComponents(self): + self.selectedComponents = list() + self.componentListChanged() + + def updateComponent(self, i): + log.debug( + 'Auto-updating %s #%s', + self.selectedComponents[i], str(i)) + self.selectedComponents[i].update(auto=True) + + def moduleIndexFor(self, compName): + try: + index = self.compNames.index(compName) + return self.moduleIndexes[index] + except ValueError: + for altName, modI in self.altCompNames: + if altName == compName: + return self.moduleIndexes[modI] + + def clearPreset(self, compIndex): + self.selectedComponents[compIndex].currentPreset = None + + def openPreset(self, filepath, compIndex, presetName): + '''Applies a preset to a specific component''' + saveValueStore = self.getPreset(filepath) + if not saveValueStore: + return False + comp = self.selectedComponents[compIndex] + comp.loadPreset( + saveValueStore, + presetName + ) + + self.savedPresets[presetName] = dict(saveValueStore) + return True + + def getPreset(self, filepath): + '''Returns the preset dict stored at this filepath''' + if not os.path.exists(filepath): + return False + with open(filepath, 'r') as f: + for line in f: + saveValueStore = toolkit.presetFromString(line.strip()) + break + return saveValueStore + + def getPresetDir(self, comp): + '''Get the preset subdir for a particular version of a component''' + return os.path.join(Core.presetDir, comp.name, str(comp.version)) + + def openProject(self, loader, filepath): + ''' loader is the object calling this method which must have + its own showMessage(**kwargs) method for displaying errors. + ''' + if not os.path.exists(filepath): + loader.showMessage(msg='Project file not found.') + return + + errcode, data = self.parseAvFile(filepath) + if errcode == 0: + self.openingProject = True + try: + if hasattr(loader, 'window'): + for widget, value in data['WindowFields']: + widget = eval('loader.%s' % widget) + with toolkit.blockSignals(widget): + toolkit.setWidgetValue(widget, value) + + for key, value in data['Settings']: + Core.settings.setValue(key, value) + for tup in data['Components']: + name, vers, preset = tup + clearThis = False + modified = False + + # add loaded named presets to savedPresets dict + if 'preset' in preset and preset['preset'] is not None: + nam = preset['preset'] + filepath2 = os.path.join( + Core.presetDir, name, str(vers), nam) + origSaveValueStore = self.getPreset(filepath2) + if origSaveValueStore: + self.savedPresets[nam] = dict(origSaveValueStore) + modified = not origSaveValueStore == preset + else: + # saved preset was renamed or deleted + clearThis = True + + # create the actual component object & get its index + i = self.insertComponent( + -1, + self.moduleIndexFor(name), + loader + ) + if i == -1: + loader.showMessage(msg="Too many components!") + break + + try: + if 'preset' in preset and preset['preset'] is not None: + self.selectedComponents[i].loadPreset( + preset + ) + else: + self.selectedComponents[i].loadPreset( + preset, + preset['preset'] + ) + except KeyError as e: + log.warning('%s missing value: %s' % ( + self.selectedComponents[i], e) + ) + + if clearThis: + self.clearPreset(i) + if hasattr(loader, 'updateComponentTitle'): + loader.updateComponentTitle(i, modified) + self.openingProject = False + return True + except Exception: + errcode = 1 + data = sys.exc_info() + + if errcode == 1: + typ, value, tb = data + if typ.__name__ == 'KeyError': + # probably just an old version, still loadable + log.warning('Project file missing value: %s' % value) + return + if hasattr(loader, 'createNewProject'): + loader.createNewProject(prompt=False) + msg = '%s: %s\n\n' % (typ.__name__, value) + msg += toolkit.formatTraceback(tb) + loader.showMessage( + msg="Project file '%s' is corrupted." % filepath, + showCancel=False, + icon='Warning', + detail=msg) + self.openingProject = False + return False + + def parseAvFile(self, filepath): + ''' + Parses an avp (project) or avl (preset package) file. + Returns dictionary with section names as the keys, each one + contains a list of tuples: (compName, version, compPresetDict) + ''' + log.debug('Parsing av file: %s', filepath) + validSections = ( + 'Components', + 'Settings', + 'WindowFields' + ) + data = {sect: [] for sect in validSections} + try: + with open(filepath, 'r') as f: + def parseLine(line): + '''Decides if a file line is a section header''' + line = line.strip() + newSection = '' + + if line.startswith('[') and line.endswith(']') \ + and line[1:-1] in validSections: + newSection = line[1:-1] + + return line, newSection + + section = '' + i = 0 + for line in f: + line, newSection = parseLine(line) + if newSection: + section = str(newSection) + continue + if line and section == 'Components': + if i == 0: + lastCompName = str(line) + i += 1 + elif i == 1: + lastCompVers = str(line) + i += 1 + elif i == 2: + lastCompPreset = toolkit.presetFromString(line) + data[section].append(( + lastCompName, + lastCompVers, + lastCompPreset + )) + i = 0 + elif line and section: + key, value = line.split('=', 1) + data[section].append((key, value.strip())) + + return 0, data + except Exception: + return 1, sys.exc_info() + + def importPreset(self, filepath): + errcode, data = self.parseAvFile(filepath) + returnList = [] + if errcode == 0: + name, vers, preset = data['Components'][0] + presetName = preset['preset'] \ + if preset['preset'] else os.path.basename(filepath)[:-4] + newPath = os.path.join( + Core.presetDir, + name, + vers, + presetName + ) + if os.path.exists(newPath): + return False, newPath + preset['preset'] = presetName + self.createPresetFile( + name, vers, presetName, preset + ) + return True, presetName + elif errcode == 1: + # TODO: an error message + return False, '' + + def exportPreset(self, exportPath, compName, vers, origName): + internalPath = os.path.join( + Core.presetDir, compName, str(vers), origName + ) + if not os.path.exists(internalPath): + return + if os.path.exists(exportPath): + os.remove(exportPath) + with open(internalPath, 'r') as f: + internalData = [line for line in f] + try: + saveValueStore = toolkit.presetFromString(internalData[0].strip()) + self.createPresetFile( + compName, vers, + origName, saveValueStore, + exportPath + ) + return True + except Exception: + return False + + def createPresetFile( + self, compName, vers, presetName, saveValueStore, filepath=''): + '''Create a preset file (.avl) at filepath using args. + Or if filepath is empty, create an internal preset using args''' + if not filepath: + dirname = os.path.join(Core.presetDir, compName, str(vers)) + if not os.path.exists(dirname): + os.makedirs(dirname) + filepath = os.path.join(dirname, presetName) + internal = True + else: + if not filepath.endswith('.avl'): + filepath += '.avl' + internal = False + + with open(filepath, 'w') as f: + if not internal: + f.write('[Components]\n') + f.write('%s\n' % compName) + f.write('%s\n' % str(vers)) + f.write(toolkit.presetToString(saveValueStore)) + + def createProjectFile(self, filepath, window=None): + '''Create a project file (.avp) using the current program state''' + log.info('Creating %s', filepath) + settingsKeys = [ + 'componentDir', + 'inputDir', + 'outputDir', + 'presetDir', + 'projectDir', + ] + try: + if not filepath.endswith(".avp"): + filepath += '.avp' + if os.path.exists(filepath): + os.remove(filepath) + + with open(filepath, 'w') as f: + f.write('[Components]\n') + for comp in self.selectedComponents: + saveValueStore = comp.savePreset() + saveValueStore['preset'] = comp.currentPreset + f.write('%s\n' % str(comp)) + f.write('%s\n' % str(comp.version)) + f.write('%s\n' % toolkit.presetToString(saveValueStore)) + + f.write('\n[Settings]\n') + for key in Core.settings.allKeys(): + if key in settingsKeys: + f.write('%s=%s\n' % (key, Core.settings.value(key))) + + if window: + f.write('\n[WindowFields]\n') + f.write( + 'lineEdit_audioFile=%s\n' + 'lineEdit_outputFile=%s\n' % ( + window.lineEdit_audioFile.text(), + window.lineEdit_outputFile.text() + ) + ) + return True + except Exception: + return False + + def newVideoWorker(self, loader, audioFile, outputPath): + '''loader is MainWindow or Command object which must own the thread''' + from . import video_thread + self.videoThread = QtCore.QThread(loader) + videoWorker = video_thread.Worker( + loader, audioFile, outputPath, self.selectedComponents + ) + videoWorker.moveToThread(self.videoThread) + videoWorker.videoCreated.connect(self.stopVideoThread) + + self.videoThread.start() + return videoWorker + + def stopVideoThread(self): + self.videoThread.quit() + self.videoThread.wait() + + def cancel(self): + Core.canceled = True + + def reset(self): + Core.canceled = False + + @classmethod + def storeSettings(cls): + '''Store settings/paths to directories as class variables''' + from .__init__ import wd + from .toolkit.ffmpeg import findFfmpeg + + cls.wd = wd + dataDir = QtCore.QStandardPaths.writableLocation( + QtCore.QStandardPaths.AppConfigLocation + ) + # Windows: C:/Users/<USER>/AppData/Local/audio-visualizer + # macOS: ~/Library/Preferences/audio-visualizer + # Linux: ~/.config/audio-visualizer + with open(os.path.join(wd, 'encoder-options.json')) as json_file: + encoderOptions = json.load(json_file) + + # Locate FFmpeg + ffmpegBin = findFfmpeg() + if not ffmpegBin: + print("Could not find FFmpeg") + + settings = { + 'canceled': False, + 'FFMPEG_BIN': ffmpegBin, + 'dataDir': dataDir, + 'settings': QtCore.QSettings( + os.path.join(dataDir, 'settings.ini'), + QtCore.QSettings.IniFormat), + 'presetDir': os.path.join(dataDir, 'presets'), + 'componentsPath': os.path.join(wd, 'components'), + 'junkStream': os.path.join(wd, 'gui', 'background.png'), + 'encoderOptions': encoderOptions, + 'resolutions': [ + '1920x1080', + '1280x720', + '854x480', + ], + 'logDir': os.path.join(dataDir, 'log'), + 'logEnabled': False, + } + + settings['videoFormats'] = toolkit.appendUppercase([ + '*.mp4', + '*.mov', + '*.mkv', + '*.avi', + '*.webm', + '*.flv', + ]) + settings['audioFormats'] = toolkit.appendUppercase([ + '*.mp3', + '*.wav', + '*.ogg', + '*.fla', + '*.flac', + '*.aac', + ]) + settings['imageFormats'] = toolkit.appendUppercase([ + '*.png', + '*.jpg', + '*.tif', + '*.tiff', + '*.gif', + '*.bmp', + '*.ico', + '*.xbm', + '*.xpm', + ]) + + # Register all settings as class variables + for classvar, val in settings.items(): + setattr(cls, classvar, val) + + cls.loadDefaultSettings() + if not os.path.exists(cls.dataDir): + os.makedirs(cls.dataDir) + for neededDirectory in ( + cls.presetDir, cls.logDir, cls.settings.value("projectDir")): + if not os.path.exists(neededDirectory): + os.mkdir(neededDirectory) + cls.makeLogger(deleteOldLogs=True) + + @classmethod + def loadDefaultSettings(cls): + # settings that get saved into the ini file + cls.defaultSettings = { + "outputWidth": 1280, + "outputHeight": 720, + "outputFrameRate": 30, + "outputAudioCodec": "AAC", + "outputAudioBitrate": "192", + "outputVideoCodec": "H264", + "outputVideoBitrate": "2500", + "outputVideoFormat": "yuv420p", + "outputPreset": "medium", + "outputFormat": "mp4", + "outputContainer": "MP4", + "projectDir": os.path.join(cls.dataDir, 'projects'), + "pref_insertCompAtTop": True, + "pref_genericPreview": True, + "pref_undoLimit": 10, + } + + for parm, value in cls.defaultSettings.items(): + if cls.settings.value(parm) is None: + cls.settings.setValue(parm, value) + + # Allow manual editing of prefs. (Surprisingly necessary as Qt seems to + # store True as 'true' but interprets a manually-added 'true' as str.) + for key in cls.settings.allKeys(): + if not key.startswith('pref_'): + continue + val = cls.settings.value(key) + try: + val = int(val) + except ValueError: + if val == 'true': + val = True + elif val == 'false': + val = False + cls.settings.setValue(key, val) + + @staticmethod + def makeLogger(deleteOldLogs=False): + # send critical log messages to stdout + logStream = logging.StreamHandler() + logStream.setLevel(STDOUT_LOGLVL) + streamFormatter = logging.Formatter( + '<%(name)s> %(levelname)s: %(message)s' + ) + logStream.setFormatter(streamFormatter) + log = logging.getLogger('AVP') + log.addHandler(logStream) + + if FILE_LOGLVL is not None: + # write log files as well! + Core.logEnabled = True + logFilename = os.path.join(Core.logDir, 'avp_debug.log') + libLogFilename = os.path.join(Core.logDir, 'global_debug.log') + + if deleteOldLogs: + for log_ in (logFilename, libLogFilename): + if os.path.exists(log_): + os.remove(log_) + + logFile = logging.FileHandler(logFilename, delay=True) + logFile.setLevel(FILE_LOGLVL) + libLogFile = logging.FileHandler(libLogFilename, delay=True) + libLogFile.setLevel(FILE_LIBLOGLVL) + fileFormatter = logging.Formatter( + '[%(asctime)s] %(threadName)-10.10s %(name)-23.23s %(levelname)s: ' + '%(message)s' + ) + logFile.setFormatter(fileFormatter) + libLogFile.setFormatter(fileFormatter) + + libLog = logging.getLogger() + log.addHandler(logFile) + libLog.addHandler(libLogFile) + # lowest level must be explicitly set on the root Logger + libLog.setLevel(0) + +# always store settings in class variables even if a Core object is not created +Core.storeSettings() |
