from __future__ import annotations import json from pathlib import Path from PySide6.QtCore import QRectF, QSize, Qt, QTimer, QUrl from PySide6.QtGui import QColor, QDesktopServices, QIcon, QImageReader, QPainter, QPainterPath, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, QCheckBox, QDoubleSpinBox, QDialog, QDialogButtonBox, QFileDialog, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSplitter, QSpinBox, 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, group_generation_records, load_generation_history, combine_generation_plans, ) 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 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() def _folder_cover(path: Path | None, width: int = 176, height: int = 118) -> QPixmap: """Draw a compact folder tile with a generated image set into its face.""" canvas = QPixmap(width, height) canvas.fill(Qt.transparent) painter = QPainter(canvas) painter.setRenderHint(QPainter.Antialiasing) painter.setPen(Qt.NoPen) painter.setBrush(QColor("#236a9d")) painter.drawRoundedRect(QRectF(8, 7, 72, 28), 7, 7) painter.setBrush(QColor("#174f78")) painter.drawRoundedRect(QRectF(5, 23, width - 10, height - 28), 9, 9) inset = QPainterPath() inset.addRoundedRect(QRectF(12, 30, width - 24, height - 42), 6, 6) painter.setClipPath(inset) if path is not None: cover = _thumbnail(path, width - 24, height - 42) if not cover.isNull(): scaled = cover.scaled( width - 24, height - 42, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation, ) x = 12 + (width - 24 - scaled.width()) // 2 y = 30 + (height - 42 - scaled.height()) // 2 painter.drawPixmap(x, y, scaled) painter.setClipping(False) painter.setPen(QColor("#55b8ff")) painter.setBrush(Qt.NoBrush) painter.drawRoundedRect(QRectF(5.5, 23.5, width - 11, height - 29), 9, 9) painter.end() return canvas 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.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_model_folder = "" 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) body.setChildrenCollapsible(False) body.addWidget(self._build_controls()) body.addWidget(self._build_history()) body.setStretchFactor(0, 0) body.setStretchFactor(1, 1) body.setSizes([430, 1000]) 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.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.itemDoubleClicked.connect(self._open_model_folder) self.folder_back_button.clicked.connect(self._show_model_folders) 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.refresh() self._restore_generation_settings() self._connect_generation_setting_persistence() def _build_controls(self) -> QFrame: card = _card() card.setMinimumWidth(340) outer_layout = QVBoxLayout(card) outer_layout.setContentsMargins(0, 0, 0, 0) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroll.setFrameShape(QFrame.NoFrame) content = QWidget() scroll.setWidget(content) outer_layout.addWidget(scroll) layout = QVBoxLayout(content) layout.setContentsMargins(16, 15, 16, 16) layout.setSpacing(9) layout.addWidget(_title("CREATE A BATCH")) layout.addWidget(QLabel("Registered generator")) self.provider = QComboBox() layout.addWidget(self.provider) layout.addWidget(QLabel("Completed model")) model_row = QHBoxLayout() self.model = QComboBox() self.refresh_button = QPushButton("Refresh") self.model_browse_button = QPushButton("Browse LoRA") self.model_browse_button.setVisible(False) self.model_browse_button.clicked.connect(self._browse_lora) model_row.addWidget(self.model, 1) model_row.addWidget(self.model_browse_button) model_row.addWidget(self.refresh_button) layout.addLayout(model_row) self.model_hint = QLabel("Select a registered generator and model.") self.model_hint.setProperty("muted", True) self.model_hint.setWordWrap(True) layout.addWidget(self.model_hint) self.prompt_label = QLabel("Label / creative note") layout.addWidget(self.prompt_label) self.prompt = QPlainTextEdit() self.prompt.setMaximumHeight(78) self.prompt.setPlaceholderText( "Optional note describing what you are exploring. Unconditional generators save it with the batch but do not use it as a text prompt." ) layout.addWidget(self.prompt) self.lora_options = QFrame() lora_layout = QGridLayout(self.lora_options) lora_layout.setContentsMargins(0, 0, 0, 0) lora_layout.setHorizontalSpacing(8) lora_layout.setVerticalSpacing(6) lora_layout.addWidget(QLabel("Base model"), 0, 0) self.base_model = QComboBox() self.base_model.setEditable(True) self.base_model.setInsertPolicy(QComboBox.NoInsert) lora_layout.addWidget(self.base_model, 0, 1) lora_layout.addWidget(QLabel("Negative prompt"), 1, 0) self.negative_prompt = QPlainTextEdit() self.negative_prompt.setMaximumHeight(62) lora_layout.addWidget(self.negative_prompt, 1, 1) lora_layout.addWidget(QLabel("Width"), 2, 0) self.width = QSpinBox() self.width.setRange(256, 2048) self.width.setSingleStep(8) lora_layout.addWidget(self.width, 2, 1) lora_layout.addWidget(QLabel("Height"), 3, 0) self.height = QSpinBox() self.height.setRange(256, 2048) self.height.setSingleStep(8) lora_layout.addWidget(self.height, 3, 1) lora_layout.addWidget(QLabel("CFG scale"), 4, 0) self.cfg_scale = QDoubleSpinBox() self.cfg_scale.setRange(0.1, 30.0) self.cfg_scale.setSingleStep(0.5) self.cfg_scale.setDecimals(2) lora_layout.addWidget(self.cfg_scale, 4, 1) lora_layout.addWidget(QLabel("LoRA strength"), 5, 0) self.lora_strength = QDoubleSpinBox() self.lora_strength.setRange(0.05, 3.0) self.lora_strength.setSingleStep(0.05) self.lora_strength.setDecimals(2) lora_layout.addWidget(self.lora_strength, 5, 1) lora_layout.addWidget(QLabel("Reference image"), 6, 0) reference_row = QHBoxLayout() self.reference_image = QLineEdit() self.reference_browse = QPushButton("Browse") self.reference_browse.clicked.connect(self._browse_reference) reference_row.addWidget(self.reference_image, 1) reference_row.addWidget(self.reference_browse) lora_layout.addLayout(reference_row, 6, 1) lora_layout.addWidget(QLabel("Denoise strength"), 7, 0) self.denoise_strength = QDoubleSpinBox() self.denoise_strength.setRange(0.0, 1.0) self.denoise_strength.setSingleStep(0.05) self.denoise_strength.setDecimals(2) lora_layout.addWidget(self.denoise_strength, 7, 1) self.prompt_weighting = QCheckBox("Use CivitAI/A1111 prompt weights") lora_layout.addWidget(self.prompt_weighting, 8, 0, 1, 2) self.lora_options.setVisible(False) layout.addWidget(self.lora_options) self.ddpm_reference_options = QFrame() ddpm_reference_layout = QGridLayout(self.ddpm_reference_options) ddpm_reference_layout.setContentsMargins(0, 0, 0, 0) ddpm_reference_layout.setHorizontalSpacing(8) ddpm_reference_layout.setVerticalSpacing(6) ddpm_reference_layout.addWidget(QLabel("Reference image"), 0, 0) ddpm_reference_row = QHBoxLayout() 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_reference_row.addWidget(self.ddpm_reference_image, 1) ddpm_reference_row.addWidget(self.ddpm_reference_browse) ddpm_reference_layout.addLayout(ddpm_reference_row, 0, 1) ddpm_reference_layout.addWidget(QLabel("Reference strength"), 1, 0) self.ddpm_reference_strength = QSpinBox() self.ddpm_reference_strength.setRange(0, 100) self.ddpm_reference_strength.setSuffix("%") self.ddpm_reference_strength.setValue(65) self.ddpm_reference_strength.setToolTip( "0% mostly ignores the image; 100% preserves it as closely as possible." ) ddpm_reference_layout.addWidget(self.ddpm_reference_strength, 1, 1) self.ddpm_custom_size = QCheckBox("Use custom dimensions") self.ddpm_custom_size.setToolTip( "Use exact dimensions instead of the selected aspect ratio. Both values must match the model's required size multiple." ) self.ddpm_custom_size.toggled.connect(self._ddpm_custom_size_changed) ddpm_reference_layout.addWidget(self.ddpm_custom_size, 2, 0, 1, 2) ddpm_reference_layout.addWidget(QLabel("Width"), 3, 0) self.ddpm_width = QSpinBox() self.ddpm_width.setRange(64, 2048) self.ddpm_width.setSingleStep(8) self.ddpm_width.setValue(256) self.ddpm_width.setSuffix(" px") ddpm_reference_layout.addWidget(self.ddpm_width, 3, 1) ddpm_reference_layout.addWidget(QLabel("Height"), 4, 0) self.ddpm_height = QSpinBox() self.ddpm_height.setRange(64, 2048) self.ddpm_height.setSingleStep(8) self.ddpm_height.setValue(256) self.ddpm_height.setSuffix(" px") ddpm_reference_layout.addWidget(self.ddpm_height, 4, 1) self._ddpm_custom_size_changed(False) self.ddpm_reference_options.setVisible(False) layout.addWidget(self.ddpm_reference_options) self.plugin_generation_group = QGroupBox("Model-specific settings") plugin_generation_layout = QVBoxLayout(self.plugin_generation_group) self.plugin_generation_form = SettingsForm() plugin_generation_layout.addWidget(self.plugin_generation_form) self.plugin_generation_group.setVisible(False) layout.addWidget(self.plugin_generation_group) grid = QGridLayout() grid.setHorizontalSpacing(8) grid.setVerticalSpacing(6) self.preset = QComboBox() self.preset.addItems(["Balanced", "Fast preview", "Detailed", "Classic"]) self.images = QSpinBox() self.images.setRange(1, 48) self.images.setValue(1) self.steps = QSpinBox() self.steps.setRange(5, 500) self.steps.setValue(50) self.preview_interval = QSpinBox() self.preview_interval.setRange(0, 500) self.preview_interval.setValue(0) self.preview_interval.setSpecialValueText("Off") self.preview_interval.setSuffix(" steps") self.preview_interval.setToolTip( "Decode and show a live preview after this many denoising steps. Smaller intervals can make generation slower." ) self.sampler = QComboBox() self.sampler.addItems(["DDIM", "DDPM"]) self.aspect = QComboBox() self.aspect.addItems( [ "1:1 (Square)", "16:9 (Widescreen)", "9:16 (Portrait)", "4:3 (Classic)", "3:4 (Portrait Classic)", "3:2 (Photo)", "2:3 (Portrait Photo)", ] ) fields = [ ("Preset", self.preset), ("Images", self.images), ("Steps", self.steps), ("Steps / preview", self.preview_interval), ("Sampler", self.sampler), ("Aspect ratio", self.aspect), ] for row, (label, widget) in enumerate(fields): grid.addWidget(QLabel(label), row, 0) grid.addWidget(widget, row, 1) layout.addLayout(grid) layout.addWidget(QLabel("Seed")) seed_row = QHBoxLayout() self.seed = QLineEdit() self.seed.setPlaceholderText("Random") self.random_seed_button = QPushButton("Randomize") seed_row.addWidget(self.seed, 1) seed_row.addWidget(self.random_seed_button) layout.addLayout(seed_row) self.smart_group = QGroupBox("Smart Generation") smart_layout = QGridLayout(self.smart_group) self.smart_enabled = QCheckBox("Smart Generation") self.smart_wanted = QSpinBox() self.smart_wanted.setRange(1, 48) self.smart_wanted.setValue(8) self.smart_max_candidates = QSpinBox() self.smart_max_candidates.setRange(1, 256) self.smart_max_candidates.setValue(32) self.smart_min_score = QDoubleSpinBox() self.smart_min_score.setRange(0.0, 1.0) self.smart_min_score.setSingleStep(0.05) self.smart_min_score.setDecimals(2) self.smart_min_score.setValue(0.70) self.smart_mode = QComboBox() 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_layout.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_layout.addWidget(QLabel(label), row, 0) smart_layout.addWidget(widget, row, 1) smart_layout.addWidget(self.smart_keep_rejected, 5, 0, 1, 2) self.smart_group.setVisible(False) layout.addWidget(self.smart_group) self.generate_button = QPushButton("Generate images →") self.generate_button.setProperty("primary", True) layout.addWidget(self.generate_button) self.status = QLabel("Ready") self.status.setProperty("muted", True) self.status.setWordWrap(True) layout.addWidget(self.status) layout.addStretch() return card def _build_history(self) -> QFrame: card = _card() layout = QVBoxLayout(card) layout.setContentsMargins(16, 15, 16, 16) layout.setSpacing(9) top = QHBoxLayout() self.history_title = _title("MODEL FOLDERS") top.addWidget(self.history_title) self.folder_back_button = QPushButton("← All models") self.folder_back_button.setVisible(False) top.addWidget(self.folder_back_button) top.addStretch() self.history_summary = QLabel("No generations yet") self.history_summary.setProperty("muted", True) top.addWidget(self.history_summary) self.clear_history_button = QPushButton("Clear displayed") self.clear_history_button.setToolTip( "Hide the displayed thumbnails without deleting generation records or image files." ) self.clear_history_button.setEnabled(False) top.addWidget(self.clear_history_button) self.auto_sort_button = QPushButton("Auto Sort") self.auto_sort_button.setToolTip( "Score visible generations against their model preference profiles and show stronger matches first." ) top.addWidget(self.auto_sort_button) layout.addLayout(top) self.generation_target = QLabel( "Double-click a model folder to browse its images and generate with that model." ) self.generation_target.setProperty("muted", True) self.generation_target.setWordWrap(True) layout.addWidget(self.generation_target) self.model_folders = QListWidget() self.model_folders.setViewMode(QListWidget.IconMode) self.model_folders.setIconSize(QSize(176, 118)) self.model_folders.setGridSize(QSize(220, 184)) self.model_folders.setResizeMode(QListWidget.Adjust) self.model_folders.setSelectionMode(QAbstractItemView.SingleSelection) layout.addWidget(self.model_folders, 1) self.gallery = QListWidget() self.gallery.setViewMode(QListWidget.IconMode) self.gallery.setIconSize(QSize(170, 128)) self.gallery.setGridSize(QSize(205, 188)) self.gallery.setResizeMode(QListWidget.Adjust) self.gallery.setSelectionMode(QAbstractItemView.SingleSelection) layout.addWidget(self.gallery, 1) self.gallery.setVisible(False) self.image_detail_panel = QWidget() image_detail_layout = QVBoxLayout(self.image_detail_panel) image_detail_layout.setContentsMargins(0, 0, 0, 0) image_detail_layout.setSpacing(7) self.detail = QLabel("Select an image to see its reproducibility settings.") self.detail.setProperty("muted", True) self.detail.setWordWrap(True) image_detail_layout.addWidget(self.detail) rating_row = QHBoxLayout() self.favorite_button = QPushButton("Favorite") self.keep_button = QPushButton("Keep") self.unsure_button = QPushButton("Unsure") self.reject_button = QPushButton("Reject") for button, rating in ( (self.favorite_button, "favorite"), (self.keep_button, "keep"), (self.unsure_button, "unsure"), (self.reject_button, "reject"), ): button.setEnabled(False) button.clicked.connect(lambda _checked=False, value=rating: self._rate_selection(value)) rating_row.addWidget(button) rating_row.addStretch() image_detail_layout.addLayout(rating_row) actions = QHBoxLayout() self.open_image_button = QPushButton("Open image") self.open_folder_button = QPushButton("Open folder") self.reuse_button = QPushButton("Reuse settings") for button in ( self.open_image_button, self.open_folder_button, self.reuse_button, ): button.setEnabled(False) actions.addWidget(button) actions.addStretch() image_detail_layout.addLayout(actions) layout.addWidget(self.image_detail_panel) self.image_detail_panel.setVisible(False) return card def refresh(self) -> None: selected_provider = self.provider.currentData() selected_model = self.model.currentData() self.assets.discover(self.config) 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) self._load_history() 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.preview_interval.setRange(0, step_max) is_prompted = "text_prompt" in tool.capabilities self.model_browse_button.setVisible(tool.id == "lora_generator") 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." ) self.lora_options.setVisible(tool.id == "lora_generator") self.ddpm_reference_options.setVisible(tool.id == "ddpm_generator") self.smart_group.setVisible("smart_generation" in tool.capabilities) self.aspect.setEnabled( tool.id != "ddpm_generator" or not self.ddpm_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.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 _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 _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"}) 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"Completed pipeline: {path}") self.generate_button.setEnabled(bool(tool and path)) def _apply_preset(self) -> None: name = self.preset.currentText() tool = self._current_tool() if tool and tool.id == "flow_generator": values = { "Balanced": (20, "Heun"), "Fast preview": (8, "Euler"), "Detailed": (30, "Heun"), "Classic": (20, "Euler"), } fallback = (20, "Heun") 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: 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 = {"preview_interval": self.preview_interval.value()} if self.smart_group.isVisible() 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(), 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"} 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(), "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(), "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.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.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.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")): 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.ddpm_reference_image.setText(str(saved.get("ddpm_reference_image", ""))) self.ddpm_custom_size.setChecked(bool(saved.get("ddpm_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) -> None: selected_image = None current = self.gallery.currentItem() if current: selected_image = current.data(Qt.UserRole) self.records = load_generation_history(self.root) folders = group_generation_records(self.records) if self._active_model_folder and not any( folder.key == self._active_model_folder for folder in folders ): self._active_model_folder = "" self._history_load_token += 1 token = self._history_load_token self._history_load_index = 0 self._history_selected_image = ( str(selected_image.get("image", "")) if isinstance(selected_image, dict) else "" ) self.gallery.clear() self.model_folders.clear() if not self._active_model_folder: for folder in folders: item = QListWidgetItem( QIcon(_folder_cover(folder.cover_image)), f"{folder.model_name}\n{folder.provider_name} · " f"{folder.image_count} image{'s' if folder.image_count != 1 else ''}", ) item.setData(Qt.UserRole, folder.key) item.setToolTip( f"Open {folder.model_name}\n" f"{len(folder.records)} batch{'es' if len(folder.records) != 1 else ''} · " f"{folder.image_count} image{'s' if folder.image_count != 1 else ''}" ) self.model_folders.addItem(item) self.model_folders.setVisible(True) self.gallery.setVisible(False) self.image_detail_panel.setVisible(False) self.history_title.setText("MODEL FOLDERS") self.folder_back_button.setVisible(False) self.generation_target.setText( "Double-click a model folder to browse its images and generate with that model." ) total_images = sum(folder.image_count for folder in folders) self.history_summary.setText( f"{len(folders)} models · {len(self.records)} batches · {total_images} images" if folders else "No generations yet" ) self.clear_history_button.setEnabled(False) self.auto_sort_button.setEnabled(False) self._selection_changed(None, None) return active_folder = next(folder for folder in folders if folder.key == self._active_model_folder) self.model_folders.setVisible(False) self.gallery.setVisible(True) self.image_detail_panel.setVisible(True) self.history_title.setText(active_folder.model_name.upper()) self.folder_back_button.setVisible(True) self.generation_target.setText( f"Generating now will add the new batch to {active_folder.model_name}." ) self.auto_sort_button.setEnabled(True) self._history_entries = [] active_batch_count = 0 for record_index, record in enumerate(self.records): if generation_model_key(record) != self._active_model_folder: continue active_batch_count += 1 for image_index, path in enumerate(record.images): if str(path) in self.hidden_history_images: continue self._history_entries.append((record_index, image_index, path)) image_total = len(self._history_entries) self.history_summary.setText( f"{active_batch_count} batches · {image_total} images · Loading thumbnails…" if image_total else "No generations yet" ) self.clear_history_button.setEnabled(image_total > 0) if not image_total: self._selection_changed(None, None) 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): batch_count = len({entry[0] for entry in self._history_entries}) self.history_summary.setText( f"{batch_count} batches · {len(self._history_entries)} images" ) 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"{record.model_name}\nSeed {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): self.gallery.setCurrentItem(item) self._history_load_index += 1 QTimer.singleShot(0, lambda: self._load_next_history_thumbnail(token)) 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), None, ) if folder is None: return self._active_model_folder = folder.key provider_index = self.provider.findData(folder.provider_id) if provider_index >= 0: self.provider.setCurrentIndex(provider_index) 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._load_history() def _show_model_folders(self) -> None: self._active_model_folder = "" self._load_history() def _clear_displayed_history(self) -> None: 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_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 self.open_image_button.setEnabled(enabled) self.open_folder_button.setEnabled(enabled) self.reuse_button.setEnabled(enabled) for button in (self.favorite_button, self.keep_button, self.unsure_button, self.reject_button): button.setEnabled(enabled) if not selection: self.detail.setText("Select an image to see its reproducibility settings.") return record, path, seed = selection note = f" · Note: {record.prompt}" if record.prompt else "" rating = self._stored_rating(record, path) 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 "" category = str(evaluation.get("category", "")) category_text = f" · {category}" if category else "" rating_text = f" · Rating: {rating.title()}" if rating else " · Not rated" self.detail.setText( f"{record.provider_name} · {record.model_name} · Seed {seed} · " f"{record.steps} steps · {record.sampler} · {record.aspect_ratio}{rating_text}{score_text}{category_text}{note}" ) 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.provider.setCurrentIndex(provider_index) model_index = self.model.findData(record.model_path) if model_index >= 0: self.model.setCurrentIndex(model_index) self.prompt.setPlainText(record.prompt) self.seed.setText(str(seed)) self.steps.setValue(max(self.steps.minimum(), min(record.steps, self.steps.maximum()))) self.sampler.setCurrentText(record.sampler) self.aspect.setCurrentText(record.aspect_ratio) self.status.setText("Settings restored from generation history.") 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}%")