better component error messages
fatal errors cancel the export instead of crashing
This commit is contained in:
parent
bf0890e7c8
commit
d38109453c
155
src/component.py
155
src/component.py
|
@ -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
|
||||
|
||||
if key in ('names'):
|
||||
attrs[key] = classmethod(attrs[key])
|
||||
|
||||
if key in ('audio'):
|
||||
attrs[key] = property(attrs[key])
|
||||
|
||||
for key in ('names'):
|
||||
if key not in attrs:
|
||||
continue
|
||||
attrs[key] = classmethod(key)
|
||||
if key == 'command':
|
||||
attrs[key] = commandWrapper(attrs[key])
|
||||
|
||||
# Do not apply these mutations to the base class
|
||||
if parents[0] != QtCore.QObject:
|
||||
attrs['command'] = commandWrapper(attrs['command'])
|
||||
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))
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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__)
|
||||
|
||||
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)
|
||||
try:
|
||||
self.video = Video(
|
||||
ffmpeg=self.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,
|
||||
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:
|
||||
|
|
10
src/core.py
10
src/core.py
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,6 +152,7 @@ class Worker(QtCore.QObject):
|
|||
]))
|
||||
self.staticComponents = {}
|
||||
for compNo, comp in enumerate(reversed(self.components)):
|
||||
try:
|
||||
comp.preFrameRender(
|
||||
worker=self,
|
||||
completeAudioArray=self.completeAudioArray,
|
||||
|
@ -160,32 +160,40 @@ class Worker(QtCore.QObject):
|
|||
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
|
||||
compError[0]
|
||||
)
|
||||
self.parent.showMessage(
|
||||
msg=errMsg,
|
||||
icon='Warning',
|
||||
parent=None # MainWindow is in a different thread
|
||||
)
|
||||
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
|
||||
|
||||
|
|
Reference in New Issue