Large refactor of ComfyUI context menus, trying to simplify and improve integration with many of the AI tasks. Also, new icons.

This commit is contained in:
Jonathan Thomas
2026-02-15 18:37:51 -06:00
parent af05981aa2
commit 71951d9325
17 changed files with 294 additions and 30 deletions
+39 -6
View File
@@ -156,8 +156,16 @@ class GenerationService:
return num / den
return None
def action_generate_trigger(self, checked=True):
selected_files = self.win.selected_files()
def _default_generation_name(self, source_file):
default_name = "generation"
if source_file:
path = source_file.data.get("path", "")
if path:
default_name = "{}_gen".format(os.path.splitext(os.path.basename(path))[0])
return default_name
def action_generate_trigger(self, checked=True, source_file=None, template_id=None, open_dialog=True):
selected_files = [source_file] if source_file else self.win.selected_files()
if len(selected_files) > 1:
return
@@ -173,11 +181,36 @@ class GenerationService:
source_file = selected_files[0] if selected_files else None
templates = available_pipelines(source_file=source_file)
win = GenerateMediaDialog(source_file=source_file, templates=templates, parent=self.win)
if win.exec_() != QDialog.Accepted:
return
available_template_ids = {str(t.get("id", "")).strip() for t in templates}
if open_dialog:
dialog_title = "Enhance with AI" if source_file else "Create with AI"
win = GenerateMediaDialog(
source_file=source_file,
templates=templates,
preselected_template_id=template_id,
dialog_title=dialog_title,
parent=self.win,
)
if win.exec_() != QDialog.Accepted:
return
payload = win.get_payload()
else:
selected_template_id = str(template_id or "").strip()
if not selected_template_id:
return
if selected_template_id not in available_template_ids:
QMessageBox.information(
self.win,
"Invalid Input",
"The selected AI action is not available for this source type.",
)
return
payload = {
"name": self._default_generation_name(source_file),
"template_id": selected_template_id,
"prompt": "",
}
payload = win.get_payload()
payload_name = self._next_generation_name(payload.get("name"))
source_file_id = source_file.id if source_file else None
try:
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2.5" y="3" width="11" height="8" rx="1.7" stroke="#9EC8F7" stroke-width="1.3"/>
<path d="M5 7H11" stroke="#9EC8F7" stroke-width="1.3" stroke-linecap="round"/>
<path d="M5 9H9.5" stroke="#9EC8F7" stroke-width="1.3" stroke-linecap="round"/>
<path d="M7.2 11L6.1 13.2L8.8 11" stroke="#9EC8F7" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 478 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3.2 9.5H5.3L7.7 11.6V4.4L5.3 6.5H3.2V9.5Z" stroke="#9EC8F7" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M9.6 6.2C10.4 7 10.4 9 9.6 9.8" stroke="#9EC8F7" stroke-width="1.3" stroke-linecap="round"/>
<path d="M11.6 4.8C13.1 6.3 13.1 9.7 11.6 11.2" stroke="#9EC8F7" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2.5" y="3" width="11" height="10" rx="1.8" stroke="#9EC8F7" stroke-width="1.3"/>
<circle cx="5.4" cy="6" r="1.1" fill="#9EC8F7"/>
<path d="M4 11L6.6 8.5L8.8 10.2L10.1 9.2L12 11" stroke="#9EC8F7" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 381 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2.5" y="3" width="8.8" height="10" rx="1.7" stroke="#9EC8F7" stroke-width="1.3"/>
<path d="M7 6.4L9.3 8L7 9.6V6.4Z" fill="#9EC8F7"/>
<path d="M11.3 6.2L13.5 5.1V10.9L11.3 9.8" stroke="#9EC8F7" stroke-width="1.3" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 2L9.4 5.6L13 7L9.4 8.4L8 12L6.6 8.4L3 7L6.6 5.6L8 2Z" stroke="#9EC8F7" stroke-width="1.3" stroke-linejoin="round"/>
<circle cx="8" cy="7" r="1" fill="#9EC8F7"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2.5" y="3.5" width="11" height="9" rx="1.5" stroke="#9EC8F7" stroke-width="1.3"/>
<path d="M6.2 3.5V12.5" stroke="#9EC8F7" stroke-width="1.3"/>
<path d="M9.8 3.5V12.5" stroke="#9EC8F7" stroke-width="1.3"/>
<path d="M2.5 8H13.5" stroke="#9EC8F7" stroke-width="1" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 398 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2.3" y="5" width="6.8" height="6.8" rx="1.7" stroke="#9EC8F7" stroke-width="1.3"/>
<rect x="4.6" y="4" width="6.8" height="6.8" rx="1.7" stroke="#9EC8F7" stroke-width="1.3" opacity="0.82"/>
<rect x="6.9" y="3" width="6.8" height="6.8" rx="1.7" stroke="#9EC8F7" stroke-width="1.3" opacity="0.64"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke="#9EC8F7" stroke-width="1.3"/>
<path d="M5.2 10.8L10.8 5.2" stroke="#9EC8F7" stroke-width="1.3" stroke-linecap="round"/>
<path d="M7.9 5.2H10.8V8.1" stroke="#9EC8F7" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="3" y="3" width="10" height="10" rx="2" stroke="#9EC8F7" stroke-width="1.3"/>
<path d="M8 5V11" stroke="#9EC8F7" stroke-width="1.3" stroke-linecap="round"/>
<path d="M5 8H11" stroke="#9EC8F7" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

+14 -2
View File
@@ -44,12 +44,20 @@ class GenerateMediaDialog(QDialog):
PREVIEW_WIDTH = 180
PREVIEW_HEIGHT = 128
def __init__(self, source_file=None, templates=None, parent=None):
def __init__(
self,
source_file=None,
templates=None,
preselected_template_id=None,
dialog_title=None,
parent=None,
):
super().__init__(parent)
self.source_file = source_file
self.templates = templates or []
self.preselected_template_id = str(preselected_template_id or "").strip()
self.setObjectName("generateDialog")
self.setWindowTitle("Generate")
self.setWindowTitle(str(dialog_title or "AI Tools"))
self.setMinimumWidth(620)
self.setMinimumHeight(460)
@@ -118,6 +126,10 @@ class GenerateMediaDialog(QDialog):
self.template_combo.addItem(template.get("name", ""), template.get("id", ""))
else:
self.template_combo.addItem("Basic Text to Image", "txt2img-basic")
if self.preselected_template_id:
index = self.template_combo.findData(self.preselected_template_id)
if index >= 0:
self.template_combo.setCurrentIndex(index)
setup_form.addRow("Template", self.template_combo)
if self.source_file:
+6 -1
View File
@@ -1146,8 +1146,13 @@ class MainWindow(updates.UpdateWatcher, QMainWindow):
""" Preview the selected media file """
log.info('actionPreview_File_trigger')
# Loop through selected files (set 1 selected file if more than 1)
# Prefer current file, but fall back to selected real files when a generation
# placeholder row has focus.
f = self.files_model.current_file()
if not f:
selected_files = self.files_model.selected_files()
if selected_files:
f = selected_files[0]
# Bail out if no file selected
if not f:
+26 -7
View File
@@ -626,16 +626,25 @@ class FilesModel(QObject, updates.UpdateInterface):
def current_file_id(self):
""" Get the file ID of the current files-view item, or the first selection """
# Prefer selected rows first, since currentIndex can become stale when
# switching between details/list views with separate selection models.
selected_rows = self.selection_model.selectedRows(5)
if selected_rows:
current = self.selection_model.currentIndex()
if current and current.isValid():
current_id = current.sibling(current.row(), 5).data()
if current_id and not self._is_generation_placeholder(current_id):
return current_id
for row_index in selected_rows:
file_id = row_index.data()
if file_id and not self._is_generation_placeholder(file_id):
return file_id
cur = self.selection_model.currentIndex()
if not cur or not cur.isValid() and self.selection_model.hasSelection():
cur = self.selection_model.selectedIndexes()[0]
if cur and cur.isValid():
file_id = cur.sibling(cur.row(), 5).data()
if self._is_generation_placeholder(file_id):
return None
return file_id
if file_id and not self._is_generation_placeholder(file_id):
return file_id
def current_file(self):
""" Get the File object for the current files-view item, or the first selection """
@@ -664,14 +673,19 @@ class FilesModel(QObject, updates.UpdateInterface):
try:
# Map selected indexes from proxy_model to list_proxy_model
list_selection = QItemSelection()
first_list_index = QModelIndex()
for index in self.selection_model.selectedRows(0):
list_index = self.list_proxy_model.mapFromSource(index)
if list_index.isValid():
list_selection.select(list_index, list_index)
if not first_list_index.isValid():
first_list_index = list_index
self.list_selection_model.select(
list_selection,
QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows
)
if first_list_index.isValid():
self.list_selection_model.setCurrentIndex(first_list_index, QItemSelectionModel.NoUpdate)
finally:
self._syncing_selection = False
@@ -683,14 +697,19 @@ class FilesModel(QObject, updates.UpdateInterface):
try:
# Map selected indexes from list_proxy_model to proxy_model
tree_selection = QItemSelection()
first_tree_index = QModelIndex()
for index in self.list_selection_model.selectedRows(0):
tree_index = self.list_proxy_model.mapToSource(index)
if tree_index.isValid():
tree_selection.select(tree_index, tree_index)
if not first_tree_index.isValid():
first_tree_index = tree_index
self.selection_model.select(
tree_selection,
QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows
)
if first_tree_index.isValid():
self.selection_model.setCurrentIndex(first_tree_index, QItemSelectionModel.NoUpdate)
finally:
self._syncing_selection = False
+101
View File
@@ -0,0 +1,101 @@
"""
@file
@brief Shared AI Tools context-menu builder for project files and timeline.
"""
import os
from functools import partial
from PyQt5.QtGui import QIcon
from classes.app import get_app
from classes import info
from .menu import StyledContextMenu
def _trigger_generation(win, template_id, source_file=None, open_dialog=False):
win.generation_service.action_generate_trigger(
source_file=source_file,
template_id=template_id,
open_dialog=open_dialog,
)
def _icon(name):
icon_path = os.path.join(info.PATH, "themes", "cosmic", "images", name)
if os.path.exists(icon_path):
return QIcon(icon_path)
return QIcon()
def add_ai_tools_menu(win, parent_menu, source_file=None):
_ = get_app()._tr
media_type = str(source_file.data.get("media_type", "")) if source_file else ""
if source_file:
ai_menu = StyledContextMenu(title=_("Enhance with AI"), parent=parent_menu)
ai_menu.setIcon(_icon("tool-generate-sparkle.svg"))
if media_type == "image":
action = ai_menu.addAction(_("Increase Resolution (4x)"))
action.setIcon(_icon("ai-action-upscale.svg"))
action.triggered.connect(
partial(_trigger_generation, win, "upscale-realesrgan-x4", source_file, False)
)
ai_menu.addSeparator()
action = ai_menu.addAction(_("Change Image Style..."))
action.setIcon(_icon("ai-action-restyle.svg"))
action.triggered.connect(
partial(_trigger_generation, win, "img2img-basic", source_file, True)
)
parent_menu.addMenu(ai_menu)
return ai_menu
elif media_type == "video":
action = ai_menu.addAction(_("Increase Resolution (4x)"))
action.setIcon(_icon("ai-action-upscale.svg"))
action.triggered.connect(
partial(_trigger_generation, win, "video-upscale-gan", source_file, False)
)
action = ai_menu.addAction(_("Smooth Motion (2x Frame Rate)"))
action.setIcon(_icon("ai-action-smooth.svg"))
action.triggered.connect(
partial(_trigger_generation, win, "video-frame-interpolation-rife2x", source_file, False)
)
action = ai_menu.addAction(_("Split into Scenes"))
action.setIcon(_icon("ai-action-scenes.svg"))
action.triggered.connect(
partial(_trigger_generation, win, "video-segment-scenes-transnet", source_file, False)
)
action = ai_menu.addAction(_("Add Captions from Speech"))
action.setIcon(_icon("ai-action-captions.svg"))
action.triggered.connect(
partial(_trigger_generation, win, "video-whisper-srt", source_file, False)
)
ai_menu.addSeparator()
action = ai_menu.addAction(_("Change Video Style..."))
action.setIcon(_icon("ai-action-restyle.svg"))
action.triggered.connect(
partial(_trigger_generation, win, "video2video-basic", source_file, True)
)
else:
action = ai_menu.addAction(_("No AI enhancement actions available yet."))
action.setEnabled(False)
parent_menu.addMenu(ai_menu)
return ai_menu
ai_menu = StyledContextMenu(title=_("Create with AI"), parent=parent_menu)
ai_menu.setIcon(_icon("tool-generate-sparkle.svg"))
action = ai_menu.addAction(_("Image..."))
action.setIcon(_icon("ai-action-create-image.svg"))
action.triggered.connect(partial(_trigger_generation, win, "txt2img-basic", source_file, True))
action = ai_menu.addAction(_("Video..."))
action.setIcon(_icon("ai-action-create-video.svg"))
action.triggered.connect(partial(_trigger_generation, win, "txt2video-svd", source_file, True))
action = ai_menu.addAction(_("Audio..."))
action.setIcon(_icon("ai-action-create-audio.svg"))
action.triggered.connect(partial(_trigger_generation, win, "txt2audio-stable-open", source_file, True))
parent_menu.addMenu(ai_menu)
return ai_menu
+36 -6
View File
@@ -29,14 +29,15 @@
import os
import uuid
from PyQt5.QtCore import QSize, Qt, QPoint, QRegExp
from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter, QIcon, QColor
from PyQt5.QtCore import QSize, Qt, QPoint, QRegExp, QItemSelectionModel
from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter, QIcon, QColor, QFontMetrics
from PyQt5.QtWidgets import QListView, QAbstractItemView, QStyledItemDelegate, QStyleOptionViewItem, QStyle
from classes import info
from classes.app import get_app
from classes.logger import log
from classes.query import File
from .ai_tools_menu import add_ai_tools_menu
from .menu import StyledContextMenu
@@ -120,9 +121,21 @@ class FilesListProgressDelegate(QStyledItemDelegate):
painter.setBrush(QColor("#53A0ED"))
painter.drawRect(fill_rect)
if status == "queued":
text_rect = full_rect.adjusted(0, -14, 0, -4)
painter.setPen(QColor("#9EC8F7"))
painter.drawText(text_rect, Qt.AlignLeft | Qt.AlignVCenter, "Queued")
label = "Queued"
fm = QFontMetrics(painter.font())
text_w = fm.horizontalAdvance(label)
text_h = fm.height()
pad_x = 5
pad_y = 2
badge_w = text_w + (pad_x * 2)
badge_h = text_h + (pad_y * 2)
badge_rect = deco_rect.adjusted(3, 3, 0, 0)
badge_rect.setWidth(badge_w)
badge_rect.setHeight(badge_h)
painter.setBrush(QColor(18, 22, 30, 220))
painter.drawRoundedRect(badge_rect, 4, 4)
painter.setPen(QColor("#EAF5FF"))
painter.drawText(badge_rect, Qt.AlignCenter, label)
painter.restore()
@@ -140,12 +153,20 @@ class FilesListView(QListView):
app.context_menu_object = "files"
index = self.indexAt(event.pos())
if index.isValid():
self.setCurrentIndex(index)
self.selectionModel().select(
index,
QItemSelectionModel.ClearAndSelect,
)
# Build menu
menu = StyledContextMenu(parent=self)
menu.addAction(self.win.actionImportFiles)
source_file = None
active_job = None
file_id = None
if index.isValid():
@@ -161,9 +182,11 @@ class FilesListView(QListView):
active_job = None
else:
active_job = self.win.active_generation_job_for_file(file_id)
source_file = File.get(id=file_id)
add_ai_tools_menu(self.win, menu, source_file=source_file)
if not active_job:
self.win.actionGenerate.setEnabled(self.win.can_open_generate_dialog())
menu.addAction(self.win.actionGenerate)
if active_job:
cancel_action = menu.addAction(_("Cancel Job"))
delete_icon_path = os.path.join(info.PATH, "themes", "cosmic", "images", "track-delete-enabled.svg")
@@ -232,6 +255,13 @@ class FilesListView(QListView):
def mouseDoubleClickEvent(self, event):
super(FilesListView, self).mouseDoubleClickEvent(event)
index = self.indexAt(event.pos())
if index.isValid():
self.setCurrentIndex(index)
self.selectionModel().select(
index,
QItemSelectionModel.ClearAndSelect,
)
# Preview File, File Properties, or Split File (depending on Shift/Ctrl)
if int(get_app().keyboardModifiers() & Qt.ShiftModifier) > 0:
get_app().window.actionSplitFile.trigger()
+21 -5
View File
@@ -31,13 +31,14 @@ import os
import uuid
from PyQt5.QtCore import QSize, Qt, QPoint
from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter, QIcon, QColor
from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter, QIcon, QColor, QFontMetrics
from PyQt5.QtWidgets import QTreeView, QAbstractItemView, QSizePolicy, QHeaderView, QStyledItemDelegate, QStyleOptionViewItem, QStyle
from classes import info
from classes.app import get_app
from classes.logger import log
from classes.query import File
from .ai_tools_menu import add_ai_tools_menu
from .menu import StyledContextMenu
@@ -116,9 +117,21 @@ class FilesTreeProgressDelegate(QStyledItemDelegate):
painter.setBrush(QColor("#53A0ED"))
painter.drawRect(fill_rect)
if status == "queued":
text_rect = full_rect.adjusted(0, -14, 0, -4)
painter.setPen(QColor("#9EC8F7"))
painter.drawText(text_rect, Qt.AlignLeft | Qt.AlignVCenter, "Queued")
label = "Queued"
fm = QFontMetrics(painter.font())
text_w = fm.horizontalAdvance(label)
text_h = fm.height()
pad_x = 5
pad_y = 2
badge_w = text_w + (pad_x * 2)
badge_h = text_h + (pad_y * 2)
badge_rect = deco_rect.adjusted(3, 3, 0, 0)
badge_rect.setWidth(badge_w)
badge_rect.setHeight(badge_h)
painter.setBrush(QColor(18, 22, 30, 220))
painter.drawRoundedRect(badge_rect, 4, 4)
painter.setPen(QColor("#EAF5FF"))
painter.drawText(badge_rect, Qt.AlignCenter, label)
painter.restore()
@@ -142,6 +155,7 @@ class FilesTreeView(QTreeView):
menu.addAction(self.win.actionImportFiles)
source_file = None
active_job = None
file_id = None
if index.isValid():
@@ -155,9 +169,11 @@ class FilesTreeView(QTreeView):
active_job = None
else:
active_job = self.win.active_generation_job_for_file(file_id)
source_file = File.get(id=file_id)
add_ai_tools_menu(self.win, menu, source_file=source_file)
if not active_job:
self.win.actionGenerate.setEnabled(self.win.can_open_generate_dialog())
menu.addAction(self.win.actionGenerate)
if active_job:
cancel_action = menu.addAction(_("Cancel Job"))
delete_icon_path = os.path.join(info.PATH, "themes", "cosmic", "images", "track-delete-enabled.svg")
+5 -3
View File
@@ -1057,7 +1057,7 @@ class TimelineView(updates.UpdateInterface, ViewClass):
if not has_clipboard and not found_gap:
return
# Get track object (ignore locked tracks)
# Get track object (ignore locked tracks for edit operations)
track = Track.get(number=layer_number)
if not track:
return
@@ -1068,6 +1068,8 @@ class TimelineView(updates.UpdateInterface, ViewClass):
# New context menu
menu = StyledContextMenu(parent=self)
has_edit_actions = False
if found_gap:
# Add 'Remove Gap' Menu
menu.addAction(self.window.actionRemoveGap)
@@ -1079,8 +1081,7 @@ class TimelineView(updates.UpdateInterface, ViewClass):
self.window.actionRemoveGap.triggered.connect(
partial(self.RemoveGap_Triggered, found_start, found_end, int(layer_number))
)
if has_clipboard and found_gap:
menu.addSeparator()
has_edit_actions = True
if has_clipboard:
# Add 'Paste' Menu
Paste_Clip = menu.addAction(_("Paste"))
@@ -1088,6 +1089,7 @@ class TimelineView(updates.UpdateInterface, ViewClass):
Paste_Clip.triggered.connect(
partial(self.Paste_Triggered, MenuCopy.PASTE, [], [])
)
has_edit_actions = True
# Show context menu
self.context_menu_cursor_position = QCursor.pos()