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}%")