aboutsummaryrefslogtreecommitdiff
path: root/src/avp/gui
diff options
context:
space:
mode:
authorAeliton G. Silva2026-01-12 22:39:55 -0300
committerAeliton G. Silva2026-01-13 04:22:25 -0300
commitf975144f25d34f97329b2d4e52891061573cea08 (patch)
tree226fe223b31af6f217b1dd413629ab2cf26964d4 /src/avp/gui
parentb8703752ffc7768b0275897b3c2a869ff41504e5 (diff)
Use pyproject.toml + uv_build
This replaces setup.py by a modern pyproject.toml using uv_build backend. Dependencies are being also managed by uv, so to install dependencies and run the project one can execute: ``` uv sync uv run pytest # optional python -m avp ``` To build the both source and binary (wheel) distribution package run: ``` uv build ``` Uv can be installed with `pip install uv`. The directory structure has been changed to reflect best practices. - src/* -> src/avp/ - src/tests -> ../tests
Diffstat (limited to 'src/avp/gui')
-rw-r--r--src/avp/gui/__init__.py0
-rw-r--r--src/avp/gui/actions.py196
-rw-r--r--src/avp/gui/background.pngbin0 -> 45367 bytes
-rw-r--r--src/avp/gui/mainwindow.py1053
-rw-r--r--src/avp/gui/mainwindow.ui835
-rw-r--r--src/avp/gui/presetmanager.py349
-rw-r--r--src/avp/gui/presetmanager.ui150
-rw-r--r--src/avp/gui/preview_thread.py93
-rw-r--r--src/avp/gui/preview_win.py58
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
new file mode 100644
index 0000000..fb58593
--- /dev/null
+++ b/src/avp/gui/background.png
Binary files differ
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>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-size:10pt; font-style:italic;&quot;&gt;Right-click components in the main window to create presets&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</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))