kopia lustrzana https://github.com/corrscope/corrscope
commit
5efd7a85f0
|
|
@ -18,7 +18,7 @@ class ChannelConfig:
|
|||
wav_path: str
|
||||
|
||||
# Supplying a dict inherits attributes from global trigger.
|
||||
trigger: Union[ITriggerConfig, dict, None] = None # TODO test channel-specific triggers
|
||||
trigger: Union[ITriggerConfig, dict, None] = attr.Factory(dict) # TODO test channel-specific triggers
|
||||
# Multiplies how wide the window is, in milliseconds.
|
||||
trigger_width: Optional[int] = None
|
||||
render_width: Optional[int] = None
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import click
|
|||
from ovgenpy.channel import ChannelConfig
|
||||
from ovgenpy.config import yaml
|
||||
from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig
|
||||
from ovgenpy.ovgenpy import default_config, Config, Ovgen
|
||||
from ovgenpy.ovgenpy import default_config, Ovgen, Config, Arguments
|
||||
|
||||
|
||||
Folder = click.Path(exists=True, file_okay=False)
|
||||
|
|
@ -36,12 +36,18 @@ YAML_NAME = YAML_EXTS[0]
|
|||
VIDEO_NAME = '.mp4'
|
||||
|
||||
|
||||
def get_path(audio_file: Union[None, str, Path], ext: str) -> Path:
|
||||
DEFAULT_NAME = 'ovgenpy'
|
||||
def get_name(audio_file: Union[None, str, Path]) -> str:
|
||||
# Write file to current working dir, not audio dir.
|
||||
if audio_file:
|
||||
name = Path(audio_file).name
|
||||
name = Path(audio_file).stem
|
||||
else:
|
||||
name = 'ovgenpy'
|
||||
name = DEFAULT_NAME
|
||||
return name
|
||||
|
||||
|
||||
def get_path(audio_file: Union[None, str, Path], ext: str) -> Path:
|
||||
name = get_name(audio_file)
|
||||
|
||||
# Add extension
|
||||
return Path(name).with_suffix(ext)
|
||||
|
|
@ -103,7 +109,7 @@ def main(
|
|||
# Create cfg: Config object.
|
||||
cfg: Optional[Config] = None
|
||||
cfg_path: Optional[Path] = None
|
||||
cfg_dir: Optional[str] = None # Changing to Path will take a lot of refactoring.
|
||||
cfg_dir: Optional[str] = None
|
||||
|
||||
wav_list: List[Path] = []
|
||||
for name in files:
|
||||
|
|
@ -145,6 +151,7 @@ def main(
|
|||
wav_list += matches
|
||||
|
||||
if not cfg:
|
||||
# cfg and cfg_dir are always initialized together.
|
||||
channels = [ChannelConfig(str(wav_path)) for wav_path in wav_list]
|
||||
|
||||
cfg = default_config(
|
||||
|
|
@ -157,7 +164,9 @@ def main(
|
|||
cfg_dir = '.'
|
||||
|
||||
if show_gui:
|
||||
raise click.UsageError('GUI not implemented')
|
||||
from ovgenpy import gui
|
||||
gui.gui_main(cfg, cfg_path)
|
||||
|
||||
else:
|
||||
if not files:
|
||||
raise click.UsageError('Must specify files or folders to play')
|
||||
|
|
@ -176,7 +185,9 @@ def main(
|
|||
|
||||
if outputs:
|
||||
assert Ovgen # to prevent PyCharm from deleting the import
|
||||
command = 'Ovgen(cfg, cfg_dir, outputs).play()'
|
||||
arg = Arguments(cfg_dir=cfg_dir, outputs=outputs)
|
||||
# TODO make it a lambda
|
||||
command = 'Ovgen(cfg, arg).play()'
|
||||
if profile:
|
||||
import cProfile
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from io import StringIO
|
||||
from typing import ClassVar, TYPE_CHECKING, Type
|
||||
import pickle
|
||||
from io import StringIO, BytesIO
|
||||
from typing import ClassVar, TYPE_CHECKING, Type, TypeVar
|
||||
|
||||
import attr
|
||||
from ruamel.yaml import yaml_object, YAML, Representer
|
||||
|
|
@ -8,7 +9,7 @@ if TYPE_CHECKING:
|
|||
from enum import Enum
|
||||
|
||||
|
||||
__all__ = ['yaml',
|
||||
__all__ = ['yaml', 'copy_config',
|
||||
'register_config', 'kw_config', 'Alias', 'Ignored', 'register_enum',
|
||||
'OvgenError', 'OvgenWarning']
|
||||
|
||||
|
|
@ -32,6 +33,38 @@ yaml = MyYAML()
|
|||
_yaml_loadable = yaml_object(yaml)
|
||||
|
||||
|
||||
"""
|
||||
Speed of copying objects:
|
||||
|
||||
number = 100
|
||||
print(timeit.timeit(lambda: f(cfg), number=number))
|
||||
|
||||
- pickle_copy 0.0566s
|
||||
- deepcopy 0.0967s
|
||||
- yaml_copy 0.4875s
|
||||
|
||||
pickle_copy is fastest.
|
||||
|
||||
According to https://stackoverflow.com/questions/1410615/ ,
|
||||
pickle is faster, but less general (works fine for @register_config objects).
|
||||
"""
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
# Unused
|
||||
# def yaml_copy(obj: T) -> T:
|
||||
# with StringIO() as stream:
|
||||
# yaml.dump(obj, stream)
|
||||
# return yaml.load(stream)
|
||||
|
||||
# AKA pickle_copy
|
||||
def copy_config(obj: T) -> T:
|
||||
with BytesIO() as stream:
|
||||
pickle.dump(obj, stream)
|
||||
stream.seek(0)
|
||||
return pickle.load(stream)
|
||||
|
||||
|
||||
# Setup configuration load/dump infrastructure.
|
||||
|
||||
def register_config(cls=None, *, kw_only=False, always_dump: str = ''):
|
||||
|
|
@ -77,7 +110,13 @@ class _ConfigMixin:
|
|||
cls = type(self)
|
||||
|
||||
for field in attr.fields(cls):
|
||||
# Skip deprecated fields with leading underscores.
|
||||
# They have already been baked into other config fields.
|
||||
|
||||
name = field.name
|
||||
if name[0] == '_':
|
||||
continue
|
||||
|
||||
value = getattr(self, name)
|
||||
|
||||
if dump_all or name in always_dump:
|
||||
|
|
@ -109,7 +148,7 @@ class _ConfigMixin:
|
|||
if isinstance(class_var, Alias):
|
||||
target = class_var.key
|
||||
if target in state:
|
||||
raise TypeError(
|
||||
raise OvgenError(
|
||||
f'{type(self).__name__} received both Alias {key} and '
|
||||
f'equivalent {target}'
|
||||
)
|
||||
|
|
@ -160,4 +199,3 @@ class OvgenWarning(UserWarning):
|
|||
""" Warning about deprecated end-user config (YAML/GUI).
|
||||
(Should be) caught by GUI and displayed to user. """
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
# C++ objects and libs
|
||||
*.slo
|
||||
*.lo
|
||||
*.o
|
||||
*.a
|
||||
*.la
|
||||
*.lai
|
||||
*.so
|
||||
*.dll
|
||||
*.dylib
|
||||
|
||||
# Qt-es
|
||||
object_script.*.Release
|
||||
object_script.*.Debug
|
||||
*_plugin_import.cpp
|
||||
/.qmake.cache
|
||||
/.qmake.stash
|
||||
*.pro.user
|
||||
*.pro.user.*
|
||||
*.qbs.user
|
||||
*.qbs.user.*
|
||||
*.moc
|
||||
moc_*.cpp
|
||||
moc_*.h
|
||||
qrc_*.cpp
|
||||
ui_*.h
|
||||
*.qmlc
|
||||
*.jsc
|
||||
Makefile*
|
||||
*build-*
|
||||
|
||||
# Qt unit tests
|
||||
target_wrapper.*
|
||||
|
||||
# QtCreator
|
||||
*.autosave
|
||||
|
||||
# QtCreator Qml
|
||||
*.qmlproject.user
|
||||
*.qmlproject.user.*
|
||||
|
||||
# QtCreator CMake
|
||||
CMakeLists.txt.user*
|
||||
|
|
@ -0,0 +1,775 @@
|
|||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import *
|
||||
from typing import List, Any
|
||||
|
||||
import PyQt5.QtCore as qc
|
||||
import PyQt5.QtWidgets as qw
|
||||
import attr
|
||||
from PyQt5 import uic
|
||||
from PyQt5.QtCore import QModelIndex, Qt
|
||||
from PyQt5.QtGui import QKeySequence, QFont, QCloseEvent
|
||||
from PyQt5.QtWidgets import QShortcut
|
||||
|
||||
from ovgenpy import cli
|
||||
from ovgenpy.channel import ChannelConfig
|
||||
from ovgenpy.config import OvgenError, copy_config, yaml
|
||||
from ovgenpy.gui.data_bind import PresentationModel, map_gui, behead, rgetattr, rsetattr
|
||||
from ovgenpy.gui.util import color2hex, Locked, get_save_with_ext, find_ranges
|
||||
from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig
|
||||
from ovgenpy.ovgenpy import Ovgen, Config, Arguments, default_config
|
||||
from ovgenpy.triggers import CorrelationTriggerConfig, ITriggerConfig
|
||||
from ovgenpy.util import obj_name
|
||||
|
||||
FILTER_WAV_FILES = "WAV files (*.wav)"
|
||||
|
||||
APP_NAME = 'ovgenpy'
|
||||
APP_DIR = Path(__file__).parent
|
||||
|
||||
def res(file: str) -> str:
|
||||
return str(APP_DIR / file)
|
||||
|
||||
|
||||
def gui_main(cfg: Config, cfg_path: Optional[Path]):
|
||||
# TODO read config within MainWindow, and show popup if loading fails.
|
||||
# qw.QApplication.setStyle('fusion')
|
||||
QApp = qw.QApplication
|
||||
QApp.setAttribute(qc.Qt.AA_EnableHighDpiScaling)
|
||||
|
||||
# Qt on Windows will finally switch default font to lfMessageFont=Segoe UI
|
||||
# (Vista, 2006)... in 2020 (Qt 6.0).
|
||||
if qc.QSysInfo.kernelType() == 'winnt':
|
||||
# This will be wrong for non-English languages, but it's better than default?
|
||||
font = QFont("Segoe UI", 9)
|
||||
font.setStyleHint(QFont.SansSerif)
|
||||
QApp.setFont(font)
|
||||
|
||||
app = qw.QApplication(sys.argv)
|
||||
window = MainWindow(cfg, cfg_path)
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
class MainWindow(qw.QMainWindow):
|
||||
"""
|
||||
Main window.
|
||||
|
||||
Control flow:
|
||||
__init__
|
||||
load_cfg
|
||||
|
||||
# Opening a document
|
||||
load_cfg
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: Config, cfg_path: Optional[Path]):
|
||||
super().__init__()
|
||||
|
||||
# Load UI.
|
||||
uic.loadUi(res('mainwindow.ui'), self) # sets windowTitle
|
||||
|
||||
# Bind UI buttons, etc. Functions block main thread, avoiding race conditions.
|
||||
self.master_audio_browse.clicked.connect(self.on_master_audio_browse)
|
||||
|
||||
self.channelUp.add_shortcut(self.channelsGroup, 'ctrl+shift+up')
|
||||
self.channelDown.add_shortcut(self.channelsGroup, 'ctrl+shift+down')
|
||||
|
||||
self.channelUp.clicked.connect(self.channel_view.on_channel_up)
|
||||
self.channelDown.clicked.connect(self.channel_view.on_channel_down)
|
||||
self.channelAdd.clicked.connect(self.on_channel_add)
|
||||
self.channelDelete.clicked.connect(self.on_channel_delete)
|
||||
|
||||
# Bind actions.
|
||||
self.actionNew.triggered.connect(self.on_action_new)
|
||||
self.actionOpen.triggered.connect(self.on_action_open)
|
||||
self.actionSave.triggered.connect(self.on_action_save)
|
||||
self.actionSaveAs.triggered.connect(self.on_action_save_as)
|
||||
self.actionPlay.triggered.connect(self.on_action_play)
|
||||
self.actionRender.triggered.connect(self.on_action_render)
|
||||
self.actionExit.triggered.connect(qw.QApplication.closeAllWindows)
|
||||
|
||||
# Initialize ovgen-thread attribute.
|
||||
self.ovgen_thread: Locked[Optional[OvgenThread]] = Locked(None)
|
||||
|
||||
# Bind config to UI.
|
||||
self.load_cfg(cfg, cfg_path)
|
||||
|
||||
self.show()
|
||||
|
||||
# Config models
|
||||
_cfg_path: Optional[Path]
|
||||
|
||||
# Whether document is dirty, changed, has unsaved changes
|
||||
_any_unsaved: bool
|
||||
|
||||
@property
|
||||
def any_unsaved(self) -> bool:
|
||||
return self._any_unsaved
|
||||
|
||||
@any_unsaved.setter
|
||||
def any_unsaved(self, value: bool):
|
||||
self._any_unsaved = value
|
||||
self._update_unsaved_title()
|
||||
|
||||
model: Optional['ConfigModel'] = None
|
||||
channel_model: 'ChannelModel'
|
||||
channel_view: 'ChannelTableView'
|
||||
channelsGroup: qw.QGroupBox
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
"""Called on closing window."""
|
||||
if self.prompt_save():
|
||||
event.accept()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def on_action_new(self):
|
||||
if not self.prompt_save():
|
||||
return
|
||||
cfg = default_config()
|
||||
self.load_cfg(cfg, None)
|
||||
|
||||
def on_action_open(self):
|
||||
if not self.prompt_save():
|
||||
return
|
||||
name, file_type = qw.QFileDialog.getOpenFileName(
|
||||
self, "Open config", self.cfg_dir, "YAML files (*.yaml)"
|
||||
)
|
||||
if name != '':
|
||||
cfg_path = Path(name)
|
||||
try:
|
||||
# Raises YAML structural exceptions
|
||||
cfg = yaml.load(cfg_path)
|
||||
# Raises color getter exceptions
|
||||
# ISSUE: catching an exception will leave UI in undefined state?
|
||||
self.load_cfg(cfg, cfg_path)
|
||||
except Exception as e:
|
||||
qw.QMessageBox.critical(self, 'Error loading file', str(e))
|
||||
return
|
||||
|
||||
def prompt_save(self) -> bool:
|
||||
"""
|
||||
Called when user is closing document
|
||||
(when opening a new document or closing the app).
|
||||
|
||||
:return: False if user cancels close-document action.
|
||||
"""
|
||||
if not self.any_unsaved:
|
||||
return True
|
||||
|
||||
Msg = qw.QMessageBox
|
||||
|
||||
save_message = f"Save changes to {self.title_cache}?"
|
||||
should_close = Msg.question(
|
||||
self, "Save Changes?", save_message,
|
||||
Msg.Save | Msg.Discard | Msg.Cancel
|
||||
)
|
||||
|
||||
if should_close == Msg.Cancel:
|
||||
return False
|
||||
elif should_close == Msg.Discard:
|
||||
return True
|
||||
else:
|
||||
return self.on_action_save()
|
||||
|
||||
def load_cfg(self, cfg: Config, cfg_path: Optional[Path]):
|
||||
self._cfg_path = cfg_path
|
||||
self._any_unsaved = False
|
||||
self.load_title()
|
||||
|
||||
if self.model is None:
|
||||
self.model = ConfigModel(cfg)
|
||||
# Calls self.on_gui_edited() whenever GUI widgets change.
|
||||
map_gui(self, self.model)
|
||||
else:
|
||||
self.model.set_cfg(cfg)
|
||||
|
||||
self.channel_model = ChannelModel(cfg.channels)
|
||||
# Calling setModel again disconnects previous model.
|
||||
self.channel_view.setModel(self.channel_model)
|
||||
self.channel_model.dataChanged.connect(self.on_gui_edited)
|
||||
|
||||
def on_gui_edited(self):
|
||||
self.any_unsaved = True
|
||||
|
||||
title_cache: str
|
||||
|
||||
def load_title(self):
|
||||
self.title_cache = self.title
|
||||
self._update_unsaved_title()
|
||||
|
||||
def _update_unsaved_title(self):
|
||||
if self.any_unsaved:
|
||||
undo_str = '*'
|
||||
else:
|
||||
undo_str = ''
|
||||
self.setWindowTitle(f'{self.title_cache}{undo_str} - {APP_NAME}')
|
||||
|
||||
# GUI actions, etc.
|
||||
master_audio_browse: qw.QPushButton
|
||||
channelAdd: 'ShortcutButton'
|
||||
channelDelete: 'ShortcutButton'
|
||||
channelUp: 'ShortcutButton'
|
||||
channelDown: 'ShortcutButton'
|
||||
# Loading mainwindow.ui changes menuBar from a getter to an attribute.
|
||||
menuBar: qw.QMenuBar
|
||||
actionNew: qw.QAction
|
||||
actionOpen: qw.QAction
|
||||
actionSave: qw.QAction
|
||||
actionSaveAs: qw.QAction
|
||||
actionPlay: qw.QAction
|
||||
actionRender: qw.QAction
|
||||
actionExit: qw.QAction
|
||||
|
||||
def on_master_audio_browse(self):
|
||||
# TODO add default file-open dir, initialized to yaml path and remembers prev
|
||||
# useless if people don't reopen old projects
|
||||
name, file_type = qw.QFileDialog.getOpenFileName(
|
||||
self, "Open master audio file", self.cfg_dir, FILTER_WAV_FILES
|
||||
)
|
||||
if name != '':
|
||||
master_audio = 'master_audio'
|
||||
self.model[master_audio] = name
|
||||
self.model.update_widget[master_audio]()
|
||||
|
||||
def on_channel_add(self):
|
||||
wavs, file_type = qw.QFileDialog.getOpenFileNames(
|
||||
self, "Add audio channels", self.cfg_dir, FILTER_WAV_FILES
|
||||
)
|
||||
if wavs:
|
||||
self.channel_view.append_channels(wavs)
|
||||
|
||||
def on_channel_delete(self):
|
||||
self.channel_view.delete_selected()
|
||||
|
||||
def on_action_save(self) -> bool:
|
||||
"""
|
||||
:return: False if user cancels save action.
|
||||
"""
|
||||
if self._cfg_path is None:
|
||||
return self.on_action_save_as()
|
||||
|
||||
yaml.dump(self.cfg, self._cfg_path)
|
||||
self.any_unsaved = False
|
||||
self._update_unsaved_title()
|
||||
return True
|
||||
|
||||
def on_action_save_as(self) -> bool:
|
||||
"""
|
||||
:return: False if user cancels save action.
|
||||
"""
|
||||
cfg_path_default = os.path.join(self.cfg_dir, self.file_stem) + cli.YAML_NAME
|
||||
|
||||
filters = ["YAML files (*.yaml)", "All files (*)"]
|
||||
path = get_save_with_ext(
|
||||
self, "Save As", cfg_path_default, filters, cli.YAML_NAME
|
||||
)
|
||||
if path:
|
||||
self._cfg_path = path
|
||||
self.load_title()
|
||||
self.on_action_save()
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def on_action_play(self):
|
||||
""" Launch ovgen and ffplay. """
|
||||
error_msg = 'Cannot play, another play/render is active'
|
||||
with self.ovgen_thread as t:
|
||||
if t is not None:
|
||||
self.ovgen_thread.unlock()
|
||||
qw.QMessageBox.critical(self, 'Error', error_msg)
|
||||
return
|
||||
|
||||
outputs = [FFplayOutputConfig()]
|
||||
self.play_thread(outputs, dlg=None)
|
||||
|
||||
def on_action_render(self):
|
||||
""" Get file name. Then show a progress dialog while rendering to file. """
|
||||
error_msg = 'Cannot render to file, another play/render is active'
|
||||
with self.ovgen_thread as t:
|
||||
if t is not None:
|
||||
self.ovgen_thread.unlock()
|
||||
qw.QMessageBox.critical(self, 'Error', error_msg)
|
||||
return
|
||||
|
||||
video_path = os.path.join(self.cfg_dir, self.file_stem) + cli.VIDEO_NAME
|
||||
filters = ["MP4 files (*.mp4)", "All files (*)"]
|
||||
path = get_save_with_ext(self, "Render to Video", video_path, filters,
|
||||
cli.VIDEO_NAME)
|
||||
if path:
|
||||
name = str(path)
|
||||
# FIXME what if missing mp4?
|
||||
dlg = OvgenProgressDialog(self, 'Rendering video')
|
||||
|
||||
outputs = [FFmpegOutputConfig(name)]
|
||||
self.play_thread(outputs, dlg)
|
||||
|
||||
def play_thread(self, outputs: List[IOutputConfig],
|
||||
dlg: Optional['OvgenProgressDialog']):
|
||||
""" self.ovgen_thread MUST be locked. """
|
||||
arg = self._get_args(outputs)
|
||||
if dlg:
|
||||
arg = attr.evolve(arg,
|
||||
on_begin=dlg.on_begin,
|
||||
progress=dlg.setValue,
|
||||
is_aborted=dlg.wasCanceled,
|
||||
on_end=dlg.reset, # TODO dlg.close
|
||||
)
|
||||
|
||||
cfg = copy_config(self.model.cfg)
|
||||
t = self.ovgen_thread.obj = OvgenThread(cfg, arg)
|
||||
t.error.connect(self.on_play_thread_error)
|
||||
t.finished.connect(self.on_play_thread_finished)
|
||||
t.start()
|
||||
|
||||
def on_play_thread_error(self, exc: BaseException):
|
||||
qw.QMessageBox.critical(self, 'Error rendering oscilloscope', str(exc))
|
||||
|
||||
def on_play_thread_finished(self):
|
||||
self.ovgen_thread.set(None)
|
||||
|
||||
def _get_args(self, outputs: List[IOutputConfig]):
|
||||
arg = Arguments(
|
||||
cfg_dir=self.cfg_dir,
|
||||
outputs=outputs,
|
||||
)
|
||||
return arg
|
||||
|
||||
# File paths
|
||||
@property
|
||||
def cfg_dir(self) -> str:
|
||||
maybe_path = self._cfg_path or self.cfg.master_audio
|
||||
if maybe_path:
|
||||
return str(Path(maybe_path).resolve().parent)
|
||||
|
||||
return '.'
|
||||
|
||||
UNTITLED = 'Untitled'
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
if self._cfg_path:
|
||||
return self._cfg_path.name
|
||||
return self.UNTITLED
|
||||
|
||||
@property
|
||||
def file_stem(self) -> str:
|
||||
return cli.get_name(self._cfg_path or self.cfg.master_audio)
|
||||
|
||||
@property
|
||||
def cfg(self):
|
||||
return self.model.cfg
|
||||
|
||||
|
||||
class ShortcutButton(qw.QPushButton):
|
||||
scoped_shortcut: QShortcut
|
||||
|
||||
def add_shortcut(self, scope: qw.QWidget, shortcut: str) -> None:
|
||||
""" Adds shortcut and tooltip. """
|
||||
keys = QKeySequence(shortcut, QKeySequence.PortableText)
|
||||
|
||||
self.scoped_shortcut = qw.QShortcut(keys, scope)
|
||||
self.scoped_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
|
||||
self.scoped_shortcut.activated.connect(self.click)
|
||||
|
||||
self.setToolTip(keys.toString(QKeySequence.NativeText))
|
||||
|
||||
|
||||
class OvgenThread(qc.QThread):
|
||||
def __init__(self, cfg: Config, arg: Arguments):
|
||||
qc.QThread.__init__(self)
|
||||
self.cfg = cfg
|
||||
self.arg = arg
|
||||
|
||||
def run(self) -> None:
|
||||
cfg = self.cfg
|
||||
arg = self.arg
|
||||
try:
|
||||
Ovgen(cfg, arg).play()
|
||||
except Exception as e:
|
||||
arg.on_end()
|
||||
self.error.emit(e)
|
||||
else:
|
||||
arg.on_end()
|
||||
|
||||
error = qc.pyqtSignal(Exception)
|
||||
|
||||
|
||||
class OvgenProgressDialog(qw.QProgressDialog):
|
||||
def __init__(self, parent: Optional[qw.QWidget], title: str):
|
||||
super().__init__(parent)
|
||||
self.setMinimumWidth(300)
|
||||
self.setWindowTitle(title)
|
||||
self.setLabelText('Progress:')
|
||||
|
||||
# If set to 0, the dialog is always shown as soon as any progress is set.
|
||||
self.setMinimumDuration(0)
|
||||
|
||||
# Don't reset when rendering is approximately finished.
|
||||
self.setAutoReset(False)
|
||||
|
||||
# Close after ovgen finishes.
|
||||
self.setAutoClose(True)
|
||||
|
||||
def on_begin(self, begin_time, end_time):
|
||||
self.setRange(int(round(begin_time)), int(round(end_time)))
|
||||
# self.setValue is called by Ovgen, on the first frame.
|
||||
|
||||
|
||||
def nrow_ncol_property(altered: str, unaltered: str) -> property:
|
||||
def get(self: 'ConfigModel'):
|
||||
val = getattr(self.cfg.layout, altered)
|
||||
if val is None:
|
||||
return 0
|
||||
else:
|
||||
return val
|
||||
|
||||
def set(self: 'ConfigModel', val: int):
|
||||
if val > 0:
|
||||
setattr(self.cfg.layout, altered, val)
|
||||
setattr(self.cfg.layout, unaltered, None)
|
||||
self.update_widget['layout__' + unaltered]()
|
||||
elif val == 0:
|
||||
setattr(self.cfg.layout, altered, None)
|
||||
else:
|
||||
raise OvgenError(f"invalid input: {altered} < 0, should never happen")
|
||||
|
||||
return property(get, set)
|
||||
|
||||
|
||||
def default_property(path: str, default):
|
||||
def getter(self: 'ConfigModel'):
|
||||
val = rgetattr(self.cfg, path)
|
||||
if val is None:
|
||||
return default
|
||||
else:
|
||||
return val
|
||||
|
||||
def setter(self: 'ConfigModel', val):
|
||||
rsetattr(self.cfg, path, val)
|
||||
|
||||
return property(getter, setter)
|
||||
|
||||
|
||||
def color2hex_property(path: str):
|
||||
def getter(self: 'ConfigModel'):
|
||||
color_attr = rgetattr(self.cfg, path)
|
||||
return color2hex(color_attr)
|
||||
|
||||
def setter(self: 'ConfigModel', val: str):
|
||||
color = color2hex(val)
|
||||
rsetattr(self.cfg, path, color)
|
||||
|
||||
return property(getter, setter)
|
||||
|
||||
|
||||
class ConfigModel(PresentationModel):
|
||||
cfg: Config
|
||||
combo_symbols = {}
|
||||
combo_text = {}
|
||||
|
||||
render__bg_color = color2hex_property('render__bg_color')
|
||||
render__init_line_color = color2hex_property('render__init_line_color')
|
||||
|
||||
@property
|
||||
def render_video_size(self) -> str:
|
||||
render = self.cfg.render
|
||||
w, h = render.width, render.height
|
||||
return f'{w}x{h}'
|
||||
|
||||
@render_video_size.setter
|
||||
def render_video_size(self, value: str):
|
||||
error = OvgenError(f"invalid video size {value}, must be WxH")
|
||||
|
||||
for sep in 'x*,':
|
||||
width_height = value.split(sep)
|
||||
if len(width_height) == 2:
|
||||
break
|
||||
else:
|
||||
raise error
|
||||
|
||||
render = self.cfg.render
|
||||
width, height = width_height
|
||||
try:
|
||||
render.width = int(width)
|
||||
render.height = int(height)
|
||||
except ValueError:
|
||||
raise error
|
||||
|
||||
layout__nrows = nrow_ncol_property('nrows', unaltered='ncols')
|
||||
layout__ncols = nrow_ncol_property('ncols', unaltered='nrows')
|
||||
combo_symbols['layout__orientation'] = ['h', 'v']
|
||||
combo_text['layout__orientation'] = ['Horizontal', 'Vertical']
|
||||
|
||||
render__line_width = default_property('render__line_width', 1.5)
|
||||
|
||||
|
||||
class ChannelTableView(qw.QTableView):
|
||||
def append_channels(self, wavs: List[str]):
|
||||
model: ChannelModel = self.model()
|
||||
|
||||
begin_row = model.rowCount()
|
||||
count_rows = len(wavs)
|
||||
|
||||
col = model.idx_of_key['wav_path']
|
||||
|
||||
model.insertRows(begin_row, count_rows)
|
||||
for row, wav_path in enumerate(wavs, begin_row):
|
||||
index = model.index(row, col)
|
||||
model.setData(index, wav_path)
|
||||
|
||||
def delete_selected(self):
|
||||
model: 'ChannelModel' = self.model()
|
||||
rows = self.selected_rows()
|
||||
row_ranges = find_ranges(rows)
|
||||
|
||||
for first_row, nrow in reversed(list(row_ranges)):
|
||||
model.removeRows(first_row, nrow)
|
||||
|
||||
def on_channel_up(self):
|
||||
self.move_selection(-1)
|
||||
|
||||
def on_channel_down(self):
|
||||
self.move_selection(1)
|
||||
|
||||
def move_selection(self, delta: int):
|
||||
model: 'ChannelModel' = self.model()
|
||||
rows = self.selected_rows()
|
||||
row_ranges = find_ranges(rows)
|
||||
|
||||
# If we hit the end, cancel all other moves.
|
||||
# If moving up, move top first.
|
||||
if delta > 0:
|
||||
# If moving down, move bottom first.
|
||||
row_ranges = reversed(list(row_ranges))
|
||||
|
||||
parent = qc.QModelIndex()
|
||||
for first_row, nrow in row_ranges:
|
||||
if delta > 0:
|
||||
dest_row = first_row + nrow + delta
|
||||
else:
|
||||
dest_row = first_row + delta
|
||||
|
||||
if not model.moveRows(parent, first_row, nrow, parent, dest_row):
|
||||
break
|
||||
|
||||
def selected_rows(self) -> List[int]:
|
||||
sel: qc.QItemSelectionModel = self.selectionModel()
|
||||
inds: List[qc.QModelIndex] = sel.selectedIndexes()
|
||||
rows: List[int] = sorted({ind.row() for ind in inds})
|
||||
return rows
|
||||
|
||||
|
||||
@attr.dataclass
|
||||
class Column:
|
||||
key: str
|
||||
cls: Type
|
||||
default: Any
|
||||
|
||||
def _display_name(self) -> str:
|
||||
return (self.key
|
||||
.replace('__', '\n')
|
||||
.replace('_', ' ')
|
||||
.title())
|
||||
display_name: str = attr.Factory(_display_name, takes_self=True)
|
||||
|
||||
|
||||
class ChannelModel(qc.QAbstractTableModel):
|
||||
""" Design based off
|
||||
https://doc.qt.io/qt-5/model-view-programming.html#a-read-only-example-model and
|
||||
https://doc.qt.io/qt-5/model-view-programming.html#model-subclassing-reference
|
||||
"""
|
||||
|
||||
def __init__(self, channels: List[ChannelConfig]):
|
||||
""" Mutates `channels` and `line_color` for convenience. """
|
||||
super().__init__()
|
||||
self.channels = channels
|
||||
|
||||
line_color = 'line_color'
|
||||
|
||||
for cfg in self.channels:
|
||||
t = cfg.trigger
|
||||
if isinstance(t, ITriggerConfig):
|
||||
if not isinstance(t, CorrelationTriggerConfig):
|
||||
raise OvgenError(
|
||||
f'Loading per-channel {obj_name(t)} not supported')
|
||||
trigger_dict = attr.asdict(t)
|
||||
else:
|
||||
trigger_dict = dict(t or {})
|
||||
|
||||
if line_color in trigger_dict:
|
||||
trigger_dict[line_color] = color2hex(trigger_dict[line_color])
|
||||
|
||||
cfg.trigger = trigger_dict
|
||||
|
||||
def triggers(self, row: int) -> dict:
|
||||
trigger = self.channels[row].trigger
|
||||
assert isinstance(trigger, dict)
|
||||
return trigger
|
||||
|
||||
# columns
|
||||
col_data = [
|
||||
Column('wav_path', str, '', 'WAV Path'),
|
||||
Column('trigger_width', int, None, 'Trigger Width ×'),
|
||||
Column('render_width', int, None, 'Render Width ×'),
|
||||
Column('line_color', str, None, 'Line Color'),
|
||||
# TODO move from table view to sidebar QDataWidgetMapper?
|
||||
Column('trigger__edge_strength', float, None),
|
||||
Column('trigger__responsiveness', float, None),
|
||||
Column('trigger__buffer_falloff', float, None),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _idx_of_key(col_data=col_data):
|
||||
return {
|
||||
col.key: idx
|
||||
for idx, col in enumerate(col_data)
|
||||
}
|
||||
idx_of_key = _idx_of_key.__func__()
|
||||
|
||||
def columnCount(self, parent: QModelIndex = ...) -> int:
|
||||
return len(self.col_data)
|
||||
|
||||
def headerData(self, section: int, orientation: Qt.Orientation,
|
||||
role=Qt.DisplayRole):
|
||||
if role == Qt.DisplayRole:
|
||||
if orientation == Qt.Horizontal:
|
||||
col = section
|
||||
try:
|
||||
return self.col_data[col].display_name
|
||||
except IndexError:
|
||||
return nope
|
||||
else:
|
||||
return str(section + 1)
|
||||
return nope
|
||||
|
||||
# rows
|
||||
def rowCount(self, parent: QModelIndex = ...) -> int:
|
||||
return len(self.channels)
|
||||
|
||||
# data
|
||||
TRIGGER = 'trigger__'
|
||||
|
||||
def data(self, index: QModelIndex, role=Qt.DisplayRole) -> qc.QVariant:
|
||||
col = index.column()
|
||||
row = index.row()
|
||||
|
||||
if role in [Qt.DisplayRole, Qt.EditRole] and index.isValid() and row < self.rowCount():
|
||||
data = self.col_data[col]
|
||||
key = data.key
|
||||
if key.startswith(self.TRIGGER):
|
||||
key = behead(key, self.TRIGGER)
|
||||
value = self.triggers(row).get(key, '')
|
||||
|
||||
else:
|
||||
value = getattr(self.channels[row], key)
|
||||
|
||||
if value == data.default:
|
||||
return ''
|
||||
if key == 'wav_path' and role == Qt.DisplayRole:
|
||||
if Path(value).parent != Path():
|
||||
return '...' + Path(value).name
|
||||
return str(value)
|
||||
|
||||
return nope
|
||||
|
||||
def setData(self, index: QModelIndex, value: str, role=Qt.EditRole) -> bool:
|
||||
col = index.column()
|
||||
row = index.row()
|
||||
|
||||
if index.isValid() and role == Qt.EditRole:
|
||||
# type(value) == str
|
||||
|
||||
data = self.col_data[col]
|
||||
key = data.key
|
||||
if value and not value.isspace():
|
||||
try:
|
||||
value = data.cls(value)
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
value = data.default
|
||||
|
||||
if key.startswith(self.TRIGGER):
|
||||
key = behead(key, self.TRIGGER)
|
||||
trigger = self.triggers(row)
|
||||
if value == data.default:
|
||||
# Delete key if (key: value) present
|
||||
trigger.pop(key, None)
|
||||
else:
|
||||
trigger[key] = value
|
||||
|
||||
else:
|
||||
setattr(self.channels[row], key, value)
|
||||
|
||||
self.dataChanged.emit(index, index, [role])
|
||||
return True
|
||||
return False
|
||||
|
||||
"""So if I understood it correctly you want to reorder the columns by moving the
|
||||
headers and then want to know how the view looks like. I believe ( 90% certain )
|
||||
when you reorder the headers it does not trigger any change in the model! and
|
||||
then if you just start printing the data of the model you will only see the data
|
||||
in the order how it was initially before you swapper/reordered some column with
|
||||
the header. """
|
||||
|
||||
def insertRows(self, row: int, count: int, parent=QModelIndex()) -> bool:
|
||||
if not (count >= 1 and 0 <= row <= len(self.channels)):
|
||||
return False
|
||||
|
||||
self.beginInsertRows(parent, row, row + count - 1)
|
||||
self.channels[row:row] = [ChannelConfig('') for _ in range(count)]
|
||||
self.endInsertRows()
|
||||
return True
|
||||
|
||||
def removeRows(self, row: int, count: int, parent=QModelIndex()) -> bool:
|
||||
nchan = len(self.channels)
|
||||
# row <= nchan for consistency.
|
||||
if not (count >= 1 and 0 <= row <= nchan and row + count <= nchan):
|
||||
return False
|
||||
|
||||
self.beginRemoveRows(parent, row, row + count - 1)
|
||||
del self.channels[row: row + count]
|
||||
self.endRemoveRows()
|
||||
return True
|
||||
|
||||
def moveRows(self,
|
||||
_sourceParent: QModelIndex, src_row: int, count: int,
|
||||
_destinationParent: QModelIndex, dest_row: int):
|
||||
nchan = len(self.channels)
|
||||
if not (count >= 1
|
||||
and 0 <= src_row <= nchan and src_row + count <= nchan
|
||||
and 0 <= dest_row <= nchan):
|
||||
return False
|
||||
|
||||
# If source and destination overlap, beginMoveRows returns False.
|
||||
if not self.beginMoveRows(
|
||||
_sourceParent, src_row, src_row + count - 1,
|
||||
_destinationParent, dest_row):
|
||||
return False
|
||||
|
||||
# We know source and destination do not overlap.
|
||||
src = slice(src_row, src_row + count)
|
||||
dest = slice(dest_row, dest_row)
|
||||
|
||||
if dest_row > src_row:
|
||||
# Move down: Insert dest, then remove src
|
||||
self.channels[dest] = self.channels[src]
|
||||
del self.channels[src]
|
||||
else:
|
||||
# Move up: Remove src, then insert dest.
|
||||
rows = self.channels[src]
|
||||
del self.channels[src]
|
||||
self.channels[dest] = rows
|
||||
self.endMoveRows()
|
||||
return True
|
||||
|
||||
def flags(self, index: QModelIndex):
|
||||
if not index.isValid():
|
||||
return Qt.ItemIsEnabled
|
||||
return (qc.QAbstractItemModel.flags(self, index)
|
||||
| Qt.ItemIsEditable | Qt.ItemNeverHasChildren)
|
||||
|
||||
|
||||
nope = qc.QVariant()
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
import functools
|
||||
import operator
|
||||
from typing import Optional, List, Callable, Dict, Any, ClassVar, TYPE_CHECKING
|
||||
|
||||
from PyQt5 import QtWidgets as qw, QtCore as qc
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtGui import QPalette, QColor
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
|
||||
from ovgenpy.config import OvgenError
|
||||
from ovgenpy.triggers import lerp
|
||||
from ovgenpy.util import obj_name, perr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ovgenpy.gui import MainWindow
|
||||
|
||||
__all__ = ['PresentationModel', 'map_gui', 'behead', 'rgetattr', 'rsetattr']
|
||||
|
||||
|
||||
WidgetUpdater = Callable[[], None]
|
||||
Attrs = Any
|
||||
|
||||
|
||||
class PresentationModel:
|
||||
""" Key-value MVP presentation-model.
|
||||
|
||||
Qt's built-in model-view framework expects all models to
|
||||
take the form of a database-style numbered [row, column] structure,
|
||||
whereas my model takes the form of a key-value struct exposed as a form.
|
||||
"""
|
||||
|
||||
# These fields are specific to each subclass, and assigned there.
|
||||
# Although less explicit, these can be assigned using __init_subclass__.
|
||||
combo_symbols: Dict[str, List[str]]
|
||||
combo_text: Dict[str, List[str]]
|
||||
|
||||
def __init__(self, cfg: Attrs):
|
||||
self.cfg = cfg
|
||||
self.update_widget: Dict[str, WidgetUpdater] = {}
|
||||
|
||||
def __getitem__(self, item):
|
||||
try:
|
||||
# Custom properties
|
||||
return getattr(self, item)
|
||||
except AttributeError:
|
||||
return rgetattr(self.cfg, item)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
# Custom properties
|
||||
if hasattr(type(self), key):
|
||||
setattr(self, key, value)
|
||||
elif rhasattr(self.cfg, key):
|
||||
rsetattr(self.cfg, key, value)
|
||||
else:
|
||||
raise AttributeError(f'cannot set attribute {key} on {obj_name(self)}()')
|
||||
|
||||
def set_cfg(self, cfg: Attrs):
|
||||
self.cfg = cfg
|
||||
for updater in self.update_widget.values():
|
||||
updater()
|
||||
|
||||
|
||||
# TODO add tests for recursive operations
|
||||
def map_gui(view: 'MainWindow', model: PresentationModel):
|
||||
"""
|
||||
Binding:
|
||||
- .ui <widget name="layout__nrows">
|
||||
- view.layout__nrows
|
||||
- pmodel['layout__nrows']
|
||||
|
||||
Only <widget>s subclassing BoundWidget will be bound.
|
||||
"""
|
||||
|
||||
widgets: List[BoundWidget] = view.findChildren(BoundWidget) # dear pyqt, add generic mypy return types
|
||||
for widget in widgets:
|
||||
path = widget.objectName()
|
||||
widget.bind_widget(model, path)
|
||||
widget.gui_changed.connect(view.on_gui_edited)
|
||||
|
||||
|
||||
Signal = Any
|
||||
|
||||
class BoundWidget(QWidget):
|
||||
default_palette: QPalette
|
||||
error_palette: QPalette
|
||||
|
||||
pmodel: PresentationModel
|
||||
path: str
|
||||
|
||||
def bind_widget(self, model: PresentationModel, path: str) -> None:
|
||||
try:
|
||||
self.default_palette = self.palette()
|
||||
self.error_palette = self.calc_error_palette()
|
||||
|
||||
self.pmodel = model
|
||||
self.path = path
|
||||
self.cfg2gui()
|
||||
|
||||
# Allow widget to be updated by other events.
|
||||
model.update_widget[path] = self.cfg2gui
|
||||
|
||||
# Allow pmodel to be changed by widget.
|
||||
self.gui_changed.connect(self.set_model)
|
||||
|
||||
except Exception:
|
||||
perr(self)
|
||||
perr(path)
|
||||
raise
|
||||
|
||||
def calc_error_palette(self) -> QPalette:
|
||||
""" Palette with red background, used for widgets with invalid input. """
|
||||
error_palette = QPalette(self.palette())
|
||||
|
||||
bg = error_palette.color(QPalette.Base)
|
||||
red = QColor(qc.Qt.red)
|
||||
|
||||
red_bg = blend_colors(bg, red, 0.5)
|
||||
error_palette.setColor(QPalette.Base, red_bg)
|
||||
return error_palette
|
||||
|
||||
def cfg2gui(self):
|
||||
""" Update the widget without triggering signals.
|
||||
|
||||
When the presentation pmodel updates dependent widget 1,
|
||||
the pmodel (not widget 1) is responsible for updating other
|
||||
dependent widgets.
|
||||
TODO add option to send signals
|
||||
"""
|
||||
with qc.QSignalBlocker(self):
|
||||
self.set_gui(self.pmodel[self.path])
|
||||
|
||||
def set_gui(self, value): pass
|
||||
|
||||
gui_changed: ClassVar[Signal]
|
||||
|
||||
def set_model(self, value): pass
|
||||
|
||||
|
||||
def blend_colors(color1: QColor, color2: QColor, ratio: float, gamma=2):
|
||||
""" Blends two colors in linear color space.
|
||||
Produces better results on both light and dark themes,
|
||||
than integer blending (which is too dark).
|
||||
"""
|
||||
rgb1 = color1.getRgbF()[:3] # r,g,b, remove alpha
|
||||
rgb2 = color2.getRgbF()[:3]
|
||||
rgb_blend = []
|
||||
|
||||
for ch1, ch2 in zip(rgb1, rgb2):
|
||||
blend = lerp(ch1 ** gamma, ch2 ** gamma, ratio) ** (1/gamma)
|
||||
rgb_blend.append(blend)
|
||||
|
||||
return QColor.fromRgbF(*rgb_blend, 1.0)
|
||||
|
||||
|
||||
def model_setter(value_type: type) -> Callable:
|
||||
@pyqtSlot(value_type)
|
||||
def set_model(self: BoundWidget, value):
|
||||
assert isinstance(value, value_type)
|
||||
try:
|
||||
self.pmodel[self.path] = value
|
||||
except OvgenError:
|
||||
self.setPalette(self.error_palette)
|
||||
else:
|
||||
self.setPalette(self.default_palette)
|
||||
return set_model
|
||||
|
||||
|
||||
def alias(name: str):
|
||||
return property(operator.attrgetter(name))
|
||||
|
||||
|
||||
class BoundLineEdit(qw.QLineEdit, BoundWidget):
|
||||
# PyQt complains when we assign unbound methods (`set_gui = qw.QLineEdit.setText`),
|
||||
# but not if we call them indirectly.
|
||||
set_gui = alias('setText')
|
||||
gui_changed = alias('textChanged')
|
||||
set_model = model_setter(str)
|
||||
|
||||
|
||||
class BoundSpinBox(qw.QSpinBox, BoundWidget):
|
||||
set_gui = alias('setValue')
|
||||
gui_changed = alias('valueChanged')
|
||||
set_model = model_setter(int)
|
||||
|
||||
|
||||
class BoundDoubleSpinBox(qw.QDoubleSpinBox, BoundWidget):
|
||||
set_gui = alias('setValue')
|
||||
gui_changed = alias('valueChanged')
|
||||
set_model = model_setter(float)
|
||||
|
||||
|
||||
class BoundComboBox(qw.QComboBox, BoundWidget):
|
||||
combo_symbols: List[str]
|
||||
symbol2idx: Dict[str, int]
|
||||
|
||||
# noinspection PyAttributeOutsideInit
|
||||
def bind_widget(self, model: PresentationModel, path: str) -> None:
|
||||
# Effectively enum values.
|
||||
self.combo_symbols = model.combo_symbols[path]
|
||||
|
||||
# symbol2idx[str] = int
|
||||
self.symbol2idx = {}
|
||||
|
||||
# Pretty-printed text
|
||||
combo_text = model.combo_text[path]
|
||||
for i, symbol in enumerate(self.combo_symbols):
|
||||
self.symbol2idx[symbol] = i
|
||||
self.addItem(combo_text[i])
|
||||
|
||||
BoundWidget.bind_widget(self, model, path)
|
||||
|
||||
# combobox.index = pmodel.attr
|
||||
def set_gui(self, symbol: str):
|
||||
combo_index = self.symbol2idx[symbol]
|
||||
self.setCurrentIndex(combo_index)
|
||||
|
||||
gui_changed = alias('currentIndexChanged')
|
||||
|
||||
# pmodel.attr = combobox.index
|
||||
@pyqtSlot(int)
|
||||
def set_model(self, combo_index: int):
|
||||
assert isinstance(combo_index, int)
|
||||
self.pmodel[self.path] = self.combo_symbols[combo_index]
|
||||
|
||||
|
||||
# Unused
|
||||
def try_behead(string: str, header: str) -> Optional[str]:
|
||||
if not string.startswith(header):
|
||||
return None
|
||||
return string[len(header):]
|
||||
|
||||
|
||||
def behead(string: str, header: str) -> str:
|
||||
if not string.startswith(header):
|
||||
raise ValueError(f'{string} does not start with {header}')
|
||||
return string[len(header):]
|
||||
|
||||
|
||||
DUNDER = '__'
|
||||
|
||||
# https://gist.github.com/wonderbeyond/d293e7a2af1de4873f2d757edd580288
|
||||
def rgetattr(obj, dunder_delim_path: str, *default):
|
||||
"""
|
||||
:param obj: Object
|
||||
:param dunder_delim_path: 'attr1__attr2__etc'
|
||||
:param default: Optional default value, at any point in the path
|
||||
:return: obj.attr1.attr2.etc
|
||||
"""
|
||||
def _getattr(obj, attr):
|
||||
return getattr(obj, attr, *default)
|
||||
|
||||
attrs = dunder_delim_path.split(DUNDER)
|
||||
return functools.reduce(_getattr, [obj] + attrs)
|
||||
|
||||
|
||||
def rhasattr(obj, dunder_delim_path: str):
|
||||
try:
|
||||
rgetattr(obj, dunder_delim_path)
|
||||
return True
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
# https://stackoverflow.com/a/31174427/2683842
|
||||
def rsetattr(obj, dunder_delim_path: str, val):
|
||||
"""
|
||||
:param obj: Object
|
||||
:param dunder_delim_path: 'attr1__attr2__etc'
|
||||
:param val: obj.attr1.attr2.etc = val
|
||||
"""
|
||||
parent, _, name = dunder_delim_path.rpartition(DUNDER)
|
||||
parent_obj = rgetattr(obj, parent) if parent else obj
|
||||
|
||||
return setattr(parent_obj, name, val)
|
||||
|
|
@ -0,0 +1,583 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1080</width>
|
||||
<height>0</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralWidget">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout" stretch="0,1">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tabGeneral">
|
||||
<attribute name="title">
|
||||
<string>&General</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="optionGlobal">
|
||||
<property name="title">
|
||||
<string>Global</string>
|
||||
</property>
|
||||
<layout class="QFormLayout" name="formLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="fpsL">
|
||||
<property name="text">
|
||||
<string>FPS</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="BoundSpinBox" name="fps">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>999</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>10</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="trigger_msL">
|
||||
<property name="text">
|
||||
<string>Trigger Width (ms)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="BoundSpinBox" name="trigger_ms">
|
||||
<property name="minimum">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>5</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="render_msL">
|
||||
<property name="text">
|
||||
<string>Render Width (ms)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="BoundSpinBox" name="render_ms">
|
||||
<property name="minimum">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>5</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="amplificationL">
|
||||
<property name="text">
|
||||
<string>Amplification</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="BoundDoubleSpinBox" name="amplification">
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="begin_timeL">
|
||||
<property name="text">
|
||||
<string>Begin Time (s)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="BoundDoubleSpinBox" name="begin_time">
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="optionAppear">
|
||||
<property name="title">
|
||||
<string>Appearance</string>
|
||||
</property>
|
||||
<layout class="QFormLayout" name="formLayout_4">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="render__bg_colorL">
|
||||
<property name="text">
|
||||
<string>Background</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="BoundLineEdit" name="render__bg_color">
|
||||
<property name="text">
|
||||
<string>bg</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="render__init_line_colorL">
|
||||
<property name="text">
|
||||
<string>Line Color</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="BoundLineEdit" name="render__init_line_color">
|
||||
<property name="text">
|
||||
<string>fg</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="render__line_widthL">
|
||||
<property name="text">
|
||||
<string>Line Width</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="BoundDoubleSpinBox" name="render__line_width">
|
||||
<property name="minimum">
|
||||
<double>0.500000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.500000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="optionLayout">
|
||||
<property name="title">
|
||||
<string>Layout</string>
|
||||
</property>
|
||||
<layout class="QFormLayout" name="formLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="layout__orientationL">
|
||||
<property name="text">
|
||||
<string>Orientation</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="BoundComboBox" name="layout__orientation"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="layout__ncolsL">
|
||||
<property name="text">
|
||||
<string>Columns</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<layout class="QHBoxLayout" name="layoutDims">
|
||||
<item>
|
||||
<widget class="BoundSpinBox" name="layout__ncols">
|
||||
<property name="specialValueText">
|
||||
<string notr="true"> </string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="layout__nrowsL">
|
||||
<property name="text">
|
||||
<string>Rows</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BoundSpinBox" name="layout__nrows">
|
||||
<property name="specialValueText">
|
||||
<string notr="true"> </string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="render_video_sizeL">
|
||||
<property name="text">
|
||||
<string>Video Size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="BoundLineEdit" name="render_video_size">
|
||||
<property name="text">
|
||||
<string>vs</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="optionChannel">
|
||||
<property name="title">
|
||||
<string>Performance</string>
|
||||
</property>
|
||||
<layout class="QFormLayout" name="formLayout_3">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="render_subfpsL">
|
||||
<property name="text">
|
||||
<string>Render FPS Divisor</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="BoundSpinBox" name="render_subfps">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="trigger_subsamplingL">
|
||||
<property name="text">
|
||||
<string>Trigger Subsampling</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="BoundSpinBox" name="trigger_subsampling">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="render_subsamplingL">
|
||||
<property name="text">
|
||||
<string>Render Subsampling</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="BoundSpinBox" name="render_subsampling">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="audioColumn">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="audioGroup">
|
||||
<property name="title">
|
||||
<string>Master Audio</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<widget class="BoundLineEdit" name="master_audio">
|
||||
<property name="text">
|
||||
<string>/</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="master_audio_browse">
|
||||
<property name="text">
|
||||
<string>&Browse...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="optionAudio">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Trigger</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="trigger__edge_strengthL">
|
||||
<property name="text">
|
||||
<string>Edge Strength</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="trigger__responsivenessL">
|
||||
<property name="text">
|
||||
<string>Responsiveness</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLabel" name="trigger__buffer_falloffL">
|
||||
<property name="text">
|
||||
<string>Buffer Falloff</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="BoundDoubleSpinBox" name="trigger__edge_strength">
|
||||
<property name="minimum">
|
||||
<double>-99.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="BoundDoubleSpinBox" name="trigger__responsiveness">
|
||||
<property name="maximum">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="BoundDoubleSpinBox" name="trigger__buffer_falloff">
|
||||
<property name="singleStep">
|
||||
<double>0.500000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="channelsGroup">
|
||||
<property name="title">
|
||||
<string>Oscilloscope Channels</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="channelBar">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ShortcutButton" name="channelAdd">
|
||||
<property name="text">
|
||||
<string>&Add...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ShortcutButton" name="channelDelete">
|
||||
<property name="text">
|
||||
<string>&Delete</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ShortcutButton" name="channelUp">
|
||||
<property name="text">
|
||||
<string>Up</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ShortcutButton" name="channelDown">
|
||||
<property name="text">
|
||||
<string>Down</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ChannelTableView" name="channel_view"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menuBar">
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>&File</string>
|
||||
</property>
|
||||
<addaction name="actionNew"/>
|
||||
<addaction name="actionOpen"/>
|
||||
<addaction name="actionSave"/>
|
||||
<addaction name="actionSaveAs"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionPlay"/>
|
||||
<addaction name="actionRender"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionExit"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
</widget>
|
||||
<widget class="QToolBar" name="toolBar">
|
||||
<property name="windowTitle">
|
||||
<string>toolBar</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionNew"/>
|
||||
<addaction name="actionOpen"/>
|
||||
<addaction name="actionSave"/>
|
||||
<addaction name="actionSaveAs"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionPlay"/>
|
||||
<addaction name="actionRender"/>
|
||||
</widget>
|
||||
<action name="actionOpen">
|
||||
<property name="text">
|
||||
<string>&Open</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+O</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave">
|
||||
<property name="text">
|
||||
<string>&Save</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+S</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionNew">
|
||||
<property name="text">
|
||||
<string>&New</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+N</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSaveAs">
|
||||
<property name="text">
|
||||
<string>Save &As</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Shift+S</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionExit">
|
||||
<property name="text">
|
||||
<string>E&xit</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Q</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionPlay">
|
||||
<property name="text">
|
||||
<string>&Play</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+P</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionRender">
|
||||
<property name="text">
|
||||
<string>&Render to Video</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+R</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<layoutdefault spacing="6" margin="11"/>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>BoundLineEdit</class>
|
||||
<extends>QLineEdit</extends>
|
||||
<header>ovgenpy/gui/data_bind.h</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BoundSpinBox</class>
|
||||
<extends>QSpinBox</extends>
|
||||
<header>ovgenpy/gui/data_bind.h</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BoundDoubleSpinBox</class>
|
||||
<extends>QDoubleSpinBox</extends>
|
||||
<header>ovgenpy/gui/data_bind.h</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BoundComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>ovgenpy/gui/data_bind.h</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ShortcutButton</class>
|
||||
<extends>QPushButton</extends>
|
||||
<header>ovgenpy/gui/__init__.h</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ChannelTableView</class>
|
||||
<extends>QTableView</extends>
|
||||
<header>ovgenpy/gui/__init__.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
from pathlib import Path
|
||||
from typing import *
|
||||
from typing import Iterable, Tuple
|
||||
|
||||
import matplotlib.colors
|
||||
import more_itertools
|
||||
from PyQt5.QtCore import QMutex
|
||||
from PyQt5.QtWidgets import QWidget, QFileDialog
|
||||
|
||||
from ovgenpy.config import OvgenError
|
||||
|
||||
|
||||
def color2hex(color):
|
||||
try:
|
||||
return matplotlib.colors.to_hex(color, keep_alpha=False)
|
||||
except ValueError:
|
||||
raise OvgenError(f'invalid color {color}')
|
||||
except Exception as e:
|
||||
raise OvgenError(
|
||||
f'doubly invalid color {color}, raises {e} (report bug!)')
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class Locked(Generic[T]):
|
||||
""" Based off https://stackoverflow.com/a/37606669 """
|
||||
|
||||
def __init__(self, obj: T):
|
||||
super().__init__()
|
||||
self.obj = obj
|
||||
self.lock = QMutex(QMutex.Recursive)
|
||||
self.skip_exit = False
|
||||
|
||||
def __enter__(self) -> T:
|
||||
self.lock.lock()
|
||||
return self.obj
|
||||
|
||||
def unlock(self):
|
||||
# FIXME does it work? i was not thinking clearly when i wrote this
|
||||
if not self.skip_exit:
|
||||
self.skip_exit = True
|
||||
self.lock.unlock()
|
||||
|
||||
def __exit__(self, *args, **kwargs):
|
||||
if self.skip_exit:
|
||||
self.skip_exit = False
|
||||
else:
|
||||
self.lock.unlock()
|
||||
|
||||
def set(self, value: T) -> T:
|
||||
with self:
|
||||
self.obj = value
|
||||
return value
|
||||
|
||||
|
||||
def get_save_with_ext(
|
||||
parent: QWidget, caption: str, dir_or_file: str,
|
||||
filters: List[str], default_suffix: str
|
||||
) -> Optional[Path]:
|
||||
""" On KDE, getSaveFileName does not append extension. This is a workaround. """
|
||||
name, sel_filter = QFileDialog.getSaveFileName(
|
||||
parent, caption, dir_or_file, ';;'.join(filters)
|
||||
)
|
||||
|
||||
if name == '':
|
||||
return None
|
||||
|
||||
path = Path(name)
|
||||
if sel_filter == filters[0] and path.suffix == '':
|
||||
path = path.with_suffix(default_suffix)
|
||||
return path
|
||||
|
||||
|
||||
def find_ranges(iterable: Iterable[T]) -> Iterable[Tuple[T, int]]:
|
||||
"""Extracts consecutive runs from a list of items.
|
||||
|
||||
:param iterable: List of items.
|
||||
:return: Iterable of (first elem, length).
|
||||
"""
|
||||
for group in more_itertools.consecutive_groups(iterable):
|
||||
group = list(group)
|
||||
yield group[0], len(group)
|
||||
|
|
@ -2,7 +2,7 @@ from typing import Optional, TypeVar, Callable, List
|
|||
|
||||
import numpy as np
|
||||
|
||||
from ovgenpy.config import register_config
|
||||
from ovgenpy.config import register_config, OvgenError
|
||||
from ovgenpy.util import ceildiv
|
||||
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ class LayoutConfig:
|
|||
self.ncols = None
|
||||
|
||||
if self.nrows and self.ncols:
|
||||
raise ValueError('cannot manually assign both nrows and ncols')
|
||||
raise OvgenError('cannot manually assign both nrows and ncols')
|
||||
|
||||
if not self.nrows and not self.ncols:
|
||||
self.ncols = 1
|
||||
|
|
@ -41,7 +41,7 @@ class RendererLayout:
|
|||
|
||||
self.orientation = cfg.orientation
|
||||
if self.orientation not in self.VALID_ORIENTATIONS:
|
||||
raise ValueError(f'Invalid orientation {self.orientation} not in '
|
||||
raise OvgenError(f'Invalid orientation {self.orientation} not in '
|
||||
f'{self.VALID_ORIENTATIONS}')
|
||||
|
||||
def _calc_layout(self):
|
||||
|
|
@ -54,12 +54,13 @@ class RendererLayout:
|
|||
if cfg.nrows:
|
||||
nrows = cfg.nrows
|
||||
if nrows is None:
|
||||
raise ValueError('invalid cfg: rows_first is True and nrows is None')
|
||||
raise ValueError('impossible cfg: nrows is None and true')
|
||||
ncols = ceildiv(self.nplots, nrows)
|
||||
else:
|
||||
ncols = cfg.ncols
|
||||
if ncols is None:
|
||||
raise ValueError('invalid cfg: rows_first is False and ncols is None')
|
||||
raise ValueError('invalid LayoutConfig: nrows,ncols is None '
|
||||
'(__attrs_post_init__ not called?)')
|
||||
nrows = ceildiv(self.nplots, ncols)
|
||||
|
||||
return nrows, ncols
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# https://ffmpeg.org/ffplay.html
|
||||
import numpy as np
|
||||
import shlex
|
||||
import subprocess
|
||||
from abc import ABC, abstractmethod
|
||||
|
|
@ -11,6 +12,7 @@ if TYPE_CHECKING:
|
|||
from ovgenpy.ovgenpy import Config
|
||||
|
||||
|
||||
ByteBuffer = Union[bytes, np.ndarray]
|
||||
RGB_DEPTH = 3
|
||||
PIXEL_FORMAT = 'rgb24'
|
||||
|
||||
|
|
@ -24,6 +26,13 @@ class IOutputConfig:
|
|||
return self.cls(ovgen_cfg, cfg=self)
|
||||
|
||||
|
||||
class _Stop:
|
||||
pass
|
||||
|
||||
|
||||
Stop = _Stop()
|
||||
|
||||
|
||||
class Output(ABC):
|
||||
def __init__(self, ovgen_cfg: 'Config', cfg: IOutputConfig):
|
||||
self.ovgen_cfg = ovgen_cfg
|
||||
|
|
@ -38,12 +47,15 @@ class Output(ABC):
|
|||
return self
|
||||
|
||||
@abstractmethod
|
||||
def write_frame(self, frame: bytes) -> None:
|
||||
def write_frame(self, frame: ByteBuffer) -> Optional[_Stop]:
|
||||
""" Output a Numpy ndarray. """
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
def terminate(self):
|
||||
pass
|
||||
|
||||
# Glue logic
|
||||
|
||||
def register_output(config_t: Type[IOutputConfig]):
|
||||
|
|
@ -117,11 +129,21 @@ class PipeOutput(Output):
|
|||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def write_frame(self, frame: bytes) -> None:
|
||||
self._stream.write(frame)
|
||||
def write_frame(self, frame: ByteBuffer) -> Optional[_Stop]:
|
||||
try:
|
||||
self._stream.write(frame)
|
||||
return None
|
||||
except BrokenPipeError:
|
||||
return Stop
|
||||
|
||||
def close(self) -> int:
|
||||
self._stream.close()
|
||||
def close(self, wait=True) -> int:
|
||||
try:
|
||||
self._stream.close()
|
||||
except (BrokenPipeError, OSError): # BrokenPipeError is a OSError
|
||||
pass
|
||||
|
||||
if not wait:
|
||||
return 0
|
||||
|
||||
retval = 0
|
||||
for popen in self._pipeline:
|
||||
|
|
@ -132,24 +154,27 @@ class PipeOutput(Output):
|
|||
if exc_type is None:
|
||||
self.close()
|
||||
else:
|
||||
# Calling self.close() is bad.
|
||||
# If exception occurred but ffplay continues running.
|
||||
# popen.wait() will prevent stack trace from showing up.
|
||||
self._stream.close()
|
||||
self.terminate()
|
||||
|
||||
exc = None
|
||||
for popen in self._pipeline:
|
||||
popen.terminate()
|
||||
# https://stackoverflow.com/a/49038779/2683842
|
||||
try:
|
||||
popen.wait(1) # timeout=seconds
|
||||
except subprocess.TimeoutExpired as e:
|
||||
# gee thanks Python, https://stackoverflow.com/questions/45292479/
|
||||
exc = e
|
||||
popen.kill()
|
||||
def terminate(self):
|
||||
# Calling self.close() is bad.
|
||||
# If exception occurred but ffplay continues running,
|
||||
# popen.wait() will prevent stack trace from showing up.
|
||||
self.close(wait=False)
|
||||
|
||||
if exc:
|
||||
raise exc
|
||||
exc = None
|
||||
for popen in self._pipeline:
|
||||
popen.terminate()
|
||||
# https://stackoverflow.com/a/49038779/2683842
|
||||
try:
|
||||
popen.wait(1) # timeout=seconds
|
||||
except subprocess.TimeoutExpired as e:
|
||||
# gee thanks Python, https://stackoverflow.com/questions/45292479/
|
||||
exc = e
|
||||
popen.kill()
|
||||
|
||||
if exc:
|
||||
raise exc
|
||||
|
||||
|
||||
# FFmpegOutput
|
||||
|
|
@ -198,7 +223,7 @@ class FFplayOutput(PipeOutput):
|
|||
def __init__(self, ovgen_cfg: 'Config', cfg: FFplayOutputConfig):
|
||||
super().__init__(ovgen_cfg, cfg)
|
||||
|
||||
ffmpeg = _FFmpegProcess([FFMPEG], ovgen_cfg)
|
||||
ffmpeg = _FFmpegProcess([FFMPEG, '-nostats'], ovgen_cfg)
|
||||
ffmpeg.add_output(cfg)
|
||||
ffmpeg.templates.append('-f nut')
|
||||
|
||||
|
|
@ -211,15 +236,3 @@ class FFplayOutput(PipeOutput):
|
|||
# assert p2.stdin is None # True unless Popen is being mocked (test_output).
|
||||
|
||||
self.open(p1, p2)
|
||||
|
||||
|
||||
# ImageOutput
|
||||
|
||||
@register_config
|
||||
class ImageOutputConfig(IOutputConfig):
|
||||
path_prefix: str
|
||||
|
||||
|
||||
@register_output(ImageOutputConfig)
|
||||
class ImageOutput(Output):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from contextlib import ExitStack, contextmanager
|
|||
from enum import unique, IntEnum
|
||||
from fractions import Fraction
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional, List, Union, TYPE_CHECKING
|
||||
from typing import Optional, List, Union, TYPE_CHECKING, Callable
|
||||
|
||||
import attr
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ class Config:
|
|||
subsampling = self._subsampling
|
||||
self.trigger_subsampling = coalesce(self.trigger_subsampling, subsampling)
|
||||
self.render_subsampling = coalesce(self.render_subsampling, subsampling)
|
||||
|
||||
|
||||
# Compute trigger_ms and render_ms.
|
||||
width_ms = self._width_ms
|
||||
try:
|
||||
|
|
@ -147,24 +147,38 @@ def default_config(**kwargs) -> Config:
|
|||
return attr.evolve(cfg, **kwargs)
|
||||
|
||||
|
||||
BeginFunc = Callable[[float, float], None]
|
||||
ProgressFunc = Callable[[int], None]
|
||||
IsAborted = Callable[[], bool]
|
||||
|
||||
@attr.dataclass
|
||||
class Arguments:
|
||||
cfg_dir: str
|
||||
outputs: List[outputs_.IOutputConfig]
|
||||
|
||||
on_begin: BeginFunc = lambda begin_time, end_time: None
|
||||
progress: ProgressFunc = print
|
||||
is_aborted: IsAborted = lambda: False
|
||||
on_end: Callable[[], None] = lambda: None
|
||||
|
||||
class Ovgen:
|
||||
def __init__(self, cfg: Config, cfg_dir: str,
|
||||
outputs: List[outputs_.IOutputConfig]):
|
||||
def __init__(self, cfg: Config, arg: Arguments):
|
||||
self.cfg = cfg
|
||||
self.cfg_dir = cfg_dir
|
||||
self.arg = arg
|
||||
self.has_played = False
|
||||
|
||||
# TODO test progress and is_aborted
|
||||
# TODO benchmark_mode/not_benchmarking == code duplication.
|
||||
benchmark_mode = self.cfg.benchmark_mode
|
||||
not_benchmarking = not benchmark_mode
|
||||
|
||||
if not_benchmarking or benchmark_mode == BenchmarkMode.OUTPUT:
|
||||
self.output_cfgs = outputs
|
||||
self.output_cfgs = arg.outputs
|
||||
else:
|
||||
self.output_cfgs = []
|
||||
|
||||
if len(self.cfg.channels) == 0:
|
||||
raise ValueError('Config.channels is empty')
|
||||
raise OvgenError('Config.channels is empty')
|
||||
|
||||
waves: List[Wave]
|
||||
channels: List[Channel]
|
||||
|
|
@ -172,7 +186,7 @@ class Ovgen:
|
|||
nchan: int
|
||||
|
||||
def _load_channels(self):
|
||||
with pushd(self.cfg_dir):
|
||||
with pushd(self.arg.cfg_dir):
|
||||
self.channels = [Channel(ccfg, self.cfg) for ccfg in self.cfg.channels]
|
||||
self.waves = [channel.wave for channel in self.channels]
|
||||
self.triggers = [channel.trigger for channel in self.channels]
|
||||
|
|
@ -180,7 +194,7 @@ class Ovgen:
|
|||
|
||||
@contextmanager
|
||||
def _load_outputs(self):
|
||||
with pushd(self.cfg_dir):
|
||||
with pushd(self.arg.cfg_dir):
|
||||
with ExitStack() as stack:
|
||||
self.outputs = [
|
||||
stack.enter_context(output_cfg(self.cfg))
|
||||
|
|
@ -204,9 +218,12 @@ class Ovgen:
|
|||
|
||||
begin_frame = round(fps * self.cfg.begin_time)
|
||||
|
||||
end_frame = fps * coalesce(self.cfg.end_time, self.waves[0].get_s())
|
||||
end_time = coalesce(self.cfg.end_time, self.waves[0].get_s())
|
||||
end_frame = fps * end_time
|
||||
end_frame = int(end_frame) + 1
|
||||
|
||||
self.arg.on_begin(self.cfg.begin_time, end_time)
|
||||
|
||||
renderer = self._load_renderer()
|
||||
|
||||
# region show_internals
|
||||
|
|
@ -256,12 +273,20 @@ class Ovgen:
|
|||
|
||||
# For each frame, render each wave
|
||||
for frame in range(begin_frame, end_frame):
|
||||
if self.arg.is_aborted():
|
||||
# Used for FPS calculation
|
||||
end_frame = frame
|
||||
|
||||
for output in self.outputs:
|
||||
output.terminate()
|
||||
break
|
||||
|
||||
time_seconds = frame / fps
|
||||
should_render = (frame - begin_frame) % render_subfps == ahead
|
||||
|
||||
rounded = int(time_seconds)
|
||||
if PRINT_TIMESTAMP and rounded != prev:
|
||||
print(rounded)
|
||||
self.arg.progress(rounded)
|
||||
prev = rounded
|
||||
|
||||
render_datas = []
|
||||
|
|
@ -296,12 +321,19 @@ class Ovgen:
|
|||
if not_benchmarking or benchmark_mode >= BenchmarkMode.RENDER:
|
||||
# Render frame
|
||||
renderer.render_frame(render_datas)
|
||||
frame = renderer.get_frame()
|
||||
frame_data = renderer.get_frame()
|
||||
|
||||
if not_benchmarking or benchmark_mode == BenchmarkMode.OUTPUT:
|
||||
# Output frame
|
||||
aborted = False
|
||||
for output in self.outputs:
|
||||
output.write_frame(frame)
|
||||
if output.write_frame(frame_data) is outputs_.Stop:
|
||||
aborted = True
|
||||
break
|
||||
if aborted:
|
||||
# Outputting frame happens after most computation finished.
|
||||
end_frame = frame + 1
|
||||
break
|
||||
|
||||
if self.raise_on_teardown:
|
||||
raise self.raise_on_teardown
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import attr
|
|||
|
||||
from ovgenpy.config import register_config
|
||||
from ovgenpy.layout import RendererLayout, LayoutConfig
|
||||
from ovgenpy.outputs import RGB_DEPTH
|
||||
from ovgenpy.outputs import RGB_DEPTH, ByteBuffer
|
||||
from ovgenpy.util import coalesce
|
||||
|
||||
matplotlib.use('agg')
|
||||
|
|
@ -48,6 +48,7 @@ class LineParam:
|
|||
color: str
|
||||
|
||||
|
||||
# TODO rename to Plotter
|
||||
class Renderer(ABC):
|
||||
def __init__(self, cfg: RendererConfig, lcfg: 'LayoutConfig', nplots: int,
|
||||
channel_cfgs: Optional[List['ChannelConfig']]):
|
||||
|
|
@ -73,7 +74,7 @@ class Renderer(ABC):
|
|||
def render_frame(self, datas: List[np.ndarray]) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_frame(self) -> bytes: ...
|
||||
def get_frame(self) -> ByteBuffer: ...
|
||||
|
||||
|
||||
class MatplotlibRenderer(Renderer):
|
||||
|
|
@ -183,7 +184,7 @@ class MatplotlibRenderer(Renderer):
|
|||
self._fig.canvas.draw()
|
||||
self._fig.canvas.flush_events()
|
||||
|
||||
def get_frame(self) -> bytes:
|
||||
def get_frame(self) -> ByteBuffer:
|
||||
""" Returns ndarray of shape w,h,3. """
|
||||
canvas = self._fig.canvas
|
||||
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ class CorrelationTriggerConfig(ITriggerConfig):
|
|||
def _validate_param(self, key: str, begin, end):
|
||||
value = getattr(self, key)
|
||||
if not begin <= value <= end:
|
||||
raise ValueError(
|
||||
raise OvgenError(
|
||||
f'Invalid {key}={value} (should be within [{begin}, {end}])')
|
||||
|
||||
|
||||
|
|
@ -457,7 +457,7 @@ class LocalPostTrigger(PostTrigger):
|
|||
|
||||
# Window data
|
||||
if cache.period is None:
|
||||
raise ValueError(
|
||||
raise OvgenError(
|
||||
"Missing 'cache.period', try stacking CorrelationTrigger "
|
||||
"before LocalPostTrigger")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
|
|
@ -96,3 +97,6 @@ def pushd(new_dir: Union[Path, str]):
|
|||
finally:
|
||||
os.chdir(previous_dir)
|
||||
|
||||
|
||||
def perr(*args, **kwargs):
|
||||
print(*args, file=sys.stderr, **kwargs)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ from scipy.io import wavfile
|
|||
|
||||
|
||||
# Internal class, not exposed via YAML
|
||||
from ovgenpy.config import OvgenError
|
||||
|
||||
|
||||
@attr.dataclass
|
||||
class _WaveConfig:
|
||||
amplification: float = 1
|
||||
|
|
@ -51,7 +54,7 @@ class Wave:
|
|||
self.max_val = 1
|
||||
|
||||
else:
|
||||
raise ValueError(f'unexpected wavfile dtype {dtype}')
|
||||
raise OvgenError(f'unexpected wavfile dtype {dtype}')
|
||||
|
||||
def __getitem__(self, index: Union[int, slice]) -> 'np.ndarray[FLOAT]':
|
||||
""" Copies self.data[item], converted to a FLOAT within range [-1, 1). """
|
||||
|
|
|
|||
4
setup.py
4
setup.py
|
|
@ -11,8 +11,10 @@ setup(
|
|||
description='',
|
||||
tests_require=['pytest>=3.2.0', 'pytest-pycharm', 'hypothesis', 'delayed-assert'],
|
||||
install_requires=[
|
||||
'numpy', 'scipy', 'click', 'ruamel.yaml',
|
||||
'ruamel.yaml>=0.15.70', # See test_config.py to pick a suitable minimum version
|
||||
'numpy', 'scipy', 'click', 'more_itertools',
|
||||
'matplotlib',
|
||||
'attrs>=18.2.0',
|
||||
'PyQt5',
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import ovgenpy.channel
|
|||
import ovgenpy.ovgenpy
|
||||
from ovgenpy.channel import ChannelConfig, Channel
|
||||
from ovgenpy.config import OvgenError
|
||||
from ovgenpy.ovgenpy import default_config, Ovgen, BenchmarkMode
|
||||
from ovgenpy.ovgenpy import default_config, Ovgen, BenchmarkMode, Arguments
|
||||
from ovgenpy.triggers import NullTriggerConfig
|
||||
from ovgenpy.util import coalesce
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ def test_config_channel_width_stride(
|
|||
assert trigger._stride == channel.trigger_stride
|
||||
|
||||
## Ensure ovgenpy calls render using channel.render_samp and render_stride.
|
||||
ovgen = Ovgen(cfg, '.', outputs=[])
|
||||
ovgen = Ovgen(cfg, Arguments(cfg_dir='.', outputs=[]))
|
||||
renderer = mocker.patch.object(Ovgen, '_load_renderer').return_value
|
||||
ovgen.play()
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from ovgenpy import cli
|
|||
from ovgenpy.cli import YAML_NAME
|
||||
from ovgenpy.config import yaml
|
||||
from ovgenpy.outputs import FFmpegOutputConfig
|
||||
from ovgenpy.ovgenpy import Config, Ovgen
|
||||
from ovgenpy.ovgenpy import Config, Ovgen, Arguments
|
||||
from ovgenpy.util import pushd
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -132,7 +132,7 @@ def test_load_yaml_another_dir(yaml_sink, mocker, Popen):
|
|||
# Issue: this test does not use cli.main() to compute output path.
|
||||
# Possible solution: Call cli.main() via Click runner.
|
||||
output = FFmpegOutputConfig(cli.get_path(cfg.master_audio, cli.VIDEO_NAME))
|
||||
ovgen = Ovgen(cfg, subdir, [output])
|
||||
ovgen = Ovgen(cfg, Arguments(subdir, [output]))
|
||||
ovgen.play()
|
||||
|
||||
# Compute absolute paths
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import pytest
|
||||
from ruamel.yaml import yaml_object
|
||||
|
||||
from ovgenpy.config import register_config, yaml, Alias, Ignored, kw_config
|
||||
from ovgenpy.config import register_config, yaml, Alias, Ignored, kw_config, OvgenError
|
||||
|
||||
# YAML Idiosyncrasies: https://docs.saltstack.com/en/develop/topics/troubleshooting/yaml_idiosyncrasies.html
|
||||
|
||||
|
|
@ -163,7 +163,7 @@ xx: 1
|
|||
x: 1
|
||||
xx: 1
|
||||
'''
|
||||
with pytest.raises(TypeError):
|
||||
with pytest.raises(OvgenError):
|
||||
yaml.load(s)
|
||||
|
||||
|
||||
|
|
@ -229,3 +229,68 @@ def test_load_post_init():
|
|||
foo: 0
|
||||
'''
|
||||
assert yaml.load(s) == Foo(99)
|
||||
|
||||
|
||||
# ruamel.yaml has a unstable and shape-shifting API.
|
||||
# Test which version numbers have properties we want.
|
||||
|
||||
def test_dump_dataclass_order():
|
||||
@register_config(always_dump='*')
|
||||
class Config:
|
||||
a: int = 1
|
||||
b: int = 1
|
||||
c: int = 1
|
||||
d: int = 1
|
||||
e: int = 1
|
||||
z: int = 1
|
||||
y: int = 1
|
||||
x: int = 1
|
||||
w: int = 1
|
||||
v: int = 1
|
||||
|
||||
assert yaml.dump(Config()) == '''\
|
||||
!Config
|
||||
a: 1
|
||||
b: 1
|
||||
c: 1
|
||||
d: 1
|
||||
e: 1
|
||||
z: 1
|
||||
y: 1
|
||||
x: 1
|
||||
w: 1
|
||||
v: 1
|
||||
'''
|
||||
|
||||
|
||||
def test_load_dump_dict_order():
|
||||
s = '''\
|
||||
a: 1
|
||||
b: 1
|
||||
c: 1
|
||||
d: 1
|
||||
e: 1
|
||||
z: 1
|
||||
y: 1
|
||||
x: 1
|
||||
w: 1
|
||||
v: 1
|
||||
'''
|
||||
dic = yaml.load(s)
|
||||
assert yaml.dump(dic) == s, yaml.dump(dic)
|
||||
|
||||
|
||||
def test_load_list_dict_type():
|
||||
"""Fails on ruamel.yaml<0.15.70 (CommentedMap/CommentedSeq)."""
|
||||
dic = yaml.load('{}')
|
||||
assert isinstance(dic, dict)
|
||||
|
||||
lis = yaml.load('[]')
|
||||
assert isinstance(lis, list)
|
||||
|
||||
|
||||
def test_list_slice_assign():
|
||||
"""Crashes on ruamel.yaml<0.15.55 (CommentedSeq)."""
|
||||
lis = yaml.load('[]')
|
||||
lis[0:0] = list(range(5))
|
||||
lis[2:5] = []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import pytest
|
||||
|
||||
from ovgenpy.gui.data_bind import rgetattr, rsetattr, rhasattr
|
||||
|
||||
|
||||
def test_rgetattr():
|
||||
""" Test to ensure recursive model access works.
|
||||
GUI elements are named "prefix__" "recursive__attr" and bind to recursive.attr.
|
||||
|
||||
https://stackoverflow__com/a/31174427/
|
||||
"""
|
||||
|
||||
class Person(object):
|
||||
def __init__(self):
|
||||
self.pet = Pet()
|
||||
self.residence = Residence()
|
||||
|
||||
class Pet(object):
|
||||
def __init__(self, name='Fido', species='Dog'):
|
||||
self.name = name
|
||||
self.species = species
|
||||
|
||||
class Residence(object):
|
||||
def __init__(self, type='House', sqft=None):
|
||||
self.type = type
|
||||
self.sqft = sqft
|
||||
|
||||
p = Person()
|
||||
|
||||
# Test rgetattr(present)
|
||||
assert rgetattr(p, 'pet__species') == 'Dog'
|
||||
assert rgetattr(p, 'pet__species', object()) == 'Dog'
|
||||
|
||||
# Test rgetattr(missing)
|
||||
assert rgetattr(p, 'pet__ghost__species', 'calico') == 'calico'
|
||||
with pytest.raises(AttributeError):
|
||||
# Without a default argument, `rgetattr`, like `getattr`, raises
|
||||
# AttributeError when the dotted attribute is missing
|
||||
print(rgetattr(p, 'pet__ghost__species'))
|
||||
|
||||
# Test rsetattr()
|
||||
rsetattr(p, 'pet__name', 'Sparky')
|
||||
rsetattr(p, 'residence__type', 'Apartment')
|
||||
assert p.pet.name == 'Sparky'
|
||||
assert p.residence.type == 'Apartment'
|
||||
|
||||
# Test rhasattr()
|
||||
assert rhasattr(p, 'pet')
|
||||
assert rhasattr(p, 'pet__name')
|
||||
|
||||
# Test rhasattr(levels of missing)
|
||||
assert not rhasattr(p, 'pet__ghost')
|
||||
assert not rhasattr(p, 'pet__ghost__species')
|
||||
assert not rhasattr(p, 'ghost')
|
||||
assert not rhasattr(p, 'ghost__species')
|
||||
|
|
@ -7,7 +7,7 @@ import pytest
|
|||
from ovgenpy.channel import ChannelConfig
|
||||
from ovgenpy.outputs import RGB_DEPTH, \
|
||||
FFmpegOutput, FFmpegOutputConfig, FFplayOutput, FFplayOutputConfig
|
||||
from ovgenpy.ovgenpy import default_config, Ovgen
|
||||
from ovgenpy.ovgenpy import default_config, Ovgen, Arguments
|
||||
from ovgenpy.renderer import RendererConfig, MatplotlibRenderer
|
||||
from tests.test_renderer import WIDTH, HEIGHT, ALL_ZEROS
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ def test_ovgen_terminate_ffplay(Popen, mocker: 'pytest_mock.MockFixture'):
|
|||
Python exceptions occur. """
|
||||
|
||||
cfg = sine440_config()
|
||||
ovgen = Ovgen(cfg, '.', outputs=[FFplayOutputConfig()])
|
||||
ovgen = Ovgen(cfg, Arguments('.', [FFplayOutputConfig()]))
|
||||
|
||||
render_frame = mocker.patch.object(MatplotlibRenderer, 'render_frame')
|
||||
render_frame.side_effect = DummyException()
|
||||
|
|
@ -110,7 +110,7 @@ def test_ovgen_terminate_works():
|
|||
`popen.terminate()` is called. """
|
||||
|
||||
cfg = sine440_config()
|
||||
ovgen = Ovgen(cfg, '.', outputs=[FFplayOutputConfig()])
|
||||
ovgen = Ovgen(cfg, Arguments('.', [FFplayOutputConfig()]))
|
||||
ovgen.raise_on_teardown = DummyException
|
||||
|
||||
with pytest.raises(DummyException):
|
||||
|
|
@ -127,7 +127,7 @@ def test_ovgen_output_without_audio():
|
|||
cfg = sine440_config()
|
||||
cfg.master_audio = None
|
||||
|
||||
ovgen = Ovgen(cfg, '.', outputs=[NULL_OUTPUT])
|
||||
ovgen = Ovgen(cfg, Arguments('.', [NULL_OUTPUT]))
|
||||
# Should not raise exception.
|
||||
ovgen.play()
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ def test_render_subfps_one():
|
|||
cfg = sine440_config()
|
||||
cfg.render_subfps = 1
|
||||
|
||||
ovgen = Ovgen(cfg, '.', outputs=[DummyOutputConfig()])
|
||||
ovgen = Ovgen(cfg, Arguments('.', [DummyOutputConfig()]))
|
||||
ovgen.play()
|
||||
assert DummyOutput.frames_written >= 2
|
||||
|
||||
|
|
@ -177,7 +177,7 @@ def test_render_subfps_non_integer(mocker: 'pytest_mock.MockFixture'):
|
|||
assert cfg.render_fps != int(cfg.render_fps)
|
||||
assert Fraction(1) == int(1)
|
||||
|
||||
ovgen = Ovgen(cfg, '.', outputs=[NULL_OUTPUT])
|
||||
ovgen = Ovgen(cfg, Arguments('.', [NULL_OUTPUT]))
|
||||
ovgen.play()
|
||||
|
||||
# But it seems FFmpeg actually allows decimal -framerate (although a bad idea).
|
||||
|
|
|
|||
Ładowanie…
Reference in New Issue