diff options
Diffstat (limited to 'src/avp/gui')
| -rw-r--r-- | src/avp/gui/__init__.py | 0 | ||||
| -rw-r--r-- | src/avp/gui/actions.py | 196 | ||||
| -rw-r--r-- | src/avp/gui/background.png | bin | 0 -> 45367 bytes | |||
| -rw-r--r-- | src/avp/gui/mainwindow.py | 1053 | ||||
| -rw-r--r-- | src/avp/gui/mainwindow.ui | 835 | ||||
| -rw-r--r-- | src/avp/gui/presetmanager.py | 349 | ||||
| -rw-r--r-- | src/avp/gui/presetmanager.ui | 150 | ||||
| -rw-r--r-- | src/avp/gui/preview_thread.py | 93 | ||||
| -rw-r--r-- | src/avp/gui/preview_win.py | 58 |
9 files changed, 2734 insertions, 0 deletions
diff --git a/src/avp/gui/__init__.py b/src/avp/gui/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/avp/gui/__init__.py diff --git a/src/avp/gui/actions.py b/src/avp/gui/actions.py new file mode 100644 index 0000000..654b2a0 --- /dev/null +++ b/src/avp/gui/actions.py @@ -0,0 +1,196 @@ +""" +QCommand classes for every undoable user action performed in the MainWindow +""" + +from PyQt6.QtGui import QUndoCommand +import os +import logging +from copy import copy + +from ..core import Core + + +log = logging.getLogger("AVP.Gui.Actions") + + +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# COMPONENT ACTIONS +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + +class AddComponent(QUndoCommand): + def __init__(self, parent, compI, moduleI): + super().__init__( + "create new %s component" % parent.core.modules[moduleI].Component.name + ) + self.parent = parent + self.moduleI = moduleI + self.compI = compI + self.comp = None + self.valid = True + + def redo(self): + if self.comp is None: + i = self.parent.core.insertComponent(self.compI, self.moduleI, self.parent) + if i != self.compI: + self.valid = False + if i is not None: + log.error( + f"Expected new component index to be {self.compI} but received {i}" + ) + else: + # inserting previously-created component + self.parent.core.insertComponent(self.compI, self.comp, self.parent) + + def undo(self): + if not self.valid: + return + self.comp = self.parent.core.selectedComponents[self.compI] + self.parent._removeComponent(self.compI) + + +class RemoveComponent(QUndoCommand): + def __init__(self, parent, selectedRows): + super().__init__("remove component") + self.parent = parent + componentList = self.parent.listWidget_componentList + self.selectedRows = [componentList.row(selected) for selected in selectedRows] + self.components = [parent.core.selectedComponents[i] for i in self.selectedRows] + + def redo(self): + self.parent._removeComponent(self.selectedRows[0]) + + def undo(self): + componentList = self.parent.listWidget_componentList + for index, comp in zip(self.selectedRows, self.components): + self.parent.core.insertComponent(index, comp, self.parent) + self.parent.drawPreview() + + +class MoveComponent(QUndoCommand): + def __init__(self, parent, row, newRow, tag): + super().__init__("move component %s" % tag) + self.parent = parent + self.row = row + self.newRow = newRow + self.id_ = ord(tag[0]) + + def id(self): + """If 2 consecutive updates have same id, Qt will call mergeWith()""" + return self.id_ + + def mergeWith(self, other): + self.newRow = other.newRow + return True + + def do(self, rowa, rowb): + componentList = self.parent.listWidget_componentList + + page = self.parent.pages.pop(rowa) + self.parent.pages.insert(rowb, page) + + item = componentList.takeItem(rowa) + componentList.insertItem(rowb, item) + + stackedWidget = self.parent.stackedWidget + widget = stackedWidget.removeWidget(page) + stackedWidget.insertWidget(rowb, page) + componentList.setCurrentRow(rowb) + stackedWidget.setCurrentIndex(rowb) + self.parent.core.moveComponent(rowa, rowb) + self.parent.drawPreview(True) + + def redo(self): + self.do(self.row, self.newRow) + + def undo(self): + self.do(self.newRow, self.row) + + +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ +# PRESET ACTIONS +# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + + +class ClearPreset(QUndoCommand): + def __init__(self, parent, compI): + super().__init__("clear preset") + self.parent = parent + self.compI = compI + self.component = self.parent.core.selectedComponents[compI] + self.store = self.component.savePreset() + self.store["preset"] = self.component.currentPreset + + def redo(self): + self.parent.core.clearPreset(self.compI) + self.parent.updateComponentTitle(self.compI, False) + + def undo(self): + self.parent.core.selectedComponents[self.compI].loadPreset(self.store) + self.parent.updateComponentTitle(self.compI, self.store) + + +class OpenPreset(QUndoCommand): + def __init__(self, parent, presetName, compI): + super().__init__("open %s preset" % presetName) + self.parent = parent + self.presetName = presetName + self.compI = compI + + comp = self.parent.core.selectedComponents[compI] + self.store = comp.savePreset() + self.store["preset"] = copy(comp.currentPreset) + + def redo(self): + self.parent._openPreset(self.presetName, self.compI) + + def undo(self): + self.parent.core.selectedComponents[self.compI].loadPreset(self.store) + self.parent.parent.updateComponentTitle(self.compI, self.store) + + +class RenamePreset(QUndoCommand): + def __init__(self, parent, path, oldName, newName): + super().__init__("rename preset") + self.parent = parent + self.path = path + self.oldName = oldName + self.newName = newName + + def redo(self): + self.parent.renamePreset(self.path, self.oldName, self.newName) + + def undo(self): + self.parent.renamePreset(self.path, self.newName, self.oldName) + + +class DeletePreset(QUndoCommand): + def __init__(self, parent, compName, vers, presetFile): + self.parent = parent + self.preset = (compName, vers, presetFile) + self.path = os.path.join(Core.presetDir, compName, str(vers), presetFile) + self.store = self.parent.core.getPreset(self.path) + self.presetName = self.store["preset"] + super().__init__("delete %s preset (%s)" % (self.presetName, compName)) + self.loadedPresets = [ + i + for i, comp in enumerate(self.parent.core.selectedComponents) + if self.presetName == str(comp.currentPreset) + ] + + def redo(self): + os.remove(self.path) + for i in self.loadedPresets: + self.parent.core.clearPreset(i) + self.parent.parent.updateComponentTitle(i, False) + self.parent.findPresets() + self.parent.drawPresetList() + + def undo(self): + self.parent.createNewPreset(*self.preset, self.store) + selectedComponents = self.parent.core.selectedComponents + for i in self.loadedPresets: + selectedComponents[i].currentPreset = self.presetName + self.parent.parent.updateComponentTitle(i) + self.parent.findPresets() + self.parent.drawPresetList() diff --git a/src/avp/gui/background.png b/src/avp/gui/background.png Binary files differnew file mode 100644 index 0000000..fb58593 --- /dev/null +++ b/src/avp/gui/background.png diff --git a/src/avp/gui/mainwindow.py b/src/avp/gui/mainwindow.py new file mode 100644 index 0000000..b0a564b --- /dev/null +++ b/src/avp/gui/mainwindow.py @@ -0,0 +1,1053 @@ +""" +When using GUI mode, this module's object (the main window) takes +user input to construct a program state (stored in the Core object). +This shows a preview of the video being created and allows for saving +projects and exporting the video at a later time. +""" + +from PyQt6 import QtCore, QtWidgets, uic +import PyQt6.QtWidgets as QtWidgets +from PyQt6.QtGui import QUndoStack, QShortcut +from PIL import Image +from queue import Queue +import sys +import os +import signal +import filecmp +import time +import logging + +from ..core import Core +from . import preview_thread +from .preview_win import PreviewWindow +from .presetmanager import PresetManager +from .actions import * +from ..toolkit import ( + disableWhenEncoding, + disableWhenOpeningProject, + checkOutput, + blockSignals, +) + + +appName = "Audio Visualizer" +log = logging.getLogger("AVP.Gui.MainWindow") + + +class MyQUndoStack(QUndoStack): + # FIXME move this class + @property + def encoding(self): + return self.parent().encoding + + @disableWhenEncoding + def undo(self, *args, **kwargs): + super().undo(*args, **kwargs) + + @disableWhenEncoding + def redo(self, *args, **kwargs): + super().redo(*args, **kwargs) + + +class MainWindow(QtWidgets.QMainWindow): + """ + The MainWindow wraps many Core methods in order to update the GUI + accordingly. E.g., instead of self.core.openProject(), it will use + self.openProject() and update the window titlebar within the wrapper. + + MainWindow manages the autosave feature, although Core has the + primary functions for opening and creating project files. + """ + + createVideo = QtCore.pyqtSignal() + newTask = QtCore.pyqtSignal(list) # for the preview window + processTask = QtCore.pyqtSignal() + + def __init__(self, project, dpi): + super().__init__() + log.debug("Main thread id: {}".format(int(QtCore.QThread.currentThreadId()))) + uic.loadUi(os.path.join(Core.wd, "gui", "mainwindow.ui"), self) + + if dpi: + self.resize( + int(self.width() * (dpi / 144)), + int(self.height() * (dpi / 144)), + ) + + self.core = Core() + Core.mode = "GUI" + # widgets of component settings + self.pages = [] + self.lastAutosave = time.time() + # list of previous five autosave times, used to reduce update spam + self.autosaveTimes = [] + self.autosaveCooldown = 0.2 + self.encoding = False + + # Find settings created by Core object + self.dataDir = Core.dataDir + self.presetDir = Core.presetDir + self.autosavePath = os.path.join(self.dataDir, "autosave.avp") + self.settings = Core.settings + + # Create stack of undoable user actions + self.undoStack = MyQUndoStack(self) + undoLimit = self.settings.value("pref_undoLimit") + self.undoStack.setUndoLimit(undoLimit) + + # Create Undo Dialog - A standard QUndoView on a standard QDialog + self.undoDialog = QtWidgets.QDialog(self) + self.undoDialog.setWindowTitle("Undo History") + undoView = QtWidgets.QUndoView(self.undoStack) + layout = QtWidgets.QVBoxLayout() + layout.addWidget(undoView) + self.undoDialog.setLayout(layout) + + # Create Preset Manager + self.presetManager = PresetManager(self) + + # Create the preview window and its thread, queues, and timers + log.debug("Creating preview window") + self.previewWindow = PreviewWindow( + self, os.path.join(Core.wd, "gui", "background.png") + ) + self.verticalLayout_previewWrapper.addWidget(self.previewWindow) + + log.debug("Starting preview thread") + self.previewQueue = Queue() + self.previewThread = QtCore.QThread(self) + self.previewWorker = preview_thread.Worker( + self.core, self.settings, self.previewQueue + ) + self.previewWorker.moveToThread(self.previewThread) + self.newTask.connect(self.previewWorker.createPreviewImage) + self.processTask.connect(self.previewWorker.process) + self.previewWorker.error.connect(self.previewWindow.threadError) + self.previewWorker.imageCreated.connect(self.showPreviewImage) + self.previewThread.start() + self.previewThread.finished.connect( + lambda: log.info("Preview thread finished.") + ) + + timeout = 500 + log.debug("Preview timer set to trigger when idle for %sms" % str(timeout)) + self.timer = QtCore.QTimer(self) + self.timer.timeout.connect(self.processTask.emit) + self.timer.start(timeout) + + # Begin decorating the window and connecting events + componentList = self.listWidget_componentList + + # Undo Feature + def toggleUndoButtonEnabled(*_): + """Enable/disable undo button depending on whether UndoStack contains Actions""" + try: + undoButton.setEnabled(self.undoStack.count()) + except RuntimeError: + # program is probably in midst of exiting + pass + + style = self.pushButton_undo.style() + undoButton = self.pushButton_undo + undoButton.setIcon( + style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_FileDialogBack) + ) + undoButton.clicked.connect(self.undoStack.undo) + undoButton.setEnabled(False) + self.undoStack.cleanChanged.connect(toggleUndoButtonEnabled) + self.undoMenu = QtWidgets.QMenu() + self.undoMenu.addAction(self.undoStack.createUndoAction(self)) + self.undoMenu.addAction(self.undoStack.createRedoAction(self)) + action = self.undoMenu.addAction("Show History...") + action.triggered.connect(lambda _: self.showUndoStack()) + undoButton.setMenu(self.undoMenu) + # end of Undo Feature + + style = self.pushButton_listMoveUp.style() + self.pushButton_listMoveUp.setIcon( + style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowUp) + ) + style = self.pushButton_listMoveDown.style() + self.pushButton_listMoveDown.setIcon( + style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowDown) + ) + style = self.pushButton_removeComponent.style() + self.pushButton_removeComponent.setIcon( + style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DialogDiscardButton) + ) + + if sys.platform == "darwin": + log.debug("Darwin detected: showing progress label below progress bar") + self.progressBar_createVideo.setTextVisible(False) + else: + self.progressLabel.setHidden(True) + + self.toolButton_selectAudioFile.clicked.connect(self.openInputFileDialog) + + self.toolButton_selectOutputFile.clicked.connect(self.openOutputFileDialog) + + def changedField(): + self.autosave() + self.updateWindowTitle() + + self.lineEdit_audioFile.textChanged.connect(changedField) + self.lineEdit_outputFile.textChanged.connect(changedField) + + self.progressBar_createVideo.setValue(0) + + self.pushButton_createVideo.clicked.connect(self.createAudioVisualization) + + self.pushButton_Cancel.clicked.connect(self.stopVideo) + + for i, container in enumerate(Core.encoderOptions["containers"]): + self.comboBox_videoContainer.addItem(container["name"]) + if container["name"] == self.settings.value("outputContainer"): + selectedContainer = i + + self.comboBox_videoContainer.setCurrentIndex(selectedContainer) + self.comboBox_videoContainer.currentIndexChanged.connect(self.updateCodecs) + + self.updateCodecs() + + for i in range(self.comboBox_videoCodec.count()): + codec = self.comboBox_videoCodec.itemText(i) + if codec == self.settings.value("outputVideoCodec"): + self.comboBox_videoCodec.setCurrentIndex(i) + + for i in range(self.comboBox_audioCodec.count()): + codec = self.comboBox_audioCodec.itemText(i) + if codec == self.settings.value("outputAudioCodec"): + self.comboBox_audioCodec.setCurrentIndex(i) + + self.comboBox_videoCodec.currentIndexChanged.connect(self.updateCodecSettings) + + self.comboBox_audioCodec.currentIndexChanged.connect(self.updateCodecSettings) + + vBitrate = int(self.settings.value("outputVideoBitrate")) + aBitrate = int(self.settings.value("outputAudioBitrate")) + + self.spinBox_vBitrate.setValue(vBitrate) + self.spinBox_aBitrate.setValue(aBitrate) + self.spinBox_vBitrate.valueChanged.connect(self.updateCodecSettings) + self.spinBox_aBitrate.valueChanged.connect(self.updateCodecSettings) + + # Make component buttons + self.compMenu = QtWidgets.QMenu() + for i, comp in enumerate(self.core.modules): + action = self.compMenu.addAction(comp.Component.name) + action.triggered.connect(lambda _, item=i: self.addComponent(0, item)) + + self.pushButton_addComponent.setMenu(self.compMenu) + + componentList.dropEvent = self.dragComponent + componentList.itemSelectionChanged.connect(self.changeComponentWidget) + componentList.itemSelectionChanged.connect( + self.presetManager.clearPresetListSelection + ) + self.pushButton_removeComponent.clicked.connect(lambda: self.removeComponent()) + + componentList.setContextMenuPolicy( + QtCore.Qt.ContextMenuPolicy.CustomContextMenu + ) + componentList.customContextMenuRequested.connect(self.componentContextMenu) + + currentRes = ( + str(self.settings.value("outputWidth")) + + "x" + + str(self.settings.value("outputHeight")) + ) + for i, res in enumerate(Core.resolutions): + self.comboBox_resolution.addItem(res) + if res == currentRes: + currentRes = i + self.comboBox_resolution.setCurrentIndex(currentRes) + self.comboBox_resolution.currentIndexChanged.connect( + self.updateResolution + ) + + self.pushButton_listMoveUp.clicked.connect(lambda: self.moveComponent(-1)) + self.pushButton_listMoveDown.clicked.connect(lambda: self.moveComponent(1)) + + # Configure the Projects Menu + self.projectMenu = QtWidgets.QMenu() + self.menuButton_newProject = self.projectMenu.addAction("New Project") + self.menuButton_newProject.triggered.connect(lambda: self.createNewProject()) + self.menuButton_openProject = self.projectMenu.addAction("Open Project") + self.menuButton_openProject.triggered.connect( + lambda: self.openOpenProjectDialog() + ) + + action = self.projectMenu.addAction("Save Project") + action.triggered.connect(self.saveCurrentProject) + + action = self.projectMenu.addAction("Save Project As") + action.triggered.connect(self.openSaveProjectDialog) + + self.pushButton_projects.setMenu(self.projectMenu) + + # Configure the Presets Button + self.pushButton_presets.clicked.connect(self.openPresetManager) + + self.updateWindowTitle() + log.debug("Showing main window") + self.show() + + if project and project != self.autosavePath: + if not project.endswith(".avp"): + project += ".avp" + # open a project from the commandline + if not os.path.dirname(project): + project = os.path.join(self.settings.value("projectDir"), project) + self.currentProject = project + self.settings.setValue("currentProject", project) + if os.path.exists(self.autosavePath): + os.remove(self.autosavePath) + else: + # open the last currentProject from settings + self.currentProject = self.settings.value("currentProject") + + # delete autosave if it's identical to this project + if self.autosaveExists(identical=True): + os.remove(self.autosavePath) + + if self.currentProject and os.path.exists(self.autosavePath): + ch = self.showMessage( + msg="Restore unsaved changes in project '%s'?" + % os.path.basename(self.currentProject)[:-4], + showCancel=True, + ) + if ch: + self.saveProjectChanges() + else: + os.remove(self.autosavePath) + + self.openProject(self.currentProject, prompt=False) + self.drawPreview(True) + + log.info("Pillow version %s", Image.__version__) + + # verify Ffmpeg version + if not self.core.FFMPEG_BIN: + self.showMessage( + msg="FFmpeg could not be found. This is a critical error. " + "Install FFmpeg, or download it and place the program executable " + "in the same folder as this program.", + icon="Critical", + ) + else: + if not self.settings.value("ffmpegMsgShown"): + try: + with open(os.devnull, "w") as f: + ffmpegVers = checkOutput( + [self.core.FFMPEG_BIN, "-version"], stderr=f + ) + goodVersion = str(ffmpegVers).split()[2].startswith("4") + except Exception: + goodVersion = False + else: + goodVersion = True + + if not goodVersion: + self.showMessage( + msg="You're using an old version of Ffmpeg. " + "Some features may not work as expected." + ) + self.settings.setValue("ffmpegMsgShown", True) + + # Hotkeys for projects + + QShortcut("Ctrl+S", self, self.saveCurrentProject) + QShortcut("Ctrl+A", self, self.openSaveProjectDialog) + QShortcut("Ctrl+O", self, self.openOpenProjectDialog) + QShortcut("Ctrl+N", self, self.createNewProject) + + # Hotkeys for undo/redo + QShortcut("Ctrl+Z", self, self.undoStack.undo) + QShortcut("Ctrl+Y", self, self.undoStack.redo) + QShortcut("Ctrl+Shift+Z", self, self.undoStack.redo) + + # Hotkeys for component list + for inskey in ("Ctrl+T", QtCore.Qt.Key.Key_Insert): + QShortcut( + inskey, + self, + activated=lambda: self.pushButton_addComponent.click(), + ) + for delkey in ("Ctrl+R", QtCore.Qt.Key.Key_Delete): + QShortcut(delkey, self.listWidget_componentList, self.removeComponent) + QShortcut( + "Ctrl+Space", + self, + activated=lambda: self.listWidget_componentList.setFocus(), + ) + QShortcut("Ctrl+Shift+S", self, self.presetManager.openSavePresetDialog) + QShortcut("Ctrl+Shift+C", self, self.presetManager.clearPreset) + + QShortcut( + "Ctrl+Up", + self.listWidget_componentList, + activated=lambda: self.moveComponent(-1), + ) + QShortcut( + "Ctrl+Down", + self.listWidget_componentList, + activated=lambda: self.moveComponent(1), + ) + QShortcut( + "Ctrl+Home", + self.listWidget_componentList, + activated=lambda: self.moveComponent("top"), + ) + QShortcut( + "Ctrl+End", + self.listWidget_componentList, + activated=lambda: self.moveComponent("bottom"), + ) + + QShortcut("Ctrl+Shift+F", self, self.showFfmpegCommand) + QShortcut("Ctrl+Shift+U", self, self.showUndoStack) + + if log.isEnabledFor(logging.DEBUG): + QShortcut("Ctrl+Alt+Shift+R", self, self.drawPreview) + QShortcut("Ctrl+Alt+Shift+A", self, lambda: log.debug(repr(self))) + + # Close MainWindow when receiving Ctrl+C from terminal + signal.signal(signal.SIGINT, lambda *args: self.close()) + + # Add initial components if none are in the list + if not self.core.selectedComponents: + self.core.insertComponent(0, 0, self) + self.core.insertComponent(1, 1, self) + + def __repr__(self): + return ( + "%s\n" + "\n%s\n" + "#####\n" + "Preview thread is %s\n" + % ( + super().__repr__(), + ( + "core not initialized" + if not hasattr(self, "core") + else repr(self.core) + ), + ( + "live" + if hasattr(self, "previewThread") and self.previewThread.isRunning() + else "dead" + ), + ) + ) + + def closeEvent(self, event): + log.info("Ending the preview thread") + self.timer.stop() + self.previewThread.quit() + self.previewThread.wait() + return super().closeEvent(event) + + @disableWhenOpeningProject + def updateWindowTitle(self): + log.debug("Setting main window's title") + windowTitle = appName + try: + if self.currentProject: + windowTitle += ( + " - %s" % os.path.splitext(os.path.basename(self.currentProject))[0] + ) + if self.autosaveExists(identical=False): + windowTitle += "*" + except AttributeError: + pass + log.verbose(f'Window title is "{windowTitle}"') + self.setWindowTitle(windowTitle) + + @QtCore.pyqtSlot(int, dict) + def updateComponentTitle(self, pos, presetStore=False): + """ + Sets component title to modified or unmodified when given boolean. + If given a preset dict, compares it against the component to + determine if it is modified. + A component with no preset is always unmodified. + """ + if type(presetStore) is dict: + name = presetStore["preset"] + if name is None or name not in self.core.savedPresets: + modified = False + else: + modified = presetStore != self.core.savedPresets[name] + + modified = bool(presetStore) + if pos < 0: + pos = len(self.core.selectedComponents) - 1 + name = self.core.selectedComponents[pos].name + title = str(name) + if self.core.selectedComponents[pos].currentPreset: + title += " - %s" % self.core.selectedComponents[pos].currentPreset + if modified: + title += "*" + if type(presetStore) is bool: + log.debug( + "Forcing %s #%s's modified status to %s: %s", + name, + pos, + modified, + title, + ) + else: + log.debug("Setting %s #%s's title: %s", name, pos, title) + self.listWidget_componentList.item(pos).setText(title) + + def updateCodecs(self): + containerWidget = self.comboBox_videoContainer + vCodecWidget = self.comboBox_videoCodec + aCodecWidget = self.comboBox_audioCodec + index = containerWidget.currentIndex() + name = containerWidget.itemText(index) + self.settings.setValue("outputContainer", name) + + vCodecWidget.clear() + aCodecWidget.clear() + + for container in Core.encoderOptions["containers"]: + if container["name"] == name: + for vCodec in container["video-codecs"]: + vCodecWidget.addItem(vCodec) + for aCodec in container["audio-codecs"]: + aCodecWidget.addItem(aCodec) + + def updateCodecSettings(self): + """Updates settings.ini to match encoder option widgets""" + vCodecWidget = self.comboBox_videoCodec + vBitrateWidget = self.spinBox_vBitrate + aBitrateWidget = self.spinBox_aBitrate + aCodecWidget = self.comboBox_audioCodec + currentVideoCodec = vCodecWidget.currentIndex() + currentVideoCodec = vCodecWidget.itemText(currentVideoCodec) + currentVideoBitrate = vBitrateWidget.value() + currentAudioCodec = aCodecWidget.currentIndex() + currentAudioCodec = aCodecWidget.itemText(currentAudioCodec) + currentAudioBitrate = aBitrateWidget.value() + self.settings.setValue("outputVideoCodec", currentVideoCodec) + self.settings.setValue("outputAudioCodec", currentAudioCodec) + self.settings.setValue("outputVideoBitrate", currentVideoBitrate) + self.settings.setValue("outputAudioBitrate", currentAudioBitrate) + + @disableWhenOpeningProject + def autosave(self, force=False): + if not self.currentProject: + if os.path.exists(self.autosavePath): + os.remove(self.autosavePath) + elif force or time.time() - self.lastAutosave >= self.autosaveCooldown: + self.core.createProjectFile(self.autosavePath, self) + self.lastAutosave = time.time() + if len(self.autosaveTimes) >= 5: + # Do some math to reduce autosave spam. This gives a smooth + # curve up to 5 seconds cooldown and maintains that for 30 secs + # if a component is continuously updated + timeDiff = self.lastAutosave - self.autosaveTimes.pop() + if not force and timeDiff >= 1.0 and timeDiff <= 10.0: + if self.autosaveCooldown / 4.0 < 0.5: + self.autosaveCooldown += 1.0 + self.autosaveCooldown = (5.0 * (self.autosaveCooldown / 5.0)) + ( + self.autosaveCooldown / 5.0 + ) * 2 + elif force or timeDiff >= self.autosaveCooldown * 5: + self.autosaveCooldown = 0.2 + self.autosaveTimes.insert(0, self.lastAutosave) + else: + log.debug("Autosave rejected by cooldown") + + def autosaveExists(self, identical=True): + """Determines if creating the autosave should be blocked.""" + try: + if ( + self.currentProject + and os.path.exists(self.autosavePath) + and filecmp.cmp(self.autosavePath, self.currentProject) == identical + ): + log.debug( + "Autosave found %s to be identical" % "not" if not identical else "" + ) + return True + except FileNotFoundError: + log.error("Project file couldn't be located: %s", self.currentProject) + return identical + return False + + def saveProjectChanges(self): + """Overwrites project file with autosave file""" + try: + os.remove(self.currentProject) + os.rename(self.autosavePath, self.currentProject) + return True + except (FileNotFoundError, IsADirectoryError) as e: + self.showMessage(msg="Project file couldn't be saved.", detail=str(e)) + return False + + def openInputFileDialog(self): + inputDir = self.settings.value("inputDir", os.path.expanduser("~")) + + fileName, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "Open Audio File", + inputDir, + "Audio Files (%s)" % " ".join(Core.audioFormats), + ) + + if fileName: + self.settings.setValue("inputDir", os.path.dirname(fileName)) + self.lineEdit_audioFile.setText(fileName) + + def openOutputFileDialog(self): + outputDir = self.settings.value("outputDir", os.path.expanduser("~")) + + fileName, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Set Output Video File", + outputDir, + "Video Files (%s);; All Files (*)" % " ".join(Core.videoFormats), + ) + + if fileName: + self.settings.setValue("outputDir", os.path.dirname(fileName)) + self.lineEdit_outputFile.setText(fileName) + + def stopVideo(self): + log.info("Export cancelled") + self.videoWorker.cancel() + self.canceled = True + + def createAudioVisualization(self): + # create output video if mandatory settings are filled in + audioFile = self.lineEdit_audioFile.text() + outputPath = self.lineEdit_outputFile.text() + + if audioFile and outputPath and self.core.selectedComponents: + if not os.path.dirname(outputPath): + outputPath = os.path.join(os.path.expanduser("~"), outputPath) + if outputPath and os.path.isdir(outputPath): + self.showMessage( + msg="Chosen filename matches a directory, which " + "cannot be overwritten. Please choose a different " + "filename or move the directory.", + icon="Warning", + ) + return + else: + if not audioFile or not outputPath: + self.showMessage( + msg="You must select an audio file and output filename." + ) + elif not self.core.selectedComponents: + self.showMessage(msg="Not enough components.") + return + + self.canceled = False + self.progressBarUpdated(-1) + self.videoWorker = self.core.newVideoWorker(self, audioFile, outputPath) + self.videoWorker.progressBarUpdate.connect(self.progressBarUpdated) + self.videoWorker.progressBarSetText.connect(self.progressBarSetText) + self.videoWorker.imageCreated.connect(self.showPreviewImage) + self.videoWorker.encoding.connect(self.changeEncodingStatus) + self.createVideo.emit() + + @QtCore.pyqtSlot(str, str) + def videoThreadError(self, msg, detail): + try: + self.stopVideo() + except AttributeError as e: + if "videoWorker" not in str(e): + raise + self.showMessage( + msg=msg, + detail=detail, + icon="Critical", + ) + log.info("%s", repr(self)) + + def changeEncodingStatus(self, status): + self.encoding = status + if status: + # Disable many widgets when starting to export + self.pushButton_createVideo.setEnabled(False) + self.pushButton_Cancel.setEnabled(True) + self.comboBox_resolution.setEnabled(False) + self.stackedWidget.setEnabled(False) + self.tab_encoderSettings.setEnabled(False) + self.label_audioFile.setEnabled(False) + self.toolButton_selectAudioFile.setEnabled(False) + self.label_outputFile.setEnabled(False) + self.toolButton_selectOutputFile.setEnabled(False) + self.lineEdit_audioFile.setEnabled(False) + self.lineEdit_outputFile.setEnabled(False) + self.listWidget_componentList.setEnabled(False) + self.pushButton_addComponent.setEnabled(False) + self.pushButton_removeComponent.setEnabled(False) + self.pushButton_listMoveDown.setEnabled(False) + self.pushButton_listMoveUp.setEnabled(False) + self.pushButton_undo.setEnabled(False) + self.menuButton_newProject.setEnabled(False) + self.menuButton_openProject.setEnabled(False) + # Close undo history dialog if open + self.undoDialog.close() + # Show label under progress bar on macOS + if sys.platform == "darwin": + self.progressLabel.setHidden(False) + else: + self.pushButton_createVideo.setEnabled(True) + self.pushButton_Cancel.setEnabled(False) + self.comboBox_resolution.setEnabled(True) + self.stackedWidget.setEnabled(True) + self.tab_encoderSettings.setEnabled(True) + self.label_audioFile.setEnabled(True) + self.toolButton_selectAudioFile.setEnabled(True) + self.lineEdit_audioFile.setEnabled(True) + self.label_outputFile.setEnabled(True) + self.toolButton_selectOutputFile.setEnabled(True) + self.lineEdit_outputFile.setEnabled(True) + self.pushButton_addComponent.setEnabled(True) + self.pushButton_removeComponent.setEnabled(True) + self.pushButton_listMoveDown.setEnabled(True) + self.pushButton_listMoveUp.setEnabled(True) + self.pushButton_undo.setEnabled(True) + self.menuButton_newProject.setEnabled(True) + self.menuButton_openProject.setEnabled(True) + self.listWidget_componentList.setEnabled(True) + self.progressLabel.setHidden(True) + self.drawPreview(True) + + @QtCore.pyqtSlot(int) + def progressBarUpdated(self, value): + self.progressBar_createVideo.setValue(value) + + @QtCore.pyqtSlot(str) + def progressBarSetText(self, value): + if sys.platform == "darwin": + self.progressLabel.setText(value) + else: + self.progressBar_createVideo.setFormat(value) + + def updateResolution(self): + resIndex = int(self.comboBox_resolution.currentIndex()) + res = Core.resolutions[resIndex].split("x") + changed = res[0] != self.settings.value("outputWidth") + self.settings.setValue("outputWidth", res[0]) + self.settings.setValue("outputHeight", res[1]) + if changed: + for i in range(len(self.core.selectedComponents)): + self.core.updateComponent(i) + + def drawPreview(self, force=False, **kwargs): + """Use autosave keyword arg to force saving or not saving if needed""" + self.newTask.emit(self.core.selectedComponents) + # self.processTask.emit() + if force or "autosave" in kwargs: + if force or kwargs["autosave"]: + self.autosave(True) + else: + self.autosave() + self.updateWindowTitle() + + @QtCore.pyqtSlot("QImage") + def showPreviewImage(self, image): + self.previewWindow.changePixmap(image) + + @disableWhenEncoding + def showUndoStack(self): + self.undoDialog.show() + + def showFfmpegCommand(self): + from textwrap import wrap + from ..toolkit.ffmpeg import createFfmpegCommand + + command = createFfmpegCommand( + self.lineEdit_audioFile.text(), + self.lineEdit_outputFile.text(), + self.core.selectedComponents, + ) + command = " ".join(command) + log.info(f"FFmpeg command: {command}") + lines = wrap(command, 49) + self.showMessage(msg=f"Current FFmpeg command:\n\n{' '.join(lines)}") + + def addComponent(self, compPos, moduleIndex): + """Creates an undoable action that adds a new component.""" + action = AddComponent(self, compPos, moduleIndex) + self.undoStack.push(action) + + def insertComponent(self, index): + """Triggered by Core to finish initializing a new component.""" + if not hasattr(self.core.selectedComponents[index], "page"): + log.error("Component failed to initialize") + return + componentList = self.listWidget_componentList + stackedWidget = self.stackedWidget + + componentList.insertItem(index, self.core.selectedComponents[index].name) + componentList.setCurrentRow(index) + + # connect to signal that adds an asterisk when modified + self.core.selectedComponents[index].modified.connect(self.updateComponentTitle) + + self.pages.insert(index, self.core.selectedComponents[index].page) + stackedWidget.insertWidget(index, self.pages[index]) + stackedWidget.setCurrentIndex(index) + + return index + + def removeComponent(self): + componentList = self.listWidget_componentList + selected = componentList.selectedItems() + if selected: + action = RemoveComponent(self, selected) + self.undoStack.push(action) + + def _removeComponent(self, index): + stackedWidget = self.stackedWidget + componentList = self.listWidget_componentList + stackedWidget.removeWidget(self.pages[index]) + componentList.takeItem(index) + self.core.removeComponent(index) + self.pages.pop(index) + self.changeComponentWidget() + self.drawPreview() + + @disableWhenEncoding + def moveComponent(self, change): + """Moves a component relatively from its current position""" + componentList = self.listWidget_componentList + tag = change + if change == "top": + change = -componentList.currentRow() + elif change == "bottom": + change = len(componentList) - componentList.currentRow() - 1 + else: + tag = "down" if change == 1 else "up" + + row = componentList.currentRow() + newRow = row + change + if newRow > -1 and newRow < componentList.count(): + action = MoveComponent(self, row, newRow, tag) + self.undoStack.push(action) + + def getComponentListMousePos(self, position): + """ + Given a QPos, returns the component index under the mouse cursor + or -1 if no component is there. + """ + componentList = self.listWidget_componentList + + if hasattr(position, "toPointF"): + position = position.toPointF() + position = position.toPoint() + + modelIndexes = [ + componentList.model().index(i) for i in range(componentList.count()) + ] + rects = [componentList.visualRect(modelIndex) for modelIndex in modelIndexes] + mousePos = [rect.contains(position) for rect in rects] + if not any(mousePos): + # Not clicking a component + mousePos = -1 + else: + mousePos = mousePos.index(True) + log.debug("Click component list row %s" % mousePos) + return mousePos + + @disableWhenEncoding + def dragComponent(self, event): + """Used as Qt drop event for the component listwidget""" + componentList = self.listWidget_componentList + mousePos = self.getComponentListMousePos(event.position()) + + if mousePos > -1: + change = (componentList.currentRow() - mousePos) * -1 + else: + change = componentList.count() - componentList.currentRow() - 1 + self.moveComponent(change) + + def changeComponentWidget(self): + selected = self.listWidget_componentList.selectedItems() + if selected: + index = self.listWidget_componentList.row(selected[0]) + self.stackedWidget.setCurrentIndex(index) + + def openPresetManager(self): + """Preset manager for importing, exporting, renaming, deleting""" + self.presetManager.show_() + + def clear(self): + """Get a blank slate""" + self.core.clearComponents() + self.listWidget_componentList.clear() + for widget in self.pages: + self.stackedWidget.removeWidget(widget) + self.pages = [] + for field in (self.lineEdit_audioFile, self.lineEdit_outputFile): + with blockSignals(field): + field.setText("") + self.progressBarUpdated(0) + self.progressBarSetText("") + self.undoStack.clear() + + @disableWhenEncoding + def createNewProject(self, prompt=True): + if prompt: + self.openSaveChangesDialog("starting a new project") + + self.clear() + self.currentProject = None + self.settings.setValue("currentProject", None) + self.drawPreview(True) + + def saveCurrentProject(self): + if self.currentProject: + self.core.createProjectFile(self.currentProject, self) + try: + os.remove(self.autosavePath) + except FileNotFoundError: + pass + self.updateWindowTitle() + else: + self.openSaveProjectDialog() + + def openSaveChangesDialog(self, phrase): + success = True + if self.autosaveExists(identical=False): + ch = self.showMessage( + msg="You have unsaved changes in project '%s'. " + "Save before %s?" + % (os.path.basename(self.currentProject)[:-4], phrase), + showCancel=True, + ) + if ch: + success = self.saveProjectChanges() + + if success and os.path.exists(self.autosavePath): + os.remove(self.autosavePath) + + def openSaveProjectDialog(self): + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Create Project File", + self.settings.value("projectDir"), + "Project Files (*.avp)", + ) + if not filename: + return + if not filename.endswith(".avp"): + filename += ".avp" + self.settings.setValue("projectDir", os.path.dirname(filename)) + self.settings.setValue("currentProject", filename) + self.currentProject = filename + self.core.createProjectFile(filename, self) + self.updateWindowTitle() + + @disableWhenEncoding + def openOpenProjectDialog(self): + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "Open Project File", + self.settings.value("projectDir"), + "Project Files (*.avp)", + ) + self.openProject(filename) + + def openProject(self, filepath, prompt=True): + if ( + not filepath + or not os.path.exists(filepath) + or not filepath.endswith(".avp") + ): + return + + self.clear() + # ask to save any changes that are about to get deleted + if prompt: + self.openSaveChangesDialog("opening another project") + + self.currentProject = filepath + self.settings.setValue("currentProject", filepath) + self.settings.setValue("projectDir", os.path.dirname(filepath)) + # actually load the project using core method + self.core.openProject(self, filepath) + self.drawPreview(autosave=False) + self.updateWindowTitle() + + def showMessage(self, **kwargs): + parent = kwargs["parent"] if "parent" in kwargs else self + msg = QtWidgets.QMessageBox(parent) + msg.setWindowTitle(appName) + msg.setModal(True) + msg.setText(kwargs["msg"]) + msg.setIcon( + eval("QtWidgets.QMessageBox.Icon.%s" % kwargs["icon"]) + if "icon" in kwargs + else QtWidgets.QMessageBox.Icon.Information + ) + msg.setDetailedText(kwargs["detail"] if "detail" in kwargs else None) + if "showCancel" in kwargs and kwargs["showCancel"]: + msg.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Ok + | QtWidgets.QMessageBox.StandardButton.Cancel + ) + else: + msg.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok) + ch = msg.exec() + if ch == 1024: + return True + return False + + @disableWhenEncoding + def componentContextMenu(self, QPos): + """Appears when right-clicking the component list""" + componentList = self.listWidget_componentList + self.menu = QtWidgets.QMenu() + parentPosition = componentList.mapToGlobal(QtCore.QPoint(0, 0)) + + index = self.getComponentListMousePos(QPos) + if index > -1: + # Show preset menu if clicking a component + self.presetManager.findPresets() + menuItem = self.menu.addAction("Save Preset") + menuItem.triggered.connect(self.presetManager.openSavePresetDialog) + + # submenu for opening presets + try: + presets = self.presetManager.presets[ + str(self.core.selectedComponents[index]) + ] + self.presetSubmenu = QtWidgets.QMenu("Open Preset") + self.menu.addMenu(self.presetSubmenu) + + for version, presetName in presets: + menuItem = self.presetSubmenu.addAction(presetName) + menuItem.triggered.connect( + lambda _, presetName=presetName: self.presetManager.openPreset( + presetName + ) + ) + except KeyError: + pass + + if self.core.selectedComponents[index].currentPreset: + menuItem = self.menu.addAction("Clear Preset") + menuItem.triggered.connect(self.presetManager.clearPreset) + self.menu.addSeparator() + + # "Add Component" submenu + self.submenu = QtWidgets.QMenu("Add") + self.menu.addMenu(self.submenu) + insertCompAtTop = self.settings.value("pref_insertCompAtTop") + for i, comp in enumerate(self.core.modules): + menuItem = self.submenu.addAction(comp.Component.name) + menuItem.triggered.connect( + lambda _, item=i: self.addComponent( + 0 if insertCompAtTop else index, item + ) + ) + + self.menu.move(parentPosition + QPos) + self.menu.show() diff --git a/src/avp/gui/mainwindow.ui b/src/avp/gui/mainwindow.ui new file mode 100644 index 0000000..cd8454d --- /dev/null +++ b/src/avp/gui/mainwindow.ui @@ -0,0 +1,835 @@ +<?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>1008</width> + <height>575</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="focusPolicy"> + <enum>Qt::StrongFocus</enum> + </property> + <property name="windowTitle"> + <string>MainWindow</string> + </property> + <widget class="QWidget" name="centralwidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="autoFillBackground"> + <bool>false</bool> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>9</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <spacer name="verticalSpacer_2"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>0</width> + <height>360</height> + </size> + </property> + </spacer> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_previewWrapper"> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <item> + <spacer name="horizontalSpacer_previewSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>420</width> + <height>0</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <property name="leftMargin"> + <number>3</number> + </property> + <item> + <layout class="QVBoxLayout" name="verticalLayout_4"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <property name="leftMargin"> + <number>3</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_16"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <item> + <widget class="QPushButton" name="pushButton_undo"> + <property name="text"> + <string>Undo</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_6"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>140</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_projects"> + <property name="text"> + <string>Projects</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_presets"> + <property name="text"> + <string>Presets</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>2</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QListWidget" name="listWidget_componentList"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>16777215</height> + </size> + </property> + <property name="acceptDrops"> + <bool>true</bool> + </property> + <property name="frameShape"> + <enum>QFrame::StyledPanel</enum> + </property> + <property name="frameShadow"> + <enum>QFrame::Sunken</enum> + </property> + <property name="lineWidth"> + <number>1</number> + </property> + <property name="tabKeyNavigation"> + <bool>true</bool> + </property> + <property name="dragEnabled"> + <bool>true</bool> + </property> + <property name="dragDropOverwriteMode"> + <bool>false</bool> + </property> + <property name="dragDropMode"> + <enum>QAbstractItemView::InternalMove</enum> + </property> + <property name="defaultDropAction"> + <enum>Qt::MoveAction</enum> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_14"> + <item> + <widget class="QPushButton" name="pushButton_addComponent"> + <property name="text"> + <string>Add</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_removeComponent"> + <property name="text"> + <string>Remove</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_listMoveUp"> + <property name="text"> + <string>Up</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_listMoveDown"> + <property name="text"> + <string>Down</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_7"> + <property name="leftMargin"> + <number>4</number> + </property> + <property name="rightMargin"> + <number>2</number> + </property> + </layout> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <property name="sizeConstraint"> + <enum>QLayout::SetFixedSize</enum> + </property> + <property name="topMargin"> + <number>4</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <item> + <widget class="QTabWidget" name="tabWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>500</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>180</height> + </size> + </property> + <property name="tabPosition"> + <enum>QTabWidget::North</enum> + </property> + <property name="tabShape"> + <enum>QTabWidget::Rounded</enum> + </property> + <property name="currentIndex"> + <number>0</number> + </property> + <widget class="QWidget" name="tab_exportVideo"> + <attribute name="title"> + <string>Export Video</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_10"> + <property name="margin"> + <number>10</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_4"> + <property name="topMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label_audioFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>80</width> + <height>16777215</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>80</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Audio File</string> + </property> + <property name="alignment"> + <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_audioFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_selectAudioFile"> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_11"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_6"> + <item> + <widget class="QLabel" name="label_outputFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="baseSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Output File</string> + </property> + <property name="alignment"> + <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="lineEdit_outputFile"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="toolButton_selectOutputFile"> + <property name="minimumSize"> + <size> + <width>0</width> + <height>28</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>28</height> + </size> + </property> + <property name="text"> + <string>...</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <property name="margin"> + <number>0</number> + </property> + <item> + <widget class="QProgressBar" name="progressBar_createVideo"> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="value"> + <number>24</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>10</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_createVideo"> + <property name="text"> + <string>Create Video</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_Cancel"> + <property name="enabled"> + <bool>false</bool> + </property> + <property name="text"> + <string>Cancel</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QLabel" name="progressLabel"> + <property name="text"> + <string/> + </property> + <property name="scaledContents"> + <bool>true</bool> + </property> + <property name="alignment"> + <set>Qt::AlignCenter</set> + </property> + <property name="indent"> + <number>-1</number> + </property> + </widget> + </item> + </layout> + <zorder></zorder> + <zorder></zorder> + <zorder>progressLabel</zorder> + </widget> + <widget class="QWidget" name="tab_encoderSettings"> + <attribute name="title"> + <string>Encoder Settings</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_9"> + <property name="margin"> + <number>10</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_13"> + <item> + <widget class="QLabel" name="label_videoFormat"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Container</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_videoContainer"> + <property name="minimumSize"> + <size> + <width>150</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Minimum</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>5</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_videoPreset"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Resolution</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_resolution"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10"> + <item> + <widget class="QLabel" name="label_videoCodec"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Video Codec</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_videoCodec"> + <property name="minimumSize"> + <size> + <width>150</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_4"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>5</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_resolution"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Video Bitrate (Kbps)</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_vBitrate"> + <property name="maximum"> + <number>99999</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_11"> + <item> + <widget class="QLabel" name="label_audioCodec"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>85</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Audio Codec</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_audioCodec"> + <property name="minimumSize"> + <size> + <width>150</width> + <height>0</height> + </size> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::Fixed</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>5</width> + <height>10</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label_audioBitrate"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Audio Bitrate (Kbps)</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="spinBox_aBitrate"> + <property name="maximum"> + <number>9999</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_5"> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeType"> + <enum>QSizePolicy::MinimumExpanding</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>500</width> + <height>0</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QStackedWidget" name="stackedWidget"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>180</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>180</height> + </size> + </property> + <property name="currentIndex"> + <number>-1</number> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/avp/gui/presetmanager.py b/src/avp/gui/presetmanager.py new file mode 100644 index 0000000..980a969 --- /dev/null +++ b/src/avp/gui/presetmanager.py @@ -0,0 +1,349 @@ +""" +Preset manager object handles all interactions with presets, including +the context menu accessed from MainWindow. +""" + +from PyQt6 import QtCore, QtWidgets, uic +import string +import os +import logging + +from ..toolkit import badName +from ..core import Core +from .actions import * + + +log = logging.getLogger("AVP.Gui.PresetManager") + + +class PresetManager(QtWidgets.QDialog): + def __init__(self, parent): + super().__init__() + uic.loadUi(os.path.join(Core.wd, "gui", "presetmanager.ui"), self) + self.parent = parent + self.core = parent.core + self.settings = parent.settings + self.presetDir = parent.presetDir + if not self.settings.value("presetDir"): + self.settings.setValue( + "presetDir", os.path.join(parent.dataDir, "projects") + ) + + self.findPresets() + + # window + self.lastFilter = "*" + self.presetRows = [] # list of (comp, vers, name) tuples + + self.setWindowFlags(QtCore.Qt.WindowType.WindowStaysOnTopHint) + + # connect button signals + self.pushButton_delete.clicked.connect(self.openDeletePresetDialog) + self.pushButton_rename.clicked.connect(self.openRenamePresetDialog) + self.pushButton_import.clicked.connect(self.openImportDialog) + self.pushButton_export.clicked.connect(self.openExportDialog) + self.pushButton_close.clicked.connect(self.close) + + # create filter box and preset list + self.drawFilterList() + self.comboBox_filter.currentIndexChanged.connect( + lambda: self.drawPresetList( + self.comboBox_filter.currentText(), self.lineEdit_search.text() + ) + ) + + # make auto-completion for search bar + self.autocomplete = QtCore.QStringListModel() + completer = QtWidgets.QCompleter() + completer.setModel(self.autocomplete) + self.lineEdit_search.setCompleter(completer) + self.lineEdit_search.textChanged.connect( + lambda: self.drawPresetList( + self.comboBox_filter.currentText(), self.lineEdit_search.text() + ) + ) + self.drawPresetList("*") + + def show_(self): + """Open a new preset manager window from the mainwindow""" + self.findPresets() + self.drawFilterList() + self.drawPresetList("*") + self.show() + + def findPresets(self): + log.debug("Searching %s for presets", self.presetDir) + parseList = [] + for dirpath, dirnames, filenames in os.walk(self.presetDir): + # anything without a subdirectory must be a preset folder + if dirnames: + continue + for preset in filenames: + compName = os.path.basename(os.path.dirname(dirpath)) + if compName not in self.core.compNames: + continue + compVers = os.path.basename(dirpath) + try: + parseList.append((compName, int(compVers), preset)) + except ValueError: + continue + self.presets = { + compName: [ + (vers, preset) for name, vers, preset in parseList if name == compName + ] + for compName, _, __ in parseList + } + + def drawPresetList(self, compFilter=None, presetFilter=""): + self.listWidget_presets.clear() + if compFilter: + self.lastFilter = str(compFilter) + else: + compFilter = str(self.lastFilter) + self.presetRows = [] + presetNames = [] + for component, presets in self.presets.items(): + if compFilter != "*" and component != compFilter: + continue + for vers, preset in presets: + if not presetFilter or presetFilter in preset: + self.listWidget_presets.addItem("%s: %s" % (component, preset)) + self.presetRows.append((component, vers, preset)) + if preset not in presetNames: + presetNames.append(preset) + self.autocomplete.setStringList(presetNames) + + def drawFilterList(self): + self.comboBox_filter.clear() + self.comboBox_filter.addItem("*") + for component in self.presets: + self.comboBox_filter.addItem(component) + + def clearPreset(self, compI=None): + """Functions on mainwindow level from the context menu""" + compI = self.parent.listWidget_componentList.currentRow() + action = ClearPreset(self.parent, compI) + self.parent.undoStack.push(action) + + def openSavePresetDialog(self): + """Functions on mainwindow level from the context menu""" + selectedComponents = self.core.selectedComponents + componentList = self.parent.listWidget_componentList + + if componentList.currentRow() == -1: + return + while True: + index = componentList.currentRow() + currentPreset = selectedComponents[index].currentPreset + newName, OK = QtWidgets.QInputDialog.getText( + self.parent, + "Audio Visualizer", + "New Preset Name:", + QtWidgets.QLineEdit.EchoMode.Normal, + currentPreset, + ) + if OK: + if badName(newName): + self.warnMessage(self.parent) + continue + if newName: + if index != -1: + selectedComponents[index].currentPreset = newName + saveValueStore = selectedComponents[index].savePreset() + saveValueStore["preset"] = newName + componentName = str(selectedComponents[index]).strip() + vers = selectedComponents[index].version + self.createNewPreset( + componentName, + vers, + newName, + saveValueStore, + window=self.parent, + ) + self.findPresets() + self.drawPresetList() + self.openPreset(newName, index) + break + + def createNewPreset(self, compName, vers, filename, saveValueStore, **kwargs): + path = os.path.join(self.presetDir, compName, str(vers), filename) + if self.presetExists(path, **kwargs): + return + self.core.createPresetFile(compName, vers, filename, saveValueStore) + + def presetExists(self, path, **kwargs): + if os.path.exists(path): + window = kwargs.get("window", self) + ch = self.parent.showMessage( + msg="%s already exists! Overwrite it?" % os.path.basename(path), + showCancel=True, + icon="Warning", + parent=window, + ) + if not ch: + # user clicked cancel + return True + + return False + + def openPreset(self, presetName, compPos=None): + componentList = self.parent.listWidget_componentList + index = compPos if compPos is not None else componentList.currentRow() + if index == -1: + return + action = OpenPreset(self, presetName, index) + self.parent.undoStack.push(action) + + def _openPreset(self, presetName, index): + selectedComponents = self.core.selectedComponents + + componentName = selectedComponents[index].name.strip() + version = selectedComponents[index].version + dirname = os.path.join(self.presetDir, componentName, str(version)) + filepath = os.path.join(dirname, presetName) + self.core.openPreset(filepath, index, presetName) + + self.parent.updateComponentTitle(index) + self.parent.drawPreview() + + def openDeletePresetDialog(self): + row = self.getPresetRow() + if row == -1: + return + comp, vers, name = self.presetRows[row] + ch = self.parent.showMessage( + msg="Really delete %s?" % name, + showCancel=True, + icon="Warning", + parent=self, + ) + if not ch: + return + self.deletePreset(comp, vers, name) + + def deletePreset(self, comp, vers, name): + action = DeletePreset(self, comp, vers, name) + self.parent.undoStack.push(action) + + def warnMessage(self, window=None): + self.parent.showMessage( + msg="Preset names must contain only letters, " "numbers, and spaces.", + parent=window if window else self, + ) + + def getPresetRow(self): + row = self.listWidget_presets.currentRow() + if row > -1: + return row + + # check if component selected in MainWindow has preset loaded + componentList = self.parent.listWidget_componentList + compIndex = componentList.currentRow() + if compIndex == -1: + return compIndex + + preset = self.core.selectedComponents[compIndex].currentPreset + if preset is None: + return -1 + else: + rowTuple = ( + self.core.selectedComponents[compIndex].name, + self.core.selectedComponents[compIndex].version, + preset, + ) + for i, tup in enumerate(self.presetRows): + if rowTuple == tup: + index = i + break + else: + return -1 + return index + + def openRenamePresetDialog(self): + presetList = self.listWidget_presets + index = self.getPresetRow() + if index == -1: + return + + while True: + newName, OK = QtWidgets.QInputDialog.getText( + self, + "Preset Manager", + "Rename Preset:", + QtWidgets.QLineEdit.EchoMode.Normal, + self.presetRows[index][2], + ) + if OK: + if badName(newName): + self.warnMessage() + continue + if newName: + comp, vers, oldName = self.presetRows[index] + path = os.path.join(self.presetDir, comp, str(vers)) + newPath = os.path.join(path, newName) + if self.presetExists(newPath): + return + action = RenamePreset(self, path, oldName, newName) + self.parent.undoStack.push(action) + break + + def renamePreset(self, path, oldName, newName): + oldPath = os.path.join(path, oldName) + newPath = os.path.join(path, newName) + if os.path.exists(newPath): + os.remove(newPath) + os.rename(oldPath, newPath) + self.findPresets() + self.drawPresetList() + path = os.path.dirname(newPath) + for i, comp in enumerate(self.core.selectedComponents): + if self.core.getPresetDir(comp) == path and comp.currentPreset == oldName: + self.core.openPreset(newPath, i, newName) + self.parent.updateComponentTitle(i, False) + self.parent.drawPreview() + + def openImportDialog(self): + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "Import Preset File", + self.settings.value("presetDir"), + "Preset Files (*.avl)", + ) + if filename: + # get installed path & ask user to overwrite if needed + path = "" + while True: + if path: + if self.presetExists(path): + break + else: + if os.path.exists(path): + os.remove(path) + success, path = self.core.importPreset(filename) + if success: + break + + self.findPresets() + self.drawPresetList() + self.settings.setValue("presetDir", os.path.dirname(filename)) + + def openExportDialog(self): + index = self.getPresetRow() + if index == -1: + return + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Export Preset", + self.settings.value("presetDir"), + "Preset Files (*.avl)", + ) + if filename: + comp, vers, name = self.presetRows[index] + if not self.core.exportPreset(filename, comp, vers, name): + self.parent.showMessage( + msg="Couldn't export %s." % filename, parent=self + ) + self.settings.setValue("presetDir", os.path.dirname(filename)) + + def clearPresetListSelection(self): + self.listWidget_presets.setCurrentRow(-1) diff --git a/src/avp/gui/presetmanager.ui b/src/avp/gui/presetmanager.ui new file mode 100644 index 0000000..5257b1c --- /dev/null +++ b/src/avp/gui/presetmanager.ui @@ -0,0 +1,150 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>presetmanager</class> + <widget class="QWidget" name="presetmanager"> + <property name="windowModality"> + <enum>Qt::NonModal</enum> + </property> + <property name="enabled"> + <bool>true</bool> + </property> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>497</width> + <height>377</height> + </rect> + </property> + <property name="windowTitle"> + <string>Preset Manager</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QLineEdit" name="lineEdit_search"> + <property name="text"> + <string/> + </property> + <property name="placeholderText"> + <string>Filter by name</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="comboBox_filter"> + <property name="minimumSize"> + <size> + <width>200</width> + <height>0</height> + </size> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QListWidget" name="listWidget_presets"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="tabKeyNavigation"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <property name="sizeConstraint"> + <enum>QLayout::SetMinimumSize</enum> + </property> + <item> + <widget class="QPushButton" name="pushButton_import"> + <property name="text"> + <string>Import</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_export"> + <property name="text"> + <string>Export</string> + </property> + </widget> + </item> + <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="QPushButton" name="pushButton_rename"> + <property name="enabled"> + <bool>true</bool> + </property> + <property name="text"> + <string>Rename</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_delete"> + <property name="text"> + <string>Delete</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_4"> + <item alignment="Qt::AlignRight"> + <widget class="QLabel" name="label"> + <property name="text"> + <string><html><head/><body><p><span style=" font-size:10pt; font-style:italic;">Right-click components in the main window to create presets</span></p></body></html></string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <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="QPushButton" name="pushButton_close"> + <property name="text"> + <string>Close</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/avp/gui/preview_thread.py b/src/avp/gui/preview_thread.py new file mode 100644 index 0000000..1d78516 --- /dev/null +++ b/src/avp/gui/preview_thread.py @@ -0,0 +1,93 @@ +""" +Thread that runs to create QImages for MainWindow's preview label. +Processes a queue of component lists. +""" + +from PyQt6 import QtCore, QtGui, uic +from PyQt6.QtCore import pyqtSignal, pyqtSlot +from PIL import Image +from PIL.ImageQt import ImageQt +from queue import Queue, Empty +import os +import logging + +from ..toolkit.frame import Checkerboard +from ..toolkit import disableWhenOpeningProject + + +log = logging.getLogger("AVP.Gui.PreviewThread") + + +class Worker(QtCore.QObject): + + imageCreated = pyqtSignal(QtGui.QImage) + error = pyqtSignal(str) + + def __init__(self, core, settings, queue): + super().__init__() + self.core = core + self.settings = settings + width = int(self.settings.value("outputWidth")) + height = int(self.settings.value("outputHeight")) + self.queue = queue + self.background = Checkerboard(width, height) + + @disableWhenOpeningProject + @pyqtSlot(list) + def createPreviewImage(self, components): + dic = { + "components": components, + } + self.queue.put(dic) + log.debug("Preview thread id: {}".format(int(QtCore.QThread.currentThreadId()))) + + @pyqtSlot() + def process(self): + try: + nextPreviewInformation = self.queue.get(block=False) + while self.queue.qsize() >= 2: + try: + self.queue.get(block=False) + except Empty: + continue + width = int(self.settings.value("outputWidth")) + height = int(self.settings.value("outputHeight")) + if self.background.width != width or self.background.height != height: + self.background = Checkerboard(width, height) + + frame = self.background.copy() + log.info("Creating new preview frame") + components = nextPreviewInformation["components"] + for component in reversed(components): + try: + component.lockSize(width, height) + newFrame = component.previewRender() + component.unlockSize() + frame = Image.alpha_composite(frame, newFrame) + + except ValueError as e: + errMsg = ( + "Bad frame returned by %s's preview renderer. " + "%s. New frame size was %s*%s; should be %s*%s." + % ( + str(component), + str(e).capitalize(), + newFrame.width, + newFrame.height, + width, + height, + ) + ) + log.critical(errMsg) + self.error.emit(errMsg) + break + except RuntimeError as e: + log.error(str(e)) + else: + # We must store a reference to this QImage + # or else Qt will garbage-collect it on the C++ side + self.frame = ImageQt(frame) + self.imageCreated.emit(QtGui.QImage(self.frame)) + + except Empty: + True diff --git a/src/avp/gui/preview_win.py b/src/avp/gui/preview_win.py new file mode 100644 index 0000000..f52f8a3 --- /dev/null +++ b/src/avp/gui/preview_win.py @@ -0,0 +1,58 @@ +from PyQt6 import QtCore, QtGui, QtWidgets +import logging + +log = logging.getLogger("AVP.Gui.PreviewWindow") + + +class PreviewWindow(QtWidgets.QLabel): + """ + Paints the preview QLabel in MainWindow and maintains the aspect ratio + when the window is resized. + """ + + def __init__(self, parent, img): + super().__init__() + self.parent = parent + # FIXME + # self.setFrameStyle(QtWidgets.QFrame.StyledPanel) + self.pixmap = QtGui.QPixmap(img) + + def paintEvent(self, event): + size = self.size() + painter = QtGui.QPainter(self) + point = QtCore.QPoint(0, 0) + scaledPix = self.pixmap.scaled( + size, + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + transformMode=QtCore.Qt.TransformationMode.SmoothTransformation, + ) + + # start painting the label from left upper corner + point.setX(int((size.width() - scaledPix.width()) / 2)) + point.setY(int((size.height() - scaledPix.height()) / 2)) + painter.drawPixmap(point, scaledPix) + + def changePixmap(self, img): + self.pixmap = QtGui.QPixmap(img) + self.repaint() + + def mousePressEvent(self, event): + if self.parent.encoding: + return + + i = self.parent.listWidget_componentList.currentRow() + if i >= 0: + component = self.parent.core.selectedComponents[i] + if not hasattr(component, "previewClickEvent"): + return + qpoint = event.position().toPoint() + pos = (qpoint.x(), qpoint.y()) + size = (self.width(), self.height()) + butt = event.button() + log.info("Click event for #%s: %s button %s" % (i, pos, butt)) + component.previewClickEvent(pos, size, butt) + + @QtCore.pyqtSlot(str) + def threadError(self, msg): + self.parent.showMessage(msg=msg, icon="Critical", parent=self) + log.info("%", repr(self.parent)) |
