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
import os
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='):
from presetmanager import getPresetDir
_, preset = arg.split('=', 1)
path = os.path.join(getPresetDir(self), preset)
if not os.path.exists(path):
@ -29,6 +28,26 @@ def commandWrapper(func):
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)):
'''
Checks the validity of each Component class imported, and
@ -37,25 +56,33 @@ class ComponentMetaclass(type(QtCore.QObject)):
'''
def __new__(cls, name, parents, 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['__module__'].split('.')[-1]
)[0]
# Turn certain class methods into properties and classmethods
for key in ('error', 'properties', 'audio'):
# if parents[0] == QtCore.QObject: else:
decorate = ('names', 'error', 'audio', 'command', 'properties')
# Auto-decorate methods
for key in decorate:
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)
if key in ('names'):
attrs[key] = classmethod(attrs[key])
# Do not apply these mutations to the base class
if parents[0] != QtCore.QObject:
attrs['command'] = commandWrapper(attrs['command'])
if key in ('audio'):
attrs[key] = property(attrs[key])
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
try:
@ -83,13 +110,13 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
name = 'Component'
# ui = 'nameOfNonDefaultUiFile'
version = '1.0.0'
# The major version (before the first dot) is used to determine
# preset compatibility; the rest is ignored so it can be non-numeric.
modified = QtCore.pyqtSignal(int, dict)
# ^ 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()
_error = QtCore.pyqtSignal(str, str)
def __init__(self, moduleIndex, compPos, core):
super().__init__()
@ -100,6 +127,9 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
self._trackedWidgets = {}
self._presetNames = {}
self._commandArgs = {}
self._lockedProperties = None
self._lockedError = None
# Stop lengthy processes in response to this variable
self.canceled = False
@ -127,6 +157,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
def error(self):
'''
Return a string containing an error message, or None for a default.
Or tuple of two strings for a message with details.
'''
return
@ -141,12 +172,6 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
https://ffmpeg.org/ffmpeg-filters.html
'''
def names():
'''
Alternative names for renaming a component between project files.
'''
return []
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
# Methods
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
@ -181,15 +206,29 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
for widget in widgets['comboBox']:
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()
Accepts a dict with attribute names as keys and widgets as values.
Optional: a dict of attribute names to map to preset variable names
Name widgets to track in update(), savePreset(), loadPreset(), and
command(). Requires a dict of attr names as keys, widgets as values
Optional args:
'presetNames': preset variable names to replace attr names
'commandArgs': arg keywords that differ from attr names
NOTE: Any kwarg key set to None will selectively disable tracking.
'''
self._trackedWidgets = trackDict
if type(presetNames) is dict:
self._presetNames = presetNames
for kwarg in kwargs:
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):
'''
@ -277,6 +316,22 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
self.commandHelp()
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):
'''Load a Qt Designer ui file to use for this component's widget'''
return uic.loadUi(os.path.join(self.core.componentsPath, filename))
@ -287,6 +342,8 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass):
def reset(self):
self.canceled = False
self.unlockProperties()
self.unlockError()
'''
### 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
a Python issue with e.g., dynamic creation of instances or something.
Decorative for now, may have future use for logging.
Indicates a Python error in constructing a component.
Raising this locks the component into an error state,
and gives the MainWindow a traceback to display.
'''
def __init__(self, arg, name):
string = '''################################
Mandatory argument "%s" not specified
in %s instance initialization
###################################'''
print(string % (arg, name))
quit()
def __init__(self, caller, name, immediate=False):
from toolkit import formatTraceback
import sys
if sys.exc_info()[0] is not None:
string = (
"%s component's %s encountered %s %s." % (
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'
version = '1.0.0'
def names():
def names(*args):
return ['Original Audio Visualization']
def widget(self, *args):

View File

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

View File

@ -14,7 +14,7 @@ from toolkit import openPipe, checkOutput
class Video:
'''Video Component Frame-Fetcher'''
'''Opens a pipe to ffmpeg and stores a buffer of raw video frames.'''
def __init__(self, **kwargs):
mandatoryArgs = [
'ffmpeg', # path to ffmpeg, usually self.core.FFMPEG_BIN
@ -28,10 +28,7 @@ class Video:
'component', # component object
]
for arg in mandatoryArgs:
try:
setattr(self, arg, kwargs[arg])
except KeyError:
raise BadComponentInit(arg, self.__doc__)
setattr(self, arg, kwargs[arg])
self.frameNo = -1
self.currentFrame = 'None'
@ -196,13 +193,16 @@ class Component(Component):
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,
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
try:
self.video = Video(
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,
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):
if self.video:

View File

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

View File

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

View File

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

View File

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

View File

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