Download adam/ui/generations.py from SyntheticMDProductions/AI_Development_Automation_Manager: direct link, hf CLI and curl.
- Browser
- Download file 73.1 kB
-
https://huggingface.co/SyntheticMDProductions/AI_Development_Automation_Manager/resolve/refs%2Fpr%2F1/adam/ui/generations.py
- Command line
-
hf download hf://SyntheticMDProductions/AI_Development_Automation_Manager@refs/pr/1/adam/ui/generations.py
-
curl -L -o generations.py https://huggingface.co/SyntheticMDProductions/AI_Development_Automation_Manager/resolve/refs%2Fpr%2F1/adam/ui/generations.py
73.1 kB
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| from PySide6.QtCore import QRectF, QSize, Qt, QTimer, QUrl | |
| from PySide6.QtGui import QColor, QDesktopServices, QIcon, QImageReader, QPainter, QPainterPath, QPixmap | |
| from PySide6.QtWidgets import ( | |
| QAbstractItemView, | |
| QComboBox, | |
| QCheckBox, | |
| QDoubleSpinBox, | |
| QDialog, | |
| QDialogButtonBox, | |
| QFileDialog, | |
| QFrame, | |
| QGridLayout, | |
| QGroupBox, | |
| QHBoxLayout, | |
| QLabel, | |
| QLineEdit, | |
| QListWidget, | |
| QListWidgetItem, | |
| QMessageBox, | |
| QPlainTextEdit, | |
| QPushButton, | |
| QScrollArea, | |
| QSplitter, | |
| QSpinBox, | |
| QVBoxLayout, | |
| QWidget, | |
| ) | |
| from adam.assets import Asset, AssetRegistry | |
| from adam.config import ConfigManager | |
| from adam.generations import ( | |
| GenerationRecord, | |
| build_generation_plan, | |
| generation_tools, | |
| generation_model_key, | |
| group_generation_records, | |
| load_generation_history, | |
| combine_generation_plans, | |
| ) | |
| from adam.image_preferences import PreferenceProfile, score_generated_images | |
| from adam.job_manager import JobManager | |
| from adam.models import Job, JobStatus | |
| from adam.registry import ToolRegistry, ToolSpec | |
| from adam.ui.settings_ui import SettingsForm | |
| def _card() -> QFrame: | |
| frame = QFrame() | |
| frame.setProperty("card", True) | |
| return frame | |
| def _title(text: str) -> QLabel: | |
| label = QLabel(text) | |
| label.setObjectName("CardTitle") | |
| return label | |
| def _header(title: str, subtitle: str) -> QWidget: | |
| widget = QWidget() | |
| layout = QVBoxLayout(widget) | |
| layout.setContentsMargins(0, 0, 0, 10) | |
| heading = QLabel(title) | |
| heading.setObjectName("PageTitle") | |
| detail = QLabel(subtitle) | |
| detail.setProperty("muted", True) | |
| detail.setWordWrap(True) | |
| layout.addWidget(heading) | |
| layout.addWidget(detail) | |
| return widget | |
| def _thumbnail(path: Path, width: int, height: int) -> QPixmap: | |
| reader = QImageReader(str(path)) | |
| reader.setAutoTransform(True) | |
| source_size = reader.size() | |
| if source_size.isValid(): | |
| source_size.scale(QSize(width, height), Qt.KeepAspectRatio) | |
| reader.setScaledSize(source_size) | |
| image = reader.read() | |
| return QPixmap.fromImage(image) if not image.isNull() else QPixmap() | |
| def _folder_cover(path: Path | None, width: int = 176, height: int = 118) -> QPixmap: | |
| """Draw a compact folder tile with a generated image set into its face.""" | |
| canvas = QPixmap(width, height) | |
| canvas.fill(Qt.transparent) | |
| painter = QPainter(canvas) | |
| painter.setRenderHint(QPainter.Antialiasing) | |
| painter.setPen(Qt.NoPen) | |
| painter.setBrush(QColor("#236a9d")) | |
| painter.drawRoundedRect(QRectF(8, 7, 72, 28), 7, 7) | |
| painter.setBrush(QColor("#174f78")) | |
| painter.drawRoundedRect(QRectF(5, 23, width - 10, height - 28), 9, 9) | |
| inset = QPainterPath() | |
| inset.addRoundedRect(QRectF(12, 30, width - 24, height - 42), 6, 6) | |
| painter.setClipPath(inset) | |
| if path is not None: | |
| cover = _thumbnail(path, width - 24, height - 42) | |
| if not cover.isNull(): | |
| scaled = cover.scaled( | |
| width - 24, height - 42, Qt.KeepAspectRatioByExpanding, | |
| Qt.SmoothTransformation, | |
| ) | |
| x = 12 + (width - 24 - scaled.width()) // 2 | |
| y = 30 + (height - 42 - scaled.height()) // 2 | |
| painter.drawPixmap(x, y, scaled) | |
| painter.setClipping(False) | |
| painter.setPen(QColor("#55b8ff")) | |
| painter.setBrush(Qt.NoBrush) | |
| painter.drawRoundedRect(QRectF(5.5, 23.5, width - 11, height - 29), 9, 9) | |
| painter.end() | |
| return canvas | |
| class GenerationCycleDialog(QDialog): | |
| """Choose several completed models and shared playback settings.""" | |
| def __init__(self, entries: list[dict], parent: QWidget | None = None) -> None: | |
| super().__init__(parent) | |
| self.entries = entries | |
| self.setWindowTitle("Generation Cycle") | |
| self.setMinimumSize(600, 560) | |
| layout = QVBoxLayout(self) | |
| layout.addWidget(_header( | |
| "Generation Cycle", | |
| "Select completed models. ADAM will generate each batch sequentially, then play the results as a labeled slideshow.", | |
| )) | |
| self.models = QListWidget() | |
| self.models.setSelectionMode(QAbstractItemView.MultiSelection) | |
| for index, entry in enumerate(entries): | |
| item = QListWidgetItem(f"{entry['name']} — {entry['trainer_label']}") | |
| item.setData(Qt.UserRole, index) | |
| self.models.addItem(item) | |
| selection_buttons = QHBoxLayout() | |
| select_all = QPushButton("Select all") | |
| clear = QPushButton("Clear") | |
| select_all.clicked.connect(self.models.selectAll) | |
| clear.clicked.connect(self.models.clearSelection) | |
| selection_buttons.addWidget(select_all); selection_buttons.addWidget(clear); selection_buttons.addStretch() | |
| layout.addLayout(selection_buttons) | |
| layout.addWidget(self.models, 1) | |
| form = QGridLayout() | |
| self.image_count = QSpinBox(); self.image_count.setRange(1, 48); self.image_count.setValue(4) | |
| self.display_seconds = QSpinBox(); self.display_seconds.setRange(1, 300); self.display_seconds.setValue(5); self.display_seconds.setSuffix(" seconds") | |
| self.seed = QSpinBox(); self.seed.setRange(0, 2_147_000_000); self.seed.setValue(123456) | |
| self.prompt = QLineEdit(); self.prompt.setPlaceholderText("Optional shared prompt or creative note") | |
| self.show_labels = QCheckBox("Show model and trainer in the bottom-left corner"); self.show_labels.setChecked(True) | |
| self.loop = QCheckBox("Loop the slideshow until closed") | |
| self.fullscreen = QCheckBox("Start playback in fullscreen") | |
| for row, (label, widget) in enumerate(( | |
| ("Images per model", self.image_count), | |
| ("Display each image", self.display_seconds), | |
| ("Starting seed", self.seed), | |
| ("Shared prompt / note", self.prompt), | |
| )): | |
| form.addWidget(QLabel(label), row, 0); form.addWidget(widget, row, 1) | |
| layout.addLayout(form) | |
| layout.addWidget(self.show_labels); layout.addWidget(self.loop); layout.addWidget(self.fullscreen) | |
| self.validation = QLabel(); self.validation.setProperty("muted", True); self.validation.setWordWrap(True) | |
| layout.addWidget(self.validation) | |
| buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) | |
| buttons.button(QDialogButtonBox.Ok).setText("Queue generation cycle") | |
| buttons.accepted.connect(self._accept); buttons.rejected.connect(self.reject) | |
| layout.addWidget(buttons) | |
| def _accept(self) -> None: | |
| if not self.models.selectedItems(): | |
| self.validation.setText("Select at least one completed model.") | |
| return | |
| self.accept() | |
| def selected_entries(self) -> list[dict]: | |
| selected = {int(item.data(Qt.UserRole)) for item in self.models.selectedItems()} | |
| return [entry for index, entry in enumerate(self.entries) if index in selected] | |
| class CycleSlideshowDialog(QDialog): | |
| """Simple local slideshow for a completed multi-model generation cycle.""" | |
| def __init__( | |
| self, | |
| slides: list[tuple[Path, str]], | |
| *, | |
| display_seconds: int, | |
| show_labels: bool, | |
| loop: bool, | |
| parent: QWidget | None = None, | |
| ) -> None: | |
| super().__init__(parent) | |
| self.slides = slides | |
| self.index = 0 | |
| self.show_labels = show_labels | |
| self.loop = loop | |
| self.setWindowTitle("Generation Cycle Playback") | |
| self.setMinimumSize(800, 560) | |
| self.setStyleSheet("background: #02070b;") | |
| layout = QVBoxLayout(self) | |
| layout.setContentsMargins(0, 0, 0, 0) | |
| self.image = QLabel() | |
| self.image.setAlignment(Qt.AlignCenter) | |
| self.image.setMinimumSize(500, 350) | |
| self.overlay = QLabel(self.image) | |
| self.overlay.setStyleSheet( | |
| "background: rgba(0, 0, 0, 180); color: white; padding: 8px 12px; " | |
| "border-radius: 5px; font-weight: 600;" | |
| ) | |
| layout.addWidget(self.image, 1) | |
| controls = QHBoxLayout() | |
| self.previous = QPushButton("Previous") | |
| self.caption = QLabel(); self.caption.setAlignment(Qt.AlignCenter) | |
| self.next = QPushButton("Next") | |
| close = QPushButton("Close") | |
| controls.addWidget(self.previous); controls.addWidget(self.caption, 1); controls.addWidget(self.next); controls.addWidget(close) | |
| layout.addLayout(controls) | |
| self.previous.clicked.connect(self._previous); self.next.clicked.connect(self._next); close.clicked.connect(self.accept) | |
| self.timer = QTimer(self) | |
| self.timer.setInterval(max(1, int(display_seconds)) * 1000) | |
| self.timer.timeout.connect(self._next) | |
| self._show_slide() | |
| self.timer.start() | |
| def resizeEvent(self, event) -> None: | |
| super().resizeEvent(event) | |
| self._show_slide() | |
| def _show_slide(self) -> None: | |
| if not self.slides: | |
| return | |
| path, label = self.slides[self.index] | |
| pixmap = QPixmap(str(path)) | |
| if not pixmap.isNull(): | |
| self.image.setPixmap(pixmap.scaled(self.image.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) | |
| else: | |
| self.image.setText(f"Could not display {path.name}") | |
| self.caption.setText(f"Image {self.index + 1} of {len(self.slides)}") | |
| self.overlay.setText(label) | |
| self.overlay.adjustSize() | |
| self.overlay.move(18, max(18, self.image.height() - self.overlay.height() - 18)) | |
| self.overlay.setVisible(self.show_labels) | |
| def _previous(self) -> None: | |
| self.index = (self.index - 1) % len(self.slides) | |
| self._show_slide() | |
| def _next(self) -> None: | |
| if self.index + 1 >= len(self.slides) and not self.loop: | |
| self.timer.stop() | |
| return | |
| self.index = (self.index + 1) % len(self.slides) | |
| self._show_slide() | |
| class GenerationsPage(QWidget): | |
| """Generate with registered providers and revisit reproducible output batches.""" | |
| def __init__( | |
| self, | |
| root: Path, | |
| registry: ToolRegistry, | |
| jobs: JobManager, | |
| assets: AssetRegistry, | |
| config: ConfigManager, | |
| ) -> None: | |
| super().__init__() | |
| self.root = root.resolve() | |
| self.registry = registry | |
| self.jobs = jobs | |
| self.assets = assets | |
| self.config = config | |
| self.records: list[GenerationRecord] = [] | |
| self.hidden_history_images: set[str] = set() | |
| self._cycle_jobs: dict[str, dict] = {} | |
| self._history_load_token = 0 | |
| self._history_load_index = 0 | |
| self._history_entries: list[tuple[int, int, Path]] = [] | |
| self._history_selected_image = "" | |
| self._active_model_folder = "" | |
| layout = QVBoxLayout(self) | |
| layout.setContentsMargins(22, 18, 22, 18) | |
| layout.setSpacing(12) | |
| header_row = QHBoxLayout() | |
| header_row.addWidget( | |
| _header( | |
| "Generations", | |
| "Create images with compatible registered tools, then revisit every seed and setting.", | |
| ), 1 | |
| ) | |
| self.cycle_button = QPushButton("Generation Cycle…") | |
| self.cycle_button.setProperty("primary", True) | |
| header_row.addWidget(self.cycle_button) | |
| layout.addLayout(header_row) | |
| body = QSplitter(Qt.Horizontal) | |
| body.setChildrenCollapsible(False) | |
| body.addWidget(self._build_controls()) | |
| body.addWidget(self._build_history()) | |
| body.setStretchFactor(0, 0) | |
| body.setStretchFactor(1, 1) | |
| body.setSizes([430, 1000]) | |
| layout.addWidget(body, 1) | |
| self.provider.currentIndexChanged.connect(self._provider_changed) | |
| self.model.currentIndexChanged.connect(self._model_changed) | |
| self.preset.currentIndexChanged.connect(self._apply_preset) | |
| self.aspect.currentTextChanged.connect(self._lora_aspect_changed) | |
| self.generate_button.clicked.connect(self._generate) | |
| self.refresh_button.clicked.connect(self.refresh) | |
| self.gallery.currentItemChanged.connect(self._selection_changed) | |
| self.gallery.itemDoubleClicked.connect(lambda _item: self._open_image()) | |
| self.model_folders.itemDoubleClicked.connect(self._open_model_folder) | |
| self.folder_back_button.clicked.connect(self._show_model_folders) | |
| self.open_image_button.clicked.connect(self._open_image) | |
| self.open_folder_button.clicked.connect(self._open_folder) | |
| self.reuse_button.clicked.connect(self._reuse_settings) | |
| self.clear_history_button.clicked.connect(self._clear_displayed_history) | |
| self.auto_sort_button.clicked.connect(self._auto_sort_history) | |
| self.random_seed_button.clicked.connect(lambda: self.seed.clear()) | |
| self.jobs.job_updated.connect(self._job_updated) | |
| self.cycle_button.clicked.connect(self._open_generation_cycle) | |
| self.refresh() | |
| self._restore_generation_settings() | |
| self._connect_generation_setting_persistence() | |
| def _build_controls(self) -> QFrame: | |
| card = _card() | |
| card.setMinimumWidth(340) | |
| outer_layout = QVBoxLayout(card) | |
| outer_layout.setContentsMargins(0, 0, 0, 0) | |
| scroll = QScrollArea() | |
| scroll.setWidgetResizable(True) | |
| scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) | |
| scroll.setFrameShape(QFrame.NoFrame) | |
| content = QWidget() | |
| scroll.setWidget(content) | |
| outer_layout.addWidget(scroll) | |
| layout = QVBoxLayout(content) | |
| layout.setContentsMargins(16, 15, 16, 16) | |
| layout.setSpacing(9) | |
| layout.addWidget(_title("CREATE A BATCH")) | |
| layout.addWidget(QLabel("Registered generator")) | |
| self.provider = QComboBox() | |
| layout.addWidget(self.provider) | |
| layout.addWidget(QLabel("Completed model")) | |
| model_row = QHBoxLayout() | |
| self.model = QComboBox() | |
| self.refresh_button = QPushButton("Refresh") | |
| self.model_browse_button = QPushButton("Browse LoRA") | |
| self.model_browse_button.setVisible(False) | |
| self.model_browse_button.clicked.connect(self._browse_lora) | |
| model_row.addWidget(self.model, 1) | |
| model_row.addWidget(self.model_browse_button) | |
| model_row.addWidget(self.refresh_button) | |
| layout.addLayout(model_row) | |
| self.model_hint = QLabel("Select a registered generator and model.") | |
| self.model_hint.setProperty("muted", True) | |
| self.model_hint.setWordWrap(True) | |
| layout.addWidget(self.model_hint) | |
| self.prompt_label = QLabel("Label / creative note") | |
| layout.addWidget(self.prompt_label) | |
| self.prompt = QPlainTextEdit() | |
| self.prompt.setMaximumHeight(78) | |
| self.prompt.setPlaceholderText( | |
| "Optional note describing what you are exploring. Unconditional generators save it with the batch but do not use it as a text prompt." | |
| ) | |
| layout.addWidget(self.prompt) | |
| self.lora_options = QFrame() | |
| lora_layout = QGridLayout(self.lora_options) | |
| lora_layout.setContentsMargins(0, 0, 0, 0) | |
| lora_layout.setHorizontalSpacing(8) | |
| lora_layout.setVerticalSpacing(6) | |
| lora_layout.addWidget(QLabel("Base model"), 0, 0) | |
| self.base_model = QComboBox() | |
| self.base_model.setEditable(True) | |
| self.base_model.setInsertPolicy(QComboBox.NoInsert) | |
| lora_layout.addWidget(self.base_model, 0, 1) | |
| lora_layout.addWidget(QLabel("Negative prompt"), 1, 0) | |
| self.negative_prompt = QPlainTextEdit() | |
| self.negative_prompt.setMaximumHeight(62) | |
| lora_layout.addWidget(self.negative_prompt, 1, 1) | |
| lora_layout.addWidget(QLabel("Width"), 2, 0) | |
| self.width = QSpinBox() | |
| self.width.setRange(256, 2048) | |
| self.width.setSingleStep(8) | |
| lora_layout.addWidget(self.width, 2, 1) | |
| lora_layout.addWidget(QLabel("Height"), 3, 0) | |
| self.height = QSpinBox() | |
| self.height.setRange(256, 2048) | |
| self.height.setSingleStep(8) | |
| lora_layout.addWidget(self.height, 3, 1) | |
| lora_layout.addWidget(QLabel("CFG scale"), 4, 0) | |
| self.cfg_scale = QDoubleSpinBox() | |
| self.cfg_scale.setRange(0.1, 30.0) | |
| self.cfg_scale.setSingleStep(0.5) | |
| self.cfg_scale.setDecimals(2) | |
| lora_layout.addWidget(self.cfg_scale, 4, 1) | |
| lora_layout.addWidget(QLabel("LoRA strength"), 5, 0) | |
| self.lora_strength = QDoubleSpinBox() | |
| self.lora_strength.setRange(0.05, 3.0) | |
| self.lora_strength.setSingleStep(0.05) | |
| self.lora_strength.setDecimals(2) | |
| lora_layout.addWidget(self.lora_strength, 5, 1) | |
| lora_layout.addWidget(QLabel("Reference image"), 6, 0) | |
| reference_row = QHBoxLayout() | |
| self.reference_image = QLineEdit() | |
| self.reference_browse = QPushButton("Browse") | |
| self.reference_browse.clicked.connect(self._browse_reference) | |
| reference_row.addWidget(self.reference_image, 1) | |
| reference_row.addWidget(self.reference_browse) | |
| lora_layout.addLayout(reference_row, 6, 1) | |
| lora_layout.addWidget(QLabel("Denoise strength"), 7, 0) | |
| self.denoise_strength = QDoubleSpinBox() | |
| self.denoise_strength.setRange(0.0, 1.0) | |
| self.denoise_strength.setSingleStep(0.05) | |
| self.denoise_strength.setDecimals(2) | |
| lora_layout.addWidget(self.denoise_strength, 7, 1) | |
| self.prompt_weighting = QCheckBox("Use CivitAI/A1111 prompt weights") | |
| lora_layout.addWidget(self.prompt_weighting, 8, 0, 1, 2) | |
| self.lora_options.setVisible(False) | |
| layout.addWidget(self.lora_options) | |
| self.ddpm_reference_options = QFrame() | |
| ddpm_reference_layout = QGridLayout(self.ddpm_reference_options) | |
| ddpm_reference_layout.setContentsMargins(0, 0, 0, 0) | |
| ddpm_reference_layout.setHorizontalSpacing(8) | |
| ddpm_reference_layout.setVerticalSpacing(6) | |
| ddpm_reference_layout.addWidget(QLabel("Reference image"), 0, 0) | |
| ddpm_reference_row = QHBoxLayout() | |
| self.ddpm_reference_image = QLineEdit() | |
| self.ddpm_reference_image.setPlaceholderText("Optional image to reimagine") | |
| self.ddpm_reference_browse = QPushButton("Browse") | |
| self.ddpm_reference_browse.clicked.connect(self._browse_ddpm_reference) | |
| ddpm_reference_row.addWidget(self.ddpm_reference_image, 1) | |
| ddpm_reference_row.addWidget(self.ddpm_reference_browse) | |
| ddpm_reference_layout.addLayout(ddpm_reference_row, 0, 1) | |
| ddpm_reference_layout.addWidget(QLabel("Reference strength"), 1, 0) | |
| self.ddpm_reference_strength = QSpinBox() | |
| self.ddpm_reference_strength.setRange(0, 100) | |
| self.ddpm_reference_strength.setSuffix("%") | |
| self.ddpm_reference_strength.setValue(65) | |
| self.ddpm_reference_strength.setToolTip( | |
| "0% mostly ignores the image; 100% preserves it as closely as possible." | |
| ) | |
| ddpm_reference_layout.addWidget(self.ddpm_reference_strength, 1, 1) | |
| self.ddpm_custom_size = QCheckBox("Use custom dimensions") | |
| self.ddpm_custom_size.setToolTip( | |
| "Use exact dimensions instead of the selected aspect ratio. Both values must match the model's required size multiple." | |
| ) | |
| self.ddpm_custom_size.toggled.connect(self._ddpm_custom_size_changed) | |
| ddpm_reference_layout.addWidget(self.ddpm_custom_size, 2, 0, 1, 2) | |
| ddpm_reference_layout.addWidget(QLabel("Width"), 3, 0) | |
| self.ddpm_width = QSpinBox() | |
| self.ddpm_width.setRange(64, 2048) | |
| self.ddpm_width.setSingleStep(8) | |
| self.ddpm_width.setValue(256) | |
| self.ddpm_width.setSuffix(" px") | |
| ddpm_reference_layout.addWidget(self.ddpm_width, 3, 1) | |
| ddpm_reference_layout.addWidget(QLabel("Height"), 4, 0) | |
| self.ddpm_height = QSpinBox() | |
| self.ddpm_height.setRange(64, 2048) | |
| self.ddpm_height.setSingleStep(8) | |
| self.ddpm_height.setValue(256) | |
| self.ddpm_height.setSuffix(" px") | |
| ddpm_reference_layout.addWidget(self.ddpm_height, 4, 1) | |
| self._ddpm_custom_size_changed(False) | |
| self.ddpm_reference_options.setVisible(False) | |
| layout.addWidget(self.ddpm_reference_options) | |
| self.plugin_generation_group = QGroupBox("Model-specific settings") | |
| plugin_generation_layout = QVBoxLayout(self.plugin_generation_group) | |
| self.plugin_generation_form = SettingsForm() | |
| plugin_generation_layout.addWidget(self.plugin_generation_form) | |
| self.plugin_generation_group.setVisible(False) | |
| layout.addWidget(self.plugin_generation_group) | |
| grid = QGridLayout() | |
| grid.setHorizontalSpacing(8) | |
| grid.setVerticalSpacing(6) | |
| self.preset = QComboBox() | |
| self.preset.addItems(["Balanced", "Fast preview", "Detailed", "Classic"]) | |
| self.images = QSpinBox() | |
| self.images.setRange(1, 48) | |
| self.images.setValue(1) | |
| self.steps = QSpinBox() | |
| self.steps.setRange(5, 500) | |
| self.steps.setValue(50) | |
| self.preview_interval = QSpinBox() | |
| self.preview_interval.setRange(0, 500) | |
| self.preview_interval.setValue(0) | |
| self.preview_interval.setSpecialValueText("Off") | |
| self.preview_interval.setSuffix(" steps") | |
| self.preview_interval.setToolTip( | |
| "Decode and show a live preview after this many denoising steps. Smaller intervals can make generation slower." | |
| ) | |
| self.sampler = QComboBox() | |
| self.sampler.addItems(["DDIM", "DDPM"]) | |
| self.aspect = QComboBox() | |
| self.aspect.addItems( | |
| [ | |
| "1:1 (Square)", | |
| "16:9 (Widescreen)", | |
| "9:16 (Portrait)", | |
| "4:3 (Classic)", | |
| "3:4 (Portrait Classic)", | |
| "3:2 (Photo)", | |
| "2:3 (Portrait Photo)", | |
| ] | |
| ) | |
| fields = [ | |
| ("Preset", self.preset), | |
| ("Images", self.images), | |
| ("Steps", self.steps), | |
| ("Steps / preview", self.preview_interval), | |
| ("Sampler", self.sampler), | |
| ("Aspect ratio", self.aspect), | |
| ] | |
| for row, (label, widget) in enumerate(fields): | |
| grid.addWidget(QLabel(label), row, 0) | |
| grid.addWidget(widget, row, 1) | |
| layout.addLayout(grid) | |
| layout.addWidget(QLabel("Seed")) | |
| seed_row = QHBoxLayout() | |
| self.seed = QLineEdit() | |
| self.seed.setPlaceholderText("Random") | |
| self.random_seed_button = QPushButton("Randomize") | |
| seed_row.addWidget(self.seed, 1) | |
| seed_row.addWidget(self.random_seed_button) | |
| layout.addLayout(seed_row) | |
| self.smart_group = QGroupBox("Smart Generation") | |
| smart_layout = QGridLayout(self.smart_group) | |
| self.smart_enabled = QCheckBox("Smart Generation") | |
| self.smart_wanted = QSpinBox() | |
| self.smart_wanted.setRange(1, 48) | |
| self.smart_wanted.setValue(8) | |
| self.smart_max_candidates = QSpinBox() | |
| self.smart_max_candidates.setRange(1, 256) | |
| self.smart_max_candidates.setValue(32) | |
| self.smart_min_score = QDoubleSpinBox() | |
| self.smart_min_score.setRange(0.0, 1.0) | |
| self.smart_min_score.setSingleStep(0.05) | |
| self.smart_min_score.setDecimals(2) | |
| self.smart_min_score.setValue(0.70) | |
| self.smart_mode = QComboBox() | |
| self.smart_mode.addItem("Stop at threshold", "threshold") | |
| self.smart_mode.addItem("Rank fixed pool", "top_n") | |
| self.smart_keep_rejected = QCheckBox("Keep rejected candidates for review") | |
| self.smart_keep_rejected.setChecked(True) | |
| smart_layout.addWidget(self.smart_enabled, 0, 0, 1, 2) | |
| for row, (label, widget) in enumerate(( | |
| ("Wanted results", self.smart_wanted), | |
| ("Maximum candidates", self.smart_max_candidates), | |
| ("Minimum score", self.smart_min_score), | |
| ("Mode", self.smart_mode), | |
| ), 1): | |
| smart_layout.addWidget(QLabel(label), row, 0) | |
| smart_layout.addWidget(widget, row, 1) | |
| smart_layout.addWidget(self.smart_keep_rejected, 5, 0, 1, 2) | |
| self.smart_group.setVisible(False) | |
| layout.addWidget(self.smart_group) | |
| self.generate_button = QPushButton("Generate images →") | |
| self.generate_button.setProperty("primary", True) | |
| layout.addWidget(self.generate_button) | |
| self.status = QLabel("Ready") | |
| self.status.setProperty("muted", True) | |
| self.status.setWordWrap(True) | |
| layout.addWidget(self.status) | |
| layout.addStretch() | |
| return card | |
| def _build_history(self) -> QFrame: | |
| card = _card() | |
| layout = QVBoxLayout(card) | |
| layout.setContentsMargins(16, 15, 16, 16) | |
| layout.setSpacing(9) | |
| top = QHBoxLayout() | |
| self.history_title = _title("MODEL FOLDERS") | |
| top.addWidget(self.history_title) | |
| self.folder_back_button = QPushButton("← All models") | |
| self.folder_back_button.setVisible(False) | |
| top.addWidget(self.folder_back_button) | |
| top.addStretch() | |
| self.history_summary = QLabel("No generations yet") | |
| self.history_summary.setProperty("muted", True) | |
| top.addWidget(self.history_summary) | |
| self.clear_history_button = QPushButton("Clear displayed") | |
| self.clear_history_button.setToolTip( | |
| "Hide the displayed thumbnails without deleting generation records or image files." | |
| ) | |
| self.clear_history_button.setEnabled(False) | |
| top.addWidget(self.clear_history_button) | |
| self.auto_sort_button = QPushButton("Auto Sort") | |
| self.auto_sort_button.setToolTip( | |
| "Score visible generations against their model preference profiles and show stronger matches first." | |
| ) | |
| top.addWidget(self.auto_sort_button) | |
| layout.addLayout(top) | |
| self.generation_target = QLabel( | |
| "Double-click a model folder to browse its images and generate with that model." | |
| ) | |
| self.generation_target.setProperty("muted", True) | |
| self.generation_target.setWordWrap(True) | |
| layout.addWidget(self.generation_target) | |
| self.model_folders = QListWidget() | |
| self.model_folders.setViewMode(QListWidget.IconMode) | |
| self.model_folders.setIconSize(QSize(176, 118)) | |
| self.model_folders.setGridSize(QSize(220, 184)) | |
| self.model_folders.setResizeMode(QListWidget.Adjust) | |
| self.model_folders.setSelectionMode(QAbstractItemView.SingleSelection) | |
| layout.addWidget(self.model_folders, 1) | |
| self.gallery = QListWidget() | |
| self.gallery.setViewMode(QListWidget.IconMode) | |
| self.gallery.setIconSize(QSize(170, 128)) | |
| self.gallery.setGridSize(QSize(205, 188)) | |
| self.gallery.setResizeMode(QListWidget.Adjust) | |
| self.gallery.setSelectionMode(QAbstractItemView.SingleSelection) | |
| layout.addWidget(self.gallery, 1) | |
| self.gallery.setVisible(False) | |
| self.image_detail_panel = QWidget() | |
| image_detail_layout = QVBoxLayout(self.image_detail_panel) | |
| image_detail_layout.setContentsMargins(0, 0, 0, 0) | |
| image_detail_layout.setSpacing(7) | |
| self.detail = QLabel("Select an image to see its reproducibility settings.") | |
| self.detail.setProperty("muted", True) | |
| self.detail.setWordWrap(True) | |
| image_detail_layout.addWidget(self.detail) | |
| rating_row = QHBoxLayout() | |
| self.favorite_button = QPushButton("Favorite") | |
| self.keep_button = QPushButton("Keep") | |
| self.unsure_button = QPushButton("Unsure") | |
| self.reject_button = QPushButton("Reject") | |
| for button, rating in ( | |
| (self.favorite_button, "favorite"), | |
| (self.keep_button, "keep"), | |
| (self.unsure_button, "unsure"), | |
| (self.reject_button, "reject"), | |
| ): | |
| button.setEnabled(False) | |
| button.clicked.connect(lambda _checked=False, value=rating: self._rate_selection(value)) | |
| rating_row.addWidget(button) | |
| rating_row.addStretch() | |
| image_detail_layout.addLayout(rating_row) | |
| actions = QHBoxLayout() | |
| self.open_image_button = QPushButton("Open image") | |
| self.open_folder_button = QPushButton("Open folder") | |
| self.reuse_button = QPushButton("Reuse settings") | |
| for button in ( | |
| self.open_image_button, | |
| self.open_folder_button, | |
| self.reuse_button, | |
| ): | |
| button.setEnabled(False) | |
| actions.addWidget(button) | |
| actions.addStretch() | |
| image_detail_layout.addLayout(actions) | |
| layout.addWidget(self.image_detail_panel) | |
| self.image_detail_panel.setVisible(False) | |
| return card | |
| def refresh(self) -> None: | |
| selected_provider = self.provider.currentData() | |
| selected_model = self.model.currentData() | |
| self.assets.discover(self.config) | |
| self.provider.blockSignals(True) | |
| self.provider.clear() | |
| for tool in generation_tools(self.registry): | |
| self.provider.addItem(tool.name, tool.id) | |
| if selected_provider: | |
| index = self.provider.findData(selected_provider) | |
| if index >= 0: | |
| self.provider.setCurrentIndex(index) | |
| self.provider.blockSignals(False) | |
| self._provider_changed() | |
| if selected_model: | |
| index = self.model.findData(selected_model) | |
| if index >= 0: | |
| self.model.setCurrentIndex(index) | |
| self._load_history() | |
| def _current_tool(self) -> ToolSpec | None: | |
| tool_id = self.provider.currentData() | |
| if not tool_id: | |
| return None | |
| try: | |
| return self.registry.get(str(tool_id)) | |
| except Exception: | |
| return None | |
| def _provider_changed(self) -> None: | |
| tool = self._current_tool() | |
| current_path = self.model.currentData() | |
| self.model.blockSignals(True) | |
| self.model.clear() | |
| if tool: | |
| candidates = [ | |
| asset | |
| for asset in self.assets.assets | |
| if asset.kind == "model" | |
| and (not tool.model_trainers or asset.trainer in tool.model_trainers) | |
| and self._model_is_ready(asset) | |
| ] | |
| for asset in candidates: | |
| self.model.addItem(asset.name, asset.path) | |
| if current_path: | |
| index = self.model.findData(current_path) | |
| if index >= 0: | |
| self.model.setCurrentIndex(index) | |
| options = tool.generation_options | |
| samplers = [str(item) for item in options.get("samplers", [])] | |
| aspects = [str(item) for item in options.get("aspect_ratios", [])] | |
| self.sampler.clear() | |
| self.sampler.addItems(samplers or ["DDIM", "DDPM"]) | |
| self.aspect.clear() | |
| self.aspect.addItems(aspects or ["1:1 (Square)"]) | |
| step_min = int(options.get("step_min", 5) or 5) | |
| step_max = int(options.get("step_max", 500) or 500) | |
| self.steps.setRange(step_min, step_max) | |
| self.steps.setValue( | |
| max(step_min, min(int(options.get("step_default", 50) or 50), step_max)) | |
| ) | |
| self.preview_interval.setRange(0, step_max) | |
| is_prompted = "text_prompt" in tool.capabilities | |
| self.model_browse_button.setVisible(tool.id == "lora_generator") | |
| self.prompt_label.setText("Positive prompt" if is_prompted else "Label / creative note") | |
| self.prompt.setPlaceholderText( | |
| "Describe the image to create with the selected LoRA." | |
| if is_prompted | |
| else "Optional note describing what you are exploring. Unconditional generators save it with the batch but do not use it as a text prompt." | |
| ) | |
| self.lora_options.setVisible(tool.id == "lora_generator") | |
| self.ddpm_reference_options.setVisible(tool.id == "ddpm_generator") | |
| self.smart_group.setVisible("smart_generation" in tool.capabilities) | |
| self.aspect.setEnabled( | |
| tool.id != "ddpm_generator" or not self.ddpm_custom_size.isChecked() | |
| ) | |
| if tool.id == "lora_generator": | |
| self._load_lora_options() | |
| self._load_plugin_generation_settings(tool) | |
| self.model.blockSignals(False) | |
| if not tool: | |
| self.smart_group.setVisible(False) | |
| self.generate_button.setEnabled(bool(tool and self.model.count())) | |
| self._apply_preset() | |
| self._model_changed() | |
| def _browse_lora(self) -> None: | |
| """Choose a LoRA outside the connected trainer's output folder.""" | |
| path, _ = QFileDialog.getOpenFileName( | |
| self, | |
| "Choose LoRA file", | |
| "", | |
| "LoRA files (*.safetensors)", | |
| ) | |
| if not path: | |
| return | |
| selected = Path(path).resolve() | |
| if "_comfy" in selected.stem.casefold(): | |
| QMessageBox.warning( | |
| self, | |
| "Choose the original LoRA", | |
| "The *_comfy.safetensors export is for ComfyUI. Choose the original LoRA file instead.", | |
| ) | |
| return | |
| index = self.model.findData(str(selected)) | |
| if index < 0: | |
| self.model.addItem(selected.stem, str(selected)) | |
| index = self.model.count() - 1 | |
| self.model.setCurrentIndex(index) | |
| def _lora_settings(self) -> dict: | |
| root = Path(str(self.config.get("tool_folders", {}).get("lora_trainer", ""))) | |
| try: | |
| value = json.loads((root / "config" / "app_settings.json").read_text(encoding="utf-8")) | |
| return value if isinstance(value, dict) else {} | |
| except (OSError, ValueError, TypeError, json.JSONDecodeError): | |
| return {} | |
| def _load_lora_options(self) -> None: | |
| saved = self._lora_settings() | |
| base_models = [str(saved.get(key, "")).strip() for key in ("generate_model", "last_model")] | |
| current = self.base_model.currentText().strip() | |
| self.base_model.clear() | |
| preferred_asset = next( | |
| ( | |
| asset for asset in self.assets.assets | |
| if asset.kind == "base_model" | |
| and "waiillustrious" in "".join( | |
| character for character in asset.name.casefold() if character.isalnum() | |
| ) | |
| ), | |
| None, | |
| ) | |
| if preferred_asset is not None: | |
| self.base_model.addItem(preferred_asset.name, preferred_asset.path) | |
| for path in dict.fromkeys(item for item in base_models if item): | |
| self.base_model.addItem(Path(path).name, path) | |
| for asset in self.assets.assets: | |
| if asset.kind == "base_model" and self.base_model.findData(asset.path) < 0: | |
| self.base_model.addItem(asset.name, asset.path) | |
| if current: | |
| self.base_model.setCurrentText(current) | |
| elif preferred_asset is not None: | |
| self.base_model.setCurrentIndex(0) | |
| elif base_models and base_models[0]: | |
| self.base_model.setCurrentText(base_models[0]) | |
| self.negative_prompt.setPlainText(str(saved.get("generate_negative_prompt", ""))) | |
| self.cfg_scale.setValue(float(saved.get("generate_cfg", 7.0) or 7.0)) | |
| self.lora_strength.setValue(float(saved.get("generate_lora_strength", 1.0) or 1.0)) | |
| self.denoise_strength.setValue(float(saved.get("generate_denoise_strength", 0.45) or 0.45)) | |
| self.prompt_weighting.setChecked(bool(saved.get("generate_prompt_weighting", True))) | |
| self._lora_aspect_changed(self.aspect.currentText()) | |
| def _lora_aspect_changed(self, aspect: str) -> None: | |
| if not self._current_tool() or self._current_tool().id != "lora_generator": | |
| return | |
| sizes = { | |
| "1:1 (Square)": (1024, 1024), "4:3 (Landscape)": (1152, 864), | |
| "3:4 (Portrait)": (864, 1152), "3:2 (Landscape)": (1216, 832), | |
| "2:3 (Portrait)": (832, 1216), "16:9 (Widescreen)": (1344, 768), | |
| "9:16 (Vertical)": (768, 1344), | |
| } | |
| if aspect in sizes: | |
| self.width.setValue(sizes[aspect][0]) | |
| self.height.setValue(sizes[aspect][1]) | |
| def _browse_reference(self) -> None: | |
| path, _ = QFileDialog.getOpenFileName(self, "Choose reference image", self.reference_image.text(), "Images (*.png *.jpg *.jpeg *.webp *.bmp)") | |
| if path: | |
| self.reference_image.setText(path) | |
| def _browse_ddpm_reference(self) -> None: | |
| path, _ = QFileDialog.getOpenFileName( | |
| self, | |
| "Choose DDPM reference image", | |
| self.ddpm_reference_image.text(), | |
| "Images (*.png *.jpg *.jpeg *.webp *.bmp)", | |
| ) | |
| if path: | |
| self.ddpm_reference_image.setText(path) | |
| def _ddpm_custom_size_changed(self, enabled: bool) -> None: | |
| self.ddpm_width.setEnabled(enabled) | |
| self.ddpm_height.setEnabled(enabled) | |
| if self._current_tool() and self._current_tool().id == "ddpm_generator": | |
| self.aspect.setEnabled(not enabled) | |
| def _load_plugin_generation_settings(self, tool: ToolSpec) -> None: | |
| schema = self.registry.model_plugins.generation_schema_for_tool(tool.id) | |
| built_in = { | |
| "model_name", "model_path", "prompt", "image_count", "steps", "seed", | |
| "sampler", "aspect_ratio", "preview_interval", | |
| "smart_generation", "smart_wanted_results", "smart_max_candidates", | |
| "smart_min_score", "smart_mode", "smart_keep_rejected", | |
| } | |
| if tool.id == "lora_generator": | |
| built_in.update({ | |
| "base_model_path", "negative_prompt", "width", "height", | |
| "cfg_scale", "lora_strength", "reference_image", | |
| "denoise_strength", "prompt_weighting", | |
| }) | |
| elif tool.id == "ddpm_generator": | |
| built_in.update({"reference_image", "reference_strength", "width", "height"}) | |
| extra_schema = { | |
| key: spec for key, spec in schema.items() | |
| if key not in built_in and key in tool.arguments | |
| } | |
| self.plugin_generation_form.set_schema(extra_schema) | |
| self.plugin_generation_group.setVisible(bool(extra_schema)) | |
| def _model_is_ready(asset: Asset) -> bool: | |
| path = Path(asset.path) | |
| if asset.trainer == "ddpm": | |
| return path.is_dir() and (path / "model_index.json").is_file() | |
| if asset.trainer == "flow": | |
| return ( | |
| path.is_dir() | |
| and (path / "flow_model_info.json").is_file() | |
| and (path / "unet" / "config.json").is_file() | |
| ) | |
| if asset.trainer == "lora": | |
| return ( | |
| path.is_file() | |
| and path.suffix.casefold() == ".safetensors" | |
| and "_comfy" not in path.stem.casefold() | |
| ) or ( | |
| path.is_dir() | |
| and any( | |
| item.is_file() and "_comfy" not in item.stem.casefold() | |
| for item in path.glob("*.safetensors") | |
| ) | |
| ) | |
| return path.exists() | |
| def _model_changed(self) -> None: | |
| tool = self._current_tool() | |
| path = self.model.currentData() | |
| if not tool: | |
| self.model_hint.setText("No registered image generators are available.") | |
| elif not path: | |
| self.model_hint.setText( | |
| f"{tool.name} is ready, but no compatible completed model was found." | |
| ) | |
| else: | |
| self.model_hint.setText(f"Completed pipeline: {path}") | |
| self.generate_button.setEnabled(bool(tool and path)) | |
| def _apply_preset(self) -> None: | |
| name = self.preset.currentText() | |
| tool = self._current_tool() | |
| if tool and tool.id == "flow_generator": | |
| values = { | |
| "Balanced": (20, "Heun"), | |
| "Fast preview": (8, "Euler"), | |
| "Detailed": (30, "Heun"), | |
| "Classic": (20, "Euler"), | |
| } | |
| fallback = (20, "Heun") | |
| elif tool and tool.id == "lora_generator": | |
| values = { | |
| "Balanced": (30, "DPM++ 2M"), | |
| "Fast preview": (12, "Euler a"), | |
| "Detailed": (50, "DPM++ 2M"), | |
| "Classic": (30, "DDIM"), | |
| } | |
| fallback = (30, "DPM++ 2M") | |
| else: | |
| values = { | |
| "Balanced": (50, "DDIM"), | |
| "Fast preview": (20, "DDIM"), | |
| "Detailed": (100, "DDIM"), | |
| "Classic": (100, "DDPM"), | |
| } | |
| fallback = (50, "DDIM") | |
| steps, sampler = values.get(name, fallback) | |
| self.steps.setValue(max(self.steps.minimum(), min(steps, self.steps.maximum()))) | |
| if self.sampler.findText(sampler) >= 0: | |
| self.sampler.setCurrentText(sampler) | |
| def _generate(self) -> None: | |
| tool = self._current_tool() | |
| model_path = str(self.model.currentData() or "") | |
| if tool is None or not model_path: | |
| QMessageBox.information( | |
| self, "Choose a model", "Choose a registered generator and completed model." | |
| ) | |
| return | |
| raw_seed = self.seed.text().strip() | |
| try: | |
| seed = int(raw_seed) if raw_seed else 0 | |
| except ValueError: | |
| QMessageBox.warning(self, "Check the seed", "Seed must be a whole number or left blank for random.") | |
| return | |
| if seed < 0 or seed > 2_147_483_647: | |
| QMessageBox.warning(self, "Check the seed", "Seed must be between 0 and 2,147,483,647.") | |
| return | |
| extra_arguments = {} | |
| if tool.id == "lora_generator": | |
| extra_arguments = { | |
| "negative_prompt": self.negative_prompt.toPlainText(), | |
| "base_model_path": str(self.base_model.currentData() or self.base_model.currentText()), | |
| "width": self.width.value(), "height": self.height.value(), | |
| "cfg_scale": self.cfg_scale.value(), "lora_strength": self.lora_strength.value(), | |
| "reference_image": self.reference_image.text().strip(), | |
| "denoise_strength": self.denoise_strength.value(), | |
| "prompt_weighting": self.prompt_weighting.isChecked(), | |
| "preview_interval": self.preview_interval.value(), | |
| } | |
| elif tool.id == "ddpm_generator": | |
| extra_arguments = { | |
| "reference_image": self.ddpm_reference_image.text().strip(), | |
| "reference_strength": self.ddpm_reference_strength.value(), | |
| "width": self.ddpm_width.value() if self.ddpm_custom_size.isChecked() else 0, | |
| "height": self.ddpm_height.value() if self.ddpm_custom_size.isChecked() else 0, | |
| "preview_interval": self.preview_interval.value(), | |
| } | |
| elif tool.id == "flow_generator": | |
| extra_arguments = {"preview_interval": self.preview_interval.value()} | |
| if self.smart_group.isVisible() and self.smart_enabled.isChecked(): | |
| if self.smart_max_candidates.value() < self.smart_wanted.value(): | |
| QMessageBox.warning(self, "Check Smart Generation", "Maximum candidates must be at least the wanted result count.") | |
| return | |
| extra_arguments.update({ | |
| "smart_generation": True, | |
| "smart_wanted_results": self.smart_wanted.value(), | |
| "smart_max_candidates": self.smart_max_candidates.value(), | |
| "smart_min_score": self.smart_min_score.value(), | |
| "smart_mode": str(self.smart_mode.currentData() or "threshold"), | |
| "smart_keep_rejected": self.smart_keep_rejected.isChecked(), | |
| }) | |
| extra_arguments.update(self.plugin_generation_form.values()) | |
| plan = build_generation_plan( | |
| tool, | |
| model_name=self.model.currentText(), | |
| model_path=model_path, | |
| prompt=self.prompt.toPlainText(), | |
| image_count=self.images.value(), | |
| steps=self.steps.value(), | |
| seed=seed, | |
| sampler=self.sampler.currentText(), | |
| aspect_ratio=self.aspect.currentText(), | |
| extra_arguments=extra_arguments, | |
| ) | |
| job = self.jobs.submit(plan) | |
| self._save_generation_settings() | |
| self.status.setText( | |
| f"Generation job {job.id} queued. You can follow it here or in Jobs / History." | |
| ) | |
| def _open_generation_cycle(self) -> None: | |
| self.assets.discover(self.config) | |
| tools = generation_tools(self.registry) | |
| trainer_labels = {"ddpm": "DDPM", "flow": "Flow Matching", "lora": "LoRA"} | |
| entries = [] | |
| for asset in self.assets.assets: | |
| if asset.kind != "model" or not self._model_is_ready(asset): | |
| continue | |
| tool = next((item for item in tools if asset.trainer in item.model_trainers), None) | |
| if tool: | |
| entries.append({ | |
| "name": asset.name, "path": asset.path, "trainer": asset.trainer, | |
| "trainer_label": trainer_labels.get(asset.trainer, asset.trainer.title()), | |
| "tool": tool, | |
| }) | |
| if not entries: | |
| QMessageBox.information(self, "No completed models", "Complete at least one compatible model before creating a generation cycle.") | |
| return | |
| dialog = GenerationCycleDialog(entries, self) | |
| if dialog.exec() != QDialog.Accepted: | |
| return | |
| selected = dialog.selected_entries() | |
| plans = [] | |
| base_seed = dialog.seed.value() | |
| for index, entry in enumerate(selected): | |
| tool = entry["tool"] | |
| options = tool.generation_options | |
| samplers = [str(value) for value in options.get("samplers", [])] or ["DDIM"] | |
| aspects = [str(value) for value in options.get("aspect_ratios", [])] or ["1:1 (Square)"] | |
| extra = {} | |
| if tool.id == "lora_generator": | |
| saved = self._lora_settings() | |
| base_path = str(saved.get("generate_model") or saved.get("last_model") or "") | |
| if not base_path: | |
| base_asset = next((asset for asset in self.assets.assets if asset.kind == "base_model"), None) | |
| base_path = base_asset.path if base_asset else "" | |
| if not base_path or not Path(base_path).exists(): | |
| QMessageBox.warning( | |
| self, "LoRA base model required", | |
| f"{entry['name']} needs a valid Stable Diffusion base model before it can join the cycle. " | |
| "Choose one in the LoRA generation settings first.", | |
| ) | |
| return | |
| extra = { | |
| "base_model_path": base_path, | |
| "negative_prompt": str(saved.get("generate_negative_prompt", "")), | |
| "width": 1024, "height": 1024, | |
| "cfg_scale": float(saved.get("generate_cfg", 7.0) or 7.0), | |
| "lora_strength": float(saved.get("generate_lora_strength", 1.0) or 1.0), | |
| "denoise_strength": float(saved.get("generate_denoise_strength", 0.45) or 0.45), | |
| "prompt_weighting": bool(saved.get("generate_prompt_weighting", True)), | |
| } | |
| plans.append(build_generation_plan( | |
| tool, | |
| model_name=entry["name"], model_path=entry["path"], | |
| prompt=dialog.prompt.text().strip() or (entry["name"] if tool.id == "lora_generator" else ""), | |
| image_count=dialog.image_count.value(), | |
| steps=int(options.get("step_default", 30) or 30), | |
| seed=base_seed + index * dialog.image_count.value(), | |
| sampler=samplers[0], aspect_ratio=aspects[0], extra_arguments=extra, | |
| )) | |
| plan = combine_generation_plans( | |
| plans, display_seconds=dialog.display_seconds.value(), | |
| show_labels=dialog.show_labels.isChecked(), loop=dialog.loop.isChecked(), | |
| ) | |
| job = self.jobs.submit(plan) | |
| self._cycle_jobs[job.id] = { | |
| "models": [(entry["name"], entry["trainer_label"]) for entry in selected], | |
| "display_seconds": dialog.display_seconds.value(), | |
| "show_labels": dialog.show_labels.isChecked(), | |
| "loop": dialog.loop.isChecked(), | |
| "fullscreen": dialog.fullscreen.isChecked(), | |
| } | |
| self.status.setText(f"Generation cycle {job.id} queued with {len(selected)} models.") | |
| def _play_finished_cycle(self, job: Job, settings: dict) -> None: | |
| records = [] | |
| history_root = self.root / "data" / "generations" | |
| if history_root.is_dir(): | |
| for metadata in history_root.rglob(f"*{job.id}*.json"): | |
| record = GenerationRecord.from_metadata(metadata) | |
| if record: | |
| records.append(record) | |
| model_order = {name: index for index, (name, _trainer) in enumerate(settings["models"])} | |
| records.sort(key=lambda record: model_order.get(record.model_name, len(model_order))) | |
| trainer_by_model = dict(settings["models"]) | |
| slides = [ | |
| (path, f"{record.model_name} — {trainer_by_model.get(record.model_name, record.provider_name)}") | |
| for record in records for path in record.images | |
| ] | |
| if not slides: | |
| self.status.setText(f"Generation cycle {job.id} finished, but no cycle images were found.") | |
| return | |
| player = CycleSlideshowDialog( | |
| slides, display_seconds=settings["display_seconds"], | |
| show_labels=settings["show_labels"], loop=settings["loop"], parent=self, | |
| ) | |
| if settings.get("fullscreen"): | |
| player.showFullScreen() | |
| player.exec() | |
| def _save_generation_settings(self) -> None: | |
| self.config.update({ | |
| "generation_settings": { | |
| "provider": self.provider.currentData(), | |
| "model_path": self.model.currentData(), | |
| "preset": self.preset.currentText(), | |
| "images": self.images.value(), | |
| "steps": self.steps.value(), | |
| "preview_interval": self.preview_interval.value(), | |
| "sampler": self.sampler.currentText(), | |
| "aspect_ratio": self.aspect.currentText(), | |
| "seed": self.seed.text(), | |
| "prompt": self.prompt.toPlainText(), | |
| "base_model_path": self.base_model.currentData() or self.base_model.currentText(), | |
| "negative_prompt": self.negative_prompt.toPlainText(), | |
| "cfg_scale": self.cfg_scale.value(), | |
| "lora_strength": self.lora_strength.value(), | |
| "denoise_strength": self.denoise_strength.value(), | |
| "prompt_weighting": self.prompt_weighting.isChecked(), | |
| "ddpm_reference_image": self.ddpm_reference_image.text(), | |
| "ddpm_reference_strength": self.ddpm_reference_strength.value(), | |
| "ddpm_custom_size": self.ddpm_custom_size.isChecked(), | |
| "ddpm_width": self.ddpm_width.value(), | |
| "ddpm_height": self.ddpm_height.value(), | |
| "smart_generation": self.smart_enabled.isChecked(), | |
| "smart_wanted_results": self.smart_wanted.value(), | |
| "smart_max_candidates": self.smart_max_candidates.value(), | |
| "smart_min_score": self.smart_min_score.value(), | |
| "smart_mode": self.smart_mode.currentData(), | |
| "smart_keep_rejected": self.smart_keep_rejected.isChecked(), | |
| } | |
| }) | |
| def _connect_generation_setting_persistence(self) -> None: | |
| self.provider.currentIndexChanged.connect(self._save_generation_settings) | |
| self.model.currentIndexChanged.connect(self._save_generation_settings) | |
| self.preset.currentTextChanged.connect(self._save_generation_settings) | |
| self.images.valueChanged.connect(self._save_generation_settings) | |
| self.steps.valueChanged.connect(self._save_generation_settings) | |
| self.preview_interval.valueChanged.connect(self._save_generation_settings) | |
| self.sampler.currentTextChanged.connect(self._save_generation_settings) | |
| self.aspect.currentTextChanged.connect(self._save_generation_settings) | |
| self.seed.editingFinished.connect(self._save_generation_settings) | |
| self.prompt.textChanged.connect(self._save_generation_settings) | |
| self.base_model.currentIndexChanged.connect(self._save_generation_settings) | |
| self.base_model.editTextChanged.connect(self._save_generation_settings) | |
| self.negative_prompt.textChanged.connect(self._save_generation_settings) | |
| self.cfg_scale.valueChanged.connect(self._save_generation_settings) | |
| self.lora_strength.valueChanged.connect(self._save_generation_settings) | |
| self.denoise_strength.valueChanged.connect(self._save_generation_settings) | |
| self.prompt_weighting.toggled.connect(self._save_generation_settings) | |
| self.ddpm_reference_image.editingFinished.connect(self._save_generation_settings) | |
| self.ddpm_reference_strength.valueChanged.connect(self._save_generation_settings) | |
| self.ddpm_custom_size.toggled.connect(self._save_generation_settings) | |
| self.ddpm_width.valueChanged.connect(self._save_generation_settings) | |
| self.ddpm_height.valueChanged.connect(self._save_generation_settings) | |
| self.smart_enabled.toggled.connect(self._save_generation_settings) | |
| self.smart_wanted.valueChanged.connect(self._save_generation_settings) | |
| self.smart_max_candidates.valueChanged.connect(self._save_generation_settings) | |
| self.smart_min_score.valueChanged.connect(self._save_generation_settings) | |
| self.smart_mode.currentIndexChanged.connect(self._save_generation_settings) | |
| self.smart_keep_rejected.toggled.connect(self._save_generation_settings) | |
| def _restore_generation_settings(self) -> None: | |
| saved = self.config.get("generation_settings", {}) | |
| if not isinstance(saved, dict): | |
| return | |
| provider_index = self.provider.findData(saved.get("provider")) | |
| if provider_index >= 0: | |
| self.provider.setCurrentIndex(provider_index) | |
| model_index = self.model.findData(saved.get("model_path")) | |
| if model_index >= 0: | |
| self.model.setCurrentIndex(model_index) | |
| for combo, key in ((self.preset, "preset"), (self.sampler, "sampler"), (self.aspect, "aspect_ratio")): | |
| value = str(saved.get(key, "")) | |
| if combo.findText(value) >= 0: | |
| combo.setCurrentText(value) | |
| for spin, key in ((self.images, "images"), (self.steps, "steps"), (self.preview_interval, "preview_interval"), (self.ddpm_reference_strength, "ddpm_reference_strength"), (self.ddpm_width, "ddpm_width"), (self.ddpm_height, "ddpm_height")): | |
| try: | |
| spin.setValue(max(spin.minimum(), min(int(saved.get(key, spin.value())), spin.maximum()))) | |
| except (TypeError, ValueError): | |
| pass | |
| self.seed.setText(str(saved.get("seed", ""))) | |
| self.prompt.setPlainText(str(saved.get("prompt", ""))) | |
| base_model_path = str(saved.get("base_model_path", "")) | |
| base_index = self.base_model.findData(base_model_path) | |
| if base_index >= 0: | |
| self.base_model.setCurrentIndex(base_index) | |
| elif base_model_path: | |
| self.base_model.setCurrentText(base_model_path) | |
| self.negative_prompt.setPlainText(str(saved.get("negative_prompt", self.negative_prompt.toPlainText()))) | |
| try: | |
| self.cfg_scale.setValue(float(saved.get("cfg_scale", self.cfg_scale.value()))) | |
| self.lora_strength.setValue(float(saved.get("lora_strength", self.lora_strength.value()))) | |
| self.denoise_strength.setValue(float(saved.get("denoise_strength", self.denoise_strength.value()))) | |
| except (TypeError, ValueError): | |
| pass | |
| self.prompt_weighting.setChecked(bool(saved.get("prompt_weighting", self.prompt_weighting.isChecked()))) | |
| self.ddpm_reference_image.setText(str(saved.get("ddpm_reference_image", ""))) | |
| self.ddpm_custom_size.setChecked(bool(saved.get("ddpm_custom_size", False))) | |
| self.smart_enabled.setChecked(bool(saved.get("smart_generation", False))) | |
| for spin, key in ((self.smart_wanted, "smart_wanted_results"), (self.smart_max_candidates, "smart_max_candidates")): | |
| try: | |
| spin.setValue(max(spin.minimum(), min(int(saved.get(key, spin.value())), spin.maximum()))) | |
| except (TypeError, ValueError): | |
| pass | |
| try: | |
| self.smart_min_score.setValue(float(saved.get("smart_min_score", self.smart_min_score.value()))) | |
| except (TypeError, ValueError): | |
| pass | |
| mode_index = self.smart_mode.findData(saved.get("smart_mode")) | |
| if mode_index >= 0: | |
| self.smart_mode.setCurrentIndex(mode_index) | |
| self.smart_keep_rejected.setChecked(bool(saved.get("smart_keep_rejected", True))) | |
| def _load_history(self) -> None: | |
| selected_image = None | |
| current = self.gallery.currentItem() | |
| if current: | |
| selected_image = current.data(Qt.UserRole) | |
| self.records = load_generation_history(self.root) | |
| folders = group_generation_records(self.records) | |
| if self._active_model_folder and not any( | |
| folder.key == self._active_model_folder for folder in folders | |
| ): | |
| self._active_model_folder = "" | |
| self._history_load_token += 1 | |
| token = self._history_load_token | |
| self._history_load_index = 0 | |
| self._history_selected_image = ( | |
| str(selected_image.get("image", "")) if isinstance(selected_image, dict) else "" | |
| ) | |
| self.gallery.clear() | |
| self.model_folders.clear() | |
| if not self._active_model_folder: | |
| for folder in folders: | |
| item = QListWidgetItem( | |
| QIcon(_folder_cover(folder.cover_image)), | |
| f"{folder.model_name}\n{folder.provider_name} · " | |
| f"{folder.image_count} image{'s' if folder.image_count != 1 else ''}", | |
| ) | |
| item.setData(Qt.UserRole, folder.key) | |
| item.setToolTip( | |
| f"Open {folder.model_name}\n" | |
| f"{len(folder.records)} batch{'es' if len(folder.records) != 1 else ''} · " | |
| f"{folder.image_count} image{'s' if folder.image_count != 1 else ''}" | |
| ) | |
| self.model_folders.addItem(item) | |
| self.model_folders.setVisible(True) | |
| self.gallery.setVisible(False) | |
| self.image_detail_panel.setVisible(False) | |
| self.history_title.setText("MODEL FOLDERS") | |
| self.folder_back_button.setVisible(False) | |
| self.generation_target.setText( | |
| "Double-click a model folder to browse its images and generate with that model." | |
| ) | |
| total_images = sum(folder.image_count for folder in folders) | |
| self.history_summary.setText( | |
| f"{len(folders)} models · {len(self.records)} batches · {total_images} images" | |
| if folders else "No generations yet" | |
| ) | |
| self.clear_history_button.setEnabled(False) | |
| self.auto_sort_button.setEnabled(False) | |
| self._selection_changed(None, None) | |
| return | |
| active_folder = next(folder for folder in folders if folder.key == self._active_model_folder) | |
| self.model_folders.setVisible(False) | |
| self.gallery.setVisible(True) | |
| self.image_detail_panel.setVisible(True) | |
| self.history_title.setText(active_folder.model_name.upper()) | |
| self.folder_back_button.setVisible(True) | |
| self.generation_target.setText( | |
| f"Generating now will add the new batch to {active_folder.model_name}." | |
| ) | |
| self.auto_sort_button.setEnabled(True) | |
| self._history_entries = [] | |
| active_batch_count = 0 | |
| for record_index, record in enumerate(self.records): | |
| if generation_model_key(record) != self._active_model_folder: | |
| continue | |
| active_batch_count += 1 | |
| for image_index, path in enumerate(record.images): | |
| if str(path) in self.hidden_history_images: | |
| continue | |
| self._history_entries.append((record_index, image_index, path)) | |
| image_total = len(self._history_entries) | |
| self.history_summary.setText( | |
| f"{active_batch_count} batches · {image_total} images · Loading thumbnails…" | |
| if image_total | |
| else "No generations yet" | |
| ) | |
| self.clear_history_button.setEnabled(image_total > 0) | |
| if not image_total: | |
| self._selection_changed(None, None) | |
| return | |
| QTimer.singleShot(0, lambda: self._load_next_history_thumbnail(token)) | |
| def _load_next_history_thumbnail(self, token: int) -> None: | |
| """Decode one history image per event-loop turn so the page opens immediately.""" | |
| if token != self._history_load_token: | |
| return | |
| if self._history_load_index >= len(self._history_entries): | |
| batch_count = len({entry[0] for entry in self._history_entries}) | |
| self.history_summary.setText( | |
| f"{batch_count} batches · {len(self._history_entries)} images" | |
| ) | |
| if self.gallery.currentRow() < 0 and self.gallery.count(): | |
| self.gallery.setCurrentRow(0) | |
| return | |
| record_index, image_index, path = self._history_entries[self._history_load_index] | |
| record = self.records[record_index] | |
| seed = record.seed + image_index | |
| rating = self._stored_rating(record, path) | |
| score = record.image_evaluations.get(str(path.resolve()), {}).get("score") | |
| badges = [] | |
| if rating: | |
| badges.append(rating.title()) | |
| if isinstance(score, (int, float)): | |
| badges.append(f"{float(score):.2f}") | |
| item = QListWidgetItem( | |
| QIcon(_thumbnail(path, 170, 128)), | |
| f"{record.model_name}\nSeed {seed}" + (f" · {' · '.join(badges)}" if badges else ""), | |
| ) | |
| item.setData(Qt.UserRole, { | |
| "record": record_index, | |
| "image": str(path), | |
| "seed": seed, | |
| }) | |
| self.gallery.addItem(item) | |
| if self._history_selected_image == str(path): | |
| self.gallery.setCurrentItem(item) | |
| self._history_load_index += 1 | |
| QTimer.singleShot(0, lambda: self._load_next_history_thumbnail(token)) | |
| def _open_model_folder(self, item: QListWidgetItem) -> None: | |
| key = str(item.data(Qt.UserRole) or "") | |
| folder = next( | |
| (candidate for candidate in group_generation_records(self.records) if candidate.key == key), | |
| None, | |
| ) | |
| if folder is None: | |
| return | |
| self._active_model_folder = folder.key | |
| provider_index = self.provider.findData(folder.provider_id) | |
| if provider_index >= 0: | |
| self.provider.setCurrentIndex(provider_index) | |
| model_index = self.model.findData(folder.model_path) | |
| if model_index >= 0: | |
| self.model.setCurrentIndex(model_index) | |
| self.status.setText(f"Opened {folder.model_name}. New images will be generated here.") | |
| else: | |
| self.status.setText( | |
| f"Opened {folder.model_name}. Its history is available, but the model is not currently connected." | |
| ) | |
| self._load_history() | |
| def _show_model_folders(self) -> None: | |
| self._active_model_folder = "" | |
| self._load_history() | |
| def _clear_displayed_history(self) -> None: | |
| for index in range(self.gallery.count()): | |
| payload = self.gallery.item(index).data(Qt.UserRole) | |
| if isinstance(payload, dict) and payload.get("image"): | |
| self.hidden_history_images.add(str(payload["image"])) | |
| self.gallery.clear() | |
| self.history_summary.setText("No images displayed") | |
| self.clear_history_button.setEnabled(False) | |
| self._selection_changed(None, None) | |
| self.status.setText("Displayed generation history cleared. Image files were not deleted.") | |
| def _auto_sort_history(self) -> None: | |
| changed = 0 | |
| skipped = 0 | |
| for record in self.records: | |
| if self._active_model_folder and generation_model_key(record) != self._active_model_folder: | |
| continue | |
| profile = PreferenceProfile(self.root, record.provider_id, record.model_name, record.model_path) | |
| if not profile.has_signal(): | |
| skipped += 1 | |
| continue | |
| try: | |
| scores = score_generated_images( | |
| self.root, | |
| provider_id=record.provider_id, | |
| model_name=record.model_name, | |
| model_path=record.model_path, | |
| image_paths=record.images, | |
| keep_threshold=profile.keep_threshold, | |
| reject_threshold=profile.reject_threshold, | |
| ) | |
| evaluations = { | |
| score.image_path: { | |
| "score": score.score, | |
| "confidence": score.confidence, | |
| "category": score.category, | |
| "reason": score.reason, | |
| } | |
| for score in scores | |
| } | |
| ordered = sorted( | |
| record.images, | |
| key=lambda path: float(evaluations.get(str(path.resolve()), {}).get("score") or -1.0), | |
| reverse=True, | |
| ) | |
| payload = json.loads(record.metadata_path.read_text(encoding="utf-8")) | |
| payload["images"] = [str(path) for path in ordered] | |
| payload["image_evaluations"] = evaluations | |
| smart = dict(payload.get("smart_generation") or {}) | |
| smart.update({ | |
| "auto_sorted": True, | |
| "profile_id": profile.id, | |
| "selected_count": sum( | |
| 1 for score in scores | |
| if score.score is not None and score.score >= profile.keep_threshold | |
| ), | |
| "candidate_count": len(scores), | |
| }) | |
| payload["smart_generation"] = smart | |
| temporary = record.metadata_path.with_suffix(".tmp") | |
| temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8") | |
| temporary.replace(record.metadata_path) | |
| changed += 1 | |
| except Exception as exc: | |
| skipped += 1 | |
| self.status.setText(f"Auto Sort skipped a batch: {exc}") | |
| self._load_history() | |
| self.status.setText(f"Auto Sort updated {changed} batch(es). {skipped} had no profile signal or could not be scored.") | |
| def _selection(self) -> tuple[GenerationRecord, Path, int] | None: | |
| item = self.gallery.currentItem() | |
| if not item: | |
| return None | |
| payload = item.data(Qt.UserRole) | |
| try: | |
| return ( | |
| self.records[int(payload["record"])], | |
| Path(str(payload["image"])), | |
| int(payload["seed"]), | |
| ) | |
| except (KeyError, IndexError, TypeError, ValueError): | |
| return None | |
| def _selection_changed(self, _current=None, _previous=None) -> None: | |
| selection = self._selection() | |
| enabled = selection is not None | |
| self.open_image_button.setEnabled(enabled) | |
| self.open_folder_button.setEnabled(enabled) | |
| self.reuse_button.setEnabled(enabled) | |
| for button in (self.favorite_button, self.keep_button, self.unsure_button, self.reject_button): | |
| button.setEnabled(enabled) | |
| if not selection: | |
| self.detail.setText("Select an image to see its reproducibility settings.") | |
| return | |
| record, path, seed = selection | |
| note = f" · Note: {record.prompt}" if record.prompt else "" | |
| rating = self._stored_rating(record, path) | |
| evaluation = record.image_evaluations.get(str(path.resolve()), {}) | |
| score = evaluation.get("score") | |
| score_text = f" · Smart score {float(score):.2f}" if isinstance(score, (int, float)) else "" | |
| category = str(evaluation.get("category", "")) | |
| category_text = f" · {category}" if category else "" | |
| rating_text = f" · Rating: {rating.title()}" if rating else " · Not rated" | |
| self.detail.setText( | |
| f"{record.provider_name} · {record.model_name} · Seed {seed} · " | |
| f"{record.steps} steps · {record.sampler} · {record.aspect_ratio}{rating_text}{score_text}{category_text}{note}" | |
| ) | |
| def _stored_rating(self, record: GenerationRecord, path: Path) -> str: | |
| profile = PreferenceProfile(self.root, record.provider_id, record.model_name, record.model_path) | |
| rating = profile.rating_for(path) | |
| return rating.rating if rating else "" | |
| def _generation_settings_for_record(self, record: GenerationRecord, seed: int) -> dict: | |
| settings = { | |
| "provider_id": record.provider_id, | |
| "provider_name": record.provider_name, | |
| "model_name": record.model_name, | |
| "model_path": record.model_path, | |
| "prompt": record.prompt, | |
| "seed": seed, | |
| "steps": record.steps, | |
| "sampler": record.sampler, | |
| "aspect_ratio": record.aspect_ratio, | |
| } | |
| settings.update(record.smart_generation) | |
| return settings | |
| def _rate_selection(self, rating: str) -> None: | |
| selection = self._selection() | |
| if not selection: | |
| return | |
| record, path, seed = selection | |
| profile = PreferenceProfile(self.root, record.provider_id, record.model_name, record.model_path) | |
| profile.set_rating( | |
| path, | |
| rating, | |
| seed=seed, | |
| sampler=record.sampler, | |
| steps=record.steps, | |
| resolution=record.aspect_ratio, | |
| generation_settings=self._generation_settings_for_record(record, seed), | |
| generation_created_at=record.created_at, | |
| ) | |
| self.status.setText(f"Saved {rating.title()} for {record.model_name}.") | |
| self._load_history() | |
| def _open_image(self) -> None: | |
| selection = self._selection() | |
| if selection: | |
| QDesktopServices.openUrl(QUrl.fromLocalFile(str(selection[1]))) | |
| def _open_folder(self) -> None: | |
| selection = self._selection() | |
| if selection: | |
| QDesktopServices.openUrl(QUrl.fromLocalFile(str(selection[0].folder))) | |
| def _reuse_settings(self) -> None: | |
| selection = self._selection() | |
| if not selection: | |
| return | |
| record, _path, seed = selection | |
| provider_index = self.provider.findData(record.provider_id) | |
| if provider_index >= 0: | |
| self.provider.setCurrentIndex(provider_index) | |
| model_index = self.model.findData(record.model_path) | |
| if model_index >= 0: | |
| self.model.setCurrentIndex(model_index) | |
| self.prompt.setPlainText(record.prompt) | |
| self.seed.setText(str(seed)) | |
| self.steps.setValue(max(self.steps.minimum(), min(record.steps, self.steps.maximum()))) | |
| self.sampler.setCurrentText(record.sampler) | |
| self.aspect.setCurrentText(record.aspect_ratio) | |
| self.status.setText("Settings restored from generation history.") | |
| def _job_updated(self, job: Job) -> None: | |
| generation_ids = {tool.id for tool in generation_tools(self.registry)} | |
| if not any(step.tool_id in generation_ids for step in job.plan.steps): | |
| return | |
| if job.status == JobStatus.FINISHED: | |
| self.status.setText(f"Generation job {job.id} finished successfully.") | |
| self._load_history() | |
| cycle = self._cycle_jobs.pop(job.id, None) | |
| if cycle: | |
| QTimer.singleShot(0, lambda current=job, settings=cycle: self._play_finished_cycle(current, settings)) | |
| elif job.status == JobStatus.FAILED: | |
| self.status.setText(f"Generation job {job.id} failed: {job.error or 'Unknown error'}") | |
| elif job.status == JobStatus.CANCELLED: | |
| self.status.setText(f"Generation job {job.id} was cancelled.") | |
| elif job.status in {JobStatus.RUNNING, JobStatus.PAUSED}: | |
| self.status.setText(f"Generation job {job.id}: {job.status.value} · {job.progress}%") | |