SyntheticMDProductions's picture
ADAM October 2026 source release: PixelRow, INRFlow, Wan Video, Oasis player and field guide
f8c73f9 verified
Raw History Blame Contribute Delete
110 kB
from __future__ import annotations
import json
import shutil
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QIcon, QImageReader, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QCheckBox,
QDoubleSpinBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QMenu,
QPlainTextEdit,
QPushButton,
QScrollArea,
QSplitter,
QSpinBox,
QSizePolicy,
QTabBar,
QVBoxLayout,
QWidget,
)
from adam.assets import Asset, AssetRegistry
from adam.config import ConfigManager
from adam.generations import (
GenerationRecord,
build_generation_plan,
generation_tools,
generation_model_key,
generation_provider_key,
group_generation_records,
group_generation_providers,
load_generation_history,
combine_generation_plans,
parse_lora_generation_metadata,
parse_plain_generation_metadata,
)
from adam.image_preferences import PreferenceProfile, score_generated_images
from adam.job_manager import JobManager
from adam.models import Job, JobStatus
from adam.registry import ToolRegistry, ToolSpec
from adam.ui.settings_ui import SettingsForm
from adam.ui.background import BackgroundRead
from adam.ui.asset_refresh import asset_refresh
from adam.ui.generation_widgets import ImagePreview, ElidedLabel, GeneratorCards, section, field
from adam.ollama import OllamaClient, OllamaError
class _PromptEnhanceSignals(QObject):
finished = Signal(str, str)
class _PromptEnhanceWorker(QRunnable):
def __init__(self, client: OllamaClient, prompt: str, context: str, signals: _PromptEnhanceSignals) -> None:
super().__init__()
self.client, self.prompt, self.context, self.signals = client, prompt, context, signals
def run(self) -> None:
system = (
"You improve positive prompts for text-to-image generation. Return only the revised prompt, "
"with no explanation, labels, or quotation marks. Preserve the user's subject, identity, "
"intent, and any specific details. Make the description clearer and more visually concrete "
"by refining composition, pose, setting, lighting, color, and relevant visual style when "
"appropriate. Do not add unrelated subjects, change requested traits, or include negative prompts."
)
request = f"Selected model context: {self.context or 'not specified'}\n\nCurrent positive prompt:\n{self.prompt}"
try:
result = self.client.generate_text(system, request).strip().strip('"“”')
if not result:
raise OllamaError("Ollama returned an empty prompt.")
self.signals.finished.emit(result, "")
except Exception as exc:
try:
self.signals.finished.emit("", str(exc))
except RuntimeError:
pass
def _card() -> QFrame:
frame = QFrame()
frame.setProperty("card", True)
return frame
def _title(text: str) -> QLabel:
label = QLabel(text)
label.setObjectName("CardTitle")
return label
def _header(title: str, subtitle: str) -> QWidget:
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 10)
heading = QLabel(title)
heading.setObjectName("PageTitle")
detail = QLabel(subtitle)
detail.setProperty("muted", True)
detail.setWordWrap(True)
layout.addWidget(heading)
layout.addWidget(detail)
return widget
def _thumbnail(path: Path, width: int, height: int) -> QPixmap:
reader = QImageReader(str(path))
reader.setAutoTransform(True)
source_size = reader.size()
if source_size.isValid():
source_size.scale(QSize(width, height), Qt.KeepAspectRatio)
reader.setScaledSize(source_size)
image = reader.read()
return QPixmap.fromImage(image) if not image.isNull() else QPixmap()
class GenerationCycleDialog(QDialog):
"""Choose several completed models and shared playback settings."""
def __init__(self, entries: list[dict], parent: QWidget | None = None) -> None:
super().__init__(parent)
self.entries = entries
self.setWindowTitle("Generation Cycle")
self.setMinimumSize(600, 560)
layout = QVBoxLayout(self)
layout.addWidget(_header(
"Generation Cycle",
"Select completed models. ADAM will generate each batch sequentially, then play the results as a labeled slideshow.",
))
self.models = QListWidget()
self.models.setSelectionMode(QAbstractItemView.MultiSelection)
for index, entry in enumerate(entries):
item = QListWidgetItem(f"{entry['name']} — {entry['trainer_label']}")
item.setData(Qt.UserRole, index)
self.models.addItem(item)
selection_buttons = QHBoxLayout()
select_all = QPushButton("Select all")
clear = QPushButton("Clear")
select_all.clicked.connect(self.models.selectAll)
clear.clicked.connect(self.models.clearSelection)
selection_buttons.addWidget(select_all); selection_buttons.addWidget(clear); selection_buttons.addStretch()
layout.addLayout(selection_buttons)
layout.addWidget(self.models, 1)
form = QGridLayout()
self.image_count = QSpinBox(); self.image_count.setRange(1, 48); self.image_count.setValue(4)
self.display_seconds = QSpinBox(); self.display_seconds.setRange(1, 300); self.display_seconds.setValue(5); self.display_seconds.setSuffix(" seconds")
self.seed = QSpinBox(); self.seed.setRange(0, 2_147_000_000); self.seed.setValue(123456)
self.prompt = QLineEdit(); self.prompt.setPlaceholderText("Optional shared prompt or creative note")
self.show_labels = QCheckBox("Show model and trainer in the bottom-left corner"); self.show_labels.setChecked(True)
self.loop = QCheckBox("Loop the slideshow until closed")
self.fullscreen = QCheckBox("Start playback in fullscreen")
for row, (label, widget) in enumerate((
("Images per model", self.image_count),
("Display each image", self.display_seconds),
("Starting seed", self.seed),
("Shared prompt / note", self.prompt),
)):
form.addWidget(QLabel(label), row, 0); form.addWidget(widget, row, 1)
layout.addLayout(form)
layout.addWidget(self.show_labels); layout.addWidget(self.loop); layout.addWidget(self.fullscreen)
self.validation = QLabel(); self.validation.setProperty("muted", True); self.validation.setWordWrap(True)
layout.addWidget(self.validation)
buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok)
buttons.button(QDialogButtonBox.Ok).setText("Queue generation cycle")
buttons.accepted.connect(self._accept); buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _accept(self) -> None:
if not self.models.selectedItems():
self.validation.setText("Select at least one completed model.")
return
self.accept()
def selected_entries(self) -> list[dict]:
selected = {int(item.data(Qt.UserRole)) for item in self.models.selectedItems()}
return [entry for index, entry in enumerate(self.entries) if index in selected]
class CycleSlideshowDialog(QDialog):
"""Simple local slideshow for a completed multi-model generation cycle."""
def __init__(
self,
slides: list[tuple[Path, str]],
*,
display_seconds: int,
show_labels: bool,
loop: bool,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.slides = slides
self.index = 0
self.show_labels = show_labels
self.loop = loop
self.setWindowTitle("Generation Cycle Playback")
self.setMinimumSize(800, 560)
self.setStyleSheet("background: #02070b;")
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.image = QLabel()
self.image.setAlignment(Qt.AlignCenter)
self.image.setMinimumSize(500, 350)
self.overlay = QLabel(self.image)
self.overlay.setStyleSheet(
"background: rgba(0, 0, 0, 180); color: white; padding: 8px 12px; "
"border-radius: 5px; font-weight: 600;"
)
layout.addWidget(self.image, 1)
controls = QHBoxLayout()
self.previous = QPushButton("Previous")
self.caption = QLabel(); self.caption.setAlignment(Qt.AlignCenter)
self.next = QPushButton("Next")
close = QPushButton("Close")
controls.addWidget(self.previous); controls.addWidget(self.caption, 1); controls.addWidget(self.next); controls.addWidget(close)
layout.addLayout(controls)
self.previous.clicked.connect(self._previous); self.next.clicked.connect(self._next); close.clicked.connect(self.accept)
self.timer = QTimer(self)
self.timer.setInterval(max(1, int(display_seconds)) * 1000)
self.timer.timeout.connect(self._next)
self._show_slide()
self.timer.start()
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._show_slide()
def _show_slide(self) -> None:
if not self.slides:
return
path, label = self.slides[self.index]
pixmap = QPixmap(str(path))
if not pixmap.isNull():
self.image.setPixmap(pixmap.scaled(self.image.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))
else:
self.image.setText(f"Could not display {path.name}")
self.caption.setText(f"Image {self.index + 1} of {len(self.slides)}")
self.overlay.setText(label)
self.overlay.adjustSize()
self.overlay.move(18, max(18, self.image.height() - self.overlay.height() - 18))
self.overlay.setVisible(self.show_labels)
def _previous(self) -> None:
self.index = (self.index - 1) % len(self.slides)
self._show_slide()
def _next(self) -> None:
if self.index + 1 >= len(self.slides) and not self.loop:
self.timer.stop()
return
self.index = (self.index + 1) % len(self.slides)
self._show_slide()
class GenerationsPage(QWidget):
"""Generate with registered providers and revisit reproducible output batches."""
def __init__(
self,
root: Path,
registry: ToolRegistry,
jobs: JobManager,
assets: AssetRegistry,
config: ConfigManager,
) -> None:
super().__init__()
self.setObjectName("GenerationsPage")
self.setStyleSheet("""
QWidget#GenerationsPage { background: #050c13; }
QFrame#GenerationSection { background: #081723; border: 1px solid #20435a; border-radius: 9px; }
QLabel#GenerationHeading { font-size: 16px; font-weight: 700; color: #edf5ff; }
QLabel#GenerationNumber { background: #0aaff0; color: #001725; font-size: 18px; font-weight: 700; border-radius: 7px; }
QScrollArea#GenerationControls, QWidget#GenerationControlContents { background: transparent; border: 0; }
QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox { padding: 5px 7px; border-radius: 6px; min-height: 20px; }
QPushButton { padding: 0 9px; min-height: 30px; }
QPushButton[compact="true"] { padding: 0 7px; min-height: 26px; font-size: 12px; }
QTabBar#GenerationTabs::tab { min-width: 0; padding: 8px 9px; border-radius: 5px; border: 0; background: transparent; }
QTabBar#GenerationTabs::tab:selected { background: #0aaff0; color: #001521; font-weight: 700; }
QListWidget#GeneratorCards { border: 0; padding: 0; background: transparent; }
QListWidget#GenerationResults { background: #050f19; border: 1px solid #19394f; padding: 3px; }
QSplitter::handle { background: #102737; }
QMenu { background: #0b1b28; border: 1px solid #24506e; padding: 5px; }
QMenu::item { padding: 6px 18px; }
QMenu::item:selected { background: #124263; }
QScrollBar:horizontal { height: 8px; background: #08131d; }
QScrollBar::handle:horizontal { min-width: 30px; border-radius: 4px; background: #24506e; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; }
""")
self.root = root.resolve()
self.registry = registry
self.jobs = jobs
self.assets = assets
self.config = config
self.records: list[GenerationRecord] = []
self.hidden_history_images: set[str] = set()
self._cycle_jobs: dict[str, dict] = {}
self._history_load_token = 0
self._history_load_index = 0
self._history_entries: list[tuple[int, int, Path]] = []
self._history_selected_image = ""
self._active_provider_folder = ""
self._active_model_folder = ""
self._prompt_enhance_signals = _PromptEnhanceSignals(self)
self._prompt_enhance_signals.finished.connect(self._prompt_enhancement_ready)
self._prompt_enhance_source = ""
layout = QVBoxLayout(self)
layout.setContentsMargins(22, 18, 22, 18)
layout.setSpacing(12)
header_row = QHBoxLayout()
header_row.addWidget(
_header(
"Generations",
"Create images with compatible registered tools, then revisit every seed and setting.",
), 1
)
self.cycle_button = QPushButton("Generation Cycle…")
self.cycle_button.setProperty("primary", True)
header_row.addWidget(self.cycle_button)
layout.addLayout(header_row)
body = QSplitter(Qt.Horizontal)
self.workspace_splitter = body
body.setChildrenCollapsible(False)
body.addWidget(self._build_controls())
body.addWidget(self._build_history())
body.setStretchFactor(0, 2)
body.setStretchFactor(1, 3)
body.setSizes([590, 850])
layout.addWidget(body, 1)
self.provider.currentIndexChanged.connect(self._provider_changed)
self.model.currentIndexChanged.connect(self._model_changed)
self.preset.currentIndexChanged.connect(self._apply_preset)
self.aspect.currentTextChanged.connect(self._lora_aspect_changed)
self.generate_button.clicked.connect(self._generate)
self.enhance_prompt_button.clicked.connect(self._enhance_prompt)
self.refresh_button.clicked.connect(self.refresh)
self.gallery.currentItemChanged.connect(self._selection_changed)
self.gallery.itemDoubleClicked.connect(lambda _item: self._open_image())
self.model_folders.itemClicked.connect(self._open_history_folder)
self.folder_back_button.clicked.connect(self._go_back_in_history)
self.open_image_button.clicked.connect(self._open_image)
self.open_folder_button.clicked.connect(self._open_folder)
self.reuse_button.clicked.connect(self._reuse_settings)
self.clear_history_button.clicked.connect(self._clear_displayed_history)
self.auto_sort_button.clicked.connect(self._auto_sort_history)
self.random_seed_button.clicked.connect(lambda: self.seed.clear())
self.jobs.job_updated.connect(self._job_updated)
self.cycle_button.clicked.connect(self._open_generation_cycle)
self._history_reader = BackgroundRead(self)
self._history_reader.ready.connect(self._history_loaded)
self._history_reader.failed.connect(lambda error: self.history_summary.setText(f"Could not refresh history: {error}"))
self._asset_refresh = asset_refresh(self.assets, self.config)
self._asset_refresh.changed.connect(self.refresh)
self._model_choices_signature = None
self.refresh()
self._restore_generation_settings()
self._connect_generation_setting_persistence()
def _build_controls(self) -> QWidget:
scroll = QScrollArea()
scroll.setObjectName("GenerationControls")
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setMinimumWidth(420)
scroll.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
content = QWidget()
content.setObjectName("GenerationControlContents")
scroll.setWidget(content)
layout = QVBoxLayout(content)
layout.setContentsMargins(0, 0, 6, 0)
layout.setSpacing(10)
def combo():
widget = QComboBox()
widget.setMinimumContentsLength(8)
widget.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
widget.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
widget.setMinimumWidth(60)
return widget
def integer(low, high, value, step=1):
widget = QSpinBox()
widget.setRange(low, high)
widget.setValue(value)
widget.setSingleStep(step)
widget.setMinimumWidth(64)
widget.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
return widget
def decimal(low, high, value, step):
widget = QDoubleSpinBox()
widget.setRange(low, high)
widget.setDecimals(2)
widget.setSingleStep(step)
widget.setValue(value)
widget.setMinimumWidth(64)
widget.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
return widget
model_card, model_layout, model_header = section("1", "Model & Prompt", "Choose a model and describe your image.")
self.model_card = model_card
layout.addWidget(model_card)
self.provider = combo()
self.model = combo()
self.refresh_button = QPushButton("↻")
self.refresh_button.setToolTip("Refresh available models")
self.refresh_button.setFixedWidth(36)
self.model_browse_button = QPushButton("Browse LoRA")
self.model_browse_button.clicked.connect(self._browse_lora)
model_row = QHBoxLayout()
model_row.addWidget(field("Generator", self.provider), 2)
selection = QWidget()
selection_row = QHBoxLayout(selection)
selection_row.setContentsMargins(0, 0, 0, 0)
selection_row.setSpacing(4)
selection_row.addWidget(self.model, 1)
selection_row.addWidget(self.refresh_button)
model_row.addWidget(field("Completed model", selection), 3)
model_layout.addLayout(model_row)
self.model_hint = ElidedLabel()
self.model_hint.setProperty("muted", True)
model_labels = model_header.itemAt(1).layout()
old_caption = model_labels.takeAt(1).widget()
old_caption.hide()
old_caption.deleteLater()
model_labels.addWidget(self.model_hint)
self.base_model = combo()
self.base_model.setEditable(True)
self.base_model.setInsertPolicy(QComboBox.NoInsert)
self.base_model_field = field("Base model", self.base_model)
model_layout.addWidget(self.base_model_field)
prompt_header = QHBoxLayout()
self.prompt_label = QLabel("Positive prompt")
prompt_header.addWidget(self.prompt_label, 1)
self.import_metadata_button = QPushButton("Paste metadata…")
self.import_metadata_button.setProperty("compact", True)
self.import_metadata_button.clicked.connect(self._paste_lora_metadata)
self.model_browse_button.setProperty("compact", True)
prompt_header.addWidget(self.model_browse_button)
prompt_header.addWidget(self.import_metadata_button)
model_layout.addLayout(prompt_header)
self.prompt = QPlainTextEdit()
self.prompt.setFixedHeight(72)
model_layout.addWidget(self.prompt)
prompt_actions = QHBoxLayout()
prompt_actions.addStretch(1)
self.enhance_prompt_button = QPushButton("✦ Enhance prompt with Ollama")
self.enhance_prompt_button.setProperty("compact", True)
self.enhance_prompt_button.setToolTip("Ask the configured Ollama model to refine this image prompt, then review the suggestion before applying it.")
prompt_actions.addWidget(self.enhance_prompt_button)
model_layout.addLayout(prompt_actions)
self.negative_prompt = QPlainTextEdit()
self.negative_prompt.setFixedHeight(44)
self.negative_prompt.setPlaceholderText("Optional: things to avoid in the image")
self.negative_prompt_field = field("Negative prompt (optional)", self.negative_prompt)
model_layout.addWidget(self.negative_prompt_field)
settings_card, settings_layout, settings_header = section("2", "Image Settings", "Size, batch and generation preset.")
self.image_settings_card = settings_card
layout.addWidget(settings_card)
self.aspect = combo()
self.aspect.addItem("1:1 (Square)")
self.images = integer(1, 48, 1)
self.width = integer(256, 2048, 1024, 8)
self.height = integer(256, 2048, 1024, 8)
size_row = QHBoxLayout()
size_row.addWidget(field("Aspect ratio", self.aspect), 2)
self.lora_dimensions = QWidget()
dimensions = QHBoxLayout(self.lora_dimensions)
dimensions.setContentsMargins(0, 0, 0, 0)
dimensions.setSpacing(6)
dimensions.addWidget(field("Width", self.width))
dimensions.addWidget(field("Height", self.height))
size_row.addWidget(self.lora_dimensions, 2)
size_row.addWidget(field("Images", self.images), 1)
settings_layout.addLayout(size_row)
self.seed = QLineEdit()
self.seed.setMinimumWidth(0)
self.seed.setPlaceholderText("Random")
self.random_seed_button = QPushButton("↻")
self.random_seed_button.setFixedWidth(34)
self.random_seed_button.setToolTip("Use a fresh random seed")
self.preset = combo()
self.preset.addItems(["Balanced", "Fast preview", "Detailed", "Classic"])
seed_row = QHBoxLayout()
seed_row.addWidget(field("Preset", self.preset), 1)
seed_box = QWidget()
seed_layout = QHBoxLayout(seed_box)
seed_layout.setContentsMargins(0, 0, 0, 0)
seed_layout.setSpacing(4)
seed_layout.addWidget(self.seed, 1)
seed_layout.addWidget(self.random_seed_button)
seed_row.addWidget(field("Seed", seed_box), 2)
settings_layout.addLayout(seed_row)
self.generate_button = QPushButton("Generate Images →")
self.generate_button.setProperty("primary", True)
self.generate_button.setMinimumHeight(38)
settings_layout.addWidget(self.generate_button)
self.status = ElidedLabel("Ready")
self.status.setProperty("muted", True)
settings_header.addWidget(self.status, 1)
advanced_card, advanced_layout, advanced_header = section("3", "Advanced Settings", "Fine-tune sampling and reference images.")
self.advanced_card = advanced_card
self.advanced_toggle = QCheckBox("Show")
self.advanced_toggle.setChecked(bool(self.config.get("generation_advanced_open", True)))
advanced_header.addWidget(self.advanced_toggle)
self.advanced_panel = QWidget()
advanced_body = QVBoxLayout(self.advanced_panel)
advanced_body.setContentsMargins(0, 0, 0, 0)
advanced_body.setSpacing(9)
advanced_layout.addWidget(self.advanced_panel)
self.advanced_panel.setVisible(self.advanced_toggle.isChecked())
self.advanced_toggle.toggled.connect(self.advanced_panel.setVisible)
self.advanced_toggle.toggled.connect(lambda value: self.config.update({"generation_advanced_open": value}))
layout.addWidget(advanced_card)
self.steps = integer(5, 500, 50)
self.preview_interval = integer(0, 500, 0)
self.preview_interval.setSpecialValueText("Off")
self.preview_interval.setToolTip("Steps between live previews. More frequent previews can slow generation.")
self.sampler = combo()
self.sampler.addItems(["DDIM", "DDPM"])
common = QGridLayout()
self.steps_label = QLabel("Steps")
self.preview_interval_label = QLabel("Steps / preview")
for column, (label, widget) in enumerate(((self.steps_label, self.steps), (QLabel("Sampler"), self.sampler), (self.preview_interval_label, self.preview_interval))):
label.setWordWrap(True)
common.addWidget(label, 0, column * 2)
common.addWidget(widget, 0, column * 2 + 1)
common.setColumnStretch(column * 2 + 1, 1)
advanced_body.addLayout(common)
self.lora_options = QWidget()
lora = QVBoxLayout(self.lora_options)
lora.setContentsMargins(0, 0, 0, 0)
self.cfg_scale = decimal(0.1, 30.0, 7.0, 0.5)
self.lora_strength = decimal(0.05, 3.0, 1.0, 0.05)
self.denoise_strength = decimal(0.0, 1.0, 0.45, 0.05)
strengths = QHBoxLayout()
for label, widget in (("CFG scale", self.cfg_scale), ("LoRA strength", self.lora_strength), ("Denoise strength", self.denoise_strength)):
caption = QLabel(label)
caption.setWordWrap(True)
strengths.addWidget(caption)
strengths.addWidget(widget, 1)
lora.addLayout(strengths)
self.reference_toggle = QCheckBox("Reference image and prompt weighting")
lora.addWidget(self.reference_toggle)
self.reference_panel = QWidget()
reference_settings = QVBoxLayout(self.reference_panel)
reference_settings.setContentsMargins(0, 0, 0, 0)
self.reference_image = QLineEdit()
self.reference_image.setPlaceholderText("Optional reference image")
self.reference_browse = QPushButton("Browse")
self.reference_browse.clicked.connect(self._browse_reference)
reference = QHBoxLayout()
reference.addWidget(self.reference_image, 1)
reference.addWidget(self.reference_browse)
reference_settings.addLayout(reference)
self.prompt_weighting = QCheckBox("Use CivitAI/A1111 prompt weights")
reference_settings.addWidget(self.prompt_weighting)
lora.addWidget(self.reference_panel)
self.reference_panel.hide()
self.reference_toggle.toggled.connect(self.reference_panel.setVisible)
self.reference_image.textChanged.connect(lambda text: self.reference_toggle.setChecked(True) if text else None)
advanced_body.addWidget(self.lora_options)
self.ddpm_reference_options = QWidget()
ddpm = QGridLayout(self.ddpm_reference_options)
ddpm.setContentsMargins(0, 0, 0, 0)
self.ddpm_reference_image = QLineEdit()
self.ddpm_reference_image.setPlaceholderText("Optional image to reimagine")
self.ddpm_reference_browse = QPushButton("Browse")
self.ddpm_reference_browse.clicked.connect(self._browse_ddpm_reference)
ddpm.addWidget(self.ddpm_reference_image, 0, 0, 1, 2)
ddpm.addWidget(self.ddpm_reference_browse, 0, 2)
self.ddpm_reference_strength = integer(0, 100, 65)
self.ddpm_reference_strength.setSuffix("%")
ddpm.addWidget(field("Reference strength", self.ddpm_reference_strength), 1, 0)
self.ddpm_custom_size = QCheckBox("Custom dimensions")
self.ddpm_width = integer(64, 2048, 256, 8)
self.ddpm_height = integer(64, 2048, 256, 8)
ddpm.addWidget(self.ddpm_custom_size, 2, 0, 1, 3)
ddpm.addWidget(field("Width", self.ddpm_width), 3, 0)
ddpm.addWidget(field("Height", self.ddpm_height), 3, 1)
self.ddpm_custom_size.toggled.connect(self._ddpm_custom_size_changed)
self._ddpm_custom_size_changed(False)
advanced_body.addWidget(self.ddpm_reference_options)
self.flow_size_options = QWidget()
flow = QGridLayout(self.flow_size_options)
flow.setContentsMargins(0, 0, 0, 0)
self.flow_custom_size = QCheckBox("Custom dimensions")
self.flow_width = integer(64, 2048, 256, 16)
self.flow_height = integer(64, 2048, 256, 16)
flow.addWidget(self.flow_custom_size, 0, 0, 1, 2)
flow.addWidget(field("Width", self.flow_width), 1, 0)
flow.addWidget(field("Height", self.flow_height), 1, 1)
self.flow_custom_size.toggled.connect(self._flow_custom_size_changed)
self._flow_custom_size_changed(False)
advanced_body.addWidget(self.flow_size_options)
self.plugin_generation_group = QGroupBox("Model-specific settings")
plugin_layout = QVBoxLayout(self.plugin_generation_group)
self.plugin_generation_form = SettingsForm()
plugin_layout.addWidget(self.plugin_generation_form)
advanced_body.addWidget(self.plugin_generation_group)
self.smart_group = QGroupBox("Smart Generation")
smart = QGridLayout(self.smart_group)
self.smart_enabled = QCheckBox("Enable Smart Generation")
self.smart_wanted = integer(1, 48, 8)
self.smart_max_candidates = integer(1, 256, 32)
self.smart_min_score = decimal(0.0, 1.0, 0.70, 0.05)
self.smart_mode = combo()
self.smart_mode.addItem("Stop at threshold", "threshold")
self.smart_mode.addItem("Rank fixed pool", "top_n")
self.smart_keep_rejected = QCheckBox("Keep rejected candidates for review")
self.smart_keep_rejected.setChecked(True)
smart.addWidget(self.smart_enabled, 0, 0, 1, 2)
for row, (label, widget) in enumerate((("Wanted results", self.smart_wanted), ("Maximum candidates", self.smart_max_candidates), ("Minimum score", self.smart_min_score), ("Mode", self.smart_mode)), 1):
smart.addWidget(QLabel(label), row, 0)
smart.addWidget(widget, row, 1)
smart.addWidget(self.smart_keep_rejected, 5, 0, 1, 2)
advanced_body.addWidget(self.smart_group)
for widget in (self.base_model_field, self.negative_prompt_field, self.lora_dimensions, self.lora_options, self.ddpm_reference_options, self.flow_size_options, self.plugin_generation_group, self.smart_group):
widget.hide()
layout.addStretch(1)
return scroll
def _build_history(self) -> QWidget:
workspace = QWidget()
workspace.setMinimumWidth(480)
layout = QVBoxLayout(workspace)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.browser_card = _card()
browser = QVBoxLayout(self.browser_card)
browser.setContentsMargins(10, 8, 10, 8)
browser.setSpacing(6)
self.history_tabs = QTabBar()
self.history_tabs.setObjectName("GenerationTabs")
self.history_tabs.setExpanding(True)
self.history_tabs.setDrawBase(False)
for title in ("Generators", "Recent Output", "All Generations", "Favorites", "Compare"):
self.history_tabs.addTab(title)
browser.addWidget(self.history_tabs)
navigation = QHBoxLayout()
self.folder_back_button = QPushButton("← All generators")
self.folder_back_button.setProperty("compact", True)
self.folder_back_button.hide()
navigation.addWidget(self.folder_back_button)
self.history_title = ElidedLabel("Generators")
self.history_title.setProperty("accent", True)
navigation.addWidget(self.history_title, 1)
self.history_summary = ElidedLabel("Loading history…")
self.history_summary.setProperty("muted", True)
navigation.addWidget(self.history_summary, 2)
browser.addLayout(navigation)
self.generation_target = ElidedLabel("Open a generator to browse its models.")
self.generation_target.setProperty("muted", True)
browser.addWidget(self.generation_target)
self.model_folders = GeneratorCards()
self.model_folders.setObjectName("GeneratorCards")
self.model_folders.setSelectionMode(QAbstractItemView.SingleSelection)
browser.addWidget(self.model_folders)
layout.addWidget(self.browser_card)
self.output_card = _card()
output = QVBoxLayout(self.output_card)
output.setContentsMargins(12, 10, 12, 10)
output.setSpacing(8)
header = QHBoxLayout()
self.output_title = QLabel("Latest Generation")
self.output_title.setObjectName("GenerationHeading")
header.addWidget(self.output_title)
self.output_date = ElidedLabel()
self.output_date.setProperty("muted", True)
header.addWidget(self.output_date, 1)
self.previous_image_button = QPushButton("‹")
self.next_image_button = QPushButton("›")
for button in (self.previous_image_button, self.next_image_button):
button.setFixedWidth(30)
button.setProperty("compact", True)
header.addWidget(button)
self.previous_image_button.setToolTip("Previous image")
self.next_image_button.setToolTip("Next image")
self.previous_image_button.clicked.connect(lambda: self._step_image(-1))
self.next_image_button.clicked.connect(lambda: self._step_image(1))
output.addLayout(header)
self.image_browser = QSplitter(Qt.Horizontal)
self.image_browser.setChildrenCollapsible(False)
self.image_browser.setMinimumHeight(160)
self.preview_image = ImagePreview("Your latest output will appear here after generation.")
self.image_browser.addWidget(self.preview_image)
self.comparison_panel = QWidget()
comparison_layout = QVBoxLayout(self.comparison_panel)
comparison_layout.setContentsMargins(0, 0, 0, 0)
self.comparison_caption = ElidedLabel("Reference image")
comparison_layout.addWidget(self.comparison_caption)
self.comparison_image = ImagePreview("Choose an image and click Pin for comparison. Then select another image.")
comparison_layout.addWidget(self.comparison_image, 1)
self.image_browser.insertWidget(0, self.comparison_panel)
self.comparison_panel.hide()
self.image_detail_panel = QWidget()
self.image_detail_panel.setMinimumWidth(210)
self.image_detail_panel.setMaximumWidth(340)
details = QVBoxLayout(self.image_detail_panel)
details.setContentsMargins(8, 2, 0, 0)
details.setSpacing(4)
self.detail_fields = {}
detail_grid = QGridLayout()
detail_grid.setHorizontalSpacing(10)
detail_grid.setVerticalSpacing(1)
for row, label in enumerate(("Model", "Base model", "Prompt", "Negative prompt", "Size", "Steps", "CFG scale", "Sampler", "Seed")):
key = QLabel(label)
key.setProperty("muted", True)
value = ElidedLabel("—")
value.setMinimumHeight(16)
detail_grid.addWidget(key, row, 0)
detail_grid.addWidget(value, row, 1)
self.detail_fields[label] = value
detail_grid.setColumnStretch(1, 1)
metadata_panel = QWidget()
metadata_panel.setLayout(detail_grid)
detail_grid.setContentsMargins(0, 0, 5, 0)
metadata_scroll = QScrollArea()
metadata_scroll.setWidgetResizable(True)
metadata_scroll.setFrameShape(QFrame.NoFrame)
metadata_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
metadata_scroll.setWidget(metadata_panel)
metadata_scroll.setMinimumHeight(20)
details.addWidget(metadata_scroll, 1)
self.detail = ElidedLabel("Select an image to see its settings.")
self.detail.setProperty("muted", True)
details.addWidget(self.detail)
actions = QHBoxLayout()
actions.setSpacing(5)
self.reuse_button = QPushButton("Reuse settings")
self.save_image_button = QPushButton("Save copy…")
self.save_image_button.clicked.connect(self._save_image_copy)
self.more_image_button = QPushButton("•••")
self.more_image_button.setFixedWidth(34)
self.more_image_button.setToolTip("Open image, open folder, or rate this result")
self.more_image_menu = QMenu(self)
self.more_image_button.setMenu(self.more_image_menu)
self.open_image_button = QPushButton("Open image", self)
self.open_folder_button = QPushButton("Open folder", self)
self.favorite_button = QPushButton("☆ Favorite")
self.keep_button = QPushButton("Keep", self)
self.unsure_button = QPushButton("Unsure", self)
self.reject_button = QPushButton("Reject", self)
for button in (self.open_image_button, self.open_folder_button, self.keep_button, self.unsure_button, self.reject_button):
button.hide()
self.more_image_menu.addAction(button.text(), button.click)
for button, rating in ((self.keep_button, "keep"), (self.unsure_button, "unsure"), (self.reject_button, "reject")):
button.clicked.connect(lambda _checked=False, value=rating: self._rate_selection(value))
for button in (self.reuse_button, self.save_image_button, self.more_image_button):
button.setProperty("compact", True)
button.setEnabled(False)
actions.addWidget(button)
details.addLayout(actions)
self.favorite_button.setProperty("compact", True)
self.favorite_button.clicked.connect(lambda: self._rate_selection("favorite"))
details.addWidget(self.favorite_button)
self.compare_pin_button = QPushButton("Pin for comparison")
self.compare_pin_button.clicked.connect(self._pin_comparison)
details.addWidget(self.compare_pin_button)
self.compare_pin_button.hide()
self.image_browser.addWidget(self.image_detail_panel)
self.image_browser.setStretchFactor(0, 1)
self.image_browser.setStretchFactor(1, 2)
self.image_browser.setStretchFactor(2, 1)
self.image_browser.setSizes([300, 460, 260])
output.addWidget(self.image_browser, 1)
strip_header = QHBoxLayout()
self.results_label = ElidedLabel("Generation Results")
strip_header.addWidget(self.results_label, 1)
self.clear_history_button = QPushButton("Hide displayed")
self.clear_history_button.setToolTip("Hide these thumbnails for this session. Files remain on disk.")
self.auto_sort_button = QPushButton("Auto Sort")
self.auto_sort_button.setToolTip("Rank the current model's images using its preference profile.")
for button in (self.auto_sort_button, self.clear_history_button):
button.setProperty("compact", True)
button.setEnabled(False)
strip_header.addWidget(button)
output.addLayout(strip_header)
self.gallery = QListWidget()
self.gallery.setObjectName("GenerationResults")
self.gallery.setViewMode(QListWidget.IconMode)
self.gallery.setFlow(QListWidget.LeftToRight)
self.gallery.setWrapping(False)
self.gallery.setMovement(QListWidget.Static)
self.gallery.setIconSize(QSize(92, 82))
self.gallery.setGridSize(QSize(112, 112))
self.gallery.setFixedHeight(124)
self.gallery.setSelectionMode(QAbstractItemView.SingleSelection)
self.gallery.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.gallery.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
output.addWidget(self.gallery)
layout.addWidget(self.output_card, 1)
self.history_tabs.currentChanged.connect(self._history_tab_changed)
return workspace
def _history_tab_changed(self, index: int) -> None:
self._active_provider_folder = ""
self._active_model_folder = ""
self._render_history()
def _step_image(self, direction: int) -> None:
if self.gallery.count():
self.gallery.setCurrentRow((self.gallery.currentRow() + direction) % self.gallery.count())
def _pin_comparison(self) -> None:
selection = self._selection()
if selection:
record, path, seed = selection
self.comparison_image.setPixmap(_thumbnail(path, 1200, 1000))
self.comparison_caption.setText(f"{record.model_name} · Seed {seed}")
def _save_image_copy(self) -> None:
selection = self._selection()
if not selection:
return
path = selection[1]
destination, _ = QFileDialog.getSaveFileName(self, "Save image copy", path.name, f"Image (*{path.suffix})")
if destination:
try:
if Path(destination).resolve() != path.resolve():
shutil.copy2(path, destination)
self.status.setText("Image copy saved.")
except OSError as exc:
QMessageBox.warning(self, "Could not save image", str(exc))
def refresh(self) -> None:
self._asset_refresh.request()
signature = (
tuple((asset.id, asset.name, asset.path, asset.trainer, asset.checkpoint, asset.epochs) for asset in self.assets.assets),
tuple(tool.id for tool in generation_tools(self.registry)),
)
if signature == self._model_choices_signature:
self._load_history(force=False)
return
if self._model_choices_signature is not None and signature[1] == self._model_choices_signature[1]:
# Discovery may finish while the user is editing a prompt or seed.
# Update model choices only; rebuilding controls would reset those edits.
self._model_choices_signature = signature
tool = self._current_tool()
selected = self.model.currentData() or self.config.get("generation_settings", {}).get("model_path")
self.model.blockSignals(True)
self.model.clear()
if tool:
for asset in self.assets.assets:
if asset.kind == "model" and (not tool.model_trainers or asset.trainer in tool.model_trainers) and self._model_is_ready(asset):
self.model.addItem(asset.name, asset.path)
index = self.model.findData(selected)
if index >= 0:
self.model.setCurrentIndex(index)
self.model.blockSignals(False)
self._model_changed()
self._load_history(force=False)
return
self._model_choices_signature = signature
selected_provider = self.provider.currentData()
selected_model = self.model.currentData()
self.provider.blockSignals(True)
self.provider.clear()
for tool in generation_tools(self.registry):
self.provider.addItem(tool.name, tool.id)
if selected_provider:
index = self.provider.findData(selected_provider)
if index >= 0:
self.provider.setCurrentIndex(index)
self.provider.blockSignals(False)
self._provider_changed()
if selected_model:
index = self.model.findData(selected_model)
if index >= 0:
self.model.setCurrentIndex(index)
# Refreshing rebuilds the provider-specific controls and the LoRA
# trainer defaults. Reapply ADAM's own last-used values afterwards,
# otherwise reopening this tab can silently replace them.
self._restore_generation_settings()
self._load_history(force=False)
def _current_tool(self) -> ToolSpec | None:
tool_id = self.provider.currentData()
if not tool_id:
return None
try:
return self.registry.get(str(tool_id))
except Exception:
return None
def _provider_changed(self) -> None:
tool = self._current_tool()
current_path = self.model.currentData()
self.model.blockSignals(True)
self.model.clear()
if tool:
candidates = [
asset
for asset in self.assets.assets
if asset.kind == "model"
and (not tool.model_trainers or asset.trainer in tool.model_trainers)
and self._model_is_ready(asset)
]
for asset in candidates:
self.model.addItem(asset.name, asset.path)
if current_path:
index = self.model.findData(current_path)
if index >= 0:
self.model.setCurrentIndex(index)
options = tool.generation_options
samplers = [str(item) for item in options.get("samplers", [])]
aspects = [str(item) for item in options.get("aspect_ratios", [])]
self.sampler.clear()
self.sampler.addItems(samplers or ["DDIM", "DDPM"])
self.aspect.clear()
self.aspect.addItems(aspects or ["1:1 (Square)"])
step_min = int(options.get("step_min", 5) or 5)
step_max = int(options.get("step_max", 500) or 500)
self.steps.setRange(step_min, step_max)
self.steps.setValue(
max(step_min, min(int(options.get("step_default", 50) or 50), step_max))
)
self.steps_label.setText(str(options.get("step_label", "Steps")))
self.preview_interval_label.setText(
str(options.get("preview_step_label", "Steps / preview"))
)
self.preview_interval.setRange(0, step_max)
self.preview_interval.setValue(max(
0,
min(int(options.get("preview_step_default", 0) or 0), step_max),
))
step_unit = str(options.get("step_label", "steps")).casefold()
self.preview_interval.setSuffix(f" {step_unit}")
self.preview_interval.setToolTip(
f"Show a live preview after this many {step_unit}. Smaller intervals can make generation slower."
)
is_prompted = "text_prompt" in tool.capabilities
self.model_browse_button.setVisible(tool.id == "lora_generator")
self.enhance_prompt_button.setVisible(is_prompted)
self.prompt_label.setText("Positive prompt" if is_prompted else "Label / creative note")
self.prompt.setPlaceholderText(
"Describe the image to create with the selected LoRA."
if is_prompted
else "Optional note describing what you are exploring. Unconditional generators save it with the batch but do not use it as a text prompt."
)
for widget in (self.lora_options, self.base_model_field, self.negative_prompt_field, self.lora_dimensions):
widget.setVisible(tool.id == "lora_generator")
self.ddpm_reference_options.setVisible(tool.id == "ddpm_generator")
self.flow_size_options.setVisible(tool.id == "flow_generator")
self.smart_group.setVisible("smart_generation" in tool.capabilities)
self.aspect.setEnabled(
(tool.id != "ddpm_generator" or not self.ddpm_custom_size.isChecked())
and (tool.id != "flow_generator" or not self.flow_custom_size.isChecked())
)
if tool.id == "lora_generator":
self._load_lora_options()
self._load_plugin_generation_settings(tool)
self.model.blockSignals(False)
if not tool:
self.smart_group.setVisible(False)
self.enhance_prompt_button.setVisible(False)
self.generate_button.setEnabled(bool(tool and self.model.count()))
self._apply_preset()
self._model_changed()
def _browse_lora(self) -> None:
"""Choose a LoRA outside the connected trainer's output folder."""
path, _ = QFileDialog.getOpenFileName(
self,
"Choose LoRA file",
"",
"LoRA files (*.safetensors)",
)
if not path:
return
selected = Path(path).resolve()
if "_comfy" in selected.stem.casefold():
QMessageBox.warning(
self,
"Choose the original LoRA",
"The *_comfy.safetensors export is for ComfyUI. Choose the original LoRA file instead.",
)
return
index = self.model.findData(str(selected))
if index < 0:
self.model.addItem(selected.stem, str(selected))
index = self.model.count() - 1
self.model.setCurrentIndex(index)
def _enhance_prompt(self) -> None:
source = self.prompt.toPlainText().strip()
if not source:
QMessageBox.information(self, "Prompt needed", "Enter a positive prompt before asking Ollama to enhance it.")
return
model = str(self.config.get("ollama_model", "")).strip()
if not model:
QMessageBox.warning(self, "Ollama model not configured", "Choose an Ollama model in Settings → Planning model first.")
return
self._prompt_enhance_source = source
self.enhance_prompt_button.setEnabled(False)
self.enhance_prompt_button.setText("Enhancing…")
self.status.setText(f"Enhancing with Ollama ({model})…")
client = OllamaClient(
str(self.config.get("ollama_url", "http://localhost:11434")),
model,
timeout=90.0,
chat_max_tokens=min(1024, int(self.config.get("ollama_chat_max_tokens", 1024))),
chat_response_length="detailed",
)
context = self.model.currentText() or self.base_model.currentText()
QThreadPool.globalInstance().start(
_PromptEnhanceWorker(client, source, context, self._prompt_enhance_signals)
)
def _prompt_enhancement_ready(self, suggestion: str, error: str) -> None:
self.enhance_prompt_button.setEnabled(True)
self.enhance_prompt_button.setText("✦ Enhance prompt with Ollama")
if error:
self.status.setText("Prompt enhancement failed.")
QMessageBox.warning(self, "Could not enhance prompt", error)
return
if self.prompt.toPlainText().strip() != self._prompt_enhance_source:
self.status.setText("Prompt changed while Ollama was working; review and try again.")
QMessageBox.information(
self, "Prompt changed",
"The prompt was edited while Ollama was working, so its suggestion was not applied. You can run the enhancer again on the updated prompt.",
)
return
dialog = QDialog(self)
dialog.setWindowTitle("Review enhanced prompt")
dialog.setMinimumSize(560, 300)
layout = QVBoxLayout(dialog)
layout.addWidget(QLabel("Review the suggested prompt. Your current prompt will only change if you choose Use enhanced prompt."))
comparison = QSplitter(Qt.Vertical)
original = QPlainTextEdit(self._prompt_enhance_source)
original.setReadOnly(True)
original.setPlaceholderText("Current prompt")
enhanced = QPlainTextEdit(suggestion)
enhanced.setPlaceholderText("Enhanced prompt")
comparison.addWidget(original)
comparison.addWidget(enhanced)
comparison.setSizes([100, 140])
layout.addWidget(comparison, 1)
buttons = QDialogButtonBox()
apply_button = buttons.addButton("Use enhanced prompt", QDialogButtonBox.AcceptRole)
buttons.addButton(QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() == QDialog.Accepted:
self.prompt.setPlainText(enhanced.toPlainText().strip())
self.status.setText("Enhanced prompt applied. Review it, then generate when ready.")
else:
self.status.setText("Prompt enhancement ready; original prompt kept.")
def _paste_lora_metadata(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("Paste LoRA metadata")
dialog.setMinimumSize(680, 440)
layout = QVBoxLayout(dialog)
layout.addWidget(_header(
"Reuse LoRA settings",
"Paste the JSON metadata directly. ADAM also accepts HTML-escaped metadata copied from a web page.",
))
source = QPlainTextEdit()
source.setPlaceholderText('{"prompt": "…", "loras": [{"path": "…", "strength": 1.0}], "model": "…"}')
layout.addWidget(source, 1)
buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok)
buttons.button(QDialogButtonBox.Ok).setText("Load settings")
buttons.rejected.connect(dialog.reject)
buttons.accepted.connect(dialog.accept)
layout.addWidget(buttons)
if dialog.exec() != QDialog.Accepted:
return
raw_metadata = source.toPlainText()
try:
metadata = parse_lora_generation_metadata(raw_metadata)
except ValueError as exc:
# CivitAI/A1111 copied text has settings but normally no local
# `loras` array. Load it while deliberately keeping the LoRA that
# is already selected in ADAM instead of guessing from folders.
plain = parse_plain_generation_metadata(raw_metadata)
if plain is None:
QMessageBox.warning(self, "Could not read metadata", str(exc))
return
provider_index = self.provider.findData("lora_generator")
if provider_index < 0:
QMessageBox.warning(self, "LoRA generator unavailable", "Enable the LoRA Generator in Settings before importing metadata.")
return
self.provider.setCurrentIndex(provider_index)
self.prompt.setPlainText(plain.prompt)
self.negative_prompt.setPlainText(plain.negative_prompt)
if plain.seed is not None:
self.seed.setText(str(plain.seed))
if plain.steps is not None:
self.steps.setValue(max(self.steps.minimum(), min(plain.steps, self.steps.maximum())))
if plain.sampler and self.sampler.findText(plain.sampler) >= 0:
self.sampler.setCurrentText(plain.sampler)
if plain.cfg_scale is not None:
self.cfg_scale.setValue(max(self.cfg_scale.minimum(), min(plain.cfg_scale, self.cfg_scale.maximum())))
if plain.width is not None:
self.width.setValue(plain.width)
if plain.height is not None:
self.height.setValue(plain.height)
selected = self.model.currentText().strip()
self.status.setText(
"Loaded CivitAI/PixAI settings. "
+ (f"Keeping selected LoRA: {selected}." if selected else "Choose a local LoRA before generating.")
)
return
provider_index = self.provider.findData("lora_generator")
if provider_index < 0:
QMessageBox.warning(self, "LoRA generator unavailable", "Enable the LoRA Generator in Settings before importing metadata.")
return
self.provider.setCurrentIndex(provider_index)
if not metadata.lora_path:
# An explicit empty list means base-model-only; do not accidentally
# reuse whichever LoRA happened to be selected before the import.
self.model.setCurrentIndex(-1)
lora_path = Path(metadata.lora_path) if metadata.lora_path else None
if lora_path is not None and not lora_path.is_absolute():
# Relative exports normally point into the connected trainer folder.
candidate = Path(str(self.config.get("tool_folders", {}).get("lora_trainer", ""))) / lora_path
if candidate.exists():
lora_path = candidate
if lora_path is not None and lora_path.is_file():
lora_path = lora_path.resolve()
model_index = self.model.findData(str(lora_path))
if model_index < 0:
self.model.addItem(lora_path.stem, str(lora_path))
model_index = self.model.count() - 1
self.model.setCurrentIndex(model_index)
self.prompt.setPlainText(metadata.prompt)
self.negative_prompt.setPlainText(metadata.negative_prompt)
self.seed.setText(str(metadata.seed))
self.steps.setValue(max(self.steps.minimum(), min(metadata.steps, self.steps.maximum())))
self.sampler.setCurrentText(metadata.sampler)
if metadata.cfg_scale is not None:
self.cfg_scale.setValue(max(self.cfg_scale.minimum(), min(metadata.cfg_scale, self.cfg_scale.maximum())))
if metadata.lora_strength is not None:
self.lora_strength.setValue(max(self.lora_strength.minimum(), min(metadata.lora_strength, self.lora_strength.maximum())))
if metadata.width is not None:
self.width.setValue(metadata.width)
if metadata.height is not None:
self.height.setValue(metadata.height)
base_path = metadata.base_model_path
base_index = self.base_model.findData(base_path)
if base_index >= 0:
self.base_model.setCurrentIndex(base_index)
else:
self.base_model.setCurrentText(base_path)
missing = (
" This metadata uses no LoRA; ADAM will generate with the selected base model only."
if lora_path is None
else "" if lora_path.is_file() else f" Choose the LoRA file manually; its saved path was {metadata.lora_path}."
)
self.status.setText("Loaded generation metadata." + (" " + metadata.sampler_note if metadata.sampler_note else "") + missing)
def _lora_settings(self) -> dict:
root = Path(str(self.config.get("tool_folders", {}).get("lora_trainer", "")))
try:
value = json.loads((root / "config" / "app_settings.json").read_text(encoding="utf-8"))
return value if isinstance(value, dict) else {}
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return {}
def _load_lora_options(self) -> None:
saved = self._lora_settings()
base_models = [str(saved.get(key, "")).strip() for key in ("generate_model", "last_model")]
current = self.base_model.currentText().strip()
self.base_model.clear()
preferred_asset = next(
(
asset for asset in self.assets.assets
if asset.kind == "base_model"
and "waiillustrious" in "".join(
character for character in asset.name.casefold() if character.isalnum()
)
),
None,
)
if preferred_asset is not None:
self.base_model.addItem(preferred_asset.name, preferred_asset.path)
for path in dict.fromkeys(item for item in base_models if item):
self.base_model.addItem(Path(path).name, path)
for asset in self.assets.assets:
if asset.kind == "base_model" and self.base_model.findData(asset.path) < 0:
self.base_model.addItem(asset.name, asset.path)
if current:
self.base_model.setCurrentText(current)
elif preferred_asset is not None:
self.base_model.setCurrentIndex(0)
elif base_models and base_models[0]:
self.base_model.setCurrentText(base_models[0])
self.negative_prompt.setPlainText(str(saved.get("generate_negative_prompt", "")))
self.cfg_scale.setValue(float(saved.get("generate_cfg", 7.0) or 7.0))
self.lora_strength.setValue(float(saved.get("generate_lora_strength", 1.0) or 1.0))
self.denoise_strength.setValue(float(saved.get("generate_denoise_strength", 0.45) or 0.45))
self.prompt_weighting.setChecked(bool(saved.get("generate_prompt_weighting", True)))
self._lora_aspect_changed(self.aspect.currentText())
def _lora_aspect_changed(self, aspect: str) -> None:
if not self._current_tool() or self._current_tool().id != "lora_generator":
return
sizes = {
"1:1 (Square)": (1024, 1024), "4:3 (Landscape)": (1152, 864),
"3:4 (Portrait)": (864, 1152), "3:2 (Landscape)": (1216, 832),
"2:3 (Portrait)": (832, 1216), "16:9 (Widescreen)": (1344, 768),
"9:16 (Vertical)": (768, 1344),
}
if aspect in sizes:
self.width.setValue(sizes[aspect][0])
self.height.setValue(sizes[aspect][1])
def _browse_reference(self) -> None:
path, _ = QFileDialog.getOpenFileName(self, "Choose reference image", self.reference_image.text(), "Images (*.png *.jpg *.jpeg *.webp *.bmp)")
if path:
self.reference_image.setText(path)
def _browse_ddpm_reference(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
"Choose DDPM reference image",
self.ddpm_reference_image.text(),
"Images (*.png *.jpg *.jpeg *.webp *.bmp)",
)
if path:
self.ddpm_reference_image.setText(path)
def _ddpm_custom_size_changed(self, enabled: bool) -> None:
self.ddpm_width.setEnabled(enabled)
self.ddpm_height.setEnabled(enabled)
if self._current_tool() and self._current_tool().id == "ddpm_generator":
self.aspect.setEnabled(not enabled)
def _flow_custom_size_changed(self, enabled: bool) -> None:
self.flow_width.setEnabled(enabled)
self.flow_height.setEnabled(enabled)
if self._current_tool() and self._current_tool().id == "flow_generator":
self.aspect.setEnabled(not enabled)
def _load_plugin_generation_settings(self, tool: ToolSpec) -> None:
schema = self.registry.model_plugins.generation_schema_for_tool(tool.id)
built_in = {
"model_name", "model_path", "prompt", "image_count", "steps", "seed",
"sampler", "aspect_ratio", "preview_interval",
"smart_generation", "smart_wanted_results", "smart_max_candidates",
"smart_min_score", "smart_mode", "smart_keep_rejected",
}
if tool.id == "lora_generator":
built_in.update({
"base_model_path", "negative_prompt", "width", "height",
"cfg_scale", "lora_strength", "reference_image",
"denoise_strength", "prompt_weighting",
})
elif tool.id == "ddpm_generator":
built_in.update({"reference_image", "reference_strength", "width", "height"})
elif tool.id == "flow_generator":
built_in.update({"width", "height"})
extra_schema = {
key: spec for key, spec in schema.items()
if key not in built_in and key in tool.arguments
}
self.plugin_generation_form.set_schema(extra_schema)
self.plugin_generation_group.setVisible(bool(extra_schema))
@staticmethod
def _model_is_ready(asset: Asset) -> bool:
path = Path(asset.path)
if asset.trainer == "ddpm":
return path.is_dir() and (path / "model_index.json").is_file()
if asset.trainer == "flow":
return (
path.is_dir()
and (path / "flow_model_info.json").is_file()
and (path / "unet" / "config.json").is_file()
)
if asset.trainer == "lora":
return (
path.is_file()
and path.suffix.casefold() == ".safetensors"
and "_comfy" not in path.stem.casefold()
) or (
path.is_dir()
and any(
item.is_file() and "_comfy" not in item.stem.casefold()
for item in path.glob("*.safetensors")
)
)
return path.exists()
def _model_changed(self) -> None:
tool = self._current_tool()
path = self.model.currentData()
if not tool:
self.model_hint.setText("No registered image generators are available.")
elif not path:
self.model_hint.setText(
f"{tool.name} is ready, but no compatible completed model was found."
)
else:
self.model_hint.setText(f"{tool.name} · {self.model.count()} available models")
self.model_hint.setToolTip(f"Completed pipeline: {path}")
self.generate_button.setEnabled(bool(tool and (path or tool.id == "lora_generator")))
def _apply_preset(self) -> None:
name = self.preset.currentText()
tool = self._current_tool()
if tool and tool.id in {"flow_generator", "inrflow_generator"}:
values = {
"Balanced": (20 if tool.id == "flow_generator" else 50, "Heun" if tool.id == "flow_generator" else "Euler"),
"Fast preview": (8, "Euler"),
"Detailed": (30 if tool.id == "flow_generator" else 100, "Heun"),
"Classic": (20, "Euler"),
}
fallback = values["Balanced"]
elif tool and tool.id == "pixelrow_generator":
values = {
"Balanced": (128, "Categorical"),
"Fast preview": (32, "Categorical"),
"Detailed": (128, "Categorical"),
"Classic": (64, "Categorical"),
}
fallback = (128, "Categorical")
elif tool and tool.id == "lora_generator":
values = {
"Balanced": (30, "DPM++ 2M"),
"Fast preview": (12, "Euler a"),
"Detailed": (50, "DPM++ 2M"),
"Classic": (30, "DDIM"),
}
fallback = (30, "DPM++ 2M")
else:
values = {
"Balanced": (50, "DDIM"),
"Fast preview": (20, "DDIM"),
"Detailed": (100, "DDIM"),
"Classic": (100, "DDPM"),
}
fallback = (50, "DDIM")
steps, sampler = values.get(name, fallback)
self.steps.setValue(max(self.steps.minimum(), min(steps, self.steps.maximum())))
if self.sampler.findText(sampler) >= 0:
self.sampler.setCurrentText(sampler)
def _generate(self) -> None:
tool = self._current_tool()
model_path = str(self.model.currentData() or "")
if tool is None or (not model_path and tool.id != "lora_generator"):
QMessageBox.information(
self, "Choose a model", "Choose a registered generator and completed model."
)
return
raw_seed = self.seed.text().strip()
try:
seed = int(raw_seed) if raw_seed else 0
except ValueError:
QMessageBox.warning(self, "Check the seed", "Seed must be a whole number or left blank for random.")
return
if seed < 0 or seed > 2_147_483_647:
QMessageBox.warning(self, "Check the seed", "Seed must be between 0 and 2,147,483,647.")
return
extra_arguments = {}
if tool.id == "lora_generator":
extra_arguments = {
"negative_prompt": self.negative_prompt.toPlainText(),
"base_model_path": str(self.base_model.currentData() or self.base_model.currentText()),
"width": self.width.value(), "height": self.height.value(),
"cfg_scale": self.cfg_scale.value(), "lora_strength": self.lora_strength.value(),
"reference_image": self.reference_image.text().strip(),
"denoise_strength": self.denoise_strength.value(),
"prompt_weighting": self.prompt_weighting.isChecked(),
"preview_interval": self.preview_interval.value(),
}
elif tool.id == "ddpm_generator":
extra_arguments = {
"reference_image": self.ddpm_reference_image.text().strip(),
"reference_strength": self.ddpm_reference_strength.value(),
"width": self.ddpm_width.value() if self.ddpm_custom_size.isChecked() else 0,
"height": self.ddpm_height.value() if self.ddpm_custom_size.isChecked() else 0,
"preview_interval": self.preview_interval.value(),
}
elif tool.id == "flow_generator":
extra_arguments = {
"width": self.flow_width.value() if self.flow_custom_size.isChecked() else 0,
"height": self.flow_height.value() if self.flow_custom_size.isChecked() else 0,
"preview_interval": self.preview_interval.value(),
}
elif "preview_interval" in tool.arguments:
extra_arguments = {"preview_interval": self.preview_interval.value()}
if "smart_generation" in tool.capabilities and self.smart_enabled.isChecked():
if self.smart_max_candidates.value() < self.smart_wanted.value():
QMessageBox.warning(self, "Check Smart Generation", "Maximum candidates must be at least the wanted result count.")
return
extra_arguments.update({
"smart_generation": True,
"smart_wanted_results": self.smart_wanted.value(),
"smart_max_candidates": self.smart_max_candidates.value(),
"smart_min_score": self.smart_min_score.value(),
"smart_mode": str(self.smart_mode.currentData() or "threshold"),
"smart_keep_rejected": self.smart_keep_rejected.isChecked(),
})
extra_arguments.update(self.plugin_generation_form.values())
plan = build_generation_plan(
tool,
model_name=self.model.currentText() or Path(str(self.base_model.currentData() or self.base_model.currentText())).stem,
model_path=model_path,
prompt=self.prompt.toPlainText(),
image_count=self.images.value(),
steps=self.steps.value(),
seed=seed,
sampler=self.sampler.currentText(),
aspect_ratio=self.aspect.currentText(),
extra_arguments=extra_arguments,
)
job = self.jobs.submit(plan)
self._save_generation_settings()
self.status.setText(
f"Generation job {job.id} queued. You can follow it here or in Jobs / History."
)
def _open_generation_cycle(self) -> None:
self.assets.discover(self.config)
tools = generation_tools(self.registry)
trainer_labels = {
"ddpm": "DDPM", "flow": "Flow Matching", "lora": "LoRA",
"pixelrow": "PixelRow", "inrflow": "INRFlow",
}
entries = []
for asset in self.assets.assets:
if asset.kind != "model" or not self._model_is_ready(asset):
continue
tool = next((item for item in tools if asset.trainer in item.model_trainers), None)
if tool:
entries.append({
"name": asset.name, "path": asset.path, "trainer": asset.trainer,
"trainer_label": trainer_labels.get(asset.trainer, asset.trainer.title()),
"tool": tool,
})
if not entries:
QMessageBox.information(self, "No completed models", "Complete at least one compatible model before creating a generation cycle.")
return
dialog = GenerationCycleDialog(entries, self)
if dialog.exec() != QDialog.Accepted:
return
selected = dialog.selected_entries()
plans = []
base_seed = dialog.seed.value()
for index, entry in enumerate(selected):
tool = entry["tool"]
options = tool.generation_options
samplers = [str(value) for value in options.get("samplers", [])] or ["DDIM"]
aspects = [str(value) for value in options.get("aspect_ratios", [])] or ["1:1 (Square)"]
extra = {}
if tool.id == "lora_generator":
saved = self._lora_settings()
base_path = str(saved.get("generate_model") or saved.get("last_model") or "")
if not base_path:
base_asset = next((asset for asset in self.assets.assets if asset.kind == "base_model"), None)
base_path = base_asset.path if base_asset else ""
if not base_path or not Path(base_path).exists():
QMessageBox.warning(
self, "LoRA base model required",
f"{entry['name']} needs a valid Stable Diffusion base model before it can join the cycle. "
"Choose one in the LoRA generation settings first.",
)
return
extra = {
"base_model_path": base_path,
"negative_prompt": str(saved.get("generate_negative_prompt", "")),
"width": 1024, "height": 1024,
"cfg_scale": float(saved.get("generate_cfg", 7.0) or 7.0),
"lora_strength": float(saved.get("generate_lora_strength", 1.0) or 1.0),
"denoise_strength": float(saved.get("generate_denoise_strength", 0.45) or 0.45),
"prompt_weighting": bool(saved.get("generate_prompt_weighting", True)),
}
plans.append(build_generation_plan(
tool,
model_name=entry["name"], model_path=entry["path"],
prompt=dialog.prompt.text().strip() or (entry["name"] if tool.id == "lora_generator" else ""),
image_count=dialog.image_count.value(),
steps=int(options.get("step_default", 30) or 30),
seed=base_seed + index * dialog.image_count.value(),
sampler=samplers[0], aspect_ratio=aspects[0], extra_arguments=extra,
))
plan = combine_generation_plans(
plans, display_seconds=dialog.display_seconds.value(),
show_labels=dialog.show_labels.isChecked(), loop=dialog.loop.isChecked(),
)
job = self.jobs.submit(plan)
self._cycle_jobs[job.id] = {
"models": [(entry["name"], entry["trainer_label"]) for entry in selected],
"display_seconds": dialog.display_seconds.value(),
"show_labels": dialog.show_labels.isChecked(),
"loop": dialog.loop.isChecked(),
"fullscreen": dialog.fullscreen.isChecked(),
}
self.status.setText(f"Generation cycle {job.id} queued with {len(selected)} models.")
def _play_finished_cycle(self, job: Job, settings: dict) -> None:
records = []
history_root = self.root / "data" / "generations"
if history_root.is_dir():
for metadata in history_root.rglob(f"*{job.id}*.json"):
record = GenerationRecord.from_metadata(metadata)
if record:
records.append(record)
model_order = {name: index for index, (name, _trainer) in enumerate(settings["models"])}
records.sort(key=lambda record: model_order.get(record.model_name, len(model_order)))
trainer_by_model = dict(settings["models"])
slides = [
(path, f"{record.model_name} — {trainer_by_model.get(record.model_name, record.provider_name)}")
for record in records for path in record.images
]
if not slides:
self.status.setText(f"Generation cycle {job.id} finished, but no cycle images were found.")
return
player = CycleSlideshowDialog(
slides, display_seconds=settings["display_seconds"],
show_labels=settings["show_labels"], loop=settings["loop"], parent=self,
)
if settings.get("fullscreen"):
player.showFullScreen()
player.exec()
def _save_generation_settings(self) -> None:
self.config.update({
"generation_settings": {
"provider": self.provider.currentData(),
"model_path": self.model.currentData(),
"preset": self.preset.currentText(),
"images": self.images.value(),
"steps": self.steps.value(),
"preview_interval": self.preview_interval.value(),
"sampler": self.sampler.currentText(),
"aspect_ratio": self.aspect.currentText(),
"seed": self.seed.text(),
"prompt": self.prompt.toPlainText(),
"base_model_path": self.base_model.currentData() or self.base_model.currentText(),
"negative_prompt": self.negative_prompt.toPlainText(),
"width": self.width.value(),
"height": self.height.value(),
"reference_image": self.reference_image.text(),
"cfg_scale": self.cfg_scale.value(),
"lora_strength": self.lora_strength.value(),
"denoise_strength": self.denoise_strength.value(),
"prompt_weighting": self.prompt_weighting.isChecked(),
"ddpm_reference_image": self.ddpm_reference_image.text(),
"ddpm_reference_strength": self.ddpm_reference_strength.value(),
"ddpm_custom_size": self.ddpm_custom_size.isChecked(),
"ddpm_width": self.ddpm_width.value(),
"ddpm_height": self.ddpm_height.value(),
"flow_custom_size": self.flow_custom_size.isChecked(),
"flow_width": self.flow_width.value(),
"flow_height": self.flow_height.value(),
"smart_generation": self.smart_enabled.isChecked(),
"smart_wanted_results": self.smart_wanted.value(),
"smart_max_candidates": self.smart_max_candidates.value(),
"smart_min_score": self.smart_min_score.value(),
"smart_mode": self.smart_mode.currentData(),
"smart_keep_rejected": self.smart_keep_rejected.isChecked(),
}
})
def _connect_generation_setting_persistence(self) -> None:
self.provider.currentIndexChanged.connect(self._save_generation_settings)
self.model.currentIndexChanged.connect(self._save_generation_settings)
self.preset.currentTextChanged.connect(self._save_generation_settings)
self.images.valueChanged.connect(self._save_generation_settings)
self.steps.valueChanged.connect(self._save_generation_settings)
self.preview_interval.valueChanged.connect(self._save_generation_settings)
self.sampler.currentTextChanged.connect(self._save_generation_settings)
self.aspect.currentTextChanged.connect(self._save_generation_settings)
self.seed.editingFinished.connect(self._save_generation_settings)
self.prompt.textChanged.connect(self._save_generation_settings)
self.base_model.currentIndexChanged.connect(self._save_generation_settings)
self.base_model.editTextChanged.connect(self._save_generation_settings)
self.negative_prompt.textChanged.connect(self._save_generation_settings)
self.width.valueChanged.connect(self._save_generation_settings)
self.height.valueChanged.connect(self._save_generation_settings)
self.reference_image.editingFinished.connect(self._save_generation_settings)
self.cfg_scale.valueChanged.connect(self._save_generation_settings)
self.lora_strength.valueChanged.connect(self._save_generation_settings)
self.denoise_strength.valueChanged.connect(self._save_generation_settings)
self.prompt_weighting.toggled.connect(self._save_generation_settings)
self.ddpm_reference_image.editingFinished.connect(self._save_generation_settings)
self.ddpm_reference_strength.valueChanged.connect(self._save_generation_settings)
self.ddpm_custom_size.toggled.connect(self._save_generation_settings)
self.ddpm_width.valueChanged.connect(self._save_generation_settings)
self.ddpm_height.valueChanged.connect(self._save_generation_settings)
self.flow_custom_size.toggled.connect(self._save_generation_settings)
self.flow_width.valueChanged.connect(self._save_generation_settings)
self.flow_height.valueChanged.connect(self._save_generation_settings)
self.smart_enabled.toggled.connect(self._save_generation_settings)
self.smart_wanted.valueChanged.connect(self._save_generation_settings)
self.smart_max_candidates.valueChanged.connect(self._save_generation_settings)
self.smart_min_score.valueChanged.connect(self._save_generation_settings)
self.smart_mode.currentIndexChanged.connect(self._save_generation_settings)
self.smart_keep_rejected.toggled.connect(self._save_generation_settings)
def _restore_generation_settings(self) -> None:
saved = self.config.get("generation_settings", {})
if not isinstance(saved, dict):
return
provider_index = self.provider.findData(saved.get("provider"))
if provider_index >= 0:
self.provider.setCurrentIndex(provider_index)
model_index = self.model.findData(saved.get("model_path"))
if model_index >= 0:
self.model.setCurrentIndex(model_index)
for combo, key in ((self.preset, "preset"), (self.sampler, "sampler"), (self.aspect, "aspect_ratio")):
value = str(saved.get(key, ""))
if combo.findText(value) >= 0:
combo.setCurrentText(value)
for spin, key in ((self.width, "width"), (self.height, "height"), (self.images, "images"), (self.steps, "steps"), (self.preview_interval, "preview_interval"), (self.ddpm_reference_strength, "ddpm_reference_strength"), (self.ddpm_width, "ddpm_width"), (self.ddpm_height, "ddpm_height"), (self.flow_width, "flow_width"), (self.flow_height, "flow_height")):
try:
spin.setValue(max(spin.minimum(), min(int(saved.get(key, spin.value())), spin.maximum())))
except (TypeError, ValueError):
pass
self.seed.setText(str(saved.get("seed", "")))
self.prompt.setPlainText(str(saved.get("prompt", "")))
base_model_path = str(saved.get("base_model_path", ""))
base_index = self.base_model.findData(base_model_path)
if base_index >= 0:
self.base_model.setCurrentIndex(base_index)
elif base_model_path:
self.base_model.setCurrentText(base_model_path)
self.negative_prompt.setPlainText(str(saved.get("negative_prompt", self.negative_prompt.toPlainText())))
try:
self.cfg_scale.setValue(float(saved.get("cfg_scale", self.cfg_scale.value())))
self.lora_strength.setValue(float(saved.get("lora_strength", self.lora_strength.value())))
self.denoise_strength.setValue(float(saved.get("denoise_strength", self.denoise_strength.value())))
except (TypeError, ValueError):
pass
self.prompt_weighting.setChecked(bool(saved.get("prompt_weighting", self.prompt_weighting.isChecked())))
self.reference_image.setText(str(saved.get("reference_image", "")))
self.ddpm_reference_image.setText(str(saved.get("ddpm_reference_image", "")))
self.ddpm_custom_size.setChecked(bool(saved.get("ddpm_custom_size", False)))
self.flow_custom_size.setChecked(bool(saved.get("flow_custom_size", False)))
self.smart_enabled.setChecked(bool(saved.get("smart_generation", False)))
for spin, key in ((self.smart_wanted, "smart_wanted_results"), (self.smart_max_candidates, "smart_max_candidates")):
try:
spin.setValue(max(spin.minimum(), min(int(saved.get(key, spin.value())), spin.maximum())))
except (TypeError, ValueError):
pass
try:
self.smart_min_score.setValue(float(saved.get("smart_min_score", self.smart_min_score.value())))
except (TypeError, ValueError):
pass
mode_index = self.smart_mode.findData(saved.get("smart_mode"))
if mode_index >= 0:
self.smart_mode.setCurrentIndex(mode_index)
self.smart_keep_rejected.setChecked(bool(saved.get("smart_keep_rejected", True)))
def _load_history(self, *, force: bool = True) -> None:
root = self.root
if not self.records:
self.history_summary.setText("Loading generation history…")
self._history_reader.request(lambda: load_generation_history(root), force=force)
def _history_loaded(self, records: object) -> None:
self.records = records
self._render_history()
def _render_history(self) -> None:
current = self.gallery.currentItem()
payload = current.data(Qt.UserRole) if current else None
self._history_selected_image = str(payload.get("image", "")) if isinstance(payload, dict) else ""
self._history_load_token += 1
token = self._history_load_token
self._history_load_index = 0
self.gallery.clear()
self.model_folders.clear()
mode = self.history_tabs.currentIndex()
folders = group_generation_providers(self.records)
provider = next((folder for folder in folders if folder.key == self._active_provider_folder), None)
if provider is None:
self._active_provider_folder = ""
self._active_model_folder = ""
provider_records = list(provider.records) if provider else self.records
models = group_generation_records(provider_records)
model = next((folder for folder in models if folder.key == self._active_model_folder), None)
if model is None:
self._active_model_folder = ""
self.model_folders.setVisible(mode == 0 and model is None and bool(folders))
self.folder_back_button.setVisible(mode == 0 and provider is not None)
self.comparison_panel.setVisible(mode == 4)
self.compare_pin_button.setVisible(mode == 4)
self.output_title.setText("Compare Images" if mode == 4 else "Latest Generation")
self.history_title.setText(model.model_name if model else provider.provider_name if provider else self.history_tabs.tabText(mode))
self.folder_back_button.setText(f"← {provider.provider_name}" if model else "← All generators")
self.generation_target.setVisible(mode != 0)
self.generation_target.setText(
"Pin an image as your reference, then choose another thumbnail to compare." if mode == 4 else
"Your favorite images across all models." if mode == 3 else
"Browse all saved generations. Select a thumbnail to inspect its settings." if mode == 2 else
"Images from the most recent batch." if mode == 1 else
"Select a thumbnail to inspect or reuse its settings." if model else
"Open a model to browse its generations." if provider else
"Open a generator to browse its models. Your latest result is shown below."
)
if mode == 0 and model is None:
for folder in models if provider else folders:
cover = folder.cover_image if provider else next((path for record in folder.records for path in record.images if path.is_file()), None)
name = folder.model_name if provider else folder.provider_name
count = (
f"{len(folder.records)} batch{'es' if len(folder.records) != 1 else ''}" if provider else
f"{folder.model_count} model{'s' if folder.model_count != 1 else ''}"
)
icon = QIcon(_thumbnail(cover, 480, 240)) if cover else QIcon()
item = QListWidgetItem(icon, f"{name}\n{count} · {folder.image_count} images")
item.setData(Qt.UserRole, folder.key)
item.setToolTip(f"Open {name}")
self.model_folders.addItem(item)
self.model_folders.fit_cards()
candidates = list(model.records) if mode == 0 and model else list(provider_records) if mode == 0 else list(self.records)
candidates.sort(key=lambda record: record.created_at or record.folder.name, reverse=True)
if mode == 1 or (mode == 0 and model is None):
candidates = candidates[:1]
record_indices = {id(record): index for index, record in enumerate(self.records)}
self._history_entries = []
for record in candidates:
for image_index, path in enumerate(record.images):
if str(path) in self.hidden_history_images:
continue
if mode == 3 and self._stored_rating(record, path) != "favorite":
continue
self._history_entries.append((record_indices[id(record)], image_index, path))
total = len(self._history_entries)
self.results_label.setText(f"Generation Results ({total} image{'s' if total != 1 else ''})")
self.history_summary.setText(
f"{len(folders)} generators · {len(self.records)} batches" if mode == 0 and not provider else
f"{len(models)} models · {provider.image_count} images" if mode == 0 and not model else
f"{len(candidates)} batches · {total} images"
)
self.auto_sort_button.setEnabled(mode == 0 and model is not None)
self.clear_history_button.setEnabled(total > 0)
if not total:
self._selection_changed()
self.preview_image.setText("No favorites yet. Select an image and click Favorite." if mode == 3 else "No images in this view yet. Generate a batch to get started.")
return
QTimer.singleShot(0, lambda: self._load_next_history_thumbnail(token))
def _load_next_history_thumbnail(self, token: int) -> None:
"""Decode one history image per event-loop turn so the page opens immediately."""
if token != self._history_load_token:
return
if self._history_load_index >= len(self._history_entries):
self.previous_image_button.setEnabled(self.gallery.count() > 1)
self.next_image_button.setEnabled(self.gallery.count() > 1)
if self.gallery.currentRow() < 0 and self.gallery.count():
self.gallery.setCurrentRow(0)
return
record_index, image_index, path = self._history_entries[self._history_load_index]
record = self.records[record_index]
seed = record.seed + image_index
rating = self._stored_rating(record, path)
score = record.image_evaluations.get(str(path.resolve()), {}).get("score")
badges = []
if rating:
badges.append(rating.title())
if isinstance(score, (int, float)):
badges.append(f"{float(score):.2f}")
item = QListWidgetItem(
QIcon(_thumbnail(path, 170, 128)),
f"Seed {seed}",
)
item.setToolTip(f"{record.model_name} · Seed {seed}" + (f" · {' · '.join(badges)}" if badges else ""))
item.setData(Qt.UserRole, {
"record": record_index,
"image": str(path),
"seed": seed,
})
self.gallery.addItem(item)
if self._history_selected_image == str(path) or (self.gallery.currentRow() < 0 and not self._history_selected_image):
self.gallery.setCurrentItem(item)
self._history_load_index += 1
QTimer.singleShot(0, lambda: self._load_next_history_thumbnail(token))
def _open_history_folder(self, item: QListWidgetItem) -> None:
if self._active_provider_folder:
self._open_model_folder(item)
return
self._open_provider_folder(item)
def _open_provider_folder(self, item: QListWidgetItem) -> None:
key = str(item.data(Qt.UserRole) or "")
folder = next(
(candidate for candidate in group_generation_providers(self.records) if candidate.key == key),
None,
)
if folder is None:
return
self._active_provider_folder = folder.key
provider_index = self.provider.findData(folder.provider_id)
if provider_index >= 0:
self.provider.setCurrentIndex(provider_index)
self.status.setText(f"Opened {folder.provider_name}. Choose a model to generate more images.")
else:
self.status.setText(
f"Opened {folder.provider_name}. Its image history is available, but this generator is not currently connected."
)
self._render_history()
def _open_model_folder(self, item: QListWidgetItem) -> None:
key = str(item.data(Qt.UserRole) or "")
folder = next(
(
candidate for candidate in group_generation_records(self.records)
if candidate.key == key and any(
generation_provider_key(record) == self._active_provider_folder
for record in candidate.records
)
),
None,
)
if folder is None:
return
self._active_model_folder = folder.key
model_index = self.model.findData(folder.model_path)
if model_index >= 0:
self.model.setCurrentIndex(model_index)
self.status.setText(f"Opened {folder.model_name}. New images will be generated here.")
else:
self.status.setText(
f"Opened {folder.model_name}. Its history is available, but the model is not currently connected."
)
self._render_history()
def _go_back_in_history(self) -> None:
if self._active_model_folder:
self._active_model_folder = ""
self._render_history()
return
self._show_provider_folders()
def _show_provider_folders(self) -> None:
self._active_provider_folder = ""
self._active_model_folder = ""
self._render_history()
def _clear_displayed_history(self) -> None:
self._history_load_token += 1
for _record, _index, path in self._history_entries:
self.hidden_history_images.add(str(path))
for index in range(self.gallery.count()):
payload = self.gallery.item(index).data(Qt.UserRole)
if isinstance(payload, dict) and payload.get("image"):
self.hidden_history_images.add(str(payload["image"]))
self.gallery.clear()
self._history_entries = []
self.results_label.setText("Generation Results (0 images)")
self.history_summary.setText("No images displayed")
self.clear_history_button.setEnabled(False)
self._selection_changed(None, None)
self.status.setText("Displayed generation history cleared. Image files were not deleted.")
def _auto_sort_history(self) -> None:
changed = 0
skipped = 0
for record in self.records:
if self._active_model_folder and generation_model_key(record) != self._active_model_folder:
continue
profile = PreferenceProfile(self.root, record.provider_id, record.model_name, record.model_path)
if not profile.has_signal():
skipped += 1
continue
try:
scores = score_generated_images(
self.root,
provider_id=record.provider_id,
model_name=record.model_name,
model_path=record.model_path,
image_paths=record.images,
keep_threshold=profile.keep_threshold,
reject_threshold=profile.reject_threshold,
)
evaluations = {
score.image_path: {
"score": score.score,
"confidence": score.confidence,
"category": score.category,
"reason": score.reason,
}
for score in scores
}
ordered = sorted(
record.images,
key=lambda path: float(evaluations.get(str(path.resolve()), {}).get("score") or -1.0),
reverse=True,
)
payload = json.loads(record.metadata_path.read_text(encoding="utf-8"))
payload["images"] = [str(path) for path in ordered]
payload["image_evaluations"] = evaluations
smart = dict(payload.get("smart_generation") or {})
smart.update({
"auto_sorted": True,
"profile_id": profile.id,
"selected_count": sum(
1 for score in scores
if score.score is not None and score.score >= profile.keep_threshold
),
"candidate_count": len(scores),
})
payload["smart_generation"] = smart
temporary = record.metadata_path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8")
temporary.replace(record.metadata_path)
changed += 1
except Exception as exc:
skipped += 1
self.status.setText(f"Auto Sort skipped a batch: {exc}")
self._load_history()
self.status.setText(f"Auto Sort updated {changed} batch(es). {skipped} had no profile signal or could not be scored.")
def _selection(self) -> tuple[GenerationRecord, Path, int] | None:
item = self.gallery.currentItem()
if not item:
return None
payload = item.data(Qt.UserRole)
try:
return (
self.records[int(payload["record"])],
Path(str(payload["image"])),
int(payload["seed"]),
)
except (KeyError, IndexError, TypeError, ValueError):
return None
def _selection_changed(self, _current=None, _previous=None) -> None:
selection = self._selection()
enabled = selection is not None
for button in (self.open_image_button, self.open_folder_button, self.reuse_button, self.save_image_button,
self.favorite_button, self.keep_button, self.unsure_button, self.reject_button, self.compare_pin_button, self.more_image_button):
button.setEnabled(enabled)
self.previous_image_button.setEnabled(self.gallery.count() > 1)
self.next_image_button.setEnabled(self.gallery.count() > 1)
if not selection:
self.detail.setText("Select an image to see its settings.")
self.output_date.setText("")
self.preview_image.setPixmap(QPixmap())
self.preview_image.setText("Your latest output will appear here after generation.")
for label in self.detail_fields.values():
label.setText("—")
return
record, path, seed = selection
self.preview_image.setPixmap(_thumbnail(path, 1600, 1200))
self.preview_image.setText("Preview unavailable")
try:
metadata = json.loads(record.metadata_path.read_text(encoding="utf-8"))
if not isinstance(metadata, dict):
metadata = {}
except (OSError, ValueError):
metadata = {}
size = f"{metadata['width']} × {metadata['height']}" if metadata.get("width") and metadata.get("height") else record.aspect_ratio
data = {
"Model": record.model_name,
"Base model": Path(str(metadata.get("base_model_path", ""))).name if metadata.get("base_model_path") else "—",
"Prompt": record.prompt or "—", "Negative prompt": str(metadata.get("negative_prompt") or "—"),
"Size": size, "Steps": str(record.steps), "CFG scale": str(metadata.get("cfg_scale", "—")),
"Sampler": record.sampler, "Seed": str(seed),
}
for name, value in data.items():
self.detail_fields[name].setText(value)
try:
date = datetime.fromisoformat(record.created_at).astimezone().strftime("%b %d, %Y · %I:%M %p")
except (TypeError, ValueError):
date = record.created_at or "Date unavailable"
self.output_date.setText(date)
rating = self._stored_rating(record, path)
self.favorite_button.setText("★ Favorited" if rating == "favorite" else "☆ Favorite")
evaluation = record.image_evaluations.get(str(path.resolve()), {})
score = evaluation.get("score")
score_text = f" · Smart score {float(score):.2f}" if isinstance(score, (int, float)) else ""
self.detail.setText((f"Rating: {rating.title()}" if rating else "Not rated") + score_text)
self.output_title.setText("Compare Images" if self.history_tabs.currentIndex() == 4 else "Latest Generation" if self.gallery.currentRow() == 0 else "Selected Generation")
def _stored_rating(self, record: GenerationRecord, path: Path) -> str:
profile = PreferenceProfile(self.root, record.provider_id, record.model_name, record.model_path)
rating = profile.rating_for(path)
return rating.rating if rating else ""
def _generation_settings_for_record(self, record: GenerationRecord, seed: int) -> dict:
settings = {
"provider_id": record.provider_id,
"provider_name": record.provider_name,
"model_name": record.model_name,
"model_path": record.model_path,
"prompt": record.prompt,
"seed": seed,
"steps": record.steps,
"sampler": record.sampler,
"aspect_ratio": record.aspect_ratio,
}
settings.update(record.smart_generation)
return settings
def _rate_selection(self, rating: str) -> None:
selection = self._selection()
if not selection:
return
record, path, seed = selection
profile = PreferenceProfile(self.root, record.provider_id, record.model_name, record.model_path)
profile.set_rating(
path,
rating,
seed=seed,
sampler=record.sampler,
steps=record.steps,
resolution=record.aspect_ratio,
generation_settings=self._generation_settings_for_record(record, seed),
generation_created_at=record.created_at,
)
self.status.setText(f"Saved {rating.title()} for {record.model_name}.")
self._load_history()
def _open_image(self) -> None:
selection = self._selection()
if selection:
QDesktopServices.openUrl(QUrl.fromLocalFile(str(selection[1])))
def _open_folder(self) -> None:
selection = self._selection()
if selection:
QDesktopServices.openUrl(QUrl.fromLocalFile(str(selection[0].folder)))
def _reuse_settings(self) -> None:
selection = self._selection()
if not selection:
return
record, _path, seed = selection
provider_index = self.provider.findData(record.provider_id)
if provider_index < 0:
self.status.setText("Connect this image's generator before reusing its settings.")
return
self.provider.setCurrentIndex(provider_index)
model_index = self.model.findData(record.model_path)
self.model.setCurrentIndex(model_index)
self.prompt.setPlainText(record.prompt)
self.seed.setText(str(seed))
self.steps.setValue(record.steps)
self.sampler.setCurrentText(record.sampler)
self.aspect.setCurrentText(record.aspect_ratio)
try:
metadata = json.loads(record.metadata_path.read_text(encoding="utf-8"))
if not isinstance(metadata, dict):
metadata = {}
except (OSError, ValueError):
metadata = {}
if record.provider_id == "lora_generator":
base = str(metadata.get("base_model_path") or "")
if base:
index = self.base_model.findData(base)
if index < 0:
self.base_model.addItem(Path(base).name, base)
index = self.base_model.count() - 1
self.base_model.setCurrentIndex(index)
self.negative_prompt.setPlainText(str(metadata.get("negative_prompt") or ""))
self.reference_image.setText(str(metadata.get("reference_image") or ""))
self.prompt_weighting.setChecked(bool(metadata.get("prompt_weighting", True)))
for widget, key in ((self.width, "width"), (self.height, "height"), (self.cfg_scale, "cfg_scale"),
(self.lora_strength, "lora_strength"), (self.denoise_strength, "denoise_strength")):
if key in metadata:
try:
widget.setValue(float(metadata[key]) if isinstance(widget, QDoubleSpinBox) else int(metadata[key]))
except (TypeError, ValueError):
pass
elif record.provider_id in {"ddpm_generator", "flow_generator"}:
ddpm = record.provider_id == "ddpm_generator"
width, height = (self.ddpm_width, self.ddpm_height) if ddpm else (self.flow_width, self.flow_height)
custom = self.ddpm_custom_size if ddpm else self.flow_custom_size
try:
custom.setChecked(int(metadata.get("width") or 0) > 0 and int(metadata.get("height") or 0) > 0)
if custom.isChecked():
width.setValue(int(metadata["width"]))
height.setValue(int(metadata["height"]))
if ddpm:
self.ddpm_reference_image.setText(str(metadata.get("reference_image") or ""))
self.ddpm_reference_strength.setValue(int(metadata.get("reference_strength", 65)))
except (TypeError, ValueError):
pass
try:
self.preview_interval.setValue(int(metadata.get("preview_interval") or 0))
except (TypeError, ValueError):
pass
self.plugin_generation_form.set_values(metadata)
self._save_generation_settings()
self.status.setText("Settings restored from this image." if model_index >= 0 else "Settings restored. Choose the original model before generating.")
def _job_updated(self, job: Job) -> None:
generation_ids = {tool.id for tool in generation_tools(self.registry)}
if not any(step.tool_id in generation_ids for step in job.plan.steps):
return
if job.status == JobStatus.FINISHED:
self.status.setText(f"Generation job {job.id} finished successfully.")
self._load_history()
cycle = self._cycle_jobs.pop(job.id, None)
if cycle:
QTimer.singleShot(0, lambda current=job, settings=cycle: self._play_finished_cycle(current, settings))
elif job.status == JobStatus.FAILED:
self.status.setText(f"Generation job {job.id} failed: {job.error or 'Unknown error'}")
elif job.status == JobStatus.CANCELLED:
self.status.setText(f"Generation job {job.id} was cancelled.")
elif job.status in {JobStatus.RUNNING, JobStatus.PAUSED}:
self.status.setText(f"Generation job {job.id}: {job.status.value} · {job.progress}%")