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