better component error messages

fatal errors cancel the export instead of crashing
This commit is contained in:
tassaron 2017-07-23 17:14:21 -04:00
parent bf0890e7c8
commit d38109453c
9 changed files with 190 additions and 82 deletions

View File

@ -5,13 +5,12 @@
from PyQt5 import uic, QtCore, QtWidgets from PyQt5 import uic, QtCore, QtWidgets
import os import os
from presetmanager import getPresetDir
def commandWrapper(func): def commandWrapper(func):
'''Intercepts each component's command() method to check for global args''' '''Intercepts each component's command() method to check for global args'''
def decorator(self, arg): def decorator(self, arg):
if arg.startswith('preset='): if arg.startswith('preset='):
from presetmanager import getPresetDir
_, preset = arg.split('=', 1) _, preset = arg.split('=', 1)
path = os.path.join(getPresetDir(self), preset) path = os.path.join(getPresetDir(self), preset)
if not os.path.exists(path): if not os.path.exists(path):
@ -29,6 +28,26 @@ def commandWrapper(func):
return decorator return decorator
def propertiesWrapper(func):
'''Intercepts the usual properties if the properties are locked.'''
def decorator(self):
if self._lockedProperties is not None:
return self._lockedProperties
else:
return func(self)
return decorator
def errorWrapper(func):
'''Intercepts the usual error message if it is locked.'''
def decorator(self):
if self._lockedError is not None:
return self._lockedError
else:
return func(self)
return decorator
class ComponentMetaclass(type(QtCore.QObject)): class ComponentMetaclass(type(QtCore.QObject)):
''' '''
Checks the validity of each Component class imported, and Checks the validity of each Component class imported, and
@ -37,25 +56,33 @@ class ComponentMetaclass(type(QtCore.QObject)):
''' '''
def __new__(cls, name, parents, attrs): def __new__(cls, name, parents, attrs):
if 'ui' not in attrs: if 'ui' not in attrs:
# use module name as ui filename by default # Use module name as ui filename by default
attrs['ui'] = '%s.ui' % os.path.splitext( attrs['ui'] = '%s.ui' % os.path.splitext(
attrs['__module__'].split('.')[-1] attrs['__module__'].split('.')[-1]
)[0] )[0]
# Turn certain class methods into properties and classmethods # if parents[0] == QtCore.QObject: else:
for key in ('error', 'properties', 'audio'): decorate = ('names', 'error', 'audio', 'command', 'properties')
# Auto-decorate methods
for key in decorate:
if key not in attrs: if key not in attrs:
continue continue
attrs[key] = property(attrs[key])
for key in ('names'): if key in ('names'):
if key not in attrs: attrs[key] = classmethod(attrs[key])
continue
attrs[key] = classmethod(key)
# Do not apply these mutations to the base class if key in ('audio'):
if parents[0] != QtCore.QObject: attrs[key] = property(attrs[key])
attrs['command'] = commandWrapper(attrs['command'])
if key == 'command':
attrs[key] = commandWrapper(attrs[key])
if key == 'properties':
attrs[key] = propertiesWrapper(attrs[key])
if key == 'error':
attrs[key] = errorWrapper(attrs[key])
# Turn version string into a number # Turn version string into a number
try: try:
@ -83,13 +110,13 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
name = 'Component' name = 'Component'
# ui = 'nameOfNonDefaultUiFile' # ui = 'nameOfNonDefaultUiFile'
version = '1.0.0' version = '1.0.0'
# The major version (before the first dot) 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. # preset compatibility; the rest is ignored so it can be non-numeric.
modified = QtCore.pyqtSignal(int, dict) modified = QtCore.pyqtSignal(int, dict)
# ^ Signal used to tell core program that the component state changed, _error = QtCore.pyqtSignal(str, str)
# you shouldn't need to use this directly, it is used by self.update()
def __init__(self, moduleIndex, compPos, core): def __init__(self, moduleIndex, compPos, core):
super().__init__() super().__init__()
@ -100,6 +127,9 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
self._trackedWidgets = {} self._trackedWidgets = {}
self._presetNames = {} self._presetNames = {}
self._commandArgs = {}
self._lockedProperties = None
self._lockedError = None
# Stop lengthy processes in response to this variable # Stop lengthy processes in response to this variable
self.canceled = False self.canceled = False
@ -127,6 +157,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
def error(self): def error(self):
''' '''
Return a string containing an error message, or None for a default. Return a string containing an error message, or None for a default.
Or tuple of two strings for a message with details.
''' '''
return return
@ -141,12 +172,6 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
https://ffmpeg.org/ffmpeg-filters.html https://ffmpeg.org/ffmpeg-filters.html
''' '''
def names():
'''
Alternative names for renaming a component between project files.
'''
return []
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
# Methods # Methods
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
@ -181,15 +206,29 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
for widget in widgets['comboBox']: for widget in widgets['comboBox']:
widget.currentIndexChanged.connect(self.update) widget.currentIndexChanged.connect(self.update)
def trackWidgets(self, trackDict, presetNames=None): def trackWidgets(self, trackDict, **kwargs):
''' '''
Name widgets to track in update(), savePreset(), and loadPreset() Name widgets to track in update(), savePreset(), loadPreset(), and
Accepts a dict with attribute names as keys and widgets as values. command(). Requires a dict of attr names as keys, widgets as values
Optional: a dict of attribute names to map to preset variable names
Optional args:
'presetNames': preset variable names to replace attr names
'commandArgs': arg keywords that differ from attr names
NOTE: Any kwarg key set to None will selectively disable tracking.
''' '''
self._trackedWidgets = trackDict self._trackedWidgets = trackDict
if type(presetNames) is dict: for kwarg in kwargs:
self._presetNames = presetNames try:
if kwarg in ('presetNames', 'commandArgs'):
setattr(self, '_%s' % kwarg, kwargs[kwarg])
else:
raise BadComponentInit(
self,
'Nonsensical keywords to trackWidgets.',
immediate=True)
except BadComponentInit:
continue
def update(self): def update(self):
''' '''
@ -277,6 +316,22 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
self.commandHelp() self.commandHelp()
quit(0) quit(0)
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
# "Private" Methods
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
def lockProperties(self, propList):
self._lockedProperties = propList
def lockError(self, msg):
self._lockedError = msg
def unlockProperties(self):
self._lockedProperties = None
def unlockError(self):
self._lockedError = None
def loadUi(self, filename): def loadUi(self, filename):
'''Load a Qt Designer ui file to use for this component's widget''' '''Load a Qt Designer ui file to use for this component's widget'''
return uic.loadUi(os.path.join(self.core.componentsPath, filename)) return uic.loadUi(os.path.join(self.core.componentsPath, filename))
@ -287,6 +342,8 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
def reset(self): def reset(self):
self.canceled = False self.canceled = False
self.unlockProperties()
self.unlockError()
''' '''
### Reference methods for creating a new component ### Reference methods for creating a new component
@ -309,16 +366,40 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
''' '''
class BadComponentInit(Exception): class BadComponentInit(AttributeError):
''' '''
General purpose exception that components can raise to indicate Indicates a Python error in constructing a component.
a Python issue with e.g., dynamic creation of instances or something. Raising this locks the component into an error state,
Decorative for now, may have future use for logging. and gives the MainWindow a traceback to display.
''' '''
def __init__(self, arg, name): def __init__(self, caller, name, immediate=False):
string = '''################################ from toolkit import formatTraceback
Mandatory argument "%s" not specified import sys
in %s instance initialization if sys.exc_info()[0] is not None:
###################################''' string = (
print(string % (arg, name)) "%s component's %s encountered %s %s." % (
quit() caller.__class__.name,
name,
'an' if any([
sys.exc_info()[0].__name__.startswith(vowel)
for vowel in ('A', 'I')
]) else 'a',
sys.exc_info()[0].__name__,
)
)
detail = formatTraceback(sys.exc_info()[2])
else:
string = name
detail = "Methods:\n%s" % (
"\n".join(
[m for m in dir(caller) if not m.startswith('_')]
)
)
if immediate:
caller.parent.showMessage(
msg=string, detail=detail, icon='Warning'
)
else:
caller.lockProperties(['error'])
caller.lockError((string, detail))

View File

@ -15,7 +15,7 @@ class Component(Component):
name = 'Classic Visualizer' name = 'Classic Visualizer'
version = '1.0.0' version = '1.0.0'
def names(): def names(*args):
return ['Original Audio Visualization'] return ['Original Audio Visualization']
def widget(self, *args): def widget(self, *args):

View File

@ -18,6 +18,8 @@ class Component(Component):
'chorus': self.page.checkBox_chorus, 'chorus': self.page.checkBox_chorus,
'delay': self.page.spinBox_delay, 'delay': self.page.spinBox_delay,
'volume': self.page.spinBox_volume, 'volume': self.page.spinBox_volume,
}, commandArgs={
'sound': None,
}) })
def previewRender(self, previewWorker): def previewRender(self, previewWorker):

View File

@ -14,7 +14,7 @@ from toolkit import openPipe, checkOutput
class Video: class Video:
'''Video Component Frame-Fetcher''' '''Opens a pipe to ffmpeg and stores a buffer of raw video frames.'''
def __init__(self, **kwargs): def __init__(self, **kwargs):
mandatoryArgs = [ mandatoryArgs = [
'ffmpeg', # path to ffmpeg, usually self.core.FFMPEG_BIN 'ffmpeg', # path to ffmpeg, usually self.core.FFMPEG_BIN
@ -28,10 +28,7 @@ class Video:
'component', # component object 'component', # component object
] ]
for arg in mandatoryArgs: for arg in mandatoryArgs:
try: setattr(self, arg, kwargs[arg])
setattr(self, arg, kwargs[arg])
except KeyError:
raise BadComponentInit(arg, self.__doc__)
self.frameNo = -1 self.frameNo = -1
self.currentFrame = 'None' self.currentFrame = 'None'
@ -196,13 +193,16 @@ class Component(Component):
height = int(self.settings.value('outputHeight')) height = int(self.settings.value('outputHeight'))
self.blankFrame_ = BlankFrame(width, height) self.blankFrame_ = BlankFrame(width, height)
self.updateChunksize(width, height) self.updateChunksize(width, height)
self.video = Video( try:
ffmpeg=self.core.FFMPEG_BIN, videoPath=self.videoPath, self.video = Video(
width=width, height=height, chunkSize=self.chunkSize, ffmpeg=self.core.FFMPEG_BIN, #videoPath=self.videoPath,
frameRate=int(self.settings.value("outputFrameRate")), width=width, height=height, chunkSize=self.chunkSize,
parent=self.parent, loopVideo=self.loopVideo, frameRate=int(self.settings.value("outputFrameRate")),
component=self, scale=self.scale parent=self.parent, loopVideo=self.loopVideo,
) if os.path.exists(self.videoPath) else None component=self, scale=self.scale
) if os.path.exists(self.videoPath) else None
except KeyError:
raise BadComponentInit(self, 'Frame Fetcher initialization')
def frameRender(self, layerNo, frameNo): def frameRender(self, layerNo, frameNo):
if self.video: if self.video:

View File

@ -22,13 +22,12 @@ class Core:
''' '''
def __init__(self): def __init__(self):
self.findComponents() self.importComponents()
self.selectedComponents = [] self.selectedComponents = []
self.savedPresets = {} # copies of presets to detect modification self.savedPresets = {} # copies of presets to detect modification
self.openingProject = False self.openingProject = False
def findComponents(self): def importComponents(self):
'''Imports all the component modules'''
def findComponents(): def findComponents():
for f in os.listdir(Core.componentsPath): for f in os.listdir(Core.componentsPath):
name, ext = os.path.splitext(f) name, ext = os.path.splitext(f)
@ -225,9 +224,8 @@ class Core:
return return
if hasattr(loader, 'createNewProject'): if hasattr(loader, 'createNewProject'):
loader.createNewProject(prompt=False) loader.createNewProject(prompt=False)
import traceback msg = '%s: %s\n\n' % (typ.__name__, value)
msg = '%s: %s\n\nTraceback:\n' % (typ.__name__, value) msg += toolkit.formatTraceback(tb)
msg += "\n".join(traceback.format_tb(tb))
loader.showMessage( loader.showMessage(
msg="Project file '%s' is corrupted." % filepath, msg="Project file '%s' is corrupted." % filepath,
showCancel=False, showCancel=False,

View File

@ -571,6 +571,15 @@ class MainWindow(QtWidgets.QMainWindow):
self.videoWorker.encoding.connect(self.changeEncodingStatus) self.videoWorker.encoding.connect(self.changeEncodingStatus)
self.createVideo.emit() self.createVideo.emit()
@QtCore.pyqtSlot(str, str)
def videoThreadError(self, msg, detail):
self.showMessage(
msg=msg,
detail=detail,
icon='Warning',
)
self.stopVideo()
def changeEncodingStatus(self, status): def changeEncodingStatus(self, status):
self.encoding = status self.encoding = status
if status: if status:
@ -675,6 +684,8 @@ class MainWindow(QtWidgets.QMainWindow):
# connect to signal that adds an asterisk when modified # connect to signal that adds an asterisk when modified
self.core.selectedComponents[index].modified.connect( self.core.selectedComponents[index].modified.connect(
self.updateComponentTitle) self.updateComponentTitle)
self.core.selectedComponents[index]._error.connect(
self.videoThreadError)
self.pages.insert(index, self.core.selectedComponents[index].page) self.pages.insert(index, self.core.selectedComponents[index].page)
stackedWidget.insertWidget(index, self.pages[index]) stackedWidget.insertWidget(index, self.pages[index])
@ -751,7 +762,7 @@ class MainWindow(QtWidgets.QMainWindow):
if mousePos > -1: if mousePos > -1:
change = (componentList.currentRow() - mousePos) * -1 change = (componentList.currentRow() - mousePos) * -1
else: else:
change = (componentList.count() - componentList.currentRow() -1) change = (componentList.count() - componentList.currentRow() - 1)
self.moveComponent(change) self.moveComponent(change)
def changeComponentWidget(self): def changeComponentWidget(self):
@ -936,7 +947,7 @@ class MainWindow(QtWidgets.QMainWindow):
if event.type() == QtCore.QEvent.WindowActivate \ if event.type() == QtCore.QEvent.WindowActivate \
or event.type() == QtCore.QEvent.FocusIn: or event.type() == QtCore.QEvent.FocusIn:
Core.windowHasFocus = True Core.windowHasFocus = True
elif event.type()== QtCore.QEvent.WindowDeactivate \ elif event.type() == QtCore.QEvent.WindowDeactivate \
or event.type() == QtCore.QEvent.FocusOut: or event.type() == QtCore.QEvent.FocusOut:
Core.windowHasFocus = False Core.windowHasFocus = False
return False return False

View File

@ -107,3 +107,11 @@ def rgbFromString(string):
return tup return tup
except: except:
return (255, 255, 255) return (255, 255, 255)
def formatTraceback(tb=None):
import traceback
if tb is None:
import sys
tb = sys.exc_info()[2]
return 'Traceback:\n%s' % "\n".join(traceback.format_tb(tb))

View File

@ -103,7 +103,7 @@ def createFfmpegCommand(inputFile, outputFile, components, duration=-1):
globalFilters = 0 # increase to add global filters globalFilters = 0 # increase to add global filters
extraAudio = [ extraAudio = [
comp.audio for comp in components comp.audio for comp in components
if 'audio' in comp.properties if 'audio' in comp.properties()
] ]
if extraAudio or globalFilters > 0: if extraAudio or globalFilters > 0:
# Add -i options for extra input files # Add -i options for extra input files

View File

@ -18,7 +18,7 @@ from threading import Thread, Event
import time import time
import signal import signal
import core from component import BadComponentInit
from toolkit import openPipe from toolkit import openPipe
from toolkit.ffmpeg import readAudioFile, createFfmpegCommand from toolkit.ffmpeg import readAudioFile, createFfmpegCommand
from toolkit.frame import Checkerboard from toolkit.frame import Checkerboard
@ -105,8 +105,7 @@ class Worker(QtCore.QObject):
while not self.stopped: while not self.stopped:
audioI, frame = self.previewQueue.get() audioI, frame = self.previewQueue.get()
if core.Core.windowHasFocus \ if time.time() - self.lastPreview >= 0.06 or audioI == 0:
and time.time() - self.lastPreview >= 0.06 or audioI == 0:
image = Image.alpha_composite(background.copy(), frame) image = Image.alpha_composite(background.copy(), frame)
self.imageCreated.emit(QtGui.QImage(ImageQt(image))) self.imageCreated.emit(QtGui.QImage(ImageQt(image)))
self.lastPreview = time.time() self.lastPreview = time.time()
@ -153,39 +152,48 @@ class Worker(QtCore.QObject):
])) ]))
self.staticComponents = {} self.staticComponents = {}
for compNo, comp in enumerate(reversed(self.components)): for compNo, comp in enumerate(reversed(self.components)):
comp.preFrameRender( try:
worker=self, comp.preFrameRender(
completeAudioArray=self.completeAudioArray, worker=self,
sampleSize=self.sampleSize, completeAudioArray=self.completeAudioArray,
progressBarUpdate=self.progressBarUpdate, sampleSize=self.sampleSize,
progressBarSetText=self.progressBarSetText progressBarUpdate=self.progressBarUpdate,
) progressBarSetText=self.progressBarSetText
)
except BadComponentInit:
pass
if 'error' in comp.properties: if 'error' in comp.properties():
self.cancel() self.cancel()
self.canceled = True self.canceled = True
canceledByComponent = True canceledByComponent = True
errMsg = "Component #%s encountered an error!" % compNo \ compError = comp.error() \
if comp.error is None else 'Component #%s (%s): %s' % ( if type(comp.error()) is tuple else (comp.error(), '')
errMsg = (
"Component #%s encountered an error!" % compNo
if comp.error() is None else
'Export cancelled by component #%s (%s): %s' % (
str(compNo), str(compNo),
str(comp), str(comp),
comp.error compError[0]
)
self.parent.showMessage(
msg=errMsg,
icon='Warning',
parent=None # MainWindow is in a different thread
) )
)
comp._error.emit(errMsg, compError[1])
break break
if 'static' in comp.properties: if 'static' in comp.properties():
self.staticComponents[compNo] = \ self.staticComponents[compNo] = \
comp.frameRender(compNo, 0).copy() comp.frameRender(compNo, 0).copy()
if self.canceled: if self.canceled:
if canceledByComponent: if canceledByComponent:
print('Export cancelled by component #%s (%s): %s' % ( print('Export cancelled by component #%s (%s): %s' % (
compNo, str(comp), comp.error compNo,
)) comp.name,
'No message.' if comp.error() is None else (
comp.error() if type(comp.error()) is str
else comp.error()[0])
)
)
self.cancelExport() self.cancelExport()
return return