Compare commits

...

23 Commits

Author SHA1 Message Date
Will Miao
a5191414cc feat(download): add configurable base model download exclusions 2026-03-26 23:07:12 +08:00
Will Miao
5b065b47d4 feat(i18n): complete translations for mature blur threshold setting
Add translations for the new mature_blur_level setting across all
supported languages:
- zh-CN: 成人内容模糊阈值
- zh-TW: 成人內容模糊閾值
- ja: 成人コンテンツぼかし閾値
- ko: 성인 콘텐츠 블러 임계값
- de: Schwelle für Unschärfe bei jugendgefährdenden Inhalten
- fr: Seuil de floutage pour contenu adulte
- es: Umbral de difuminado para contenido adulto
- ru: Порог размытия взрослого контента
- he: סף טשטוש תוכן מבוגרים

Completes TODOs from previous commit.
2026-03-26 18:40:33 +08:00
Will Miao
ceeab0c998 feat: add configurable mature blur threshold setting
Add new setting 'mature_blur_level' with options PG13/R/X/XXX to control
which NSFW rating level triggers blur filtering when NSFW blur is enabled.

- Backend: update preview selection logic to respect threshold
- Frontend: update UI components to use configurable threshold
- Settings: add validation and normalization for mature_blur_level
- Tests: add coverage for new threshold behavior
- Translations: add keys for all supported languages

Fixes #867
2026-03-26 18:24:47 +08:00
Will Miao
3b001a6cd8 fix(tests): update tests to match current download implementation
- Remove calculate_sha256 mocking from download_manager tests since
  SHA256 now comes from API metadata (not recalculated during download)
- Update chunk_size assertion from 4MB to 16MB in downloader config test
2026-03-26 18:00:04 +08:00
Will Miao
95e5bc26d1 feat: Add bulk download missing LoRAs feature for recipes
- Add BulkMissingLoraDownloadManager.js for handling bulk LoRA downloads
- Add context menu item to bulk mode for downloading missing LoRAs
- Add confirmation modal with deduplicated LoRA list preview
- Implement sequential downloading with WebSocket progress updates
- Fix CSS class naming conflicts to avoid import-modal.css collision
- Update translations for 9 languages (en, zh-CN, zh-TW, ja, ko, ru, de, fr, es, he)
- Style modal without internal scrolling for better UX
2026-03-26 17:46:53 +08:00
Will Miao
de3d0571f8 fix: verify returned image ID matches requested ID in CivitAI API
Fix issue #870 where importing recipes from CivitAI image URLs would
return the wrong image when the API response did not contain the
requested image ID.

The get_image_info() method now:
- Iterates through all returned items to find matching ID
- Returns None when no match is found and logs warning with returned IDs
- Handles invalid (non-numeric) ID formats

New test cases:
- test_get_image_info_returns_matching_item
- test_get_image_info_returns_none_when_id_mismatch
- test_get_image_info_handles_invalid_id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 20:37:51 +08:00
Will Miao
6f2a01dc86 优化下载性能:移除 SHA256 计算并使用 16MB chunks
- 移除下载后的 SHA256 计算,直接使用 API 返回的 hash 值
- 将 chunk size 从 4MB 调整为 16MB,减少 75% 的 I/O 操作
- 这有助于缓解 ComfyUI 执行期间的卡顿问题
2026-03-25 19:29:48 +08:00
Will Miao
c5c1b8fd2a Fix: border corner clipping in duplicate recipe warning
Fix the bottom corners of duplicate warning border being clipped
due to parent container overflow:hidden and mismatched border-radius.

- Changed border-radius from top-only to all corners
- Ensures yellow border displays fully without being cut off
2026-03-25 13:57:38 +08:00
Will Miao
e97648c70b feat(import): add import-only option for recipes without downloading missing LoRAs
Add dual-button design in recipe import flow:
- Details step: [Import Recipe Only] [Import & Download]
- Location step: [Back] [Import & Download] (removed redundant Import Only)

Changes:
- templates/components/import_modal.html: Add secondary button for import-only
- static/js/managers/ImportManager.js: Add saveRecipeOnlyFromDetails() method
- static/js/managers/import/RecipeDataManager.js: Update button state management
- static/js/managers/import/DownloadManager.js: Support skipDownload flag
- locales/*.json: Complete all translation TODOs

Closes #868
2026-03-25 11:56:34 +08:00
Will Miao
8b85e083e2 feat(recipe-parser): add SuiImage metadata format support
- Add SuiImageParamsParser for sui_image_params JSON format
- Register new parser in RecipeParserFactory
- Fix metadata_provider auto-initialization when not ready
- Add 10 test cases for SuiImageParamsParser

Fixes batch import failure for images with sui_image_params metadata.
2026-03-25 08:43:33 +08:00
Will Miao
9112cd3b62 chore: Add .claude/ to gitignore
Exclude Claude Code personal configuration directory containing:
- settings.local.json (personal permissions and local paths)
- skills/ (personal skills)

These contain machine-specific paths and personal preferences
that should not be shared across the team.
2026-03-22 14:17:15 +08:00
Will Miao
7df4e8d037 fix(metadata_hook): correct function signature to fix bound method error
Fix issue #866 where the metadata hook's async wrapper used *args/**kwargs
which caused AttributeError when ComfyUI's make_locked_method_func tried
to access __func__ on the func parameter.

The async_map_node_over_list_with_metadata wrapper now uses the exact
same signature as ComfyUI's _async_map_node_over_list:
- Removed: *args, **kwargs
- Added: explicit v3_data=None parameter

This ensures the func parameter (always a string like obj.FUNCTION) is
passed correctly to make_locked_method_func without any type conversion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 13:25:04 +08:00
Will Miao
4000b7f7e7 feat: Add configurable LoRA strength adjustment step setting
Implements issue #808 - Allow users to customize the strength
variation range for LoRA widget arrow buttons.

Changes:
- Add 'Strength Adjustment Step' setting (0.01-0.1) in settings.js
- Replace hardcoded 0.05 increments with configurable step value
- Apply to both LoRA strength and CLIP strength controls

Fixes #808
2026-03-19 17:33:18 +08:00
Will Miao
76c15105e6 feat(lora-pool): add regex include/exclude name pattern filtering (#839)
Add name pattern filtering to LoRA Pool node allowing users to filter
LoRAs by filename or model name using either plain text or regex patterns.

Features:
- Include patterns: only show LoRAs matching at least one pattern
- Exclude patterns: exclude LoRAs matching any pattern
- Regex toggle: switch between substring and regex matching
- Case-insensitive matching for both modes
- Invalid regex automatically falls back to substring matching
- Filters apply to both file_name and model_name fields

Backend:
- Update LoraPoolLM._default_config() with namePatterns structure
- Add name pattern filtering to _apply_pool_filters() and _apply_specific_filters()
- Add API parameter parsing for name_pattern_include/exclude/use_regex
- Update LoraPoolConfig type with namePatterns field

Frontend:
- Add NamePatternsSection.vue component with pattern input UI
- Update useLoraPoolState to manage pattern state and API integration
- Update LoraPoolSummaryView to display NamePatternsSection
- Increase LORA_POOL_WIDGET_MIN_HEIGHT to accommodate new UI

Tests:
- Add 7 test cases covering text/regex include, exclude, combined
  filtering, model name fallback, and invalid regex handling

Closes #839
2026-03-19 17:15:05 +08:00
Will Miao
b11c90e19b feat: add type ignore comments and remove unused imports
- Add `# type: ignore` comments to comfy.sd and folder_paths imports
- Remove unused imports: os, random, and extract_lora_name
- Clean up import statements across checkpoint_loader, lora_randomizer, and unet_loader nodes
2026-03-19 15:54:49 +08:00
pixelpaws
9f5d2d0c18 Merge pull request #862 from EnragedAntelope/claude/add-webp-image-support-t8kG9
Improve webp image support
2026-03-19 15:35:16 +08:00
Will Miao
a0dc5229f4 feat(unet_loader): move torch import inside methods for lazy loading
- Delay torch import until needed in load_unet and load_unet_gguf methods
- This improves module loading performance by avoiding unnecessary imports
- Maintains functionality while reducing initial import overhead
2026-03-19 15:29:41 +08:00
Will Miao
61c31ecbd0 fix: exclude __init__.py from pytest collection to prevent CI import errors 2026-03-19 14:43:45 +08:00
Will Miao
1ae1b0d607 refactor: move No LoRA feature from LoRA Pool to Lora Cycler widget
Move the 'empty/no LoRA' cycling functionality from the LoRA Pool node
to the Lora Cycler widget for cleaner architecture:

Frontend changes:
- Add include_no_lora field to CyclerConfig interface
- Add includeNoLora state and logic to useLoraCyclerState composable
- Add toggle UI in LoraCyclerSettingsView with special styling
- Show 'No LoRA' entry in LoraListModal when enabled
- Update LoraCyclerWidget to integrate new logic

Backend changes:
- lora_cycler.py reads include_no_lora from config
- Calculate effective_total_count (actual count + 1 when enabled)
- Return empty lora_stack when on No LoRA position
- Return actual LoRA count in total_count (not effective count)

Reverted files to pre-PR state:
- lora_loader.py, lora_pool.py, lora_randomizer.py, lora_stacker.py
- lora_routes.py, lora_service.py
- LoraPoolWidget.vue and related files

Related to PR #861

Co-authored-by: dogatech <dogatech@dogatech.home>
2026-03-19 14:19:49 +08:00
dogatech
8dd849892d Allow for empty lora (no loras option) in Lora Pool 2026-03-19 09:23:03 +08:00
EnragedAntelope
a32325402e Merge branch 'willmiao:main' into claude/add-webp-image-support-t8kG9 2026-03-17 08:37:46 -04:00
Claude
05ebd7493d chore: update package-lock.json after npm install
https://claude.ai/code/session_01SgT2pkisi27bEQELX5EeXZ
2026-03-17 01:33:34 +00:00
Claude
90986bd795 feat: add case-insensitive webp support for lora cover photos
Make preview file discovery case-insensitive so files with uppercase
extensions like .WEBP are found on case-sensitive filesystems. Also
explicitly list image/webp in the file picker accept attribute for
broader browser compatibility.

https://claude.ai/code/session_01SgT2pkisi27bEQELX5EeXZ
2026-03-17 01:32:48 +00:00
89 changed files with 5144 additions and 1203 deletions

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ model_cache/
# agent # agent
.opencode/ .opencode/
.claude/
# Vue widgets development cache (but keep build output) # Vue widgets development cache (but keep build output)
vue-widgets/node_modules/ vue-widgets/node_modules/

View File

@@ -179,6 +179,8 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met
- Context menu for quick actions - Context menu for quick actions
- Custom notes and usage tips - Custom notes and usage tips
- Multi-folder support - Multi-folder support
- Configurable mature blur threshold (`PG13` / `R` / `X` / `XXX`, default `R+`)
- Example: setting threshold to `PG13` blurs `PG13`, `R`, `X`, and `XXX` previews when blur is enabled
- Visual progress indicators during initialization - Visual progress indicators during initialization
--- ---

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFW-Inhalte unscharf stellen", "blurNsfwContent": "NSFW-Inhalte unscharf stellen",
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen", "blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
"showOnlySfw": "Nur SFW-Ergebnisse anzeigen", "showOnlySfw": "Nur SFW-Ergebnisse anzeigen",
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern" "showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern",
"matureBlurThreshold": "Schwelle für Unschärfe bei jugendgefährdenden Inhalten",
"matureBlurThresholdHelp": "Legen Sie fest, ab welcher Altersfreigabe die Unschärfe beginnt, wenn NSFW-Unschärfe aktiviert ist.",
"matureBlurThresholdOptions": {
"pg13": "PG13 und höher",
"r": "R und höher (Standard)",
"x": "X und höher",
"xxx": "Nur XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Videos bei Hover automatisch abspielen", "autoplayOnHover": "Videos bei Hover automatisch abspielen",
@@ -315,6 +323,24 @@
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}" "saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Downloads für Basismodelle überspringen",
"help": "Gilt für alle Download-Abläufe. Hier können nur unterstützte Basismodelle ausgewählt werden.",
"searchPlaceholder": "Basismodelle filtern...",
"empty": "Keine Basismodelle entsprechen der aktuellen Suche.",
"summary": {
"none": "Nichts ausgewählt",
"count": "{count} ausgewählt"
},
"actions": {
"edit": "Bearbeiten",
"collapse": "Einklappen",
"clear": "Löschen"
},
"validation": {
"saveFailed": "Ausgeschlossene Basismodelle konnten nicht gespeichert werden: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Anzeige-Dichte", "displayDensity": "Anzeige-Dichte",
"displayDensityOptions": { "displayDensityOptions": {
@@ -575,6 +601,7 @@
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen", "skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen", "resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
"deleteAll": "Alle Modelle löschen", "deleteAll": "Alle Modelle löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
"clear": "Auswahl löschen", "clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle", "skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle", "resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
@@ -645,6 +672,8 @@
"root": "Stammverzeichnis", "root": "Stammverzeichnis",
"browseFolders": "Ordner durchsuchen:", "browseFolders": "Ordner durchsuchen:",
"downloadAndSaveRecipe": "Herunterladen & Rezept speichern", "downloadAndSaveRecipe": "Herunterladen & Rezept speichern",
"importRecipeOnly": "Nur Rezept importieren",
"importAndDownload": "Importieren & Herunterladen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen", "downloadMissingLoras": "Fehlende LoRAs herunterladen",
"saveRecipe": "Rezept speichern", "saveRecipe": "Rezept speichern",
"loraCountInfo": "({existing}/{total} in Bibliothek)", "loraCountInfo": "({existing}/{total} in Bibliothek)",
@@ -732,61 +761,61 @@
} }
}, },
"batchImport": { "batchImport": {
"title": "[TODO: Translate] Batch Import Recipes", "title": "Batch Import Recipes",
"action": "[TODO: Translate] Batch Import", "action": "Batch Import",
"urlList": "[TODO: Translate] URL List", "urlList": "URL List",
"directory": "[TODO: Translate] Directory", "directory": "Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", "urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.", "directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths", "urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line", "urlsHint": "Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path", "directoryPath": "Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder", "directoryPlaceholder": "/path/to/images/folder",
"browse": "[TODO: Translate] Browse", "browse": "Browse",
"recursive": "[TODO: Translate] Include subdirectories", "recursive": "Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)", "tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas", "tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes", "tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata", "skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.", "skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import", "start": "Start Import",
"startImport": "[TODO: Translate] Start Import", "startImport": "Start Import",
"importing": "[TODO: Translate] Importing...", "importing": "Importing...",
"progress": "[TODO: Translate] Progress", "progress": "Progress",
"total": "[TODO: Translate] Total", "total": "Total",
"success": "[TODO: Translate] Success", "success": "Success",
"failed": "[TODO: Translate] Failed", "failed": "Failed",
"skipped": "[TODO: Translate] Skipped", "skipped": "Skipped",
"current": "[TODO: Translate] Current", "current": "Current",
"currentItem": "[TODO: Translate] Current", "currentItem": "Current",
"preparing": "[TODO: Translate] Preparing...", "preparing": "Preparing...",
"cancel": "[TODO: Translate] Cancel", "cancel": "Cancel",
"cancelImport": "[TODO: Translate] Cancel", "cancelImport": "Cancel",
"cancelled": "[TODO: Translate] Import cancelled", "cancelled": "Import cancelled",
"completed": "[TODO: Translate] Import completed", "completed": "Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)", "completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful", "successCount": "Successful",
"failedCount": "[TODO: Translate] Failed", "failedCount": "Failed",
"skippedCount": "[TODO: Translate] Skipped", "skippedCount": "Skipped",
"totalProcessed": "[TODO: Translate] Total processed", "totalProcessed": "Total processed",
"viewDetails": "[TODO: Translate] View Details", "viewDetails": "View Details",
"newImport": "[TODO: Translate] New Import", "newImport": "New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.", "manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.", "batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.", "batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory", "backToParent": "Back to parent directory",
"folders": "[TODO: Translate] Folders", "folders": "Folders",
"folderCount": "[TODO: Translate] {count} folders", "folderCount": "{count} folders",
"imageFiles": "[TODO: Translate] Image Files", "imageFiles": "Image Files",
"images": "[TODO: Translate] images", "images": "images",
"imageCount": "[TODO: Translate] {count} images", "imageCount": "{count} images",
"selectFolder": "[TODO: Translate] Select This Folder", "selectFolder": "Select This Folder",
"errors": { "errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path", "enterUrls": "Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path", "enterDirectory": "Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}" "startFailed": "Failed to start import: {message}"
} }
} }
}, },
@@ -981,6 +1010,14 @@
"save": "Basis-Modell aktualisieren", "save": "Basis-Modell aktualisieren",
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"bulkDownloadMissingLoras": {
"title": "Fehlende LoRAs herunterladen",
"message": "{uniqueCount} einzigartige fehlende LoRAs gefunden (von insgesamt {totalCount} in ausgewählten Rezepten).",
"previewTitle": "Zu herunterladende LoRAs:",
"moreItems": "...und {count} weitere",
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
"downloadButton": "{count} LoRA(s) herunterladen"
},
"exampleAccess": { "exampleAccess": {
"title": "Lokale Beispielbilder", "title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:", "message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -1448,6 +1485,7 @@
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus", "pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek", "versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
"downloadCompleted": "Download erfolgreich abgeschlossen", "downloadCompleted": "Download erfolgreich abgeschlossen",
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen", "autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen", "autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}", "autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
@@ -1495,16 +1533,20 @@
"processingError": "Verarbeitungsfehler: {message}", "processingError": "Verarbeitungsfehler: {message}",
"folderBrowserError": "Fehler beim Laden des Ordner-Browsers: {message}", "folderBrowserError": "Fehler beim Laden des Ordner-Browsers: {message}",
"recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}", "recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Import fehlgeschlagen: {message}", "importFailed": "Import fehlgeschlagen: {message}",
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums", "folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
"folderTreeError": "Fehler beim Laden des Ordnerbaums", "folderTreeError": "Fehler beim Laden des Ordnerbaums",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}", "batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...", "batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt",
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
}, },
"models": { "models": {
"noModelsSelected": "Keine Modelle ausgewählt", "noModelsSelected": "Keine Modelle ausgewählt",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Blur NSFW Content", "blurNsfwContent": "Blur NSFW Content",
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images", "blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
"showOnlySfw": "Show Only SFW Results", "showOnlySfw": "Show Only SFW Results",
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching" "showOnlySfwHelp": "Filter out all NSFW content when browsing and searching",
"matureBlurThreshold": "Mature Blur Threshold",
"matureBlurThresholdHelp": "Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "PG13 and above",
"r": "R and above (default)",
"x": "X and above",
"xxx": "XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Autoplay Videos on Hover", "autoplayOnHover": "Autoplay Videos on Hover",
@@ -315,6 +323,24 @@
"saveFailed": "Unable to save skip paths: {message}" "saveFailed": "Unable to save skip paths: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Skip downloads for base models",
"help": "When a model version uses one of these base models, LoRA Manager will skip the download before any file transfer starts. Applies to all download flows. Only supported base models can be selected here.",
"searchPlaceholder": "Filter base models...",
"empty": "No base models match the current search.",
"summary": {
"none": "None selected",
"count": "{count} selected"
},
"actions": {
"edit": "Edit",
"collapse": "Collapse",
"clear": "Clear"
},
"validation": {
"saveFailed": "Unable to save excluded base models: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Display Density", "displayDensity": "Display Density",
"displayDensityOptions": { "displayDensityOptions": {
@@ -575,6 +601,7 @@
"skipMetadataRefresh": "Skip Metadata Refresh for Selected", "skipMetadataRefresh": "Skip Metadata Refresh for Selected",
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected", "resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
"deleteAll": "Delete Selected Models", "deleteAll": "Delete Selected Models",
"downloadMissingLoras": "Download Missing LoRAs",
"clear": "Clear Selection", "clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)", "skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)", "resumeMetadataRefreshCount": "Resume ({count} models)",
@@ -645,6 +672,8 @@
"root": "Root", "root": "Root",
"browseFolders": "Browse Folders:", "browseFolders": "Browse Folders:",
"downloadAndSaveRecipe": "Download & Save Recipe", "downloadAndSaveRecipe": "Download & Save Recipe",
"importRecipeOnly": "Import Recipe Only",
"importAndDownload": "Import & Download",
"downloadMissingLoras": "Download Missing LoRAs", "downloadMissingLoras": "Download Missing LoRAs",
"saveRecipe": "Save Recipe", "saveRecipe": "Save Recipe",
"loraCountInfo": "({existing}/{total} in library)", "loraCountInfo": "({existing}/{total} in library)",
@@ -981,6 +1010,14 @@
"save": "Update Base Model", "save": "Update Base Model",
"cancel": "Cancel" "cancel": "Cancel"
}, },
"bulkDownloadMissingLoras": {
"title": "Download Missing LoRAs",
"message": "Found {uniqueCount} unique missing LoRAs (from {totalCount} total across selected recipes).",
"previewTitle": "LoRAs to download:",
"moreItems": "...and {count} more",
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
"downloadButton": "Download {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Local Example Images", "title": "Local Example Images",
"message": "No local example images found for this model. View options:", "message": "No local example images found for this model. View options:",
@@ -1448,6 +1485,7 @@
"pleaseSelectVersion": "Please select a version", "pleaseSelectVersion": "Please select a version",
"versionExists": "This version already exists in your library", "versionExists": "This version already exists in your library",
"downloadCompleted": "Download completed successfully", "downloadCompleted": "Download completed successfully",
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}", "autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models", "autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
"autoOrganizeFailed": "Auto-organize failed: {error}", "autoOrganizeFailed": "Auto-organize failed: {error}",
@@ -1495,6 +1533,7 @@
"processingError": "Processing error: {message}", "processingError": "Processing error: {message}",
"folderBrowserError": "Error loading folder browser: {message}", "folderBrowserError": "Error loading folder browser: {message}",
"recipeSaveFailed": "Failed to save recipe: {error}", "recipeSaveFailed": "Failed to save recipe: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Import failed: {message}", "importFailed": "Import failed: {message}",
"folderTreeFailed": "Failed to load folder tree", "folderTreeFailed": "Failed to load folder tree",
"folderTreeError": "Error loading folder tree", "folderTreeError": "Error loading folder tree",
@@ -1504,7 +1543,10 @@
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected",
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
}, },
"models": { "models": {
"noModelsSelected": "No models selected", "noModelsSelected": "No models selected",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Difuminar contenido NSFW", "blurNsfwContent": "Difuminar contenido NSFW",
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)", "blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
"showOnlySfw": "Mostrar solo resultados SFW", "showOnlySfw": "Mostrar solo resultados SFW",
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar" "showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar",
"matureBlurThreshold": "Umbral de difuminado para contenido adulto",
"matureBlurThresholdHelp": "Establecer a partir de qué nivel de clasificación comienza el filtrado por difuminado cuando el difuminado NSFW está habilitado.",
"matureBlurThresholdOptions": {
"pg13": "PG13 y superior",
"r": "R y superior (predeterminado)",
"x": "X y superior",
"xxx": "Solo XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón", "autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
@@ -315,6 +323,24 @@
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}" "saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Omitir descargas para modelos base",
"help": "Se aplica a todos los flujos de descarga. Aquí solo se pueden seleccionar modelos base compatibles.",
"searchPlaceholder": "Filtrar modelos base...",
"empty": "Ningún modelo base coincide con la búsqueda actual.",
"summary": {
"none": "Ninguno seleccionado",
"count": "{count} seleccionados"
},
"actions": {
"edit": "Editar",
"collapse": "Contraer",
"clear": "Limpiar"
},
"validation": {
"saveFailed": "No se pudieron guardar los modelos base excluidos: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Densidad de visualización", "displayDensity": "Densidad de visualización",
"displayDensityOptions": { "displayDensityOptions": {
@@ -575,6 +601,7 @@
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados", "skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados", "resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
"deleteAll": "Eliminar todos los modelos", "deleteAll": "Eliminar todos los modelos",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"clear": "Limpiar selección", "clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos", "skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos", "resumeMetadataRefreshCount": "Reanudar{count} modelos",
@@ -645,6 +672,8 @@
"root": "Raíz", "root": "Raíz",
"browseFolders": "Explorar carpetas:", "browseFolders": "Explorar carpetas:",
"downloadAndSaveRecipe": "Descargar y guardar receta", "downloadAndSaveRecipe": "Descargar y guardar receta",
"importRecipeOnly": "Importar solo la receta",
"importAndDownload": "Importar y descargar",
"downloadMissingLoras": "Descargar LoRAs faltantes", "downloadMissingLoras": "Descargar LoRAs faltantes",
"saveRecipe": "Guardar receta", "saveRecipe": "Guardar receta",
"loraCountInfo": "({existing}/{total} en la biblioteca)", "loraCountInfo": "({existing}/{total} en la biblioteca)",
@@ -732,61 +761,61 @@
} }
}, },
"batchImport": { "batchImport": {
"title": "[TODO: Translate] Batch Import Recipes", "title": "Batch Import Recipes",
"action": "[TODO: Translate] Batch Import", "action": "Batch Import",
"urlList": "[TODO: Translate] URL List", "urlList": "URL List",
"directory": "[TODO: Translate] Directory", "directory": "Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", "urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.", "directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths", "urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line", "urlsHint": "Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path", "directoryPath": "Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder", "directoryPlaceholder": "/path/to/images/folder",
"browse": "[TODO: Translate] Browse", "browse": "Browse",
"recursive": "[TODO: Translate] Include subdirectories", "recursive": "Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)", "tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas", "tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes", "tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata", "skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.", "skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import", "start": "Start Import",
"startImport": "[TODO: Translate] Start Import", "startImport": "Start Import",
"importing": "[TODO: Translate] Importing...", "importing": "Importing...",
"progress": "[TODO: Translate] Progress", "progress": "Progress",
"total": "[TODO: Translate] Total", "total": "Total",
"success": "[TODO: Translate] Success", "success": "Success",
"failed": "[TODO: Translate] Failed", "failed": "Failed",
"skipped": "[TODO: Translate] Skipped", "skipped": "Skipped",
"current": "[TODO: Translate] Current", "current": "Current",
"currentItem": "[TODO: Translate] Current", "currentItem": "Current",
"preparing": "[TODO: Translate] Preparing...", "preparing": "Preparing...",
"cancel": "[TODO: Translate] Cancel", "cancel": "Cancel",
"cancelImport": "[TODO: Translate] Cancel", "cancelImport": "Cancel",
"cancelled": "[TODO: Translate] Import cancelled", "cancelled": "Import cancelled",
"completed": "[TODO: Translate] Import completed", "completed": "Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)", "completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful", "successCount": "Successful",
"failedCount": "[TODO: Translate] Failed", "failedCount": "Failed",
"skippedCount": "[TODO: Translate] Skipped", "skippedCount": "Skipped",
"totalProcessed": "[TODO: Translate] Total processed", "totalProcessed": "Total processed",
"viewDetails": "[TODO: Translate] View Details", "viewDetails": "View Details",
"newImport": "[TODO: Translate] New Import", "newImport": "New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.", "manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.", "batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.", "batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory", "backToParent": "Back to parent directory",
"folders": "[TODO: Translate] Folders", "folders": "Folders",
"folderCount": "[TODO: Translate] {count} folders", "folderCount": "{count} folders",
"imageFiles": "[TODO: Translate] Image Files", "imageFiles": "Image Files",
"images": "[TODO: Translate] images", "images": "images",
"imageCount": "[TODO: Translate] {count} images", "imageCount": "{count} images",
"selectFolder": "[TODO: Translate] Select This Folder", "selectFolder": "Select This Folder",
"errors": { "errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path", "enterUrls": "Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path", "enterDirectory": "Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}" "startFailed": "Failed to start import: {message}"
} }
} }
}, },
@@ -981,6 +1010,14 @@
"save": "Actualizar modelo base", "save": "Actualizar modelo base",
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"bulkDownloadMissingLoras": {
"title": "Descargar LoRAs faltantes",
"message": "Se encontraron {uniqueCount} LoRAs faltantes únicos (de {totalCount} en total entre las recetas seleccionadas).",
"previewTitle": "LoRAs para descargar:",
"moreItems": "...y {count} más",
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
"downloadButton": "Descargar {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Imágenes de ejemplo locales", "title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:", "message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -1448,6 +1485,7 @@
"pleaseSelectVersion": "Por favor selecciona una versión", "pleaseSelectVersion": "Por favor selecciona una versión",
"versionExists": "Esta versión ya existe en tu biblioteca", "versionExists": "Esta versión ya existe en tu biblioteca",
"downloadCompleted": "Descarga completada exitosamente", "downloadCompleted": "Descarga completada exitosamente",
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}", "autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos", "autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
"autoOrganizeFailed": "Auto-organización fallida: {error}", "autoOrganizeFailed": "Auto-organización fallida: {error}",
@@ -1495,16 +1533,20 @@
"processingError": "Error de procesamiento: {message}", "processingError": "Error de procesamiento: {message}",
"folderBrowserError": "Error cargando explorador de carpetas: {message}", "folderBrowserError": "Error cargando explorador de carpetas: {message}",
"recipeSaveFailed": "Error al guardar receta: {error}", "recipeSaveFailed": "Error al guardar receta: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Importación falló: {message}", "importFailed": "Importación falló: {message}",
"folderTreeFailed": "Error al cargar árbol de carpetas", "folderTreeFailed": "Error al cargar árbol de carpetas",
"folderTreeError": "Error cargando árbol de carpetas", "folderTreeError": "Error cargando árbol de carpetas",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}", "batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...", "batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No se han seleccionado recetas",
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
}, },
"models": { "models": {
"noModelsSelected": "No hay modelos seleccionados", "noModelsSelected": "No hay modelos seleccionados",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Flouter le contenu NSFW", "blurNsfwContent": "Flouter le contenu NSFW",
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)", "blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
"showOnlySfw": "Afficher uniquement les résultats SFW", "showOnlySfw": "Afficher uniquement les résultats SFW",
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche" "showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche",
"matureBlurThreshold": "Seuil de floutage pour contenu adulte",
"matureBlurThresholdHelp": "Définir à partir de quel niveau de classification le floutage s'applique lorsque le floutage NSFW est activé.",
"matureBlurThresholdOptions": {
"pg13": "PG13 et plus",
"r": "R et plus (par défaut)",
"x": "X et plus",
"xxx": "XXX uniquement"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Lecture automatique vidéo au survol", "autoplayOnHover": "Lecture automatique vidéo au survol",
@@ -315,6 +323,24 @@
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}" "saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Ignorer les téléchargements pour certains modèles de base",
"help": "Sapplique à tous les flux de téléchargement. Seuls les modèles de base pris en charge peuvent être sélectionnés ici.",
"searchPlaceholder": "Filtrer les modèles de base...",
"empty": "Aucun modèle de base ne correspond à la recherche actuelle.",
"summary": {
"none": "Aucune sélection",
"count": "{count} sélectionnés"
},
"actions": {
"edit": "Modifier",
"collapse": "Réduire",
"clear": "Effacer"
},
"validation": {
"saveFailed": "Impossible denregistrer les modèles de base exclus : {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Densité d'affichage", "displayDensity": "Densité d'affichage",
"displayDensityOptions": { "displayDensityOptions": {
@@ -575,6 +601,7 @@
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection", "skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection", "resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
"deleteAll": "Supprimer tous les modèles", "deleteAll": "Supprimer tous les modèles",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"clear": "Effacer la sélection", "clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles", "skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{count} modèles", "resumeMetadataRefreshCount": "Reprendre{count} modèles",
@@ -645,6 +672,8 @@
"root": "Racine", "root": "Racine",
"browseFolders": "Parcourir les dossiers :", "browseFolders": "Parcourir les dossiers :",
"downloadAndSaveRecipe": "Télécharger et sauvegarder la recipe", "downloadAndSaveRecipe": "Télécharger et sauvegarder la recipe",
"importRecipeOnly": "Importer uniquement la recette",
"importAndDownload": "Importer et télécharger",
"downloadMissingLoras": "Télécharger les LoRAs manquants", "downloadMissingLoras": "Télécharger les LoRAs manquants",
"saveRecipe": "Sauvegarder la recipe", "saveRecipe": "Sauvegarder la recipe",
"loraCountInfo": "({existing}/{total} dans la bibliothèque)", "loraCountInfo": "({existing}/{total} dans la bibliothèque)",
@@ -732,61 +761,61 @@
} }
}, },
"batchImport": { "batchImport": {
"title": "[TODO: Translate] Batch Import Recipes", "title": "Batch Import Recipes",
"action": "[TODO: Translate] Batch Import", "action": "Batch Import",
"urlList": "[TODO: Translate] URL List", "urlList": "URL List",
"directory": "[TODO: Translate] Directory", "directory": "Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", "urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.", "directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths", "urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line", "urlsHint": "Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path", "directoryPath": "Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder", "directoryPlaceholder": "/path/to/images/folder",
"browse": "[TODO: Translate] Browse", "browse": "Browse",
"recursive": "[TODO: Translate] Include subdirectories", "recursive": "Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)", "tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas", "tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes", "tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata", "skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.", "skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import", "start": "Start Import",
"startImport": "[TODO: Translate] Start Import", "startImport": "Start Import",
"importing": "[TODO: Translate] Importing...", "importing": "Importing...",
"progress": "[TODO: Translate] Progress", "progress": "Progress",
"total": "[TODO: Translate] Total", "total": "Total",
"success": "[TODO: Translate] Success", "success": "Success",
"failed": "[TODO: Translate] Failed", "failed": "Failed",
"skipped": "[TODO: Translate] Skipped", "skipped": "Skipped",
"current": "[TODO: Translate] Current", "current": "Current",
"currentItem": "[TODO: Translate] Current", "currentItem": "Current",
"preparing": "[TODO: Translate] Preparing...", "preparing": "Preparing...",
"cancel": "[TODO: Translate] Cancel", "cancel": "Cancel",
"cancelImport": "[TODO: Translate] Cancel", "cancelImport": "Cancel",
"cancelled": "[TODO: Translate] Import cancelled", "cancelled": "Import cancelled",
"completed": "[TODO: Translate] Import completed", "completed": "Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)", "completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful", "successCount": "Successful",
"failedCount": "[TODO: Translate] Failed", "failedCount": "Failed",
"skippedCount": "[TODO: Translate] Skipped", "skippedCount": "Skipped",
"totalProcessed": "[TODO: Translate] Total processed", "totalProcessed": "Total processed",
"viewDetails": "[TODO: Translate] View Details", "viewDetails": "View Details",
"newImport": "[TODO: Translate] New Import", "newImport": "New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.", "manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.", "batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.", "batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory", "backToParent": "Back to parent directory",
"folders": "[TODO: Translate] Folders", "folders": "Folders",
"folderCount": "[TODO: Translate] {count} folders", "folderCount": "{count} folders",
"imageFiles": "[TODO: Translate] Image Files", "imageFiles": "Image Files",
"images": "[TODO: Translate] images", "images": "images",
"imageCount": "[TODO: Translate] {count} images", "imageCount": "{count} images",
"selectFolder": "[TODO: Translate] Select This Folder", "selectFolder": "Select This Folder",
"errors": { "errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path", "enterUrls": "Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path", "enterDirectory": "Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}" "startFailed": "Failed to start import: {message}"
} }
} }
}, },
@@ -981,6 +1010,14 @@
"save": "Mettre à jour le modèle de base", "save": "Mettre à jour le modèle de base",
"cancel": "Annuler" "cancel": "Annuler"
}, },
"bulkDownloadMissingLoras": {
"title": "Télécharger les LoRAs manquants",
"message": "{uniqueCount} LoRAs manquants uniques trouvés (sur un total de {totalCount} dans les recettes sélectionnées).",
"previewTitle": "LoRAs à télécharger :",
"moreItems": "...et {count} de plus",
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
"downloadButton": "Télécharger {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Images d'exemple locales", "title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :", "message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -1448,6 +1485,7 @@
"pleaseSelectVersion": "Veuillez sélectionner une version", "pleaseSelectVersion": "Veuillez sélectionner une version",
"versionExists": "Cette version existe déjà dans votre bibliothèque", "versionExists": "Cette version existe déjà dans votre bibliothèque",
"downloadCompleted": "Téléchargement terminé avec succès", "downloadCompleted": "Téléchargement terminé avec succès",
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}", "autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles", "autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}", "autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
@@ -1495,16 +1533,20 @@
"processingError": "Erreur de traitement : {message}", "processingError": "Erreur de traitement : {message}",
"folderBrowserError": "Erreur lors du chargement du navigateur de dossiers : {message}", "folderBrowserError": "Erreur lors du chargement du navigateur de dossiers : {message}",
"recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}", "recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Échec de l'importation : {message}", "importFailed": "Échec de l'importation : {message}",
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers", "folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers", "folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}", "batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...", "batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Aucune recette sélectionnée",
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
}, },
"models": { "models": {
"noModelsSelected": "Aucun modèle sélectionné", "noModelsSelected": "Aucun modèle sélectionné",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "טשטש תוכן NSFW", "blurNsfwContent": "טשטש תוכן NSFW",
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)", "blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
"showOnlySfw": "הצג רק תוצאות SFW", "showOnlySfw": "הצג רק תוצאות SFW",
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש" "showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש",
"matureBlurThreshold": "סף טשטוש תוכן מבוגרים",
"matureBlurThresholdHelp": "הגדר מאיזו רמת דירוג מתחיל סינון הטשטוש כאשר טשטוש NSFW מופעל.",
"matureBlurThresholdOptions": {
"pg13": "PG13 ומעלה",
"r": "R ומעלה (ברירת מחדל)",
"x": "X ומעלה",
"xxx": "XXX בלבד"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף", "autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
@@ -315,6 +323,24 @@
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}" "saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "דלג על הורדות עבור מודלי בסיס",
"help": "חל על כל תהליכי ההורדה. ניתן לבחור כאן רק מודלי בסיס נתמכים.",
"searchPlaceholder": "סנן מודלי בסיס...",
"empty": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"summary": {
"none": "לא נבחר דבר",
"count": "{count} נבחרו"
},
"actions": {
"edit": "עריכה",
"collapse": "כווץ",
"clear": "נקה"
},
"validation": {
"saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "צפיפות תצוגה", "displayDensity": "צפיפות תצוגה",
"displayDensityOptions": { "displayDensityOptions": {
@@ -575,6 +601,7 @@
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים", "skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים", "resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
"deleteAll": "מחק את כל המודלים", "deleteAll": "מחק את כל המודלים",
"downloadMissingLoras": "הורדת LoRAs חסרים",
"clear": "נקה בחירה", "clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)", "skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)", "resumeMetadataRefreshCount": "המשך({count} מודלים)",
@@ -645,6 +672,8 @@
"root": "שורש", "root": "שורש",
"browseFolders": "דפדף בתיקיות:", "browseFolders": "דפדף בתיקיות:",
"downloadAndSaveRecipe": "הורד ושמור מתכון", "downloadAndSaveRecipe": "הורד ושמור מתכון",
"importRecipeOnly": "יבא רק מתכון",
"importAndDownload": "יבא והורד",
"downloadMissingLoras": "הורד LoRAs חסרים", "downloadMissingLoras": "הורד LoRAs חסרים",
"saveRecipe": "שמור מתכון", "saveRecipe": "שמור מתכון",
"loraCountInfo": "({existing}/{total} בספרייה)", "loraCountInfo": "({existing}/{total} בספרייה)",
@@ -732,61 +761,61 @@
} }
}, },
"batchImport": { "batchImport": {
"title": "[TODO: Translate] Batch Import Recipes", "title": "Batch Import Recipes",
"action": "[TODO: Translate] Batch Import", "action": "Batch Import",
"urlList": "[TODO: Translate] URL List", "urlList": "URL List",
"directory": "[TODO: Translate] Directory", "directory": "Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", "urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.", "directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths", "urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line", "urlsHint": "Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path", "directoryPath": "Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder", "directoryPlaceholder": "/path/to/images/folder",
"browse": "[TODO: Translate] Browse", "browse": "Browse",
"recursive": "[TODO: Translate] Include subdirectories", "recursive": "Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)", "tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas", "tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes", "tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata", "skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.", "skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import", "start": "Start Import",
"startImport": "[TODO: Translate] Start Import", "startImport": "Start Import",
"importing": "[TODO: Translate] Importing...", "importing": "Importing...",
"progress": "[TODO: Translate] Progress", "progress": "Progress",
"total": "[TODO: Translate] Total", "total": "Total",
"success": "[TODO: Translate] Success", "success": "Success",
"failed": "[TODO: Translate] Failed", "failed": "Failed",
"skipped": "[TODO: Translate] Skipped", "skipped": "Skipped",
"current": "[TODO: Translate] Current", "current": "Current",
"currentItem": "[TODO: Translate] Current", "currentItem": "Current",
"preparing": "[TODO: Translate] Preparing...", "preparing": "Preparing...",
"cancel": "[TODO: Translate] Cancel", "cancel": "Cancel",
"cancelImport": "[TODO: Translate] Cancel", "cancelImport": "Cancel",
"cancelled": "[TODO: Translate] Import cancelled", "cancelled": "Import cancelled",
"completed": "[TODO: Translate] Import completed", "completed": "Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)", "completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful", "successCount": "Successful",
"failedCount": "[TODO: Translate] Failed", "failedCount": "Failed",
"skippedCount": "[TODO: Translate] Skipped", "skippedCount": "Skipped",
"totalProcessed": "[TODO: Translate] Total processed", "totalProcessed": "Total processed",
"viewDetails": "[TODO: Translate] View Details", "viewDetails": "View Details",
"newImport": "[TODO: Translate] New Import", "newImport": "New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.", "manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.", "batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.", "batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory", "backToParent": "Back to parent directory",
"folders": "[TODO: Translate] Folders", "folders": "Folders",
"folderCount": "[TODO: Translate] {count} folders", "folderCount": "{count} folders",
"imageFiles": "[TODO: Translate] Image Files", "imageFiles": "Image Files",
"images": "[TODO: Translate] images", "images": "images",
"imageCount": "[TODO: Translate] {count} images", "imageCount": "{count} images",
"selectFolder": "[TODO: Translate] Select This Folder", "selectFolder": "Select This Folder",
"errors": { "errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path", "enterUrls": "Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path", "enterDirectory": "Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}" "startFailed": "Failed to start import: {message}"
} }
} }
}, },
@@ -981,6 +1010,14 @@
"save": "עדכן מודל בסיס", "save": "עדכן מודל בסיס",
"cancel": "ביטול" "cancel": "ביטול"
}, },
"bulkDownloadMissingLoras": {
"title": "הורדת LoRAs חסרים",
"message": "נמצאו {uniqueCount} LoRAs חסרים ייחודיים (מתוך {totalCount} בסך הכל במתכונים שנבחרו).",
"previewTitle": "LoRAs להורדה:",
"moreItems": "...ועוד {count}",
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
"downloadButton": "הורד {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "תמונות דוגמה מקומיות", "title": "תמונות דוגמה מקומיות",
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:", "message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
@@ -1448,6 +1485,7 @@
"pleaseSelectVersion": "אנא בחר גרסה", "pleaseSelectVersion": "אנא בחר גרסה",
"versionExists": "גרסה זו כבר קיימת בספרייה שלך", "versionExists": "גרסה זו כבר קיימת בספרייה שלך",
"downloadCompleted": "ההורדה הושלמה בהצלחה", "downloadCompleted": "ההורדה הושלמה בהצלחה",
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}", "autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים", "autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}", "autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
@@ -1495,16 +1533,20 @@
"processingError": "שגיאת עיבוד: {message}", "processingError": "שגיאת עיבוד: {message}",
"folderBrowserError": "שגיאה בטעינת דפדפן התיקיות: {message}", "folderBrowserError": "שגיאה בטעינת דפדפן התיקיות: {message}",
"recipeSaveFailed": "שמירת המתכון נכשלה: {error}", "recipeSaveFailed": "שמירת המתכון נכשלה: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "הייבוא נכשל: {message}", "importFailed": "הייבוא נכשל: {message}",
"folderTreeFailed": "טעינת עץ התיקיות נכשלה", "folderTreeFailed": "טעינת עץ התיקיות נכשלה",
"folderTreeError": "שגיאה בטעינת עץ התיקיות", "folderTreeError": "שגיאה בטעינת עץ התיקיות",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}", "batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...", "batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "לא נבחרו מתכונים",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
}, },
"models": { "models": {
"noModelsSelected": "לא נבחרו מודלים", "noModelsSelected": "לא נבחרו מודלים",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFWコンテンツをぼかす", "blurNsfwContent": "NSFWコンテンツをぼかす",
"blurNsfwContentHelp": "成人向けNSFWコンテンツのプレビュー画像をぼかします", "blurNsfwContentHelp": "成人向けNSFWコンテンツのプレビュー画像をぼかします",
"showOnlySfw": "SFWコンテンツのみ表示", "showOnlySfw": "SFWコンテンツのみ表示",
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します" "showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します",
"matureBlurThreshold": "成人コンテンツぼかし閾値",
"matureBlurThresholdHelp": "NSFWぼかしが有効な場合、どのレーティングレベルからぼかしフィルタリングを開始するかを設定します。",
"matureBlurThresholdOptions": {
"pg13": "PG13 以上",
"r": "R 以上(デフォルト)",
"x": "X 以上",
"xxx": "XXX のみ"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "ホバー時に動画を自動再生", "autoplayOnHover": "ホバー時に動画を自動再生",
@@ -315,6 +323,24 @@
"saveFailed": "スキップパスの保存に失敗しました:{message}" "saveFailed": "スキップパスの保存に失敗しました:{message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "ベースモデルのダウンロードをスキップ",
"help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。",
"searchPlaceholder": "ベースモデルを絞り込む...",
"empty": "現在の検索に一致するベースモデルはありません。",
"summary": {
"none": "未選択",
"count": "{count} 件を選択"
},
"actions": {
"edit": "編集",
"collapse": "折りたたむ",
"clear": "クリア"
},
"validation": {
"saveFailed": "除外するベースモデルを保存できませんでした: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "表示密度", "displayDensity": "表示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -575,6 +601,7 @@
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ", "skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開", "resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
"deleteAll": "すべてのモデルを削除", "deleteAll": "すべてのモデルを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード",
"clear": "選択をクリア", "clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)", "skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)", "resumeMetadataRefreshCount": "再開({count}モデル)",
@@ -645,6 +672,8 @@
"root": "ルート", "root": "ルート",
"browseFolders": "フォルダを参照:", "browseFolders": "フォルダを参照:",
"downloadAndSaveRecipe": "ダウンロード & レシピ保存", "downloadAndSaveRecipe": "ダウンロード & レシピ保存",
"importRecipeOnly": "レシピのみインポート",
"importAndDownload": "インポートとダウンロード",
"downloadMissingLoras": "不足しているLoRAをダウンロード", "downloadMissingLoras": "不足しているLoRAをダウンロード",
"saveRecipe": "レシピを保存", "saveRecipe": "レシピを保存",
"loraCountInfo": "{existing}/{total} ライブラリ内)", "loraCountInfo": "{existing}/{total} ライブラリ内)",
@@ -732,61 +761,61 @@
} }
}, },
"batchImport": { "batchImport": {
"title": "[TODO: Translate] Batch Import Recipes", "title": "Batch Import Recipes",
"action": "[TODO: Translate] Batch Import", "action": "Batch Import",
"urlList": "[TODO: Translate] URL List", "urlList": "URL List",
"directory": "[TODO: Translate] Directory", "directory": "Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", "urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.", "directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths", "urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line", "urlsHint": "Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path", "directoryPath": "Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder", "directoryPlaceholder": "/path/to/images/folder",
"browse": "[TODO: Translate] Browse", "browse": "Browse",
"recursive": "[TODO: Translate] Include subdirectories", "recursive": "Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)", "tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas", "tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes", "tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata", "skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.", "skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import", "start": "Start Import",
"startImport": "[TODO: Translate] Start Import", "startImport": "Start Import",
"importing": "[TODO: Translate] Importing...", "importing": "Importing...",
"progress": "[TODO: Translate] Progress", "progress": "Progress",
"total": "[TODO: Translate] Total", "total": "Total",
"success": "[TODO: Translate] Success", "success": "Success",
"failed": "[TODO: Translate] Failed", "failed": "Failed",
"skipped": "[TODO: Translate] Skipped", "skipped": "Skipped",
"current": "[TODO: Translate] Current", "current": "Current",
"currentItem": "[TODO: Translate] Current", "currentItem": "Current",
"preparing": "[TODO: Translate] Preparing...", "preparing": "Preparing...",
"cancel": "[TODO: Translate] Cancel", "cancel": "Cancel",
"cancelImport": "[TODO: Translate] Cancel", "cancelImport": "Cancel",
"cancelled": "[TODO: Translate] Import cancelled", "cancelled": "Import cancelled",
"completed": "[TODO: Translate] Import completed", "completed": "Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)", "completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful", "successCount": "Successful",
"failedCount": "[TODO: Translate] Failed", "failedCount": "Failed",
"skippedCount": "[TODO: Translate] Skipped", "skippedCount": "Skipped",
"totalProcessed": "[TODO: Translate] Total processed", "totalProcessed": "Total processed",
"viewDetails": "[TODO: Translate] View Details", "viewDetails": "View Details",
"newImport": "[TODO: Translate] New Import", "newImport": "New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.", "manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.", "batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.", "batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory", "backToParent": "Back to parent directory",
"folders": "[TODO: Translate] Folders", "folders": "Folders",
"folderCount": "[TODO: Translate] {count} folders", "folderCount": "{count} folders",
"imageFiles": "[TODO: Translate] Image Files", "imageFiles": "Image Files",
"images": "[TODO: Translate] images", "images": "images",
"imageCount": "[TODO: Translate] {count} images", "imageCount": "{count} images",
"selectFolder": "[TODO: Translate] Select This Folder", "selectFolder": "Select This Folder",
"errors": { "errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path", "enterUrls": "Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path", "enterDirectory": "Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}" "startFailed": "Failed to start import: {message}"
} }
} }
}, },
@@ -981,6 +1010,14 @@
"save": "ベースモデルを更新", "save": "ベースモデルを更新",
"cancel": "キャンセル" "cancel": "キャンセル"
}, },
"bulkDownloadMissingLoras": {
"title": "不足している LoRA をダウンロード",
"message": "選択したレシピから合計 {totalCount} 個中 {uniqueCount} 個のユニークな不足している LoRA が見つかりました。",
"previewTitle": "ダウンロードする LoRA:",
"moreItems": "...あと {count} 個",
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
"downloadButton": "{count} 個の LoRA をダウンロード"
},
"exampleAccess": { "exampleAccess": {
"title": "ローカル例画像", "title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:", "message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -1448,6 +1485,7 @@
"pleaseSelectVersion": "バージョンを選択してください", "pleaseSelectVersion": "バージョンを選択してください",
"versionExists": "このバージョンは既にライブラリに存在します", "versionExists": "このバージョンは既にライブラリに存在します",
"downloadCompleted": "ダウンロードが正常に完了しました", "downloadCompleted": "ダウンロードが正常に完了しました",
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました", "autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗", "autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
"autoOrganizeFailed": "自動整理に失敗しました:{error}", "autoOrganizeFailed": "自動整理に失敗しました:{error}",
@@ -1495,16 +1533,20 @@
"processingError": "処理エラー:{message}", "processingError": "処理エラー:{message}",
"folderBrowserError": "フォルダブラウザの読み込みエラー:{message}", "folderBrowserError": "フォルダブラウザの読み込みエラー:{message}",
"recipeSaveFailed": "レシピの保存に失敗しました:{error}", "recipeSaveFailed": "レシピの保存に失敗しました:{error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "インポートに失敗しました:{message}", "importFailed": "インポートに失敗しました:{message}",
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました", "folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
"folderTreeError": "フォルダツリー読み込みエラー", "folderTreeError": "フォルダツリー読み込みエラー",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}", "batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...", "batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "レシピが選択されていません",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
}, },
"models": { "models": {
"noModelsSelected": "モデルが選択されていません", "noModelsSelected": "モデルが選択されていません",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFW 콘텐츠 블러 처리", "blurNsfwContent": "NSFW 콘텐츠 블러 처리",
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다", "blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
"showOnlySfw": "SFW 결과만 표시", "showOnlySfw": "SFW 결과만 표시",
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다" "showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다",
"matureBlurThreshold": "성인 콘텐츠 블러 임계값",
"matureBlurThresholdHelp": "NSFW 블러가 활성화될 때 어떤 등급 레벨부터 블러 필터링을 시작할지 설정합니다.",
"matureBlurThresholdOptions": {
"pg13": "PG13 이상",
"r": "R 이상(기본값)",
"x": "X 이상",
"xxx": "XXX만"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "호버 시 비디오 자동 재생", "autoplayOnHover": "호버 시 비디오 자동 재생",
@@ -315,6 +323,24 @@
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}" "saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "기본 모델 다운로드 건너뛰기",
"help": "모든 다운로드 흐름에 적용됩니다. 여기서는 지원되는 기본 모델만 선택할 수 있습니다.",
"searchPlaceholder": "기본 모델 필터링...",
"empty": "현재 검색과 일치하는 기본 모델이 없습니다.",
"summary": {
"none": "선택 없음",
"count": "{count}개 선택됨"
},
"actions": {
"edit": "편집",
"collapse": "접기",
"clear": "지우기"
},
"validation": {
"saveFailed": "제외된 기본 모델을 저장할 수 없습니다: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "표시 밀도", "displayDensity": "표시 밀도",
"displayDensityOptions": { "displayDensityOptions": {
@@ -575,6 +601,7 @@
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기", "skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개", "resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
"deleteAll": "모든 모델 삭제", "deleteAll": "모든 모델 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"clear": "선택 지우기", "clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)", "skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)", "resumeMetadataRefreshCount": "재개({count}개 모델)",
@@ -645,6 +672,8 @@
"root": "루트", "root": "루트",
"browseFolders": "폴더 탐색:", "browseFolders": "폴더 탐색:",
"downloadAndSaveRecipe": "다운로드 및 레시피 저장", "downloadAndSaveRecipe": "다운로드 및 레시피 저장",
"importRecipeOnly": "레시피만 가져오기",
"importAndDownload": "가져오기 및 다운로드",
"downloadMissingLoras": "누락된 LoRA 다운로드", "downloadMissingLoras": "누락된 LoRA 다운로드",
"saveRecipe": "레시피 저장", "saveRecipe": "레시피 저장",
"loraCountInfo": "({existing}/{total} 라이브러리에 있음)", "loraCountInfo": "({existing}/{total} 라이브러리에 있음)",
@@ -732,61 +761,61 @@
} }
}, },
"batchImport": { "batchImport": {
"title": "[TODO: Translate] Batch Import Recipes", "title": "Batch Import Recipes",
"action": "[TODO: Translate] Batch Import", "action": "Batch Import",
"urlList": "[TODO: Translate] URL List", "urlList": "URL List",
"directory": "[TODO: Translate] Directory", "directory": "Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", "urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.", "directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths", "urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line", "urlsHint": "Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path", "directoryPath": "Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder", "directoryPlaceholder": "/path/to/images/folder",
"browse": "[TODO: Translate] Browse", "browse": "Browse",
"recursive": "[TODO: Translate] Include subdirectories", "recursive": "Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)", "tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas", "tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes", "tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata", "skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.", "skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import", "start": "Start Import",
"startImport": "[TODO: Translate] Start Import", "startImport": "Start Import",
"importing": "[TODO: Translate] Importing...", "importing": "Importing...",
"progress": "[TODO: Translate] Progress", "progress": "Progress",
"total": "[TODO: Translate] Total", "total": "Total",
"success": "[TODO: Translate] Success", "success": "Success",
"failed": "[TODO: Translate] Failed", "failed": "Failed",
"skipped": "[TODO: Translate] Skipped", "skipped": "Skipped",
"current": "[TODO: Translate] Current", "current": "Current",
"currentItem": "[TODO: Translate] Current", "currentItem": "Current",
"preparing": "[TODO: Translate] Preparing...", "preparing": "Preparing...",
"cancel": "[TODO: Translate] Cancel", "cancel": "Cancel",
"cancelImport": "[TODO: Translate] Cancel", "cancelImport": "Cancel",
"cancelled": "[TODO: Translate] Import cancelled", "cancelled": "Import cancelled",
"completed": "[TODO: Translate] Import completed", "completed": "Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)", "completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful", "successCount": "Successful",
"failedCount": "[TODO: Translate] Failed", "failedCount": "Failed",
"skippedCount": "[TODO: Translate] Skipped", "skippedCount": "Skipped",
"totalProcessed": "[TODO: Translate] Total processed", "totalProcessed": "Total processed",
"viewDetails": "[TODO: Translate] View Details", "viewDetails": "View Details",
"newImport": "[TODO: Translate] New Import", "newImport": "New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.", "manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.", "batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.", "batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory", "backToParent": "Back to parent directory",
"folders": "[TODO: Translate] Folders", "folders": "Folders",
"folderCount": "[TODO: Translate] {count} folders", "folderCount": "{count} folders",
"imageFiles": "[TODO: Translate] Image Files", "imageFiles": "Image Files",
"images": "[TODO: Translate] images", "images": "images",
"imageCount": "[TODO: Translate] {count} images", "imageCount": "{count} images",
"selectFolder": "[TODO: Translate] Select This Folder", "selectFolder": "Select This Folder",
"errors": { "errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path", "enterUrls": "Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path", "enterDirectory": "Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}" "startFailed": "Failed to start import: {message}"
} }
} }
}, },
@@ -981,6 +1010,14 @@
"save": "베이스 모델 업데이트", "save": "베이스 모델 업데이트",
"cancel": "취소" "cancel": "취소"
}, },
"bulkDownloadMissingLoras": {
"title": "누락된 LoRA 다운로드",
"message": "선택한 레시피에서 총 {totalCount}개 중 {uniqueCount}개의 고유한 누락된 LoRA를 찾았습니다.",
"previewTitle": "다운로드할 LoRA:",
"moreItems": "...그리고 {count}개 더",
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
"downloadButton": "{count}개 LoRA 다운로드"
},
"exampleAccess": { "exampleAccess": {
"title": "로컬 예시 이미지", "title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:", "message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -1448,6 +1485,7 @@
"pleaseSelectVersion": "버전을 선택해주세요", "pleaseSelectVersion": "버전을 선택해주세요",
"versionExists": "이 버전은 이미 라이브러리에 있습니다", "versionExists": "이 버전은 이미 라이브러리에 있습니다",
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다", "downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다", "autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패", "autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
"autoOrganizeFailed": "자동 정리 실패: {error}", "autoOrganizeFailed": "자동 정리 실패: {error}",
@@ -1495,16 +1533,20 @@
"processingError": "처리 오류: {message}", "processingError": "처리 오류: {message}",
"folderBrowserError": "폴더 브라우저 로딩 오류: {message}", "folderBrowserError": "폴더 브라우저 로딩 오류: {message}",
"recipeSaveFailed": "레시피 저장 실패: {error}", "recipeSaveFailed": "레시피 저장 실패: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "가져오기 실패: {message}", "importFailed": "가져오기 실패: {message}",
"folderTreeFailed": "폴더 트리 로딩 실패", "folderTreeFailed": "폴더 트리 로딩 실패",
"folderTreeError": "폴더 트리 로딩 오류", "folderTreeError": "폴더 트리 로딩 오류",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}", "batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...", "batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
}, },
"models": { "models": {
"noModelsSelected": "선택된 모델이 없습니다", "noModelsSelected": "선택된 모델이 없습니다",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Размывать NSFW контент", "blurNsfwContent": "Размывать NSFW контент",
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)", "blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
"showOnlySfw": "Показывать только SFW результаты", "showOnlySfw": "Показывать только SFW результаты",
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске" "showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске",
"matureBlurThreshold": "Порог размытия взрослого контента",
"matureBlurThresholdHelp": "Установить, с какого уровня рейтинга начинается размытие при включенном размытии NSFW.",
"matureBlurThresholdOptions": {
"pg13": "PG13 и выше",
"r": "R и выше (по умолчанию)",
"x": "X и выше",
"xxx": "Только XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Автовоспроизведение видео при наведении", "autoplayOnHover": "Автовоспроизведение видео при наведении",
@@ -315,6 +323,24 @@
"saveFailed": "Не удалось сохранить пути для пропуска: {message}" "saveFailed": "Не удалось сохранить пути для пропуска: {message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "Пропускать загрузки для базовых моделей",
"help": "Применяется ко всем сценариям загрузки. Здесь можно выбрать только поддерживаемые базовые модели.",
"searchPlaceholder": "Фильтровать базовые модели...",
"empty": "Нет базовых моделей, соответствующих текущему поиску.",
"summary": {
"none": "Ничего не выбрано",
"count": "Выбрано: {count}"
},
"actions": {
"edit": "Изменить",
"collapse": "Свернуть",
"clear": "Очистить"
},
"validation": {
"saveFailed": "Не удалось сохранить исключённые базовые модели: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Плотность отображения", "displayDensity": "Плотность отображения",
"displayDensityOptions": { "displayDensityOptions": {
@@ -575,6 +601,7 @@
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных", "skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных", "resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
"deleteAll": "Удалить все модели", "deleteAll": "Удалить все модели",
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
"clear": "Очистить выбор", "clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)", "skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)", "resumeMetadataRefreshCount": "Возобновить({count} моделей)",
@@ -645,6 +672,8 @@
"root": "Корень", "root": "Корень",
"browseFolders": "Обзор папок:", "browseFolders": "Обзор папок:",
"downloadAndSaveRecipe": "Скачать и сохранить рецепт", "downloadAndSaveRecipe": "Скачать и сохранить рецепт",
"importRecipeOnly": "Импортировать только рецепт",
"importAndDownload": "Импорт и скачивание",
"downloadMissingLoras": "Скачать отсутствующие LoRAs", "downloadMissingLoras": "Скачать отсутствующие LoRAs",
"saveRecipe": "Сохранить рецепт", "saveRecipe": "Сохранить рецепт",
"loraCountInfo": "({existing}/{total} в библиотеке)", "loraCountInfo": "({existing}/{total} в библиотеке)",
@@ -732,61 +761,61 @@
} }
}, },
"batchImport": { "batchImport": {
"title": "[TODO: Translate] Batch Import Recipes", "title": "Batch Import Recipes",
"action": "[TODO: Translate] Batch Import", "action": "Batch Import",
"urlList": "[TODO: Translate] URL List", "urlList": "URL List",
"directory": "[TODO: Translate] Directory", "directory": "Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", "urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.", "directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths", "urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line", "urlsHint": "Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path", "directoryPath": "Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder", "directoryPlaceholder": "/path/to/images/folder",
"browse": "[TODO: Translate] Browse", "browse": "Browse",
"recursive": "[TODO: Translate] Include subdirectories", "recursive": "Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)", "tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas", "tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes", "tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata", "skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.", "skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import", "start": "Start Import",
"startImport": "[TODO: Translate] Start Import", "startImport": "Start Import",
"importing": "[TODO: Translate] Importing...", "importing": "Importing...",
"progress": "[TODO: Translate] Progress", "progress": "Progress",
"total": "[TODO: Translate] Total", "total": "Total",
"success": "[TODO: Translate] Success", "success": "Success",
"failed": "[TODO: Translate] Failed", "failed": "Failed",
"skipped": "[TODO: Translate] Skipped", "skipped": "Skipped",
"current": "[TODO: Translate] Current", "current": "Current",
"currentItem": "[TODO: Translate] Current", "currentItem": "Current",
"preparing": "[TODO: Translate] Preparing...", "preparing": "Preparing...",
"cancel": "[TODO: Translate] Cancel", "cancel": "Cancel",
"cancelImport": "[TODO: Translate] Cancel", "cancelImport": "Cancel",
"cancelled": "[TODO: Translate] Import cancelled", "cancelled": "Import cancelled",
"completed": "[TODO: Translate] Import completed", "completed": "Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)", "completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful", "successCount": "Successful",
"failedCount": "[TODO: Translate] Failed", "failedCount": "Failed",
"skippedCount": "[TODO: Translate] Skipped", "skippedCount": "Skipped",
"totalProcessed": "[TODO: Translate] Total processed", "totalProcessed": "Total processed",
"viewDetails": "[TODO: Translate] View Details", "viewDetails": "View Details",
"newImport": "[TODO: Translate] New Import", "newImport": "New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.", "manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.", "batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.", "batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory", "backToParent": "Back to parent directory",
"folders": "[TODO: Translate] Folders", "folders": "Folders",
"folderCount": "[TODO: Translate] {count} folders", "folderCount": "{count} folders",
"imageFiles": "[TODO: Translate] Image Files", "imageFiles": "Image Files",
"images": "[TODO: Translate] images", "images": "images",
"imageCount": "[TODO: Translate] {count} images", "imageCount": "{count} images",
"selectFolder": "[TODO: Translate] Select This Folder", "selectFolder": "Select This Folder",
"errors": { "errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path", "enterUrls": "Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path", "enterDirectory": "Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}" "startFailed": "Failed to start import: {message}"
} }
} }
}, },
@@ -981,6 +1010,14 @@
"save": "Обновить базовую модель", "save": "Обновить базовую модель",
"cancel": "Отмена" "cancel": "Отмена"
}, },
"bulkDownloadMissingLoras": {
"title": "Скачать отсутствующие LoRAs",
"message": "Найдено {uniqueCount} уникальных отсутствующих LoRAs (из {totalCount} всего в выбранных рецептах).",
"previewTitle": "LoRAs для скачивания:",
"moreItems": "...и еще {count}",
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
"downloadButton": "Скачать {count} LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "Локальные примеры изображений", "title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:", "message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -1448,6 +1485,7 @@
"pleaseSelectVersion": "Пожалуйста, выберите версию", "pleaseSelectVersion": "Пожалуйста, выберите версию",
"versionExists": "Эта версия уже существует в вашей библиотеке", "versionExists": "Эта версия уже существует в вашей библиотеке",
"downloadCompleted": "Загрузка успешно завершена", "downloadCompleted": "Загрузка успешно завершена",
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}", "autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей", "autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
"autoOrganizeFailed": "Ошибка автоматической организации: {error}", "autoOrganizeFailed": "Ошибка автоматической организации: {error}",
@@ -1495,16 +1533,20 @@
"processingError": "Ошибка обработки: {message}", "processingError": "Ошибка обработки: {message}",
"folderBrowserError": "Ошибка загрузки браузера папок: {message}", "folderBrowserError": "Ошибка загрузки браузера папок: {message}",
"recipeSaveFailed": "Не удалось сохранить рецепт: {error}", "recipeSaveFailed": "Не удалось сохранить рецепт: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Импорт не удался: {message}", "importFailed": "Импорт не удался: {message}",
"folderTreeFailed": "Не удалось загрузить дерево папок", "folderTreeFailed": "Не удалось загрузить дерево папок",
"folderTreeError": "Ошибка загрузки дерева папок", "folderTreeError": "Ошибка загрузки дерева папок",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}", "batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...", "batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}" "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Рецепты не выбраны",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
}, },
"models": { "models": {
"noModelsSelected": "Модели не выбраны", "noModelsSelected": "Модели не выбраны",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "模糊 NSFW 内容", "blurNsfwContent": "模糊 NSFW 内容",
"blurNsfwContentHelp": "模糊成熟NSFW内容预览图片", "blurNsfwContentHelp": "模糊成熟NSFW内容预览图片",
"showOnlySfw": "仅显示 SFW 结果", "showOnlySfw": "仅显示 SFW 结果",
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容" "showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容",
"matureBlurThreshold": "成人内容模糊阈值",
"matureBlurThresholdHelp": "设置当启用 NSFW 模糊时,从哪个评级级别开始模糊过滤。",
"matureBlurThresholdOptions": {
"pg13": "PG13 及以上",
"r": "R 及以上(默认)",
"x": "X 及以上",
"xxx": "仅 XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "悬停时自动播放视频", "autoplayOnHover": "悬停时自动播放视频",
@@ -315,6 +323,24 @@
"saveFailed": "无法保存跳过路径:{message}" "saveFailed": "无法保存跳过路径:{message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "跳过这些基础模型的下载",
"help": "适用于所有下载流程。这里只能选择受支持的基础模型。",
"searchPlaceholder": "筛选基础模型...",
"empty": "没有与当前搜索匹配的基础模型。",
"summary": {
"none": "未选择",
"count": "已选择 {count} 项"
},
"actions": {
"edit": "编辑",
"collapse": "收起",
"clear": "清空"
},
"validation": {
"saveFailed": "无法保存已排除的基础模型:{message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "显示密度", "displayDensity": "显示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -575,6 +601,7 @@
"skipMetadataRefresh": "跳过所选模型的元数据刷新", "skipMetadataRefresh": "跳过所选模型的元数据刷新",
"resumeMetadataRefresh": "恢复所选模型的元数据刷新", "resumeMetadataRefresh": "恢复所选模型的元数据刷新",
"deleteAll": "删除选中模型", "deleteAll": "删除选中模型",
"downloadMissingLoras": "下载缺失的 LoRAs",
"clear": "清除选择", "clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)", "skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)", "resumeMetadataRefreshCount": "恢复({count} 个模型)",
@@ -645,6 +672,8 @@
"root": "根目录", "root": "根目录",
"browseFolders": "浏览文件夹:", "browseFolders": "浏览文件夹:",
"downloadAndSaveRecipe": "下载并保存配方", "downloadAndSaveRecipe": "下载并保存配方",
"importRecipeOnly": "仅导入配方",
"importAndDownload": "导入并下载",
"downloadMissingLoras": "下载缺失的 LoRA", "downloadMissingLoras": "下载缺失的 LoRA",
"saveRecipe": "保存配方", "saveRecipe": "保存配方",
"loraCountInfo": "({existing}/{total} in library)", "loraCountInfo": "({existing}/{total} in library)",
@@ -734,55 +763,55 @@
"batchImport": { "batchImport": {
"title": "批量导入配方", "title": "批量导入配方",
"action": "批量导入", "action": "批量导入",
"urlList": "[TODO: Translate] URL List", "urlList": "URL 列表",
"directory": "[TODO: Translate] Directory", "directory": "目录",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", "urlDescription": "输入图像 URL 或本地文件路径(每行一个)。每个都将作为配方导入。",
"directoryDescription": "输入目录路径以导入该文件夹中的所有图片。", "directoryDescription": "输入目录路径以导入该文件夹中的所有图片。",
"urlsLabel": "图片 URL 或本地路径", "urlsLabel": "图片 URL 或本地路径",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line", "urlsHint": "每行输入一个 URL 或路径",
"directoryPath": "[TODO: Translate] Directory Path", "directoryPath": "目录路径",
"directoryPlaceholder": "/图片/文件夹/路径", "directoryPlaceholder": "/图片/文件夹/路径",
"browse": "[TODO: Translate] Browse", "browse": "浏览",
"recursive": "[TODO: Translate] Include subdirectories", "recursive": "包含子目录",
"tagsOptional": "标签(可选,应用于所有配方)", "tagsOptional": "标签(可选,应用于所有配方)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas", "tagsPlaceholder": "输入以逗号分隔的标签",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes", "tagsHint": "标签将被添加到所有导入的配方中",
"skipNoMetadata": "跳过无元数据的图片", "skipNoMetadata": "跳过无元数据的图片",
"skipNoMetadataHelp": "没有 LoRA 元数据的图片将自动跳过。", "skipNoMetadataHelp": "没有 LoRA 元数据的图片将自动跳过。",
"start": "[TODO: Translate] Start Import", "start": "开始导入",
"startImport": "开始导入", "startImport": "开始导入",
"importing": "正在导入配方...", "importing": "正在导入配方...",
"progress": "进度", "progress": "进度",
"total": "[TODO: Translate] Total", "total": "总计",
"success": "[TODO: Translate] Success", "success": "成功",
"failed": "[TODO: Translate] Failed", "failed": "失败",
"skipped": "[TODO: Translate] Skipped", "skipped": "跳过",
"current": "[TODO: Translate] Current", "current": "当前",
"currentItem": "当前", "currentItem": "当前",
"preparing": "准备中...", "preparing": "准备中...",
"cancel": "[TODO: Translate] Cancel", "cancel": "取消",
"cancelImport": "取消", "cancelImport": "取消",
"cancelled": "批量导入已取消", "cancelled": "批量导入已取消",
"completed": "导入完成", "completed": "导入完成",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "导入完成但有错误",
"completedSuccess": "成功导入 {count} 个配方", "completedSuccess": "成功导入 {count} 个配方",
"successCount": "成功", "successCount": "成功",
"failedCount": "失败", "failedCount": "失败",
"skippedCount": "跳过", "skippedCount": "跳过",
"totalProcessed": "总计处理", "totalProcessed": "总计处理",
"viewDetails": "[TODO: Translate] View Details", "viewDetails": "查看详情",
"newImport": "[TODO: Translate] New Import", "newImport": "新建导入",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.", "manualPathEntry": "请手动输入目录路径。此浏览器中文件浏览器不可用。",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.", "batchImportDirectorySelected": "已选择目录:{path}",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.", "batchImportManualEntryRequired": "文件浏览器不可用。请手动输入目录路径。",
"backToParent": "[TODO: Translate] Back to parent directory", "backToParent": "返回上级目录",
"folders": "[TODO: Translate] Folders", "folders": "文件夹",
"folderCount": "[TODO: Translate] {count} folders", "folderCount": "{count} 个文件夹",
"imageFiles": "[TODO: Translate] Image Files", "imageFiles": "图像文件",
"images": "[TODO: Translate] images", "images": "图像",
"imageCount": "[TODO: Translate] {count} images", "imageCount": "{count} 个图像",
"selectFolder": "[TODO: Translate] Select This Folder", "selectFolder": "选择此文件夹",
"errors": { "errors": {
"enterUrls": "请至少输入一个 URL 或路径", "enterUrls": "请至少输入一个 URL 或路径",
"enterDirectory": "请输入目录路径", "enterDirectory": "请输入目录路径",
@@ -981,6 +1010,14 @@
"save": "更新基础模型", "save": "更新基础模型",
"cancel": "取消" "cancel": "取消"
}, },
"bulkDownloadMissingLoras": {
"title": "下载缺失的 LoRAs",
"message": "发现 {uniqueCount} 个独特的缺失 LoRAs从选定配方中的 {totalCount} 个总数)。",
"previewTitle": "要下载的 LoRAs",
"moreItems": "...还有 {count} 个",
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
"downloadButton": "下载 {count} 个 LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "本地示例图片", "title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:", "message": "未找到此模型的本地示例图片。可选操作:",
@@ -1448,6 +1485,7 @@
"pleaseSelectVersion": "请选择版本", "pleaseSelectVersion": "请选择版本",
"versionExists": "该版本已存在于你的库中", "versionExists": "该版本已存在于你的库中",
"downloadCompleted": "下载成功完成", "downloadCompleted": "下载成功完成",
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}", "autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型", "autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
"autoOrganizeFailed": "自动整理失败:{error}", "autoOrganizeFailed": "自动整理失败:{error}",
@@ -1495,16 +1533,20 @@
"processingError": "处理出错:{message}", "processingError": "处理出错:{message}",
"folderBrowserError": "加载文件夹浏览器出错:{message}", "folderBrowserError": "加载文件夹浏览器出错:{message}",
"recipeSaveFailed": "保存配方失败:{error}", "recipeSaveFailed": "保存配方失败:{error}",
"recipeSaved": "配方保存成功",
"importFailed": "导入失败:{message}", "importFailed": "导入失败:{message}",
"folderTreeFailed": "加载文件夹树失败", "folderTreeFailed": "加载文件夹树失败",
"folderTreeError": "加载文件夹树出错", "folderTreeError": "加载文件夹树出错",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}", "batchImportFailed": "启动批量导入失败:{message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...", "batchImportCancelling": "正在取消批量导入...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}", "batchImportCancelFailed": "取消批量导入失败:{message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path", "batchImportNoUrls": "请输入至少一个 URL 或文件路径",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path", "batchImportNoDirectory": "请输入目录路径",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}", "batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}" "batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
}, },
"models": { "models": {
"noModelsSelected": "未选中模型", "noModelsSelected": "未选中模型",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "模糊 NSFW 內容", "blurNsfwContent": "模糊 NSFW 內容",
"blurNsfwContentHelp": "模糊成熟NSFW內容預覽圖片", "blurNsfwContentHelp": "模糊成熟NSFW內容預覽圖片",
"showOnlySfw": "僅顯示 SFW 結果", "showOnlySfw": "僅顯示 SFW 結果",
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容" "showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容",
"matureBlurThreshold": "成人內容模糊閾值",
"matureBlurThresholdHelp": "設定當啟用 NSFW 模糊時,從哪個評級級別開始模糊過濾。",
"matureBlurThresholdOptions": {
"pg13": "PG13 及以上",
"r": "R 及以上(預設)",
"x": "X 及以上",
"xxx": "僅 XXX"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "滑鼠懸停自動播放影片", "autoplayOnHover": "滑鼠懸停自動播放影片",
@@ -315,6 +323,24 @@
"saveFailed": "無法儲存跳過路徑:{message}" "saveFailed": "無法儲存跳過路徑:{message}"
} }
}, },
"downloadSkipBaseModels": {
"label": "跳過這些基礎模型的下載",
"help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。",
"searchPlaceholder": "篩選基礎模型...",
"empty": "沒有符合目前搜尋條件的基礎模型。",
"summary": {
"none": "未選擇",
"count": "已選擇 {count} 項"
},
"actions": {
"edit": "編輯",
"collapse": "收起",
"clear": "清空"
},
"validation": {
"saveFailed": "無法儲存已排除的基礎模型:{message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "顯示密度", "displayDensity": "顯示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -575,6 +601,7 @@
"skipMetadataRefresh": "跳過所選模型的元數據更新", "skipMetadataRefresh": "跳過所選模型的元數據更新",
"resumeMetadataRefresh": "恢復所選模型的元數據更新", "resumeMetadataRefresh": "恢復所選模型的元數據更新",
"deleteAll": "刪除全部模型", "deleteAll": "刪除全部模型",
"downloadMissingLoras": "下載缺失的 LoRAs",
"clear": "清除選取", "clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)", "skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)", "resumeMetadataRefreshCount": "恢復({count} 個模型)",
@@ -645,6 +672,8 @@
"root": "根目錄", "root": "根目錄",
"browseFolders": "瀏覽資料夾:", "browseFolders": "瀏覽資料夾:",
"downloadAndSaveRecipe": "下載並儲存配方", "downloadAndSaveRecipe": "下載並儲存配方",
"importRecipeOnly": "僅匯入配方",
"importAndDownload": "匯入並下載",
"downloadMissingLoras": "下載缺少的 LoRA", "downloadMissingLoras": "下載缺少的 LoRA",
"saveRecipe": "儲存配方", "saveRecipe": "儲存配方",
"loraCountInfo": "(庫存 {existing}/{total}", "loraCountInfo": "(庫存 {existing}/{total}",
@@ -732,61 +761,61 @@
} }
}, },
"batchImport": { "batchImport": {
"title": "[TODO: Translate] Batch Import Recipes", "title": "批量匯入配方",
"action": "[TODO: Translate] Batch Import", "action": "批量匯入",
"urlList": "[TODO: Translate] URL List", "urlList": "URL 列表",
"directory": "[TODO: Translate] Directory", "directory": "目錄",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.", "urlDescription": "輸入圖像 URL 或本地檔案路徑(每行一個)。每個都將作為配方匯入。",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.", "directoryDescription": "輸入目錄路徑以匯入該資料夾中的所有圖像。",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths", "urlsLabel": "圖像 URL 或本地路徑",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...", "urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line", "urlsHint": "每行輸入一個 URL 或路徑",
"directoryPath": "[TODO: Translate] Directory Path", "directoryPath": "目錄路徑",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder", "directoryPlaceholder": "/path/to/images/folder",
"browse": "[TODO: Translate] Browse", "browse": "瀏覽",
"recursive": "[TODO: Translate] Include subdirectories", "recursive": "包含子目錄",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)", "tagsOptional": "標籤(可選,應用於所有配方)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas", "tagsPlaceholder": "輸入以逗號分隔的標籤",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes", "tagsHint": "標籤將被添加到所有匯入的配方中",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata", "skipNoMetadata": "跳過無元資料的圖像",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.", "skipNoMetadataHelp": "沒有 LoRA 元資料的圖像將被自動跳過。",
"start": "[TODO: Translate] Start Import", "start": "開始匯入",
"startImport": "[TODO: Translate] Start Import", "startImport": "開始匯入",
"importing": "[TODO: Translate] Importing...", "importing": "匯入中...",
"progress": "[TODO: Translate] Progress", "progress": "進度",
"total": "[TODO: Translate] Total", "total": "總計",
"success": "[TODO: Translate] Success", "success": "成功",
"failed": "[TODO: Translate] Failed", "failed": "失敗",
"skipped": "[TODO: Translate] Skipped", "skipped": "跳過",
"current": "[TODO: Translate] Current", "current": "當前",
"currentItem": "[TODO: Translate] Current", "currentItem": "當前項目",
"preparing": "[TODO: Translate] Preparing...", "preparing": "準備中...",
"cancel": "[TODO: Translate] Cancel", "cancel": "取消",
"cancelImport": "[TODO: Translate] Cancel", "cancelImport": "取消匯入",
"cancelled": "[TODO: Translate] Import cancelled", "cancelled": "匯入已取消",
"completed": "[TODO: Translate] Import completed", "completed": "匯入完成",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "匯入完成但有錯誤",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)", "completedSuccess": "成功匯入 {count} 個配方",
"successCount": "[TODO: Translate] Successful", "successCount": "成功",
"failedCount": "[TODO: Translate] Failed", "failedCount": "失敗",
"skippedCount": "[TODO: Translate] Skipped", "skippedCount": "跳過",
"totalProcessed": "[TODO: Translate] Total processed", "totalProcessed": "總計處理",
"viewDetails": "[TODO: Translate] View Details", "viewDetails": "查看詳情",
"newImport": "[TODO: Translate] New Import", "newImport": "新建匯入",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.", "manualPathEntry": "請手動輸入目錄路徑。此瀏覽器中檔案瀏覽器不可用。",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.", "batchImportDirectorySelected": "已選擇目錄:{path}",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.", "batchImportManualEntryRequired": "檔案瀏覽器不可用。請手動輸入目錄路徑。",
"backToParent": "[TODO: Translate] Back to parent directory", "backToParent": "返回上級目錄",
"folders": "[TODO: Translate] Folders", "folders": "資料夾",
"folderCount": "[TODO: Translate] {count} folders", "folderCount": "{count} 個資料夾",
"imageFiles": "[TODO: Translate] Image Files", "imageFiles": "圖像檔案",
"images": "[TODO: Translate] images", "images": "圖像",
"imageCount": "[TODO: Translate] {count} images", "imageCount": "{count} 個圖像",
"selectFolder": "[TODO: Translate] Select This Folder", "selectFolder": "選擇此資料夾",
"errors": { "errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path", "enterUrls": "請輸入至少一個 URL 或路徑",
"enterDirectory": "[TODO: Translate] Please enter a directory path", "enterDirectory": "請輸入目錄路徑",
"startFailed": "[TODO: Translate] Failed to start import: {message}" "startFailed": "啟動匯入失敗:{message}"
} }
} }
}, },
@@ -981,6 +1010,14 @@
"save": "更新基礎模型", "save": "更新基礎模型",
"cancel": "取消" "cancel": "取消"
}, },
"bulkDownloadMissingLoras": {
"title": "下載缺失的 LoRAs",
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs從選取食譜中的 {totalCount} 個總數)。",
"previewTitle": "要下載的 LoRAs",
"moreItems": "...還有 {count} 個",
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
"downloadButton": "下載 {count} 個 LoRA(s)"
},
"exampleAccess": { "exampleAccess": {
"title": "本機範例圖片", "title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:", "message": "此模型未找到本機範例圖片。可選擇:",
@@ -1448,6 +1485,7 @@
"pleaseSelectVersion": "請選擇一個版本", "pleaseSelectVersion": "請選擇一個版本",
"versionExists": "此版本已存在於您的庫中", "versionExists": "此版本已存在於您的庫中",
"downloadCompleted": "下載成功完成", "downloadCompleted": "下載成功完成",
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理", "autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型", "autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
"autoOrganizeFailed": "自動整理失敗:{error}", "autoOrganizeFailed": "自動整理失敗:{error}",
@@ -1495,16 +1533,20 @@
"processingError": "處理錯誤:{message}", "processingError": "處理錯誤:{message}",
"folderBrowserError": "載入資料夾瀏覽器錯誤:{message}", "folderBrowserError": "載入資料夾瀏覽器錯誤:{message}",
"recipeSaveFailed": "儲存配方失敗:{error}", "recipeSaveFailed": "儲存配方失敗:{error}",
"recipeSaved": "配方儲存成功",
"importFailed": "匯入失敗:{message}", "importFailed": "匯入失敗:{message}",
"folderTreeFailed": "載入資料夾樹狀結構失敗", "folderTreeFailed": "載入資料夾樹狀結構失敗",
"folderTreeError": "載入資料夾樹狀結構錯誤", "folderTreeError": "載入資料夾樹狀結構錯誤",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}", "batchImportFailed": "啟動批量匯入失敗:{message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...", "batchImportCancelling": "正在取消批量匯入...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}", "batchImportCancelFailed": "取消批量匯入失敗:{message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path", "batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path", "batchImportNoDirectory": "請輸入目錄路徑",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}", "batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}" "batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何食譜",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
}, },
"models": { "models": {
"noModelsSelected": "未選擇模型", "noModelsSelected": "未選擇模型",

3
package-lock.json generated
View File

@@ -114,7 +114,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -138,7 +137,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -1613,7 +1611,6 @@
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssstyle": "^4.0.1", "cssstyle": "^4.0.1",
"data-urls": "^5.0.0", "data-urls": "^5.0.0",

View File

@@ -149,9 +149,12 @@ class MetadataHook:
# Store the original _async_map_node_over_list function # Store the original _async_map_node_over_list function
original_map_node_over_list = getattr(execution, map_node_func_name) original_map_node_over_list = getattr(execution, map_node_func_name)
# Wrapped async function, compatible with both stable and nightly # Wrapped async function - signature must exactly match _async_map_node_over_list
async def async_map_node_over_list_with_metadata(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt=False, execution_block_cb=None, pre_execute_cb=None, *args, **kwargs): async def async_map_node_over_list_with_metadata(
hidden_inputs = kwargs.get('hidden_inputs', None) prompt_id, unique_id, obj, input_data_all, func,
allow_interrupt=False, execution_block_cb=None,
pre_execute_cb=None, v3_data=None
):
# Only collect metadata when calling the main function of nodes # Only collect metadata when calling the main function of nodes
if func == obj.FUNCTION and hasattr(obj, '__class__'): if func == obj.FUNCTION and hasattr(obj, '__class__'):
try: try:
@@ -164,10 +167,10 @@ class MetadataHook:
except Exception as e: except Exception as e:
logger.error(f"Error collecting metadata (pre-execution): {str(e)}") logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
# Call original function with all args/kwargs # Call original function with exact parameters
results = await original_map_node_over_list( results = await original_map_node_over_list(
prompt_id, unique_id, obj, input_data_all, func, prompt_id, unique_id, obj, input_data_all, func,
allow_interrupt, execution_block_cb, pre_execute_cb, *args, **kwargs allow_interrupt, execution_block_cb, pre_execute_cb, v3_data=v3_data
) )
if func == obj.FUNCTION and hasattr(obj, '__class__'): if func == obj.FUNCTION and hasattr(obj, '__class__'):

View File

@@ -1,8 +1,7 @@
import logging import logging
import os
from typing import List, Tuple from typing import List, Tuple
import comfy.sd import comfy.sd # type: ignore
import folder_paths import folder_paths # type: ignore
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -56,6 +56,9 @@ class LoraCyclerLM:
clip_strength = float(cycler_config.get("clip_strength", 1.0)) clip_strength = float(cycler_config.get("clip_strength", 1.0))
sort_by = "filename" sort_by = "filename"
# Include "no lora" option
include_no_lora = cycler_config.get("include_no_lora", False)
# Dual-index mechanism for batch queue synchronization # Dual-index mechanism for batch queue synchronization
execution_index = cycler_config.get("execution_index") # Can be None execution_index = cycler_config.get("execution_index") # Can be None
# next_index_from_config = cycler_config.get("next_index") # Not used on backend # next_index_from_config = cycler_config.get("next_index") # Not used on backend
@@ -71,7 +74,10 @@ class LoraCyclerLM:
total_count = len(lora_list) total_count = len(lora_list)
if total_count == 0: # Calculate effective total count (includes no lora option if enabled)
effective_total_count = total_count + 1 if include_no_lora else total_count
if total_count == 0 and not include_no_lora:
logger.warning("[LoraCyclerLM] No LoRAs available in pool") logger.warning("[LoraCyclerLM] No LoRAs available in pool")
return { return {
"result": ([],), "result": ([],),
@@ -93,15 +99,31 @@ class LoraCyclerLM:
else: else:
actual_index = current_index actual_index = current_index
# Clamp index to valid range (1-based) # Clamp index to valid range (1-based, includes no lora if enabled)
clamped_index = max(1, min(actual_index, total_count)) clamped_index = max(1, min(actual_index, effective_total_count))
# Check if current index is the "no lora" option (last position when include_no_lora is True)
is_no_lora = include_no_lora and clamped_index == effective_total_count
if is_no_lora:
# "No LoRA" option - return empty stack
lora_stack = []
current_lora_name = "No LoRA"
current_lora_filename = "No LoRA"
else:
# Get LoRA at current index (convert to 0-based for list access) # Get LoRA at current index (convert to 0-based for list access)
current_lora = lora_list[clamped_index - 1] current_lora = lora_list[clamped_index - 1]
current_lora_name = current_lora["file_name"]
current_lora_filename = current_lora["file_name"]
# Build LORA_STACK with single LoRA # Build LORA_STACK with single LoRA
if current_lora["file_name"] == "None":
lora_path = None
else:
lora_path, _ = get_lora_info(current_lora["file_name"]) lora_path, _ = get_lora_info(current_lora["file_name"])
if not lora_path: if not lora_path:
if current_lora["file_name"] != "None":
logger.warning( logger.warning(
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}" f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
) )
@@ -113,22 +135,30 @@ class LoraCyclerLM:
# Calculate next index (wrap to 1 if at end) # Calculate next index (wrap to 1 if at end)
next_index = clamped_index + 1 next_index = clamped_index + 1
if next_index > total_count: if next_index > effective_total_count:
next_index = 1 next_index = 1
# Get next LoRA for UI display (what will be used next generation) # Get next LoRA for UI display (what will be used next generation)
is_next_no_lora = include_no_lora and next_index == effective_total_count
if is_next_no_lora:
next_display_name = "No LoRA"
next_lora_filename = "No LoRA"
else:
next_lora = lora_list[next_index - 1] next_lora = lora_list[next_index - 1]
next_display_name = next_lora["file_name"] next_display_name = next_lora["file_name"]
next_lora_filename = next_lora["file_name"]
return { return {
"result": (lora_stack,), "result": (lora_stack,),
"ui": { "ui": {
"current_index": [clamped_index], "current_index": [clamped_index],
"next_index": [next_index], "next_index": [next_index],
"total_count": [total_count], "total_count": [
"current_lora_name": [current_lora["file_name"]], total_count
"current_lora_filename": [current_lora["file_name"]], ], # Return actual LoRA count, not effective_total_count
"current_lora_name": [current_lora_name],
"current_lora_filename": [current_lora_filename],
"next_lora_name": [next_display_name], "next_lora_name": [next_display_name],
"next_lora_filename": [next_lora["file_name"]], "next_lora_filename": [next_lora_filename],
}, },
} }

View File

@@ -82,6 +82,7 @@ class LoraPoolLM:
"folders": {"include": [], "exclude": []}, "folders": {"include": [], "exclude": []},
"favoritesOnly": False, "favoritesOnly": False,
"license": {"noCreditRequired": False, "allowSelling": False}, "license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": [], "exclude": [], "useRegex": False},
}, },
"preview": {"matchCount": 0, "lastUpdated": 0}, "preview": {"matchCount": 0, "lastUpdated": 0},
} }

View File

@@ -7,10 +7,8 @@ and tracks the last used combination for reuse.
""" """
import logging import logging
import random
import os import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info
from .utils import extract_lora_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -1,8 +1,7 @@
import logging import logging
import os import os
from typing import List, Tuple from typing import List, Tuple
import torch import comfy.sd # type: ignore
import comfy.sd
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -101,6 +100,8 @@ class UNETLoaderLM:
Returns: Returns:
Tuple of (MODEL,) Tuple of (MODEL,)
""" """
import torch
# Get absolute path from cache using ComfyUI-style name # Get absolute path from cache using ComfyUI-style name
unet_path, metadata = get_checkpoint_info_absolute(unet_name) unet_path, metadata = get_checkpoint_info_absolute(unet_name)
@@ -143,6 +144,7 @@ class UNETLoaderLM:
Returns: Returns:
Tuple of (MODEL,) Tuple of (MODEL,)
""" """
import torch
from .gguf_import_helper import get_gguf_modules from .gguf_import_helper import get_gguf_modules
# Get ComfyUI-GGUF modules using helper (handles various import scenarios) # Get ComfyUI-GGUF modules using helper (handles various import scenarios)

View File

@@ -7,6 +7,7 @@ from .parsers import (
MetaFormatParser, MetaFormatParser,
AutomaticMetadataParser, AutomaticMetadataParser,
CivitaiApiMetadataParser, CivitaiApiMetadataParser,
SuiImageParamsParser,
) )
from .base import RecipeMetadataParser from .base import RecipeMetadataParser
@@ -55,6 +56,13 @@ class RecipeParserFactory:
# If JSON parsing fails, move on to other parsers # If JSON parsing fails, move on to other parsers
pass pass
# Try SuiImageParamsParser for SuiImage metadata format
try:
if SuiImageParamsParser().is_metadata_matching(metadata_str):
return SuiImageParamsParser()
except Exception:
pass
# Check other parsers that expect string input # Check other parsers that expect string input
if RecipeFormatParser().is_metadata_matching(metadata_str): if RecipeFormatParser().is_metadata_matching(metadata_str):
return RecipeFormatParser() return RecipeFormatParser()

View File

@@ -5,6 +5,7 @@ from .comfy import ComfyMetadataParser
from .meta_format import MetaFormatParser from .meta_format import MetaFormatParser
from .automatic import AutomaticMetadataParser from .automatic import AutomaticMetadataParser
from .civitai_image import CivitaiApiMetadataParser from .civitai_image import CivitaiApiMetadataParser
from .sui_image_params import SuiImageParamsParser
__all__ = [ __all__ = [
'RecipeFormatParser', 'RecipeFormatParser',
@@ -12,4 +13,5 @@ __all__ = [
'MetaFormatParser', 'MetaFormatParser',
'AutomaticMetadataParser', 'AutomaticMetadataParser',
'CivitaiApiMetadataParser', 'CivitaiApiMetadataParser',
'SuiImageParamsParser',
] ]

View File

@@ -0,0 +1,188 @@
"""Parser for SuiImage (Stable Diffusion WebUI) metadata format."""
import json
import logging
from typing import Dict, Any, Optional, List
from ..base import RecipeMetadataParser
from ...services.metadata_service import get_default_metadata_provider
logger = logging.getLogger(__name__)
class SuiImageParamsParser(RecipeMetadataParser):
"""Parser for SuiImage metadata JSON format.
This format is used by some Stable Diffusion WebUI variants.
Structure:
{
"sui_image_params": {
"prompt": "...",
"negativeprompt": "...",
"model": "...",
"seed": ...,
"steps": ...,
...
},
"sui_models": [
{"name": "...", "param": "model", "hash": "..."},
...
],
"sui_extra_data": {...}
}
"""
def is_metadata_matching(self, user_comment: str) -> bool:
"""Check if the user comment matches the SuiImage metadata format"""
try:
data = json.loads(user_comment)
return isinstance(data, dict) and 'sui_image_params' in data
except (json.JSONDecodeError, TypeError):
return False
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
"""Parse metadata from SuiImage metadata format"""
try:
metadata_provider = await get_default_metadata_provider()
data = json.loads(user_comment)
params = data.get('sui_image_params', {})
models = data.get('sui_models', [])
# Extract prompt and negative prompt
prompt = params.get('prompt', '')
negative_prompt = params.get('negativeprompt', '') or params.get('negative_prompt', '')
# Extract generation parameters
gen_params = {}
if prompt:
gen_params['prompt'] = prompt
if negative_prompt:
gen_params['negative_prompt'] = negative_prompt
# Map standard parameters
param_mapping = {
'steps': 'steps',
'seed': 'seed',
'cfgscale': 'cfg_scale',
'cfg_scale': 'cfg_scale',
'width': 'width',
'height': 'height',
'sampler': 'sampler',
'scheduler': 'scheduler',
'model': 'model',
'vae': 'vae',
}
for src_key, dest_key in param_mapping.items():
if src_key in params and params[src_key] is not None:
gen_params[dest_key] = params[src_key]
# Add size info if available
if 'width' in gen_params and 'height' in gen_params:
gen_params['size'] = f"{gen_params['width']}x{gen_params['height']}"
# Process models - extract checkpoint and loras
loras: List[Dict[str, Any]] = []
checkpoint: Optional[Dict[str, Any]] = None
for model in models:
model_name = model.get('name', '')
param_type = model.get('param', '')
model_hash = model.get('hash', '')
# Remove .safetensors extension for cleaner name
clean_name = model_name.replace('.safetensors', '') if model_name else ''
# Check if this is a LoRA by looking at the name or param type
is_lora = 'lora' in model_name.lower() or param_type.lower().startswith('lora')
if is_lora:
lora_entry = {
'id': 0,
'modelId': 0,
'name': clean_name,
'version': '',
'type': 'lora',
'weight': 1.0,
'existsLocally': False,
'localPath': None,
'file_name': model_name,
'hash': model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Try to get additional info from metadata provider
if metadata_provider and model_hash:
try:
civitai_info = await metadata_provider.get_model_by_hash(
model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash
)
if civitai_info:
lora_entry = await self.populate_lora_from_civitai(
lora_entry, civitai_info, recipe_scanner
)
except Exception as e:
logger.debug(f"Error fetching info for LoRA {clean_name}: {e}")
if lora_entry:
loras.append(lora_entry)
elif param_type == 'model' or 'lora' not in model_name.lower():
# This is likely a checkpoint
checkpoint_entry = {
'id': 0,
'modelId': 0,
'name': clean_name,
'version': '',
'type': 'checkpoint',
'hash': model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash,
'existsLocally': False,
'localPath': None,
'file_name': model_name,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Try to get additional info from metadata provider
if metadata_provider and model_hash:
try:
civitai_info = await metadata_provider.get_model_by_hash(
model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash
)
if civitai_info:
checkpoint_entry = await self.populate_checkpoint_from_civitai(
checkpoint_entry, civitai_info
)
except Exception as e:
logger.debug(f"Error fetching info for checkpoint {clean_name}: {e}")
checkpoint = checkpoint_entry
# Determine base model from loras or checkpoint
base_model = None
if loras:
base_models = [lora.get('baseModel') for lora in loras if lora.get('baseModel')]
if base_models:
from collections import Counter
base_model_counts = Counter(base_models)
base_model = base_model_counts.most_common(1)[0][0]
elif checkpoint and checkpoint.get('baseModel'):
base_model = checkpoint['baseModel']
return {
'base_model': base_model,
'loras': loras,
'checkpoint': checkpoint,
'gen_params': gen_params,
'from_sui_image_params': True
}
except Exception as e:
logger.error(f"Error parsing SuiImage metadata: {e}", exc_info=True)
return {"error": str(e), "loras": []}

View File

@@ -309,6 +309,13 @@ class ModelListingHandler:
else: else:
allow_selling_generated_content = None # None means no filter applied allow_selling_generated_content = None # None means no filter applied
# Name pattern filters for LoRA Pool
name_pattern_include = request.query.getall("name_pattern_include", [])
name_pattern_exclude = request.query.getall("name_pattern_exclude", [])
name_pattern_use_regex = (
request.query.get("name_pattern_use_regex", "false").lower() == "true"
)
return { return {
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -328,6 +335,9 @@ class ModelListingHandler:
"credit_required": credit_required, "credit_required": credit_required,
"allow_selling_generated_content": allow_selling_generated_content, "allow_selling_generated_content": allow_selling_generated_content,
"model_types": model_types, "model_types": model_types,
"name_pattern_include": name_pattern_include,
"name_pattern_exclude": name_pattern_exclude,
"name_pattern_use_regex": name_pattern_use_regex,
**self._parse_specific_params(request), **self._parse_specific_params(request),
} }

View File

@@ -490,14 +490,33 @@ class CivitaiClient:
""" """
try: try:
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X" url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
requested_id = int(image_id)
logger.debug(f"Fetching image info for ID: {image_id}") logger.debug(f"Fetching image info for ID: {image_id}")
success, result = await self._make_request("GET", url, use_auth=True) success, result = await self._make_request("GET", url, use_auth=True)
if success: if success:
if result and "items" in result and len(result["items"]) > 0: if result and "items" in result and isinstance(result["items"], list):
items = result["items"]
# First, try to find the item with matching ID
for item in items:
if isinstance(item, dict) and item.get("id") == requested_id:
logger.debug(f"Successfully fetched image info for ID: {image_id}") logger.debug(f"Successfully fetched image info for ID: {image_id}")
return result["items"][0] return item
# No matching ID found - log warning with details about returned items
returned_ids = [
item.get("id") for item in items
if isinstance(item, dict) and "id" in item
]
logger.warning(
f"CivitAI API returned no matching image for requested ID {image_id}. "
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
f"This may indicate the image was deleted, hidden, or there is a database lag."
)
return None
logger.warning(f"No image found with ID: {image_id}") logger.warning(f"No image found with ID: {image_id}")
return None return None
@@ -505,6 +524,10 @@ class CivitaiClient:
return None return None
except RateLimitError: except RateLimitError:
raise raise
except ValueError as e:
error_msg = f"Invalid image ID format: {image_id}"
logger.error(error_msg)
return None
except Exception as e: except Exception as e:
error_msg = f"Error fetching image info: {e}" error_msg = f"Error fetching image info: {e}"
logger.error(error_msg) logger.error(error_msg)

View File

@@ -13,13 +13,13 @@ from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import ( from ..utils.constants import (
CARD_PREVIEW_WIDTH, CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS, DIFFUSION_MODEL_BASE_MODELS,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.file_utils import calculate_sha256
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from .service_registry import ServiceRegistry from .service_registry import ServiceRegistry
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
@@ -229,7 +229,9 @@ class DownloadManager:
# Update status based on result # Update status based on result
if task_id in self._active_downloads: if task_id in self._active_downloads:
self._active_downloads[task_id]["status"] = ( self._active_downloads[task_id]["status"] = (
"completed" if result["success"] else "failed" result.get("status", "completed")
if result["success"]
else "failed"
) )
if not result["success"]: if not result["success"]:
self._active_downloads[task_id]["error"] = result.get( self._active_downloads[task_id]["error"] = result.get(
@@ -353,10 +355,54 @@ class DownloadManager:
"error": f'Model type "{model_type_from_info}" is not supported for download', "error": f'Model type "{model_type_from_info}" is not supported for download',
} }
excluded_base_models = get_settings_manager().get_download_skip_base_models()
base_model_value = version_info.get("baseModel", "")
if (
isinstance(base_model_value, str)
and base_model_value in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS
and base_model_value in excluded_base_models
):
file_name = ""
files = version_info.get("files")
if isinstance(files, list):
primary_file = next(
(
file_info
for file_info in files
if isinstance(file_info, dict) and file_info.get("primary")
),
None,
)
selected_file = primary_file
if selected_file is None:
selected_file = next(
(file_info for file_info in files if isinstance(file_info, dict)),
None,
)
if isinstance(selected_file, dict):
raw_file_name = selected_file.get("name", "")
if isinstance(raw_file_name, str):
file_name = raw_file_name.strip()
message = (
f"Skipped download for '{file_name or version_info.get('name') or f'model_version:{model_version_id or model_id}'}' "
f"because base model '{base_model_value}' is excluded in settings"
)
logger.info(message)
return {
"success": True,
"skipped": True,
"status": "skipped",
"reason": "base_model_excluded",
"message": message,
"base_model": base_model_value,
"file_name": file_name,
"download_id": download_id,
}
# Check if this checkpoint should be treated as a diffusion model based on baseModel # Check if this checkpoint should be treated as a diffusion model based on baseModel
is_diffusion_model = False is_diffusion_model = False
if model_type == "checkpoint": if model_type == "checkpoint":
base_model_value = version_info.get("baseModel", "")
if base_model_value in DIFFUSION_MODEL_BASE_MODELS: if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True is_diffusion_model = True
logger.info( logger.info(
@@ -847,9 +893,13 @@ class DownloadManager:
blur_mature_content = bool( blur_mature_content = bool(
settings_manager.get("blur_mature_content", True) settings_manager.get("blur_mature_content", True)
) )
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
)
selected_image, nsfw_level = select_preview_media( selected_image, nsfw_level = select_preview_media(
images, images,
blur_mature_content=blur_mature_content, blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
) )
preview_url = selected_image.get("url") if selected_image else None preview_url = selected_image.get("url") if selected_image else None
@@ -965,11 +1015,12 @@ class DownloadManager:
for download_url in download_urls: for download_url in download_urls:
use_auth = download_url.startswith("https://civitai.com/api/download/") use_auth = download_url.startswith("https://civitai.com/api/download/")
download_kwargs = { download_kwargs = {
"progress_callback": lambda progress, "progress_callback": lambda progress, snapshot=None: (
snapshot=None: self._handle_download_progress( self._handle_download_progress(
progress, progress,
progress_callback, progress_callback,
snapshot, snapshot,
)
), ),
"use_auth": use_auth, # Only use authentication for Civitai downloads "use_auth": use_auth, # Only use authentication for Civitai downloads
} }
@@ -1238,7 +1289,8 @@ class DownloadManager:
entry.file_name = os.path.splitext(os.path.basename(file_path))[0] entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
# Update size to actual downloaded file size # Update size to actual downloaded file size
entry.size = os.path.getsize(file_path) entry.size = os.path.getsize(file_path)
entry.sha256 = await calculate_sha256(file_path) # Use SHA256 from API metadata (already set in from_civitai_info)
# Do not recalculate to avoid blocking during ComfyUI execution
entries.append(entry) entries.append(entry)
return entries return entries

View File

@@ -44,7 +44,9 @@ class DownloadStreamControl:
self._event.set() self._event.set()
self._reconnect_requested = False self._reconnect_requested = False
self.last_progress_timestamp: Optional[float] = None self.last_progress_timestamp: Optional[float] = None
self.stall_timeout: float = float(stall_timeout) if stall_timeout is not None else 120.0 self.stall_timeout: float = (
float(stall_timeout) if stall_timeout is not None else 120.0
)
def is_set(self) -> bool: def is_set(self) -> bool:
return self._event.is_set() return self._event.is_set()
@@ -85,7 +87,9 @@ class DownloadStreamControl:
self.last_progress_timestamp = timestamp or datetime.now().timestamp() self.last_progress_timestamp = timestamp or datetime.now().timestamp()
self._reconnect_requested = False self._reconnect_requested = False
def time_since_last_progress(self, *, now: Optional[float] = None) -> Optional[float]: def time_since_last_progress(
self, *, now: Optional[float] = None
) -> Optional[float]:
if self.last_progress_timestamp is None: if self.last_progress_timestamp is None:
return None return None
reference = now if now is not None else datetime.now().timestamp() reference = now if now is not None else datetime.now().timestamp()
@@ -120,7 +124,7 @@ class Downloader:
def __init__(self): def __init__(self):
"""Initialize the downloader with optimal settings""" """Initialize the downloader with optimal settings"""
# Check if already initialized for singleton pattern # Check if already initialized for singleton pattern
if hasattr(self, '_initialized'): if hasattr(self, "_initialized"):
return return
self._initialized = True self._initialized = True
@@ -131,7 +135,9 @@ class Downloader:
self._session_lock = asyncio.Lock() self._session_lock = asyncio.Lock()
# Configuration # Configuration
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput self.chunk_size = (
16 * 1024 * 1024
) # 16MB chunks to balance I/O reduction and memory usage
self.max_retries = 5 self.max_retries = 5
self.base_delay = 2.0 # Base delay for exponential backoff self.base_delay = 2.0 # Base delay for exponential backoff
self.session_timeout = 300 # 5 minutes self.session_timeout = 300 # 5 minutes
@@ -139,10 +145,10 @@ class Downloader:
# Default headers # Default headers
self.default_headers = { self.default_headers = {
'User-Agent': 'ComfyUI-LoRA-Manager/1.0', "User-Agent": "ComfyUI-LoRA-Manager/1.0",
# Explicitly request uncompressed payloads so aiohttp doesn't need optional # Explicitly request uncompressed payloads so aiohttp doesn't need optional
# decoders (e.g. zstandard) that may be missing in runtime environments. # decoders (e.g. zstandard) that may be missing in runtime environments.
'Accept-Encoding': 'identity', "Accept-Encoding": "identity",
} }
@property @property
@@ -158,7 +164,7 @@ class Downloader:
@property @property
def proxy_url(self) -> Optional[str]: def proxy_url(self) -> Optional[str]:
"""Get the current proxy URL (initialize if needed)""" """Get the current proxy URL (initialize if needed)"""
if not hasattr(self, '_proxy_url'): if not hasattr(self, "_proxy_url"):
self._proxy_url = None self._proxy_url = None
return self._proxy_url return self._proxy_url
@@ -169,14 +175,14 @@ class Downloader:
try: try:
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
settings_timeout = settings_manager.get('download_stall_timeout_seconds') settings_timeout = settings_manager.get("download_stall_timeout_seconds")
except Exception as exc: # pragma: no cover - defensive guard except Exception as exc: # pragma: no cover - defensive guard
logger.debug("Failed to read stall timeout from settings: %s", exc) logger.debug("Failed to read stall timeout from settings: %s", exc)
raw_value = ( raw_value = (
settings_timeout settings_timeout
if settings_timeout not in (None, "") if settings_timeout not in (None, "")
else os.environ.get('COMFYUI_DOWNLOAD_STALL_TIMEOUT') else os.environ.get("COMFYUI_DOWNLOAD_STALL_TIMEOUT")
) )
try: try:
@@ -191,11 +197,13 @@ class Downloader:
if self._session is None: if self._session is None:
return True return True
if not hasattr(self, '_session_created_at') or self._session_created_at is None: if not hasattr(self, "_session_created_at") or self._session_created_at is None:
return True return True
# Refresh if session is older than timeout # Refresh if session is older than timeout
if (datetime.now() - self._session_created_at).total_seconds() > self.session_timeout: if (
datetime.now() - self._session_created_at
).total_seconds() > self.session_timeout:
return True return True
return False return False
@@ -217,12 +225,12 @@ class Downloader:
# Check for app-level proxy settings # Check for app-level proxy settings
proxy_url = None proxy_url = None
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
if settings_manager.get('proxy_enabled', False): if settings_manager.get("proxy_enabled", False):
proxy_host = settings_manager.get('proxy_host', '').strip() proxy_host = settings_manager.get("proxy_host", "").strip()
proxy_port = settings_manager.get('proxy_port', '').strip() proxy_port = settings_manager.get("proxy_port", "").strip()
proxy_type = settings_manager.get('proxy_type', 'http').lower() proxy_type = settings_manager.get("proxy_type", "http").lower()
proxy_username = settings_manager.get('proxy_username', '').strip() proxy_username = settings_manager.get("proxy_username", "").strip()
proxy_password = settings_manager.get('proxy_password', '').strip() proxy_password = settings_manager.get("proxy_password", "").strip()
if proxy_host and proxy_port: if proxy_host and proxy_port:
# Build proxy URL # Build proxy URL
@@ -231,37 +239,46 @@ class Downloader:
else: else:
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}" proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
logger.debug(f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}") logger.debug(
f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}"
)
logger.debug("Proxy mode: app-level proxy is active.") logger.debug("Proxy mode: app-level proxy is active.")
else: else:
logger.debug("Proxy mode: system-level proxy (trust_env) will be used if configured in environment.") logger.debug(
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
)
# Optimize TCP connection parameters # Optimize TCP connection parameters
connector = aiohttp.TCPConnector( connector = aiohttp.TCPConnector(
ssl=True, ssl=True,
limit=8, # Concurrent connections limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse force_close=False, # Keep connections for reuse
enable_cleanup_closed=True enable_cleanup_closed=True,
) )
# Configure timeout parameters # Configure timeout parameters
timeout = aiohttp.ClientTimeout( timeout = aiohttp.ClientTimeout(
total=None, # No total timeout for large downloads total=None, # No total timeout for large downloads
connect=60, # Connection timeout connect=60, # Connection timeout
sock_read=300 # 5 minute socket read timeout sock_read=300, # 5 minute socket read timeout
) )
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
connector=connector, connector=connector,
trust_env=proxy_url is None, # Only use system proxy if no app-level proxy is set trust_env=proxy_url
timeout=timeout is None, # Only use system proxy if no app-level proxy is set
timeout=timeout,
) )
# Store proxy URL for use in requests # Store proxy URL for use in requests
self._proxy_url = proxy_url self._proxy_url = proxy_url
self._session_created_at = datetime.now() self._session_created_at = datetime.now()
logger.debug("Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s", bool(proxy_url), proxy_url is None) logger.debug(
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
bool(proxy_url),
proxy_url is None,
)
def _get_auth_headers(self, use_auth: bool = False) -> Dict[str, str]: def _get_auth_headers(self, use_auth: bool = False) -> Dict[str, str]:
"""Get headers with optional authentication""" """Get headers with optional authentication"""
@@ -270,10 +287,10 @@ class Downloader:
if use_auth: if use_auth:
# Add CivitAI API key if available # Add CivitAI API key if available
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
api_key = settings_manager.get('civitai_api_key') api_key = settings_manager.get("civitai_api_key")
if api_key: if api_key:
headers['Authorization'] = f'Bearer {api_key}' headers["Authorization"] = f"Bearer {api_key}"
headers['Content-Type'] = 'application/json' headers["Content-Type"] = "application/json"
return headers return headers
@@ -303,7 +320,7 @@ class Downloader:
Tuple[bool, str]: (success, save_path or error message) Tuple[bool, str]: (success, save_path or error message)
""" """
retry_count = 0 retry_count = 0
part_path = save_path + '.part' if allow_resume else save_path part_path = save_path + ".part" if allow_resume else save_path
# Prepare headers # Prepare headers
headers = self._get_auth_headers(use_auth) headers = self._get_auth_headers(use_auth)
@@ -323,50 +340,71 @@ class Downloader:
session = await self.session session = await self.session
# Debug log for proxy mode at request time # Debug log for proxy mode at request time
if self.proxy_url: if self.proxy_url:
logger.debug(f"[download_file] Using app-level proxy: {self.proxy_url}") logger.debug(
f"[download_file] Using app-level proxy: {self.proxy_url}"
)
else: else:
logger.debug("[download_file] Using system-level proxy (trust_env) if configured.") logger.debug(
"[download_file] Using system-level proxy (trust_env) if configured."
)
# Add Range header for resume if we have partial data # Add Range header for resume if we have partial data
request_headers = headers.copy() request_headers = headers.copy()
if allow_resume and resume_offset > 0: if allow_resume and resume_offset > 0:
request_headers['Range'] = f'bytes={resume_offset}-' request_headers["Range"] = f"bytes={resume_offset}-"
# Disable compression for better chunked downloads # Disable compression for better chunked downloads
request_headers['Accept-Encoding'] = 'identity' request_headers["Accept-Encoding"] = "identity"
logger.debug(f"Download attempt {retry_count + 1}/{self.max_retries + 1} from: {url}") logger.debug(
f"Download attempt {retry_count + 1}/{self.max_retries + 1} from: {url}"
)
if resume_offset > 0: if resume_offset > 0:
logger.debug(f"Requesting range from byte {resume_offset}") logger.debug(f"Requesting range from byte {resume_offset}")
async with session.get(url, headers=request_headers, allow_redirects=True, proxy=self.proxy_url) as response: async with session.get(
url,
headers=request_headers,
allow_redirects=True,
proxy=self.proxy_url,
) as response:
# Handle different response codes # Handle different response codes
if response.status == 200: if response.status == 200:
# Full content response # Full content response
if resume_offset > 0: if resume_offset > 0:
# Server doesn't support ranges, restart from beginning # Server doesn't support ranges, restart from beginning
logger.warning("Server doesn't support range requests, restarting download") logger.warning(
"Server doesn't support range requests, restarting download"
)
resume_offset = 0 resume_offset = 0
if os.path.exists(part_path): if os.path.exists(part_path):
os.remove(part_path) os.remove(part_path)
elif response.status == 206: elif response.status == 206:
# Partial content response (resume successful) # Partial content response (resume successful)
content_range = response.headers.get('Content-Range') content_range = response.headers.get("Content-Range")
if content_range: if content_range:
# Parse total size from Content-Range header (e.g., "bytes 1024-2047/2048") # Parse total size from Content-Range header (e.g., "bytes 1024-2047/2048")
range_parts = content_range.split('/') range_parts = content_range.split("/")
if len(range_parts) == 2: if len(range_parts) == 2:
total_size = int(range_parts[1]) total_size = int(range_parts[1])
logger.info(f"Successfully resumed download from byte {resume_offset}") logger.info(
f"Successfully resumed download from byte {resume_offset}"
)
elif response.status == 416: elif response.status == 416:
# Range not satisfiable - file might be complete or corrupted # Range not satisfiable - file might be complete or corrupted
if allow_resume and os.path.exists(part_path): if allow_resume and os.path.exists(part_path):
part_size = os.path.getsize(part_path) part_size = os.path.getsize(part_path)
logger.warning(f"Range not satisfiable. Part file size: {part_size}") logger.warning(
f"Range not satisfiable. Part file size: {part_size}"
)
# Try to get actual file size # Try to get actual file size
head_response = await session.head(url, headers=headers, proxy=self.proxy_url) head_response = await session.head(
url, headers=headers, proxy=self.proxy_url
)
if head_response.status == 200: if head_response.status == 200:
actual_size = int(head_response.headers.get('content-length', 0)) actual_size = int(
head_response.headers.get("content-length", 0)
)
if part_size == actual_size: if part_size == actual_size:
# File is complete, just rename it # File is complete, just rename it
if allow_resume: if allow_resume:
@@ -388,21 +426,36 @@ class Downloader:
resume_offset = 0 resume_offset = 0
continue continue
elif response.status == 401: elif response.status == 401:
logger.warning(f"Unauthorized access to resource: {url} (Status 401)") logger.warning(
return False, "Invalid or missing API key, or early access restriction." f"Unauthorized access to resource: {url} (Status 401)"
)
return (
False,
"Invalid or missing API key, or early access restriction.",
)
elif response.status == 403: elif response.status == 403:
logger.warning(f"Forbidden access to resource: {url} (Status 403)") logger.warning(
return False, "Access forbidden: You don't have permission to download this file." f"Forbidden access to resource: {url} (Status 403)"
)
return (
False,
"Access forbidden: You don't have permission to download this file.",
)
elif response.status == 404: elif response.status == 404:
logger.warning(f"Resource not found: {url} (Status 404)") logger.warning(f"Resource not found: {url} (Status 404)")
return False, "File not found - the download link may be invalid or expired." return (
False,
"File not found - the download link may be invalid or expired.",
)
else: else:
logger.error(f"Download failed for {url} with status {response.status}") logger.error(
f"Download failed for {url} with status {response.status}"
)
return False, f"Download failed with status {response.status}" return False, f"Download failed with status {response.status}"
# Get total file size for progress calculation (if not set from Content-Range) # Get total file size for progress calculation (if not set from Content-Range)
if total_size == 0: if total_size == 0:
total_size = int(response.headers.get('content-length', 0)) total_size = int(response.headers.get("content-length", 0))
if response.status == 206: if response.status == 206:
# For partial content, add the offset to get total file size # For partial content, add the offset to get total file size
total_size += resume_offset total_size += resume_offset
@@ -417,7 +470,7 @@ class Downloader:
# Stream download to file with progress updates # Stream download to file with progress updates
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
mode = 'ab' if (allow_resume and resume_offset > 0) else 'wb' mode = "ab" if (allow_resume and resume_offset > 0) else "wb"
control = pause_event control = pause_event
if control is not None: if control is not None:
@@ -425,7 +478,9 @@ class Downloader:
with open(part_path, mode) as f: with open(part_path, mode) as f:
while True: while True:
active_stall_timeout = control.stall_timeout if control else self.stall_timeout active_stall_timeout = (
control.stall_timeout if control else self.stall_timeout
)
if control is not None: if control is not None:
if control.is_paused(): if control.is_paused():
@@ -437,7 +492,9 @@ class Downloader:
"Reconnect requested after resume" "Reconnect requested after resume"
) )
elif control.consume_reconnect_request(): elif control.consume_reconnect_request():
raise DownloadRestartRequested("Reconnect requested") raise DownloadRestartRequested(
"Reconnect requested"
)
try: try:
chunk = await asyncio.wait_for( chunk = await asyncio.wait_for(
@@ -466,22 +523,32 @@ class Downloader:
control.mark_progress(timestamp=now.timestamp()) control.mark_progress(timestamp=now.timestamp())
# Limit progress update frequency to reduce overhead # Limit progress update frequency to reduce overhead
time_diff = (now - last_progress_report_time).total_seconds() time_diff = (
now - last_progress_report_time
).total_seconds()
if progress_callback and time_diff >= 1.0: if progress_callback and time_diff >= 1.0:
progress_samples.append((now, current_size)) progress_samples.append((now, current_size))
cutoff = now - timedelta(seconds=5) cutoff = now - timedelta(seconds=5)
while progress_samples and progress_samples[0][0] < cutoff: while (
progress_samples and progress_samples[0][0] < cutoff
):
progress_samples.popleft() progress_samples.popleft()
percent = (current_size / total_size) * 100 if total_size else 0.0 percent = (
(current_size / total_size) * 100
if total_size
else 0.0
)
bytes_per_second = 0.0 bytes_per_second = 0.0
if len(progress_samples) >= 2: if len(progress_samples) >= 2:
first_time, first_bytes = progress_samples[0] first_time, first_bytes = progress_samples[0]
last_time, last_bytes = progress_samples[-1] last_time, last_bytes = progress_samples[-1]
elapsed = (last_time - first_time).total_seconds() elapsed = (last_time - first_time).total_seconds()
if elapsed > 0: if elapsed > 0:
bytes_per_second = (last_bytes - first_bytes) / elapsed bytes_per_second = (
last_bytes - first_bytes
) / elapsed
progress_snapshot = DownloadProgress( progress_snapshot = DownloadProgress(
percent_complete=percent, percent_complete=percent,
@@ -491,21 +558,23 @@ class Downloader:
timestamp=now.timestamp(), timestamp=now.timestamp(),
) )
await self._dispatch_progress_callback(progress_callback, progress_snapshot) await self._dispatch_progress_callback(
progress_callback, progress_snapshot
)
last_progress_report_time = now last_progress_report_time = now
# Download completed successfully # Download completed successfully
# Verify file size integrity before finalizing # Verify file size integrity before finalizing
final_size = os.path.getsize(part_path) if os.path.exists(part_path) else 0 final_size = (
os.path.getsize(part_path) if os.path.exists(part_path) else 0
)
expected_size = total_size if total_size > 0 else None expected_size = total_size if total_size > 0 else None
integrity_error: Optional[str] = None integrity_error: Optional[str] = None
if final_size <= 0: if final_size <= 0:
integrity_error = "Downloaded file is empty" integrity_error = "Downloaded file is empty"
elif expected_size is not None and final_size != expected_size: elif expected_size is not None and final_size != expected_size:
integrity_error = ( integrity_error = f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
)
if integrity_error is not None: if integrity_error is not None:
logger.error( logger.error(
@@ -555,7 +624,9 @@ class Downloader:
rename_attempt = 0 rename_attempt = 0
rename_success = False rename_success = False
while rename_attempt < max_rename_attempts and not rename_success: while (
rename_attempt < max_rename_attempts and not rename_success
):
try: try:
# If the destination file exists, remove it first (Windows safe) # If the destination file exists, remove it first (Windows safe)
if os.path.exists(save_path): if os.path.exists(save_path):
@@ -566,11 +637,18 @@ class Downloader:
except PermissionError as e: except PermissionError as e:
rename_attempt += 1 rename_attempt += 1
if rename_attempt < max_rename_attempts: if rename_attempt < max_rename_attempts:
logger.info(f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})") logger.info(
f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})"
)
await asyncio.sleep(2) await asyncio.sleep(2)
else: else:
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}") logger.error(
return False, f"Failed to finalize download: {str(e)}" f"Failed to rename file after {max_rename_attempts} attempts: {e}"
)
return (
False,
f"Failed to finalize download: {str(e)}",
)
final_size = os.path.getsize(save_path) final_size = os.path.getsize(save_path)
@@ -583,8 +661,9 @@ class Downloader:
bytes_per_second=0.0, bytes_per_second=0.0,
timestamp=datetime.now().timestamp(), timestamp=datetime.now().timestamp(),
) )
await self._dispatch_progress_callback(progress_callback, final_snapshot) await self._dispatch_progress_callback(
progress_callback, final_snapshot
)
return True, save_path return True, save_path
@@ -597,7 +676,9 @@ class Downloader:
DownloadRestartRequested, DownloadRestartRequested,
) as e: ) as e:
retry_count += 1 retry_count += 1
logger.warning(f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}") logger.warning(
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
)
if retry_count <= self.max_retries: if retry_count <= self.max_retries:
# Calculate delay with exponential backoff # Calculate delay with exponential backoff
@@ -615,7 +696,10 @@ class Downloader:
continue continue
else: else:
logger.error(f"Max retries exceeded for download: {e}") logger.error(f"Max retries exceeded for download: {e}")
return False, f"Network error after {self.max_retries + 1} attempts: {str(e)}" return (
False,
f"Network error after {self.max_retries + 1} attempts: {str(e)}",
)
except Exception as e: except Exception as e:
logger.error(f"Unexpected download error: {e}") logger.error(f"Unexpected download error: {e}")
@@ -645,7 +729,7 @@ class Downloader:
url: str, url: str,
use_auth: bool = False, use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None, custom_headers: Optional[Dict[str, str]] = None,
return_headers: bool = False return_headers: bool = False,
) -> Tuple[bool, Union[bytes, str], Optional[Dict]]: ) -> Tuple[bool, Union[bytes, str], Optional[Dict]]:
""" """
Download a file to memory (for small files like preview images) Download a file to memory (for small files like preview images)
@@ -663,16 +747,22 @@ class Downloader:
session = await self.session session = await self.session
# Debug log for proxy mode at request time # Debug log for proxy mode at request time
if self.proxy_url: if self.proxy_url:
logger.debug(f"[download_to_memory] Using app-level proxy: {self.proxy_url}") logger.debug(
f"[download_to_memory] Using app-level proxy: {self.proxy_url}"
)
else: else:
logger.debug("[download_to_memory] Using system-level proxy (trust_env) if configured.") logger.debug(
"[download_to_memory] Using system-level proxy (trust_env) if configured."
)
# Prepare headers # Prepare headers
headers = self._get_auth_headers(use_auth) headers = self._get_auth_headers(use_auth)
if custom_headers: if custom_headers:
headers.update(custom_headers) headers.update(custom_headers)
async with session.get(url, headers=headers, proxy=self.proxy_url) as response: async with session.get(
url, headers=headers, proxy=self.proxy_url
) as response:
if response.status == 200: if response.status == 200:
content = await response.read() content = await response.read()
if return_headers: if return_headers:
@@ -700,7 +790,7 @@ class Downloader:
self, self,
url: str, url: str,
use_auth: bool = False, use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None custom_headers: Optional[Dict[str, str]] = None,
) -> Tuple[bool, Union[Dict, str]]: ) -> Tuple[bool, Union[Dict, str]]:
""" """
Get response headers without downloading the full content Get response headers without downloading the full content
@@ -717,16 +807,22 @@ class Downloader:
session = await self.session session = await self.session
# Debug log for proxy mode at request time # Debug log for proxy mode at request time
if self.proxy_url: if self.proxy_url:
logger.debug(f"[get_response_headers] Using app-level proxy: {self.proxy_url}") logger.debug(
f"[get_response_headers] Using app-level proxy: {self.proxy_url}"
)
else: else:
logger.debug("[get_response_headers] Using system-level proxy (trust_env) if configured.") logger.debug(
"[get_response_headers] Using system-level proxy (trust_env) if configured."
)
# Prepare headers # Prepare headers
headers = self._get_auth_headers(use_auth) headers = self._get_auth_headers(use_auth)
if custom_headers: if custom_headers:
headers.update(custom_headers) headers.update(custom_headers)
async with session.head(url, headers=headers, proxy=self.proxy_url) as response: async with session.head(
url, headers=headers, proxy=self.proxy_url
) as response:
if response.status == 200: if response.status == 200:
return True, dict(response.headers) return True, dict(response.headers)
else: else:
@@ -742,7 +838,7 @@ class Downloader:
url: str, url: str,
use_auth: bool = False, use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None, custom_headers: Optional[Dict[str, str]] = None,
**kwargs **kwargs,
) -> Tuple[bool, Union[Dict, str]]: ) -> Tuple[bool, Union[Dict, str]]:
""" """
Make a generic HTTP request and return JSON response Make a generic HTTP request and return JSON response
@@ -763,7 +859,9 @@ class Downloader:
if self.proxy_url: if self.proxy_url:
logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}") logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}")
else: else:
logger.debug("[make_request] Using system-level proxy (trust_env) if configured.") logger.debug(
"[make_request] Using system-level proxy (trust_env) if configured."
)
# Prepare headers # Prepare headers
headers = self._get_auth_headers(use_auth) headers = self._get_auth_headers(use_auth)
@@ -771,10 +869,12 @@ class Downloader:
headers.update(custom_headers) headers.update(custom_headers)
# Add proxy to kwargs if not already present # Add proxy to kwargs if not already present
if 'proxy' not in kwargs: if "proxy" not in kwargs:
kwargs['proxy'] = self.proxy_url kwargs["proxy"] = self.proxy_url
async with session.request(method, url, headers=headers, **kwargs) as response: async with session.request(
method, url, headers=headers, **kwargs
) as response:
if response.status == 200: if response.status == 200:
# Try to parse as JSON, fall back to text # Try to parse as JSON, fall back to text
try: try:

View File

@@ -48,7 +48,9 @@ class LoraService(BaseModelService):
"notes": lora_data.get("notes", ""), "notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False), "favorite": lora_data.get("favorite", False),
"update_available": bool(lora_data.get("update_available", False)), "update_available": bool(lora_data.get("update_available", False)),
"skip_metadata_refresh": bool(lora_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(
lora_data.get("skip_metadata_refresh", False)
),
"sub_type": sub_type, "sub_type": sub_type,
"civitai": self.filter_civitai_data( "civitai": self.filter_civitai_data(
lora_data.get("civitai", {}), minimal=True lora_data.get("civitai", {}), minimal=True
@@ -62,6 +64,68 @@ class LoraService(BaseModelService):
if first_letter: if first_letter:
data = self._filter_by_first_letter(data, first_letter) data = self._filter_by_first_letter(data, first_letter)
# Handle name pattern filters
name_pattern_include = kwargs.get("name_pattern_include", [])
name_pattern_exclude = kwargs.get("name_pattern_exclude", [])
name_pattern_use_regex = kwargs.get("name_pattern_use_regex", False)
if name_pattern_include or name_pattern_exclude:
import re
def matches_pattern(name, pattern, use_regex):
"""Check if name matches pattern (regex or substring)"""
if not name:
return False
if use_regex:
try:
return bool(re.search(pattern, name, re.IGNORECASE))
except re.error:
# Invalid regex, fall back to substring match
return pattern.lower() in name.lower()
else:
return pattern.lower() in name.lower()
def matches_any_pattern(name, patterns, use_regex):
"""Check if name matches any of the patterns"""
if not patterns:
return True
return any(matches_pattern(name, p, use_regex) for p in patterns)
filtered = []
for lora in data:
model_name = lora.get("model_name", "")
file_name = lora.get("file_name", "")
names_to_check = [n for n in [model_name, file_name] if n]
# Check exclude patterns first
excluded = False
if name_pattern_exclude:
for name in names_to_check:
if matches_any_pattern(
name, name_pattern_exclude, name_pattern_use_regex
):
excluded = True
break
if excluded:
continue
# Check include patterns
if name_pattern_include:
included = False
for name in names_to_check:
if matches_any_pattern(
name, name_pattern_include, name_pattern_use_regex
):
included = True
break
if not included:
continue
filtered.append(lora)
data = filtered
return data return data
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]: def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
@@ -368,9 +432,7 @@ class LoraService(BaseModelService):
rng.uniform(clip_strength_min, clip_strength_max), 2 rng.uniform(clip_strength_min, clip_strength_max), 2
) )
else: else:
clip_str = round( clip_str = round(rng.uniform(clip_strength_min, clip_strength_max), 2)
rng.uniform(clip_strength_min, clip_strength_max), 2
)
result_loras.append( result_loras.append(
{ {
@@ -485,12 +547,69 @@ class LoraService(BaseModelService):
if bool(lora.get("license_flags", 127) & (1 << 1)) if bool(lora.get("license_flags", 127) & (1 << 1))
] ]
# Apply name pattern filters
name_patterns = filter_section.get("namePatterns", {})
include_patterns = name_patterns.get("include", [])
exclude_patterns = name_patterns.get("exclude", [])
use_regex = name_patterns.get("useRegex", False)
if include_patterns or exclude_patterns:
import re
def matches_pattern(name, pattern, use_regex):
"""Check if name matches pattern (regex or substring)"""
if not name:
return False
if use_regex:
try:
return bool(re.search(pattern, name, re.IGNORECASE))
except re.error:
# Invalid regex, fall back to substring match
return pattern.lower() in name.lower()
else:
return pattern.lower() in name.lower()
def matches_any_pattern(name, patterns, use_regex):
"""Check if name matches any of the patterns"""
if not patterns:
return True
return any(matches_pattern(name, p, use_regex) for p in patterns)
filtered = []
for lora in available_loras:
model_name = lora.get("model_name", "")
file_name = lora.get("file_name", "")
names_to_check = [n for n in [model_name, file_name] if n]
# Check exclude patterns first
excluded = False
if exclude_patterns:
for name in names_to_check:
if matches_any_pattern(name, exclude_patterns, use_regex):
excluded = True
break
if excluded:
continue
# Check include patterns
if include_patterns:
included = False
for name in names_to_check:
if matches_any_pattern(name, include_patterns, use_regex):
included = True
break
if not included:
continue
filtered.append(lora)
available_loras = filtered
return available_loras return available_loras
async def get_cycler_list( async def get_cycler_list(
self, self, pool_config: Optional[Dict] = None, sort_by: str = "filename"
pool_config: Optional[Dict] = None,
sort_by: str = "filename"
) -> List[Dict]: ) -> List[Dict]:
""" """
Get filtered and sorted LoRA list for cycling. Get filtered and sorted LoRA list for cycling.
@@ -518,16 +637,16 @@ class LoraService(BaseModelService):
available_loras, available_loras,
key=lambda x: ( key=lambda x: (
(x.get("model_name") or x.get("file_name", "")).lower(), (x.get("model_name") or x.get("file_name", "")).lower(),
x.get("file_path", "").lower() x.get("file_path", "").lower(),
) ),
) )
else: # Default to filename else: # Default to filename
available_loras = sorted( available_loras = sorted(
available_loras, available_loras,
key=lambda x: ( key=lambda x: (
x.get("file_name", "").lower(), x.get("file_name", "").lower(),
x.get("file_path", "").lower() x.get("file_path", "").lower(),
) ),
) )
# Return minimal data needed for cycling # Return minimal data needed for cycling

View File

@@ -122,11 +122,25 @@ async def get_metadata_provider(provider_name: str = None):
provider_manager = await ModelMetadataProviderManager.get_instance() provider_manager = await ModelMetadataProviderManager.get_instance()
try:
provider = ( provider = (
provider_manager._get_provider(provider_name) provider_manager._get_provider(provider_name)
if provider_name if provider_name
else provider_manager._get_provider() else provider_manager._get_provider()
) )
except ValueError as e:
# Provider not initialized, attempt to initialize
if "No default provider set" in str(e) or "not registered" in str(e):
logger.warning(f"Metadata provider not initialized ({e}), initializing now...")
await initialize_metadata_providers()
provider_manager = await ModelMetadataProviderManager.get_instance()
provider = (
provider_manager._get_provider(provider_name)
if provider_name
else provider_manager._get_provider()
)
else:
raise
return _wrap_provider_with_rate_limit(provider_name, provider) return _wrap_provider_with_rate_limit(provider_name, provider)

View File

@@ -14,7 +14,6 @@ from ..utils.metadata_manager import MetadataManager
from ..utils.civitai_utils import resolve_license_info from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex from .model_hash_index import ModelHashIndex
from ..utils.constants import PREVIEW_EXTENSIONS
from .model_lifecycle_service import delete_model_artifacts from .model_lifecycle_service import delete_model_artifacts
from .service_registry import ServiceRegistry from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager from .websocket_manager import ws_manager
@@ -1443,11 +1442,10 @@ class ModelScanner:
if not file_path: if not file_path:
return None return None
base_name = os.path.splitext(file_path)[0] dir_path = os.path.dirname(file_path)
base_name = os.path.splitext(os.path.basename(file_path))[0]
for ext in PREVIEW_EXTENSIONS: preview_path = find_preview_file(base_name, dir_path)
preview_path = f"{base_name}{ext}" if preview_path:
if os.path.exists(preview_path):
return config.get_preview_static_url(preview_path) return config.get_preview_static_url(preview_path)
return None return None

View File

@@ -13,7 +13,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1252,14 +1252,23 @@ class ModelUpdateService:
return None return None
blur_mature_content = True blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
settings = getattr(self, "_settings", None) settings = getattr(self, "_settings", None)
if settings is not None and hasattr(settings, "get"): if settings is not None and hasattr(settings, "get"):
try: try:
blur_mature_content = bool(settings.get("blur_mature_content", True)) blur_mature_content = bool(settings.get("blur_mature_content", True))
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings.get("mature_blur_level", "R")}
)
except Exception: # pragma: no cover - defensive guard except Exception: # pragma: no cover - defensive guard
blur_mature_content = True blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
selected, _ = select_preview_media(candidates, blur_mature_content=blur_mature_content) selected, _ = select_preview_media(
candidates,
blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
)
if not selected: if not selected:
return None return None

View File

@@ -9,7 +9,7 @@ from urllib.parse import urlparse
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,9 +49,13 @@ class PreviewAssetService:
blur_mature_content = bool( blur_mature_content = bool(
settings_manager.get("blur_mature_content", True) settings_manager.get("blur_mature_content", True)
) )
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
)
first_preview, nsfw_level = select_preview_media( first_preview, nsfw_level = select_preview_media(
images, images,
blur_mature_content=blur_mature_content, blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
) )
if not first_preview: if not first_preview:
@@ -216,4 +220,3 @@ class PreviewAssetService:
if "webm" in content_type: if "webm" in content_type:
return ".webm" return ".webm"
return ".mp4" return ".mp4"

View File

@@ -11,7 +11,12 @@ from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequ
from platformdirs import user_config_dir from platformdirs import user_config_dir
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG from ..utils.constants import (
DEFAULT_HASH_CHUNK_SIZE_MB,
DEFAULT_PRIORITY_TAG_CONFIG,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
)
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
from ..utils.tag_priorities import ( from ..utils.tag_priorities import (
PriorityTagEntry, PriorityTagEntry,
@@ -59,6 +64,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"optimize_example_images": True, "optimize_example_images": True,
"auto_download_example_images": False, "auto_download_example_images": False,
"blur_mature_content": True, "blur_mature_content": True,
"mature_blur_level": "R",
"autoplay_on_hover": False, "autoplay_on_hover": False,
"display_density": "default", "display_density": "default",
"card_info_display": "always", "card_info_display": "always",
@@ -71,6 +77,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"update_flag_strategy": "same_base", "update_flag_strategy": "same_base",
"auto_organize_exclusions": [], "auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [], "metadata_refresh_skip_paths": [],
"download_skip_base_models": [],
} }
@@ -274,6 +281,31 @@ class SettingsManager:
self.settings["metadata_refresh_skip_paths"] = [] self.settings["metadata_refresh_skip_paths"] = []
inserted_defaults = True inserted_defaults = True
if "download_skip_base_models" in self.settings:
normalized_skip_base_models = self.normalize_download_skip_base_models(
self.settings.get("download_skip_base_models")
)
if normalized_skip_base_models != self.settings.get(
"download_skip_base_models"
):
self.settings["download_skip_base_models"] = (
normalized_skip_base_models
)
updated_existing = True
else:
self.settings["download_skip_base_models"] = []
inserted_defaults = True
had_mature_level = "mature_blur_level" in self.settings
raw_mature_level = self.settings.get("mature_blur_level")
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
if normalized_mature_level != raw_mature_level:
self.settings["mature_blur_level"] = normalized_mature_level
if had_mature_level:
updated_existing = True
else:
inserted_defaults = True
for key, value in defaults.items(): for key, value in defaults.items():
if key == "priority_tags": if key == "priority_tags":
continue continue
@@ -608,6 +640,7 @@ class SettingsManager:
'optimizeExampleImages': 'optimize_example_images', 'optimizeExampleImages': 'optimize_example_images',
'autoDownloadExampleImages': 'auto_download_example_images', 'autoDownloadExampleImages': 'auto_download_example_images',
'blurMatureContent': 'blur_mature_content', 'blurMatureContent': 'blur_mature_content',
'matureBlurLevel': 'mature_blur_level',
'autoplayOnHover': 'autoplay_on_hover', 'autoplayOnHover': 'autoplay_on_hover',
'displayDensity': 'display_density', 'displayDensity': 'display_density',
'cardInfoDisplay': 'card_info_display', 'cardInfoDisplay': 'card_info_display',
@@ -860,6 +893,13 @@ class SettingsManager:
return normalized return normalized
def normalize_mature_blur_level(self, value: Any) -> str:
if isinstance(value, str):
normalized = value.strip().upper()
if normalized in VALID_MATURE_BLUR_LEVELS:
return normalized
return "R"
def normalize_auto_organize_exclusions(self, value: Any) -> List[str]: def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
if value is None: if value is None:
return [] return []
@@ -944,6 +984,45 @@ class SettingsManager:
self._save_settings() self._save_settings()
return skip_paths return skip_paths
def normalize_download_skip_base_models(self, value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, str):
candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",")
)
elif isinstance(value, Sequence) and not isinstance(
value, (bytes, bytearray, str)
):
candidates = value
else:
return []
base_models: List[str] = []
seen = set()
for raw in candidates:
if not isinstance(raw, str):
continue
token = raw.strip()
if not token or token not in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS:
continue
if token in seen:
continue
seen.add(token)
base_models.append(token)
return base_models
def get_download_skip_base_models(self) -> List[str]:
base_models = self.normalize_download_skip_base_models(
self.settings.get("download_skip_base_models")
)
if base_models != self.settings.get("download_skip_base_models"):
self.settings["download_skip_base_models"] = base_models
self._save_settings()
return base_models
def get_extra_folder_paths(self) -> Dict[str, List[str]]: def get_extra_folder_paths(self) -> Dict[str, List[str]]:
"""Get extra folder paths for the active library. """Get extra folder paths for the active library.
@@ -1012,6 +1091,10 @@ class SettingsManager:
value = self.normalize_auto_organize_exclusions(value) value = self.normalize_auto_organize_exclusions(value)
elif key == "metadata_refresh_skip_paths": elif key == "metadata_refresh_skip_paths":
value = self.normalize_metadata_refresh_skip_paths(value) value = self.normalize_metadata_refresh_skip_paths(value)
elif key == "download_skip_base_models":
value = self.normalize_download_skip_base_models(value)
elif key == "mature_blur_level":
value = self.normalize_mature_blur_level(value)
self.settings[key] = value self.settings[key] = value
portable_switch_pending = False portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool): if key == "use_portable_settings" and isinstance(value, bool):

View File

@@ -113,3 +113,59 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Qwen", "Qwen",
] ]
) )
# Supported baseModel values for download exclusion settings.
# Keep this aligned with static/js/utils/constants.js, excluding the generic "Other" value.
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
[
"SD 1.4",
"SD 1.5",
"SD 1.5 LCM",
"SD 1.5 Hyper",
"SD 2.0",
"SD 2.1",
"SD 3",
"SD 3.5",
"SD 3.5 Medium",
"SD 3.5 Large",
"SD 3.5 Large Turbo",
"SDXL 1.0",
"SDXL Lightning",
"SDXL Hyper",
"Flux.1 D",
"Flux.1 S",
"Flux.1 Krea",
"Flux.1 Kontext",
"Flux.2 D",
"Flux.2 Klein 9B",
"Flux.2 Klein 9B-base",
"Flux.2 Klein 4B",
"Flux.2 Klein 4B-base",
"AuraFlow",
"Chroma",
"PixArt a",
"PixArt E",
"Hunyuan 1",
"Lumina",
"Kolors",
"NoobAI",
"Illustrious",
"Pony",
"HiDream",
"Qwen",
"ZImageTurbo",
"ZImageBase",
"SVD",
"LTXV",
"LTXV2",
"Wan Video",
"Wan Video 1.3B t2v",
"Wan Video 14B t2v",
"Wan Video 14B i2v 480p",
"Wan Video 14B i2v 720p",
"Wan Video 2.2 TI2V-5B",
"Wan Video 2.2 T2V-A14B",
"Wan Video 2.2 I2V-A14B",
"Hunyuan Video",
]
)

View File

@@ -40,49 +40,39 @@ async def calculate_sha256(file_path: str) -> str:
return sha256_hash.hexdigest() return sha256_hash.hexdigest()
def find_preview_file(base_name: str, dir_path: str) -> str: def find_preview_file(base_name: str, dir_path: str) -> str:
"""Find preview file for given base name in directory""" """Find preview file for given base name in directory.
Performs an exact-case check first (fast path), then falls back to a
case-insensitive scan so that files like ``model.WEBP`` or ``model.Png``
are discovered on case-sensitive filesystems.
"""
temp_extensions = PREVIEW_EXTENSIONS.copy() temp_extensions = PREVIEW_EXTENSIONS.copy()
# Add example extension for compatibility # Add example extension for compatibility
# https://github.com/willmiao/ComfyUI-Lora-Manager/issues/225 # https://github.com/willmiao/ComfyUI-Lora-Manager/issues/225
# The preview image will be optimized to lora-name.webp, so it won't affect other logic # The preview image will be optimized to lora-name.webp, so it won't affect other logic
temp_extensions.append(".example.0.jpeg") temp_extensions.append(".example.0.jpeg")
# Fast path: exact-case match
for ext in temp_extensions: for ext in temp_extensions:
full_pattern = os.path.join(dir_path, f"{base_name}{ext}") full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
if os.path.exists(full_pattern): if os.path.exists(full_pattern):
# Check if this is an image and not already webp
# TODO: disable the optimization for now, maybe add a config option later
# if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
# try:
# # Optimize the image to webp format
# webp_path = os.path.join(dir_path, f"{base_name}.webp")
# # Use ExifUtils to optimize the image
# with open(full_pattern, 'rb') as f:
# image_data = f.read()
# optimized_data, _ = ExifUtils.optimize_image(
# image_data=image_data,
# target_width=CARD_PREVIEW_WIDTH,
# format='webp',
# quality=85,
# preserve_metadata=False
# )
# # Save the optimized webp file
# with open(webp_path, 'wb') as f:
# f.write(optimized_data)
# logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
# return webp_path.replace(os.sep, "/")
# except Exception as e:
# logger.error(f"Error optimizing preview image {full_pattern}: {e}")
# # Fall back to original file if optimization fails
# return full_pattern.replace(os.sep, "/")
# Return the original path for webp images or non-image files
return full_pattern.replace(os.sep, "/") return full_pattern.replace(os.sep, "/")
# Slow path: case-insensitive match for systems with mixed-case extensions
# (e.g. .WEBP, .Png, .JPG placed manually or by external tools)
try:
dir_entries = os.listdir(dir_path)
except OSError:
return ""
base_lower = base_name.lower()
for ext in temp_extensions:
target = f"{base_lower}{ext}" # ext is already lowercase
for entry in dir_entries:
if entry.lower() == target:
return os.path.join(dir_path, entry).replace(os.sep, "/")
return "" return ""
def get_preview_extension(preview_path: str) -> str: def get_preview_extension(preview_path: str) -> str:

View File

@@ -2,11 +2,12 @@
from __future__ import annotations from __future__ import annotations
from typing import Mapping, Optional, Sequence, Tuple from typing import Any, Mapping, Optional, Sequence, Tuple
from .constants import NSFW_LEVELS from .constants import NSFW_LEVELS
PreviewMedia = Mapping[str, object] PreviewMedia = Mapping[str, object]
VALID_MATURE_BLUR_LEVELS = ("PG13", "R", "X", "XXX")
def _extract_nsfw_level(entry: Mapping[str, object]) -> int: def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
@@ -19,17 +20,36 @@ def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
return 0 return 0
def resolve_mature_threshold(settings: Mapping[str, Any] | None) -> int:
"""Resolve the configured mature blur threshold from settings.
Allowed values are ``PG13``, ``R``, ``X``, and ``XXX``. Any invalid or
missing value falls back to ``R``.
"""
if not isinstance(settings, Mapping):
return NSFW_LEVELS.get("R", 4)
raw_level = settings.get("mature_blur_level", "R")
normalized = str(raw_level).strip().upper()
if normalized not in VALID_MATURE_BLUR_LEVELS:
normalized = "R"
return NSFW_LEVELS.get(normalized, NSFW_LEVELS.get("R", 4))
def select_preview_media( def select_preview_media(
images: Sequence[Mapping[str, object]] | None, images: Sequence[Mapping[str, object]] | None,
*, *,
blur_mature_content: bool, blur_mature_content: bool,
mature_threshold: int | None = None,
) -> Tuple[Optional[PreviewMedia], int]: ) -> Tuple[Optional[PreviewMedia], int]:
"""Select the most appropriate preview media entry. """Select the most appropriate preview media entry.
When ``blur_mature_content`` is enabled we first try to return the first media When ``blur_mature_content`` is enabled we first try to return the first media
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are item with an ``nsfwLevel`` lower than the configured mature threshold
available we return the media entry with the lowest NSFW level. When the (defaults to :pydata:`NSFW_LEVELS["R"]`). If none are available we return
setting is disabled we simply return the first entry. the media entry with the lowest NSFW level. When the setting is disabled we
simply return the first entry.
""" """
if not images: if not images:
@@ -45,7 +65,9 @@ def select_preview_media(
if not blur_mature_content: if not blur_mature_content:
return selected, selected_level return selected, selected_level
safe_threshold = NSFW_LEVELS.get("R", 4) safe_threshold = (
mature_threshold if isinstance(mature_threshold, int) else NSFW_LEVELS.get("R", 4)
)
for candidate in candidates: for candidate in candidates:
level = _extract_nsfw_level(candidate) level = _extract_nsfw_level(candidate)
if level < safe_threshold: if level < safe_threshold:
@@ -60,4 +82,4 @@ def select_preview_media(
return selected, selected_level return selected, selected_level
__all__ = ["select_preview_media"] __all__ = ["resolve_mature_threshold", "select_preview_media", "VALID_MATURE_BLUR_LEVELS"]

View File

@@ -1,5 +1,5 @@
[pytest] [pytest]
addopts = -v --import-mode=importlib -m "not performance" addopts = -v --import-mode=importlib -m "not performance" --ignore=__init__.py
testpaths = tests testpaths = tests
python_files = test_*.py python_files = test_*.py
python_classes = Test* python_classes = Test*

View File

@@ -687,7 +687,7 @@
padding: 12px 16px; padding: 12px 16px;
background: oklch(var(--lora-warning) / 0.1); background: oklch(var(--lora-warning) / 0.1);
border: 1px solid var(--lora-warning); border: 1px solid var(--lora-warning);
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0; border-radius: var(--border-radius-sm);
color: var(--text-color); color: var(--text-color);
} }

View File

@@ -151,7 +151,8 @@ body.modal-open {
[data-theme="dark"] .changelog-section, [data-theme="dark"] .changelog-section,
[data-theme="dark"] .update-info, [data-theme="dark"] .update-info,
[data-theme="dark"] .info-item, [data-theme="dark"] .info-item,
[data-theme="dark"] .path-preview { [data-theme="dark"] .path-preview,
[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -349,3 +350,87 @@ button:disabled,
margin-top: var(--space-1); margin-top: var(--space-1);
text-align: center; text-align: center;
} }
/* Bulk Download Missing LoRAs Modal */
#bulkDownloadMissingLorasModal .modal-body {
padding: var(--space-3);
}
#bulkDownloadMissingLorasModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-preview {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
padding: var(--space-3);
margin-bottom: var(--space-3);
}
#bulkDownloadMissingLorasModal .preview-title {
font-weight: 600;
margin-bottom: var(--space-2);
color: var(--text-color);
font-size: 0.95em;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list {
list-style: none;
padding: 0;
margin: 0;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-1) 0;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li:last-child {
border-bottom: none;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li.more-items {
font-style: italic;
opacity: 0.7;
text-align: center;
justify-content: center;
padding: var(--space-2) 0;
}
#bulkDownloadMissingLorasModal .lora-name {
font-weight: 500;
color: var(--text-color);
flex: 1;
}
#bulkDownloadMissingLorasModal .lora-version {
font-size: 0.85em;
opacity: 0.7;
margin-left: var(--space-1);
color: var(--text-muted);
}
#bulkDownloadMissingLorasModal .confirmation-note {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2);
background: rgba(59, 130, 246, 0.1);
border-radius: var(--border-radius-sm);
font-size: 0.9em;
color: var(--text-color);
}
#bulkDownloadMissingLorasModal .confirmation-note i {
color: var(--lora-accent);
margin-top: 2px;
flex-shrink: 0;
}

View File

@@ -430,6 +430,88 @@
box-sizing: border-box; box-sizing: border-box;
} }
.base-model-skip-toggle {
min-width: 220px;
justify-content: space-between;
gap: 10px;
}
.base-model-skip-toggle-label {
opacity: 0.75;
white-space: nowrap;
}
.base-model-skip-panel {
margin-top: var(--space-2);
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background-color: var(--lora-surface);
}
.base-model-skip-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.base-model-skip-search {
flex: 1;
min-width: 0;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--settings-bg);
color: var(--text-color);
}
.base-model-skip-search:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.base-model-skip-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
max-height: 220px;
overflow-y: auto;
}
.base-model-skip-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background-color: var(--settings-bg);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.base-model-skip-option:hover {
border-color: var(--lora-accent);
background-color: rgba(var(--lora-accent-rgb, 79, 70, 229), 0.05);
}
.base-model-skip-option input {
margin: 0;
}
.base-model-skip-option span {
font-size: 0.9em;
line-height: 1.25;
}
.base-model-skip-empty {
padding: 8px 0 0;
font-size: 0.9em;
opacity: 0.75;
}
.priority-tags-input:focus { .priority-tags-input:focus {
border-color: var(--lora-accent); border-color: var(--lora-accent);
outline: none; outline: none;

View File

@@ -251,7 +251,7 @@ export class BaseModelApiClient {
replaceModelPreview(filePath) { replaceModelPreview(filePath) {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = 'image/*,video/mp4'; input.accept = 'image/*,image/webp,video/mp4';
input.onchange = async () => { input.onchange = async () => {
if (!input.files || !input.files[0]) return; if (!input.files || !input.files[0]) return;

View File

@@ -2,6 +2,8 @@ import { BaseContextMenu } from './BaseContextMenu.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { updateElementText, translate } from '../../utils/i18nHelpers.js'; import { updateElementText, translate } from '../../utils/i18nHelpers.js';
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
import { showToast } from '../../utils/uiHelpers.js';
export class BulkContextMenu extends BaseContextMenu { export class BulkContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -37,6 +39,7 @@ export class BulkContextMenu extends BaseContextMenu {
const moveAllItem = this.menu.querySelector('[data-action="move-all"]'); const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]'); const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]'); const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
if (sendToWorkflowAppendItem) { if (sendToWorkflowAppendItem) {
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none'; sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
@@ -71,6 +74,10 @@ export class BulkContextMenu extends BaseContextMenu {
if (setContentRatingItem) { if (setContentRatingItem) {
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none'; setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
} }
if (downloadMissingLorasItem) {
// Only show for recipes page
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
}
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]'); const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]'); const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
@@ -178,6 +185,9 @@ export class BulkContextMenu extends BaseContextMenu {
case 'delete-all': case 'delete-all':
bulkManager.showBulkDeleteModal(); bulkManager.showBulkDeleteModal();
break; break;
case 'download-missing-loras':
this.handleDownloadMissingLoras();
break;
case 'clear': case 'clear':
bulkManager.clearSelection(); bulkManager.clearSelection();
break; break;
@@ -185,4 +195,39 @@ export class BulkContextMenu extends BaseContextMenu {
console.warn(`Unknown bulk action: ${action}`); console.warn(`Unknown bulk action: ${action}`);
} }
} }
/**
* Handle downloading missing LoRAs for selected recipes
*/
async handleDownloadMissingLoras() {
if (state.selectedModels.size === 0) {
return;
}
// Get selected recipes from the virtual scroller
const selectedRecipes = [];
state.selectedModels.forEach(filePath => {
const card = document.querySelector(`.model-card[data-filepath="${CSS.escape(filePath)}"]`);
if (card && card.recipeData) {
selectedRecipes.push(card.recipeData);
}
});
if (selectedRecipes.length === 0) {
// Try to get recipes from virtual scroller state
const items = state.virtualScroller?.items || [];
items.forEach(recipe => {
if (recipe.file_path && state.selectedModels.has(recipe.file_path)) {
selectedRecipes.push(recipe);
}
});
}
if (selectedRecipes.length === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
}
} }

View File

@@ -6,7 +6,7 @@ import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js'; import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js'; import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
class RecipeCard { class RecipeCard {
constructor(recipe, clickHandler) { constructor(recipe, clickHandler) {
@@ -74,7 +74,8 @@ class RecipeCard {
// NSFW blur logic - similar to LoraCard // NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0; const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
if (shouldBlur) { if (shouldBlur) {
card.classList.add('nsfw-content'); card.classList.add('nsfw-content');

View File

@@ -1299,7 +1299,6 @@ class RecipeModal {
// New method to navigate to the LoRAs page // New method to navigate to the LoRAs page
navigateToLorasPage(specificLoraIndex = null) { navigateToLorasPage(specificLoraIndex = null) {
debugger;
// Close the current modal // Close the current modal
modalManager.closeModal('recipeModal'); modalManager.closeModal('recipeModal');

View File

@@ -4,7 +4,7 @@ import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js'; import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js'; import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
import { MODEL_TYPES } from '../../api/apiConfig.js'; import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js'; import { showDeleteModal } from '../../utils/modalUtils.js';
@@ -478,7 +478,8 @@ export function createModelCard(model, modelType) {
card.dataset.nsfwLevel = nsfwLevel; card.dataset.nsfwLevel = nsfwLevel;
// Determine if the preview should be blurred based on NSFW level and user settings // Determine if the preview should be blurred based on NSFW level and user settings
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
if (shouldBlur) { if (shouldBlur) {
card.classList.add('nsfw-content'); card.classList.add('nsfw-content');
} }

View File

@@ -6,7 +6,7 @@
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js'; import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js'; import { state } from '../../../state/index.js';
import { getModelApiClient } from '../../../api/modelApiFactory.js'; import { getModelApiClient } from '../../../api/modelApiFactory.js';
import { NSFW_LEVELS } from '../../../utils/constants.js'; import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js'; import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
/** /**
@@ -607,7 +607,8 @@ function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
} }
mediaWrapper.dataset.nsfwLevel = String(nsfwLevel); mediaWrapper.dataset.nsfwLevel = String(nsfwLevel);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
let overlay = mediaWrapper.querySelector('.nsfw-overlay'); let overlay = mediaWrapper.querySelector('.nsfw-overlay');
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn'); let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');

View File

@@ -6,7 +6,7 @@ import { showToast } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js'; import { state } from '../../../state/index.js';
import { modalManager } from '../../../managers/ModalManager.js'; import { modalManager } from '../../../managers/ModalManager.js';
import { translate } from '../../../utils/i18nHelpers.js'; import { translate } from '../../../utils/i18nHelpers.js';
import { NSFW_LEVELS } from '../../../utils/constants.js'; import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import { import {
initLazyLoading, initLazyLoading,
initNsfwBlurHandlers, initNsfwBlurHandlers,
@@ -184,7 +184,8 @@ function renderMediaItem(img, index, exampleFiles) {
// Check if media should be blurred // Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
// Determine NSFW warning text based on level // Determine NSFW warning text based on level
let nsfwText = "Mature Content"; let nsfwText = "Mature Content";

View File

@@ -0,0 +1,357 @@
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { state } from '../state/index.js';
import { modalManager } from './ModalManager.js';
/**
* Manager for downloading missing LoRAs for selected recipes in bulk
*/
export class BulkMissingLoraDownloadManager {
constructor() {
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
this.pendingLoras = [];
this.pendingRecipes = [];
}
/**
* Collect missing LoRAs from selected recipes with deduplication
* @param {Array} selectedRecipes - Array of selected recipe objects
* @returns {Object} - Object containing unique missing LoRAs and statistics
*/
collectMissingLoras(selectedRecipes) {
const uniqueLoras = new Map(); // key: hash or modelVersionId, value: lora object
const missingLorasByRecipe = new Map();
let totalMissingCount = 0;
selectedRecipes.forEach(recipe => {
const missingLoras = [];
if (recipe.loras && Array.isArray(recipe.loras)) {
recipe.loras.forEach(lora => {
// Only include LoRAs not in library and not deleted
if (!lora.inLibrary && !lora.isDeleted) {
const uniqueKey = lora.hash || lora.id || lora.modelVersionId;
if (uniqueKey && !uniqueLoras.has(uniqueKey)) {
// Store the LoRA info
uniqueLoras.set(uniqueKey, {
...lora,
modelId: lora.modelId || lora.model_id,
id: lora.id || lora.modelVersionId,
});
}
missingLoras.push(lora);
totalMissingCount++;
}
});
}
if (missingLoras.length > 0) {
missingLorasByRecipe.set(recipe.id || recipe.file_path, {
recipe,
missingLoras
});
}
});
return {
uniqueLoras: Array.from(uniqueLoras.values()),
uniqueCount: uniqueLoras.size,
totalMissingCount,
missingLorasByRecipe
};
}
/**
* Show confirmation modal for downloading missing LoRAs
* @param {Object} stats - Statistics about missing LoRAs
* @returns {Promise<boolean>} - Whether user confirmed
*/
async showConfirmationModal(stats) {
const { uniqueCount, totalMissingCount, uniqueLoras } = stats;
if (uniqueCount === 0) {
showToast('toast.recipes.noMissingLoras', {}, 'info');
return false;
}
// Store pending data for confirmation
this.pendingLoras = uniqueLoras;
// Update modal content
const messageEl = document.getElementById('bulkDownloadMissingLorasMessage');
const listEl = document.getElementById('bulkDownloadMissingLorasList');
const confirmBtn = document.getElementById('bulkDownloadMissingLorasConfirmBtn');
if (messageEl) {
messageEl.textContent = translate('modals.bulkDownloadMissingLoras.message', {
uniqueCount,
totalCount: totalMissingCount
}, `Found ${uniqueCount} unique missing LoRAs (from ${totalMissingCount} total across selected recipes).`);
}
if (listEl) {
listEl.innerHTML = uniqueLoras.slice(0, 10).map(lora => `
<li>
<span class="lora-name">${lora.name || lora.file_name || 'Unknown'}</span>
${lora.version ? `<span class="lora-version">${lora.version}</span>` : ''}
</li>
`).join('') +
(uniqueLoras.length > 10 ? `
<li class="more-items">${translate('modals.bulkDownloadMissingLoras.moreItems', { count: uniqueLoras.length - 10 }, `...and ${uniqueLoras.length - 10} more`)}</li>
` : '');
}
if (confirmBtn) {
confirmBtn.innerHTML = `
<i class="fas fa-download"></i>
${translate('modals.bulkDownloadMissingLoras.downloadButton', { count: uniqueCount }, `Download ${uniqueCount} LoRA(s)`)}
`;
}
// Show modal
modalManager.showModal('bulkDownloadMissingLorasModal');
// Return a promise that will be resolved when user confirms or cancels
return new Promise((resolve) => {
this.confirmResolve = resolve;
});
}
/**
* Called when user confirms download in modal
*/
async confirmDownload() {
modalManager.closeModal('bulkDownloadMissingLorasModal');
if (this.confirmResolve) {
this.confirmResolve(true);
this.confirmResolve = null;
}
// Execute download
await this.executeDownload(this.pendingLoras);
this.pendingLoras = [];
}
/**
* Download missing LoRAs for selected recipes
* @param {Array} selectedRecipes - Array of selected recipe objects
*/
async downloadMissingLoras(selectedRecipes) {
if (!selectedRecipes || selectedRecipes.length === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
// Store selected recipes
this.pendingRecipes = selectedRecipes;
// Collect missing LoRAs with deduplication
const stats = this.collectMissingLoras(selectedRecipes);
if (stats.uniqueCount === 0) {
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
return;
}
// Show confirmation modal
const confirmed = await this.showConfirmationModal(stats);
if (!confirmed) {
return;
}
}
/**
* Execute the download process
* @param {Array} lorasToDownload - Array of unique LoRAs to download
*/
async executeDownload(lorasToDownload) {
const totalLoras = lorasToDownload.length;
// Get LoRA root directory
const loraRoot = await this.getLoraRoot();
if (!loraRoot) {
showToast('toast.recipes.noLoraRootConfigured', {}, 'error');
return;
}
// Generate batch download ID
const batchDownloadId = Date.now().toString();
// Use default paths
const useDefaultPaths = true;
// Set up WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
// Show download progress UI
const loadingManager = state.loadingManager;
const updateProgress = loadingManager.showDownloadProgress(totalLoras);
let completedDownloads = 0;
let failedDownloads = 0;
let currentLoraProgress = 0;
// Set up WebSocket message handler
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Handle download ID confirmation
if (data.type === 'download_id') {
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
return;
}
// Process progress updates
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
currentLoraProgress = data.progress;
const currentLora = lorasToDownload[completedDownloads + failedDownloads];
const loraName = currentLora ? (currentLora.name || currentLora.file_name || 'Unknown') : '';
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second
};
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
// Update status message
if (currentLoraProgress < 3) {
loadingManager.setStatus(
translate('recipes.controls.import.startingDownload',
{ current: completedDownloads + failedDownloads + 1, total: totalLoras },
`Starting download for LoRA ${completedDownloads + failedDownloads + 1}/${totalLoras}`
)
);
} else if (currentLoraProgress > 3 && currentLoraProgress < 100) {
loadingManager.setStatus(
translate('recipes.controls.import.downloadingLoras', {}, `Downloading LoRAs...`)
);
}
}
};
// Wait for WebSocket to connect
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
});
// Download each LoRA sequentially
for (let i = 0; i < lorasToDownload.length; i++) {
const lora = lorasToDownload[i];
currentLoraProgress = 0;
loadingManager.setStatus(
translate('recipes.controls.import.startingDownload',
{ current: i + 1, total: totalLoras },
`Starting download for LoRA ${i + 1}/${totalLoras}`
)
);
updateProgress(0, completedDownloads, lora.name || lora.file_name || 'Unknown');
try {
const modelId = lora.modelId || lora.model_id;
const versionId = lora.id || lora.modelVersionId;
if (!modelId && !versionId) {
console.warn(`Skipping LoRA without model/version ID:`, lora);
failedDownloads++;
continue;
}
const response = await this.loraApiClient.downloadModel(
modelId,
versionId,
loraRoot,
'', // Empty relative path, use default paths
useDefaultPaths,
batchDownloadId
);
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++;
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
}
} catch (error) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
}
}
// Close WebSocket
ws.close();
// Hide loading UI
loadingManager.hide();
// Show completion message
if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads,
total: totalLoras
}, 'warning');
}
// Refresh the recipes list to update LoRA status
if (window.recipeManager) {
window.recipeManager.loadRecipes();
}
}
/**
* Get LoRA root directory from API
* @returns {Promise<string|null>} - LoRA root directory or null
*/
async getLoraRoot() {
try {
// Fetch available LoRA roots from API
const rootsData = await this.loraApiClient.fetchModelRoots();
if (!rootsData || !rootsData.roots || rootsData.roots.length === 0) {
console.error('No LoRA roots available');
return null;
}
// Try to get default root from settings
const defaultRootKey = 'default_lora_root';
const defaultRoot = state.global?.settings?.[defaultRootKey];
// If default root is set and exists in available roots, use it
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
return defaultRoot;
}
// Otherwise, return the first available root
return rootsData.roots[0];
} catch (error) {
console.error('Error getting LoRA root:', error);
return null;
}
}
}
// Export singleton instance
export const bulkMissingLoraDownloadManager = new BulkMissingLoraDownloadManager();
// Make available globally for HTML onclick handlers
if (typeof window !== 'undefined') {
window.bulkMissingLoraDownloadManager = bulkMissingLoraDownloadManager;
}

View File

@@ -492,7 +492,7 @@ export class DownloadManager {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
}; };
await this.apiClient.downloadModel( const response = await this.apiClient.downloadModel(
modelId, modelId,
versionId, versionId,
modelRoot, modelRoot,
@@ -502,6 +502,16 @@ export class DownloadManager {
source source
); );
if (response?.skipped) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
updateProgress(100, 0, displayName);
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
if (closeModal) {
modalManager.closeModal('downloadModal');
}
return true;
}
showToast('toast.loras.downloadCompleted', {}, 'success'); showToast('toast.loras.downloadCompleted', {}, 'success');
if (closeModal) { if (closeModal) {

View File

@@ -142,6 +142,28 @@ export class ImportManager {
// Reset duplicate related properties // Reset duplicate related properties
this.duplicateRecipes = []; this.duplicateRecipes = [];
// Reset button visibility in location step
this.resetLocationStepButtons();
}
resetLocationStepButtons() {
// Reset buttons to default state
const locationStep = document.getElementById('locationStep');
if (!locationStep) return;
const backBtn = locationStep.querySelector('.secondary-btn');
const primaryBtn = locationStep.querySelector('.primary-btn');
// Back button - show
if (backBtn) {
backBtn.style.display = 'inline-block';
}
// Primary button - reset text
if (primaryBtn) {
primaryBtn.textContent = translate('recipes.controls.import.downloadAndSaveRecipe', {}, 'Download & Save Recipe');
}
} }
toggleImportMode(mode) { toggleImportMode(mode) {
@@ -261,11 +283,57 @@ export class ImportManager {
this.loadDefaultPathSetting(); this.loadDefaultPathSetting();
this.updateTargetPath(); this.updateTargetPath();
// Update download button with missing LoRA count (if any)
if (this.missingLoras && this.missingLoras.length > 0) {
this.updateDownloadButtonCount();
this.updateImportButtonsVisibility(true);
} else {
this.updateImportButtonsVisibility(false);
}
} catch (error) { } catch (error) {
showToast('toast.recipes.importFailed', { message: error.message }, 'error'); showToast('toast.recipes.importFailed', { message: error.message }, 'error');
} }
} }
updateImportButtonsVisibility(hasMissingLoras) {
// Update primary button text based on whether there are missing LoRAs
const locationStep = document.getElementById('locationStep');
if (!locationStep) return;
const backBtn = locationStep.querySelector('.secondary-btn');
const primaryBtn = locationStep.querySelector('.primary-btn');
// Back button - always show
if (backBtn) {
backBtn.style.display = 'inline-block';
}
// Update primary button text
if (primaryBtn) {
const downloadCountSpan = locationStep.querySelector('#downloadLoraCount');
if (hasMissingLoras) {
// Rebuild button content to ensure proper structure
const buttonText = translate('recipes.controls.import.importAndDownload', {}, 'Import & Download');
primaryBtn.innerHTML = `${buttonText} <span id="downloadLoraCount"></span>`;
} else {
primaryBtn.textContent = translate('recipes.controls.import.downloadAndSaveRecipe', {}, 'Download & Save Recipe');
}
}
}
updateDownloadButtonCount() {
// Update the download count badge on the primary button
const locationStep = document.getElementById('locationStep');
if (!locationStep) return;
const downloadCountSpan = locationStep.querySelector('#downloadLoraCount');
if (downloadCountSpan) {
const missingCount = this.missingLoras?.length || 0;
downloadCountSpan.textContent = missingCount > 0 ? `(${missingCount})` : '';
}
}
backToUpload() { backToUpload() {
this.stepManager.showStep('uploadStep'); this.stepManager.showStep('uploadStep');
@@ -426,12 +494,54 @@ export class ImportManager {
const modalTitle = document.querySelector('#importModal h2'); const modalTitle = document.querySelector('#importModal h2');
if (modalTitle) modalTitle.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs'); if (modalTitle) modalTitle.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
// Update the save button text // Update button texts and show download count
const saveButton = document.querySelector('#locationStep .primary-btn'); const locationStep = document.getElementById('locationStep');
if (saveButton) saveButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs'); if (!locationStep) return;
// Hide the back button const primaryBtn = locationStep.querySelector('.primary-btn');
const backButton = document.querySelector('#locationStep .secondary-btn'); const backBtn = locationStep.querySelector('.secondary-btn');
if (backButton) backButton.style.display = 'none';
// primaryBtn should be the "Import & Download" button
if (primaryBtn) {
const buttonText = translate('recipes.controls.import.importAndDownload', {}, 'Import & Download');
primaryBtn.innerHTML = `${buttonText} <span id="downloadLoraCount">(${recipeData.loras?.length || 0})</span>`;
}
// Hide the "Back" button in download-only mode
if (backBtn) {
backBtn.style.display = 'none';
}
}
saveRecipeWithoutDownload() {
// Call save recipe with skip download flag
return this.downloadManager.saveRecipe(true);
}
async saveRecipeOnlyFromDetails() {
// Validate recipe name first
if (!this.recipeName) {
showToast('toast.recipes.enterRecipeName', {}, 'error');
return;
}
// Mark deleted LoRAs as excluded
if (this.recipeData && this.recipeData.loras) {
this.recipeData.loras.forEach(lora => {
if (lora.isDeleted) {
lora.exclude = true;
}
});
}
// Update missing LoRAs list
this.missingLoras = this.recipeData.loras.filter(lora =>
!lora.existsLocally && !lora.isDeleted);
// For import only, we don't need downloadableLoRAs
this.downloadableLoRAs = [];
// Save recipe without downloading
await this.downloadManager.saveRecipe(true);
} }
} }

View File

@@ -291,6 +291,19 @@ export class ModalManager {
}); });
} }
// Register bulkDownloadMissingLorasModal
const bulkDownloadMissingLorasModal = document.getElementById('bulkDownloadMissingLorasModal');
if (bulkDownloadMissingLorasModal) {
this.registerModal('bulkDownloadMissingLorasModal', {
element: bulkDownloadMissingLorasModal,
onClose: () => {
this.getModal('bulkDownloadMissingLorasModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape); document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true; this.initialized = true;
} }

View File

@@ -10,6 +10,8 @@ import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePrio
import { bannerService } from './BannerService.js'; import { bannerService } from './BannerService.js';
import { sidebarManager } from '../components/SidebarManager.js'; import { sidebarManager } from '../components/SidebarManager.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
export class SettingsManager { export class SettingsManager {
constructor() { constructor() {
this.initialized = false; this.initialized = false;
@@ -137,11 +139,29 @@ export class SettingsManager {
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
); );
merged.download_skip_base_models = this.normalizeDownloadSkipBaseModels(
backendSettings?.download_skip_base_models ?? defaults.download_skip_base_models
);
merged.mature_blur_level = this.normalizeMatureBlurLevel(
backendSettings?.mature_blur_level ?? defaults.mature_blur_level
);
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key)); Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
return merged; return merged;
} }
normalizeMatureBlurLevel(value) {
if (typeof value === 'string') {
const normalized = value.trim().toUpperCase();
if (VALID_MATURE_BLUR_LEVELS.has(normalized)) {
return normalized;
}
}
return 'R';
}
normalizePatternList(value) { normalizePatternList(value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
const sanitized = value const sanitized = value
@@ -163,6 +183,15 @@ export class SettingsManager {
return []; return [];
} }
getAvailableDownloadSkipBaseModels() {
return MAPPABLE_BASE_MODELS.filter(model => model !== 'Other');
}
normalizeDownloadSkipBaseModels(value) {
const allowed = new Set(this.getAvailableDownloadSkipBaseModels());
return this.normalizePatternList(value).filter(model => allowed.has(model));
}
registerStartupMessages(messages = []) { registerStartupMessages(messages = []) {
if (!Array.isArray(messages) || messages.length === 0) { if (!Array.isArray(messages) || messages.length === 0) {
return; return;
@@ -363,6 +392,36 @@ export class SettingsManager {
}); });
} }
const downloadSkipBaseModelsContainer = document.getElementById('downloadSkipBaseModelsContainer');
if (downloadSkipBaseModelsContainer) {
downloadSkipBaseModelsContainer.addEventListener('change', (event) => {
if (event.target instanceof HTMLInputElement && event.target.name === 'downloadSkipBaseModel') {
this.saveDownloadSkipBaseModels();
}
});
}
const downloadSkipBaseModelsToggle = document.getElementById('downloadSkipBaseModelsToggle');
if (downloadSkipBaseModelsToggle) {
downloadSkipBaseModelsToggle.addEventListener('click', () => {
this.toggleDownloadSkipBaseModelsPanel();
});
}
const downloadSkipBaseModelsSearch = document.getElementById('downloadSkipBaseModelsSearch');
if (downloadSkipBaseModelsSearch) {
downloadSkipBaseModelsSearch.addEventListener('input', () => {
this.renderDownloadSkipBaseModels();
});
}
const downloadSkipBaseModelsClear = document.getElementById('downloadSkipBaseModelsClear');
if (downloadSkipBaseModelsClear) {
downloadSkipBaseModelsClear.addEventListener('click', () => {
this.clearDownloadSkipBaseModels();
});
}
this.setupPriorityTagInputs(); this.setupPriorityTagInputs();
this.initializeNavigation(); this.initializeNavigation();
this.initializeSearch(); this.initializeSearch();
@@ -682,6 +741,13 @@ export class SettingsManager {
showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false; showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false;
} }
const matureBlurLevelSelect = document.getElementById('matureBlurLevel');
if (matureBlurLevelSelect) {
matureBlurLevelSelect.value = this.normalizeMatureBlurLevel(
state.global.settings.mature_blur_level
);
}
const usePortableCheckbox = document.getElementById('usePortableSettings'); const usePortableCheckbox = document.getElementById('usePortableSettings');
if (usePortableCheckbox) { if (usePortableCheckbox) {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
@@ -707,6 +773,13 @@ export class SettingsManager {
metadataRefreshSkipPathsError.textContent = ''; metadataRefreshSkipPathsError.textContent = '';
} }
this.renderDownloadSkipBaseModels();
const downloadSkipBaseModelsError = document.getElementById('downloadSkipBaseModelsError');
if (downloadSkipBaseModelsError) {
downloadSkipBaseModelsError.textContent = '';
}
this.setDownloadSkipBaseModelsPanelOpen(false);
// Set video autoplay on hover setting // Set video autoplay on hover setting
const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover'); const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover');
if (autoplayOnHoverCheckbox) { if (autoplayOnHoverCheckbox) {
@@ -1811,7 +1884,9 @@ export class SettingsManager {
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
if (!element) return; if (!element) return;
const value = element.value; const value = settingKey === 'mature_blur_level'
? this.normalizeMatureBlurLevel(element.value)
: element.value;
try { try {
// Update frontend state with mapped keys // Update frontend state with mapped keys
@@ -1834,7 +1909,12 @@ export class SettingsManager {
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success'); showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
if (settingKey === 'model_name_display' || settingKey === 'model_card_footer_action' || settingKey === 'update_flag_strategy') { if (
settingKey === 'model_name_display'
|| settingKey === 'model_card_footer_action'
|| settingKey === 'update_flag_strategy'
|| settingKey === 'mature_blur_level'
) {
this.reloadContent(); this.reloadContent();
} }
} catch (error) { } catch (error) {
@@ -2140,6 +2220,190 @@ export class SettingsManager {
} }
} }
renderDownloadSkipBaseModels() {
const container = document.getElementById('downloadSkipBaseModelsContainer');
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
const emptyState = document.getElementById('downloadSkipBaseModelsEmpty');
if (!container) {
return;
}
const selectedValues = this.normalizeDownloadSkipBaseModels(
state.global.settings.download_skip_base_models
);
const selected = new Set(selectedValues);
const options = this.getAvailableDownloadSkipBaseModels();
const query = (searchInput?.value || '').trim().toLowerCase();
const filteredOptions = query
? options.filter((baseModel) => baseModel.toLowerCase().includes(query))
: options;
container.innerHTML = filteredOptions.map((baseModel) => `
<label class="base-model-skip-option">
<input
type="checkbox"
name="downloadSkipBaseModel"
value="${baseModel}"
${selected.has(baseModel) ? 'checked' : ''}
>
<span>${baseModel}</span>
</label>
`).join('');
if (emptyState) {
emptyState.hidden = filteredOptions.length > 0;
}
this.renderDownloadSkipBaseModelsSummary(selectedValues);
}
renderDownloadSkipBaseModelsSummary(selectedValues = null) {
const summaryElement = document.getElementById('downloadSkipBaseModelsSummary');
if (!summaryElement) {
return;
}
const values = Array.isArray(selectedValues)
? selectedValues
: this.normalizeDownloadSkipBaseModels(state.global.settings.download_skip_base_models);
if (values.length === 0) {
summaryElement.textContent = translate(
'settings.downloadSkipBaseModels.summary.none',
{},
'None selected'
);
return;
}
if (values.length <= 2) {
summaryElement.textContent = values.join(', ');
return;
}
summaryElement.textContent = translate(
'settings.downloadSkipBaseModels.summary.count',
{ count: values.length },
`${values.length} selected`
);
}
setDownloadSkipBaseModelsPanelOpen(isOpen) {
const panel = document.getElementById('downloadSkipBaseModelsPanel');
const toggle = document.getElementById('downloadSkipBaseModelsToggle');
const toggleLabel = toggle?.querySelector('.base-model-skip-toggle-label');
if (!panel || !toggle) {
return;
}
panel.hidden = !isOpen;
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
if (toggleLabel) {
toggleLabel.textContent = isOpen
? translate('settings.downloadSkipBaseModels.actions.collapse', {}, 'Collapse')
: translate('settings.downloadSkipBaseModels.actions.edit', {}, 'Edit');
}
if (isOpen) {
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
searchInput?.focus();
}
}
toggleDownloadSkipBaseModelsPanel() {
const panel = document.getElementById('downloadSkipBaseModelsPanel');
if (!panel) {
return;
}
this.setDownloadSkipBaseModelsPanelOpen(panel.hidden);
}
async saveDownloadSkipBaseModels() {
const container = document.getElementById('downloadSkipBaseModelsContainer');
const errorElement = document.getElementById('downloadSkipBaseModelsError');
if (!container) return;
const selected = Array.from(
container.querySelectorAll('input[name="downloadSkipBaseModel"]:checked')
).map((input) => input.value);
const normalized = this.normalizeDownloadSkipBaseModels(selected);
const current = this.normalizeDownloadSkipBaseModels(state.global.settings.download_skip_base_models);
if (normalized.join('|') === current.join('|')) {
if (errorElement) {
errorElement.textContent = '';
}
return;
}
try {
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('download_skip_base_models', normalized);
this.renderDownloadSkipBaseModels();
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.downloadSkipBaseModels.label') },
'success'
);
} catch (error) {
console.error('Failed to save download skip base models:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.downloadSkipBaseModels.validation.saveFailed',
{ message: error.message },
`Unable to save excluded base models: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async clearDownloadSkipBaseModels() {
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
if (searchInput) {
searchInput.value = '';
}
const current = this.normalizeDownloadSkipBaseModels(
state.global.settings.download_skip_base_models
);
if (current.length === 0) {
this.renderDownloadSkipBaseModels();
return;
}
try {
const errorElement = document.getElementById('downloadSkipBaseModelsError');
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('download_skip_base_models', []);
this.renderDownloadSkipBaseModels();
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.downloadSkipBaseModels.label') },
'success'
);
} catch (error) {
const errorElement = document.getElementById('downloadSkipBaseModelsError');
console.error('Failed to clear download skip base models:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.downloadSkipBaseModels.validation.saveFailed',
{ message: error.message },
`Unable to save excluded base models: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async saveMetadataRefreshSkipPaths() { async saveMetadataRefreshSkipPaths() {
const input = document.getElementById('metadataRefreshSkipPaths'); const input = document.getElementById('metadataRefreshSkipPaths');
const errorElement = document.getElementById('metadataRefreshSkipPathsError'); const errorElement = document.getElementById('metadataRefreshSkipPathsError');

View File

@@ -9,7 +9,7 @@ export class DownloadManager {
this.importManager = importManager; this.importManager = importManager;
} }
async saveRecipe() { async saveRecipe(skipDownload = false) {
// Check if we're in download-only mode (for existing recipe) // Check if we're in download-only mode (for existing recipe)
const isDownloadOnly = !!this.importManager.recipeId; const isDownloadOnly = !!this.importManager.recipeId;
@@ -20,7 +20,10 @@ export class DownloadManager {
try { try {
// Show progress indicator // Show progress indicator
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...')); const loadingMessage = skipDownload
? translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...')
: (isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
this.importManager.loadingManager.showSimpleLoading(loadingMessage);
// Only send the complete recipe to save if not in download-only mode // Only send the complete recipe to save if not in download-only mode
if (!isDownloadOnly) { if (!isDownloadOnly) {
@@ -98,15 +101,17 @@ export class DownloadManager {
} }
} }
// Check if we need to download LoRAs // Check if we need to download LoRAs (skip if skipDownload is true)
let failedDownloads = 0; let failedDownloads = 0;
if (this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) { if (!skipDownload && this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) {
await this.downloadMissingLoras(); await this.downloadMissingLoras();
} }
// Show success message // Show success message
if (isDownloadOnly) { if (isDownloadOnly) {
if (failedDownloads === 0) { if (skipDownload) {
showToast('toast.recipes.recipeSaved', {}, 'success');
} else if (failedDownloads === 0) {
showToast('toast.loras.downloadSuccessful', {}, 'success'); showToast('toast.loras.downloadSuccessful', {}, 'success');
} }
} else { } else {

View File

@@ -325,7 +325,8 @@ export class RecipeDataManager {
} }
updateNextButtonState() { updateNextButtonState() {
const nextButton = document.querySelector('#detailsStep .primary-btn'); const nextButton = document.getElementById('nextBtn');
const importOnlyBtn = document.getElementById('importOnlyBtn');
const actionsContainer = document.querySelector('#detailsStep .modal-actions'); const actionsContainer = document.querySelector('#detailsStep .modal-actions');
if (!nextButton || !actionsContainer) return; if (!nextButton || !actionsContainer) return;
@@ -365,7 +366,7 @@ export class RecipeDataManager {
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
} }
// Check for duplicates but don't change button actions // Check for downloadable missing LoRAs
const missingNotDeleted = this.importManager.recipeData.loras.filter( const missingNotDeleted = this.importManager.recipeData.loras.filter(
lora => !lora.existsLocally && !lora.isDeleted lora => !lora.existsLocally && !lora.isDeleted
).length; ).length;
@@ -374,8 +375,16 @@ export class RecipeDataManager {
nextButton.classList.remove('warning-btn'); nextButton.classList.remove('warning-btn');
if (missingNotDeleted > 0) { if (missingNotDeleted > 0) {
nextButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs'); // Show import only button and update primary button
if (importOnlyBtn) {
importOnlyBtn.style.display = 'inline-block';
}
nextButton.textContent = translate('recipes.controls.import.importAndDownload', {}, 'Import & Download') + ` (${missingNotDeleted})`;
} else { } else {
// Hide import only button and show save recipe
if (importOnlyBtn) {
importOnlyBtn.style.display = 'none';
}
nextButton.textContent = translate('recipes.controls.import.saveRecipe', {}, 'Save Recipe'); nextButton.textContent = translate('recipes.controls.import.saveRecipe', {}, 'Save Recipe');
} }
} }
@@ -440,8 +449,11 @@ export class RecipeDataManager {
// Store only downloadable LoRAs for the download step // Store only downloadable LoRAs for the download step
this.importManager.downloadableLoRAs = this.importManager.missingLoras; this.importManager.downloadableLoRAs = this.importManager.missingLoras;
this.importManager.proceedToLocation(); this.importManager.proceedToLocation();
} else if (this.importManager.missingLoras.length === 0 && this.importManager.recipeData.loras.some(l => !l.existsLocally)) {
// All missing LoRAs are deleted, save recipe without download
this.importManager.saveRecipe();
} else { } else {
// Otherwise, save the recipe directly // No missing LoRAs at all, save the recipe directly
this.importManager.saveRecipe(); this.importManager.saveRecipe();
} }
} }

View File

@@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
optimize_example_images: true, optimize_example_images: true,
auto_download_example_images: false, auto_download_example_images: false,
blur_mature_content: true, blur_mature_content: true,
mature_blur_level: 'R',
autoplay_on_hover: false, autoplay_on_hover: false,
display_density: 'default', display_density: 'default',
card_info_display: 'always', card_info_display: 'always',
@@ -37,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
hide_early_access_updates: false, hide_early_access_updates: false,
auto_organize_exclusions: [], auto_organize_exclusions: [],
metadata_refresh_skip_paths: [], metadata_refresh_skip_paths: [],
download_skip_base_models: [],
}); });
export function createDefaultSettings() { export function createDefaultSettings() {

View File

@@ -309,6 +309,15 @@ export const NSFW_LEVELS = {
BLOCKED: 32 BLOCKED: 32
}; };
export const VALID_MATURE_BLUR_LEVELS = ['PG13', 'R', 'X', 'XXX'];
export function getMatureBlurThreshold(settings = {}) {
const rawValue = settings?.mature_blur_level;
const normalizedValue = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : '';
const levelName = VALID_MATURE_BLUR_LEVELS.includes(normalizedValue) ? normalizedValue : 'R';
return NSFW_LEVELS[levelName] ?? NSFW_LEVELS.R;
}
// Node type constants // Node type constants
export const NODE_TYPES = { export const NODE_TYPES = {
LORA_LOADER: 1, LORA_LOADER: 1,

View File

@@ -87,6 +87,9 @@
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span> <i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
</div> </div>
<div class="context-menu-separator"></div> <div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
</div>
<div class="context-menu-item" data-action="move-all"> <div class="context-menu-item" data-action="move-all">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span> <i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</div> </div>

View File

@@ -92,9 +92,10 @@
<!-- Duplicate recipes will be populated here --> <!-- Duplicate recipes will be populated here -->
</div> </div>
<div class="modal-actions"> <div class="modal-actions" id="detailsStepActions">
<button class="secondary-btn" onclick="importManager.backToUpload()">{{ t('common.actions.back') }}</button> <button class="secondary-btn" onclick="importManager.backToUpload()">{{ t('common.actions.back') }}</button>
<button class="primary-btn" onclick="importManager.proceedFromDetails()">{{ t('common.actions.next') }}</button> <button class="secondary-btn" id="importOnlyBtn" onclick="importManager.saveRecipeOnlyFromDetails()" style="display: none;">{{ t('recipes.controls.import.importRecipeOnly') }}</button>
<button class="primary-btn" id="nextBtn" onclick="importManager.proceedFromDetails()">{{ t('common.actions.next') }}</button>
</div> </div>
</div> </div>
@@ -159,7 +160,7 @@
<div class="modal-actions"> <div class="modal-actions">
<button class="secondary-btn" onclick="importManager.backToDetails()">{{ t('common.actions.back') }}</button> <button class="secondary-btn" onclick="importManager.backToDetails()">{{ t('common.actions.back') }}</button>
<button class="primary-btn" onclick="importManager.saveRecipe()">{{ t('recipes.controls.import.downloadAndSaveRecipe') }}</button> <button class="primary-btn" onclick="importManager.saveRecipe()">{{ t('recipes.controls.import.importAndDownload') }} <span id="downloadLoraCount"></span></button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -81,3 +81,31 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Bulk Download Missing LoRAs Confirmation Modal -->
<div id="bulkDownloadMissingLorasModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('modals.bulkDownloadMissingLoras.title') }}</h2>
<span class="close" onclick="modalManager.closeModal('bulkDownloadMissingLorasModal')">&times;</span>
</div>
<div class="modal-body">
<p class="confirmation-message" id="bulkDownloadMissingLorasMessage"></p>
<div class="bulk-download-loras-preview" id="bulkDownloadMissingLorasPreview">
<p class="preview-title">{{ t('modals.bulkDownloadMissingLoras.previewTitle') }}</p>
<ul class="bulk-download-loras-list" id="bulkDownloadMissingLorasList"></ul>
</div>
<p class="confirmation-note">
<i class="fas fa-info-circle"></i>
{{ t('modals.bulkDownloadMissingLoras.note') }}
</p>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="modalManager.closeModal('bulkDownloadMissingLorasModal')">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" id="bulkDownloadMissingLorasConfirmBtn" onclick="bulkMissingLoraDownloadManager.confirmDownload()">
<i class="fas fa-download"></i>
{{ t('modals.bulkDownloadMissingLoras.downloadButton') }}
</button>
</div>
</div>
</div>

View File

@@ -281,6 +281,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="matureBlurLevel">
{{ t('settings.contentFiltering.matureBlurThreshold') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.contentFiltering.matureBlurThresholdHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="matureBlurLevel"
onchange="settingsManager.saveSelectSetting('matureBlurLevel', 'mature_blur_level')">
<option value="PG13">{{ t('settings.contentFiltering.matureBlurThresholdOptions.pg13') }}</option>
<option value="R">{{ t('settings.contentFiltering.matureBlurThresholdOptions.r') }}</option>
<option value="X">{{ t('settings.contentFiltering.matureBlurThresholdOptions.x') }}</option>
<option value="XXX">{{ t('settings.contentFiltering.matureBlurThresholdOptions.xxx') }}</option>
</select>
</div>
</div>
</div>
</div> </div>
<!-- Video Settings --> <!-- Video Settings -->
@@ -723,6 +743,46 @@
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadSkipBaseModelsToggle">
{{ t('settings.downloadSkipBaseModels.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
</label>
</div>
<div class="setting-control">
<button
type="button"
id="downloadSkipBaseModelsToggle"
class="secondary-btn base-model-skip-toggle"
aria-expanded="false"
>
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
</button>
</div>
</div>
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
<div class="base-model-skip-toolbar">
<input
type="text"
id="downloadSkipBaseModelsSearch"
class="base-model-skip-search"
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
/>
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
</button>
</div>
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
{{ t('settings.downloadSkipBaseModels.empty') }}
</div>
</div>
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
</div>
<!-- Priority Tags --> <!-- Priority Tags -->
<div class="setting-item priority-tags-item"> <div class="setting-item priority-tags-item">
<div class="setting-row priority-tags-header-row"> <div class="setting-row priority-tags-header-row">

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
closeModal: vi.fn(),
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/state/index.js', () => {
const settings = {};
return {
state: {
global: {
settings,
},
},
createDefaultSettings: () => ({
language: 'en',
download_skip_base_models: [],
}),
};
});
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: vi.fn(),
}));
vi.mock('../../../static/js/utils/constants.js', () => ({
DOWNLOAD_PATH_TEMPLATES: {},
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
PATH_TEMPLATE_PLACEHOLDERS: {},
DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style',
checkpoint: 'base, guide',
embedding: 'hint',
},
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (key, params, fallback) => {
if (key === 'settings.downloadSkipBaseModels.summary.none') {
return 'None selected';
}
if (key === 'settings.downloadSkipBaseModels.summary.count') {
return `${params?.count ?? 0} selected`;
}
return fallback ?? '';
},
}));
vi.mock('../../../static/js/i18n/index.js', () => ({
i18n: {
getCurrentLocale: () => 'en',
setLanguage: vi.fn().mockResolvedValue(),
},
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
configureModelCardVideo: vi.fn(),
}));
vi.mock('../../../static/js/managers/BannerService.js', () => ({
bannerService: {
registerBanner: vi.fn(),
},
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setSidebarEnabled: vi.fn().mockResolvedValue(),
},
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { state } from '../../../static/js/state/index.js';
const createManager = () => {
const initSettingsSpy = vi
.spyOn(SettingsManager.prototype, 'initializeSettings')
.mockResolvedValue();
const initializeSpy = vi
.spyOn(SettingsManager.prototype, 'initialize')
.mockImplementation(() => {});
const manager = new SettingsManager();
initSettingsSpy.mockRestore();
initializeSpy.mockRestore();
return manager;
};
const appendDownloadSkipUi = () => {
document.body.innerHTML = `
<button id="downloadSkipBaseModelsToggle" aria-expanded="false">
<span id="downloadSkipBaseModelsSummary"></span>
<span class="base-model-skip-toggle-label"></span>
</button>
<div id="downloadSkipBaseModelsPanel" hidden>
<input id="downloadSkipBaseModelsSearch" />
<button id="downloadSkipBaseModelsClear" type="button">Clear</button>
<div id="downloadSkipBaseModelsContainer"></div>
<div id="downloadSkipBaseModelsEmpty" hidden></div>
</div>
<div id="downloadSkipBaseModelsError"></div>
`;
};
describe('SettingsManager download skip base models UI', () => {
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
state.global.settings = {
download_skip_base_models: [],
};
});
it('renders a compact summary for selected base models', () => {
appendDownloadSkipUi();
state.global.settings.download_skip_base_models = ['Flux.1 D', 'Pony'];
const manager = createManager();
manager.renderDownloadSkipBaseModels();
expect(document.getElementById('downloadSkipBaseModelsSummary').textContent).toBe('Flux.1 D, Pony');
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(3);
});
it('filters the list using the search input and shows an empty state', () => {
appendDownloadSkipUi();
state.global.settings.download_skip_base_models = ['Flux.1 D'];
const manager = createManager();
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
searchInput.value = 'pony';
manager.renderDownloadSkipBaseModels();
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(1);
expect(document.querySelector('#downloadSkipBaseModelsContainer input').value).toBe('Pony');
searchInput.value = 'zzz';
manager.renderDownloadSkipBaseModels();
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(0);
expect(document.getElementById('downloadSkipBaseModelsEmpty').hidden).toBe(false);
});
});

View File

@@ -15,7 +15,8 @@ describe('state module', () => {
expect(defaultSettings).toMatchObject({ expect(defaultSettings).toMatchObject({
civitai_api_key: '', civitai_api_key: '',
language: 'en', language: 'en',
blur_mature_content: true blur_mature_content: true,
mature_blur_level: 'R'
}); });
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES); expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../static/js/utils/constants.js';
describe('getMatureBlurThreshold', () => {
it('returns configured PG13 threshold', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'PG13' })).toBe(NSFW_LEVELS.PG13);
});
it('normalizes lowercase values', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'x' })).toBe(NSFW_LEVELS.X);
});
it('falls back to R when value is invalid or missing', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'invalid' })).toBe(NSFW_LEVELS.R);
expect(getMatureBlurThreshold({})).toBe(NSFW_LEVELS.R);
});
});

View File

@@ -719,3 +719,42 @@ def test_auto_organize_conflict_when_running(mock_service):
await client.close() await client.close()
asyncio.run(scenario()) asyncio.run(scenario())
def test_download_model_returns_skipped_success(mock_service, download_manager_stub):
async def scenario():
download_manager_stub.last_progress_snapshot = None
async def fake_download(**kwargs):
download_manager_stub.calls.append(kwargs)
return {
"success": True,
"skipped": True,
"status": "skipped",
"reason": "base_model_excluded",
"message": "Skipped by settings",
"base_model": "SDXL 1.0",
"file_name": "demo.safetensors",
}
download_manager_stub.download_from_civitai = fake_download
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/download-model",
json={"model_version_id": 123},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["skipped"] is True
assert payload["reason"] == "base_model_excluded"
assert payload["base_model"] == "SDXL 1.0"
assert payload["file_name"] == "demo.safetensors"
finally:
await client.close()
asyncio.run(scenario())

View File

@@ -484,9 +484,11 @@ async def test_get_model_version_info_success(monkeypatch, downloader):
assert result["images"][0]["meta"]["other"] == "keep" assert result["images"][0]["meta"]["other"] == "keep"
async def test_get_image_info_returns_first_item(monkeypatch, downloader): async def test_get_image_info_returns_matching_item(monkeypatch, downloader):
"""When API returns multiple items, return the one matching the requested ID."""
async def fake_make_request(method, url, use_auth=True, **kwargs): async def fake_make_request(method, url, use_auth=True, **kwargs):
return True, {"items": [{"id": 1}, {"id": 2}]} # Requested ID is 42, but it's the second item in the response
return True, {"items": [{"id": 41}, {"id": 42, "name": "target"}, {"id": 43}]}
downloader.make_request = fake_make_request downloader.make_request = fake_make_request
@@ -494,7 +496,25 @@ async def test_get_image_info_returns_first_item(monkeypatch, downloader):
result = await client.get_image_info("42") result = await client.get_image_info("42")
assert result == {"id": 1} assert result == {"id": 42, "name": "target"}
async def test_get_image_info_returns_none_when_id_mismatch(monkeypatch, downloader, caplog):
"""When API returns items but none match the requested ID, return None and log warning."""
async def fake_make_request(method, url, use_auth=True, **kwargs):
# Requested ID is 999, but API returns different IDs (simulating deleted/hidden image)
return True, {"items": [{"id": 1}, {"id": 2}, {"id": 3}]}
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info("999")
assert result is None
# Verify warning was logged
assert "CivitAI API returned no matching image for requested ID 999" in caplog.text
assert "Returned 3 item(s) with IDs: [1, 2, 3]" in caplog.text
async def test_get_image_info_handles_missing(monkeypatch, downloader): async def test_get_image_info_handles_missing(monkeypatch, downloader):
@@ -508,3 +528,13 @@ async def test_get_image_info_handles_missing(monkeypatch, downloader):
result = await client.get_image_info("42") result = await client.get_image_info("42")
assert result is None assert result is None
async def test_get_image_info_handles_invalid_id(monkeypatch, downloader, caplog):
"""When given a non-numeric image ID, return None and log error."""
client = await CivitaiClient.get_instance()
result = await client.get_image_info("not-a-number")
assert result is None
assert "Invalid image ID format" in caplog.text

View File

@@ -38,6 +38,7 @@ def isolate_settings(monkeypatch, tmp_path):
"embedding": "{base_model}/{first_tag}", "embedding": "{base_model}/{first_tag}",
}, },
"base_model_path_mappings": {"BaseModel": "MappedModel"}, "base_model_path_mappings": {"BaseModel": "MappedModel"},
"download_skip_base_models": [],
} }
) )
monkeypatch.setattr(manager, "settings", default_settings) monkeypatch.setattr(manager, "settings", default_settings)
@@ -443,3 +444,49 @@ def test_distribute_preview_to_entries_keeps_existing_file(tmp_path):
assert targets[0] == str(existing_preview) assert targets[0] == str(existing_preview)
assert Path(targets[1]).read_bytes() == b"preview" assert Path(targets[1]).read_bytes() == b"preview"
@pytest.mark.asyncio
async def test_download_skips_excluded_base_model(monkeypatch, scanners, metadata_provider):
manager = DownloadManager()
get_settings_manager().settings["download_skip_base_models"] = ["SDXL 1.0"]
metadata_provider.get_model_version = AsyncMock(
return_value={
"id": 42,
"model": {"type": "LoRA", "tags": ["fantasy"]},
"baseModel": "SDXL 1.0",
"creator": {"username": "Author"},
"files": [
{
"type": "Model",
"primary": True,
"downloadUrl": "https://example.invalid/file.safetensors",
"name": "file.safetensors",
}
],
}
)
execute_download = AsyncMock()
monkeypatch.setattr(
DownloadManager, "_execute_download", execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=99,
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert result["skipped"] is True
assert result["status"] == "skipped"
assert result["reason"] == "base_model_excluded"
assert result["base_model"] == "SDXL 1.0"
assert result["file_name"] == "file.safetensors"
assert "file.safetensors" in result["message"]
execute_download.assert_not_called()
assert manager._active_downloads[result["download_id"]]["status"] == "skipped"

View File

@@ -281,8 +281,6 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner) DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
) )
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True)) monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
hash_calculator = AsyncMock(return_value="hash-single")
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
result = await manager._execute_download( result = await manager._execute_download(
download_urls=download_urls, download_urls=download_urls,
@@ -299,10 +297,10 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
assert not zip_path.exists() assert not zip_path.exists()
extracted = save_dir / "model.safetensors" extracted = save_dir / "model.safetensors"
assert extracted.exists() assert extracted.exists()
assert hash_calculator.await_args.args[0] == str(extracted)
saved_call = MetadataManager.save_metadata.await_args saved_call = MetadataManager.save_metadata.await_args
assert saved_call.args[0] == str(extracted) assert saved_call.args[0] == str(extracted)
assert saved_call.args[1].sha256 == "hash-single" # SHA256 comes from metadata (API value), not recalculated
assert saved_call.args[1].sha256 == "sha256"
assert dummy_scanner.add_model_to_cache.await_count == 1 assert dummy_scanner.add_model_to_cache.await_count == 1
@@ -351,8 +349,6 @@ async def test_execute_download_extracts_zip_multiple_models(monkeypatch, tmp_pa
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner) DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
) )
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True)) monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
hash_calculator = AsyncMock(side_effect=["hash-one", "hash-two"])
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
result = await manager._execute_download( result = await manager._execute_download(
download_urls=download_urls, download_urls=download_urls,
@@ -372,15 +368,15 @@ async def test_execute_download_extracts_zip_multiple_models(monkeypatch, tmp_pa
assert extracted_one.exists() assert extracted_one.exists()
assert extracted_two.exists() assert extracted_two.exists()
assert hash_calculator.await_count == 2
assert MetadataManager.save_metadata.await_count == 2 assert MetadataManager.save_metadata.await_count == 2
assert dummy_scanner.add_model_to_cache.await_count == 2 assert dummy_scanner.add_model_to_cache.await_count == 2
metadata_calls = MetadataManager.save_metadata.await_args_list metadata_calls = MetadataManager.save_metadata.await_args_list
assert metadata_calls[0].args[0] == str(extracted_one) assert metadata_calls[0].args[0] == str(extracted_one)
assert metadata_calls[0].args[1].sha256 == "hash-one" # SHA256 comes from metadata (API value), not recalculated
assert metadata_calls[0].args[1].sha256 == "sha256"
assert metadata_calls[1].args[0] == str(extracted_two) assert metadata_calls[1].args[0] == str(extracted_two)
assert metadata_calls[1].args[1].sha256 == "hash-two" assert metadata_calls[1].args[1].sha256 == "sha256"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -427,8 +423,6 @@ async def test_execute_download_extracts_zip_pt_embedding(monkeypatch, tmp_path)
ServiceRegistry, "get_embedding_scanner", AsyncMock(return_value=dummy_scanner) ServiceRegistry, "get_embedding_scanner", AsyncMock(return_value=dummy_scanner)
) )
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True)) monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
hash_calculator = AsyncMock(return_value="hash-pt")
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
result = await manager._execute_download( result = await manager._execute_download(
download_urls=download_urls, download_urls=download_urls,
@@ -445,10 +439,10 @@ async def test_execute_download_extracts_zip_pt_embedding(monkeypatch, tmp_path)
assert not zip_path.exists() assert not zip_path.exists()
extracted = save_dir / "embedding.pt" extracted = save_dir / "embedding.pt"
assert extracted.exists() assert extracted.exists()
assert hash_calculator.await_args.args[0] == str(extracted)
saved_call = MetadataManager.save_metadata.await_args saved_call = MetadataManager.save_metadata.await_args
assert saved_call.args[0] == str(extracted) assert saved_call.args[0] == str(extracted)
assert saved_call.args[1].sha256 == "hash-pt" # SHA256 comes from metadata (API value), not recalculated
assert saved_call.args[1].sha256 == "sha256"
assert dummy_scanner.add_model_to_cache.await_count == 1 assert dummy_scanner.add_model_to_cache.await_count == 1

View File

@@ -9,7 +9,11 @@ from unittest.mock import AsyncMock, patch, MagicMock
import aiohttp import aiohttp
from py.services.downloader import Downloader, DownloadStalledError, DownloadRestartRequested from py.services.downloader import (
Downloader,
DownloadStalledError,
DownloadRestartRequested,
)
class TestDownloadStreamControl: class TestDownloadStreamControl:
@@ -118,6 +122,7 @@ class TestDownloaderConfiguration:
return instance1, instance2 return instance1, instance2
import asyncio import asyncio
instance1, instance2 = asyncio.run(get_instances()) instance1, instance2 = asyncio.run(get_instances())
assert instance1 is instance2 assert instance1 is instance2
@@ -131,7 +136,7 @@ class TestDownloaderConfiguration:
downloader = Downloader() downloader = Downloader()
assert downloader.chunk_size == 4 * 1024 * 1024 # 4MB assert downloader.chunk_size == 16 * 1024 * 1024 # 16MB
assert downloader.max_retries == 5 assert downloader.max_retries == 5
assert downloader.base_delay == 2.0 assert downloader.base_delay == 2.0
assert downloader.session_timeout == 300 assert downloader.session_timeout == 300
@@ -145,9 +150,9 @@ class TestDownloaderConfiguration:
downloader = Downloader() downloader = Downloader()
assert 'User-Agent' in downloader.default_headers assert "User-Agent" in downloader.default_headers
assert 'ComfyUI-LoRA-Manager' in downloader.default_headers['User-Agent'] assert "ComfyUI-LoRA-Manager" in downloader.default_headers["User-Agent"]
assert downloader.default_headers['Accept-Encoding'] == 'identity' assert downloader.default_headers["Accept-Encoding"] == "identity"
# Cleanup # Cleanup
Downloader._instance = None Downloader._instance = None
@@ -204,7 +209,10 @@ class TestDownloaderExceptions:
with pytest.raises(DownloadRestartRequested) as exc_info: with pytest.raises(DownloadRestartRequested) as exc_info:
raise DownloadRestartRequested("Reconnect requested after resume") raise DownloadRestartRequested("Reconnect requested after resume")
assert "reconnect" in str(exc_info.value).lower() or "restart" in str(exc_info.value).lower() assert (
"reconnect" in str(exc_info.value).lower()
or "restart" in str(exc_info.value).lower()
)
class TestDownloaderAuthHeaders: class TestDownloaderAuthHeaders:
@@ -217,8 +225,8 @@ class TestDownloaderAuthHeaders:
headers = downloader._get_auth_headers(use_auth=False) headers = downloader._get_auth_headers(use_auth=False)
assert 'User-Agent' in headers assert "User-Agent" in headers
assert 'Authorization' not in headers assert "Authorization" not in headers
Downloader._instance = None Downloader._instance = None
@@ -231,12 +239,14 @@ class TestDownloaderAuthHeaders:
mock_settings = MagicMock() mock_settings = MagicMock()
mock_settings.get.return_value = None mock_settings.get.return_value = None
with patch('py.services.downloader.get_settings_manager', return_value=mock_settings): with patch(
"py.services.downloader.get_settings_manager", return_value=mock_settings
):
headers = downloader._get_auth_headers(use_auth=True) headers = downloader._get_auth_headers(use_auth=True)
# Should still have User-Agent but no Authorization # Should still have User-Agent but no Authorization
assert 'User-Agent' in headers assert "User-Agent" in headers
assert 'Authorization' not in headers assert "Authorization" not in headers
Downloader._instance = None Downloader._instance = None
@@ -249,14 +259,16 @@ class TestDownloaderAuthHeaders:
mock_settings = MagicMock() mock_settings = MagicMock()
mock_settings.get.return_value = "test-api-key-12345" mock_settings.get.return_value = "test-api-key-12345"
with patch('py.services.downloader.get_settings_manager', return_value=mock_settings): with patch(
"py.services.downloader.get_settings_manager", return_value=mock_settings
):
headers = downloader._get_auth_headers(use_auth=True) headers = downloader._get_auth_headers(use_auth=True)
# Should have both User-Agent and Authorization # Should have both User-Agent and Authorization
assert 'User-Agent' in headers assert "User-Agent" in headers
assert 'Authorization' in headers assert "Authorization" in headers
assert 'test-api-key-12345' in headers['Authorization'] assert "test-api-key-12345" in headers["Authorization"]
assert headers['Content-Type'] == 'application/json' assert headers["Content-Type"] == "application/json"
Downloader._instance = None Downloader._instance = None
@@ -286,6 +298,7 @@ class TestDownloaderSessionManagement:
# Mock datetime to return current time # Mock datetime to return current time
from datetime import datetime, timedelta from datetime import datetime, timedelta
current_time = datetime.now() current_time = datetime.now()
downloader._session_created_at = current_time downloader._session_created_at = current_time
@@ -301,6 +314,7 @@ class TestDownloaderSessionManagement:
# Simulate an old session (older than timeout) # Simulate an old session (older than timeout)
from datetime import datetime, timedelta from datetime import datetime, timedelta
old_time = datetime.now() - timedelta(seconds=downloader.session_timeout + 1) old_time = datetime.now() - timedelta(seconds=downloader.session_timeout + 1)
downloader._session_created_at = old_time downloader._session_created_at = old_time
downloader._session = MagicMock() downloader._session = MagicMock()

View File

@@ -369,3 +369,289 @@ async def test_pool_filter_combined_all_filters(lora_service):
# - tags: tag1 ✓ # - tags: tag1 ✓
assert len(filtered) == 1 assert len(filtered) == 1
assert filtered[0]["file_name"] == "match_all.safetensors" assert filtered[0]["file_name"] == "match_all.safetensors"
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_include_text(lora_service):
"""Test filtering by name patterns with text matching (useRegex=False)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test include patterns with text matching
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["character"], "exclude": [], "useRegex": False},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"character_anime_v1.safetensors",
"character_realistic_v1.safetensors",
}
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_exclude_text(lora_service):
"""Test excluding by name patterns with text matching (useRegex=False)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test exclude patterns with text matching
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": [], "exclude": ["anime"], "useRegex": False},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"character_realistic_v1.safetensors",
"style_watercolor_v1.safetensors",
}
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_include_regex(lora_service):
"""Test filtering by name patterns with regex matching (useRegex=True)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test include patterns with regex matching - match files starting with "character_"
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["^character_"], "exclude": [], "useRegex": True},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"character_anime_v1.safetensors",
"character_realistic_v1.safetensors",
}
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_exclude_regex(lora_service):
"""Test excluding by name patterns with regex matching (useRegex=True)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test exclude patterns with regex matching - exclude files ending with "_v1.safetensors"
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {
"include": [],
"exclude": ["_v1\\.safetensors$"],
"useRegex": True,
},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 0 # All files match the exclude pattern
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_combined(lora_service):
"""Test combining include and exclude name patterns."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test include "character" but exclude "anime"
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {
"include": ["character"],
"exclude": ["anime"],
"useRegex": False,
},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 1
assert filtered[0]["file_name"] == "character_realistic_v1.safetensors"
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_model_name_fallback(lora_service):
"""Test that name pattern filtering falls back to model_name when file_name doesn't match."""
sample_loras = [
{
"file_name": "abc123.safetensors",
"model_name": "Super Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "def456.safetensors",
"model_name": "Realistic Portrait",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Should match model_name even if file_name doesn't contain the pattern
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["anime"], "exclude": [], "useRegex": False},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 1
assert filtered[0]["file_name"] == "abc123.safetensors"
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_invalid_regex(lora_service):
"""Test that invalid regex falls back to substring matching."""
sample_loras = [
{
"file_name": "character_anime[test]_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Invalid regex pattern (unclosed character class) should fall back to substring matching
# The pattern "anime[" is invalid regex but valid substring - it exists in the filename
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["anime["], "exclude": [], "useRegex": True},
}
# Should not crash and should match using substring fallback
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 1 # Substring match works even with invalid regex

View File

@@ -492,7 +492,7 @@ async def test_analyze_remote_video(tmp_path):
class DummyFactory: class DummyFactory:
def create_parser(self, metadata): def create_parser(self, metadata):
async def parse_metadata(m, recipe_scanner): async def parse_metadata(m, recipe_scanner=None, civitai_client=None):
return {"loras": []} return {"loras": []}
return SimpleNamespace(parse_metadata=parse_metadata) return SimpleNamespace(parse_metadata=parse_metadata)

View File

@@ -265,6 +265,32 @@ def test_delete_setting(manager):
assert manager.get("example") is None assert manager.get("example") is None
def test_missing_mature_blur_level_defaults_to_r(tmp_path, monkeypatch):
manager = _create_manager_with_settings(
tmp_path,
monkeypatch,
{
"blur_mature_content": True,
"folder_paths": {},
},
)
assert manager.get("mature_blur_level") == "R"
def test_invalid_mature_blur_level_is_normalized_to_r(tmp_path, monkeypatch):
manager = _create_manager_with_settings(
tmp_path,
monkeypatch,
{
"mature_blur_level": "unsafe",
"folder_paths": {},
},
)
assert manager.get("mature_blur_level") == "R"
def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch): def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
initial = { initial = {
"libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}}, "libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}},
@@ -579,3 +605,28 @@ def test_delete_library_switches_active(manager, tmp_path):
manager.delete_library("other") manager.delete_library("other")
assert manager.get_active_library_name() == "default" assert manager.get_active_library_name() == "default"
def test_download_skip_base_models_are_normalized(manager):
manager.settings["download_skip_base_models"] = [
"SDXL 1.0",
"Invalid",
"SDXL 1.0",
"Pony",
"Other",
]
result = manager.get_download_skip_base_models()
assert result == ["SDXL 1.0", "Pony"]
assert manager.settings["download_skip_base_models"] == ["SDXL 1.0", "Pony"]
def test_setting_download_skip_base_models_normalizes_string_input(manager):
manager.set(
"download_skip_base_models",
"SDXL 1.0, Pony; Invalid\nSDXL 1.0"
)
assert manager.get("download_skip_base_models") == ["SDXL 1.0", "Pony"]

View File

@@ -0,0 +1,202 @@
"""Tests for SuiImageParamsParser."""
import pytest
import json
from py.recipes.parsers import SuiImageParamsParser
class TestSuiImageParamsParser:
"""Test cases for SuiImageParamsParser."""
def setup_method(self):
"""Set up test fixtures."""
self.parser = SuiImageParamsParser()
def test_is_metadata_matching_positive(self):
"""Test that parser correctly identifies SuiImage metadata format."""
metadata = {
"sui_image_params": {
"prompt": "test prompt",
"model": "test_model"
}
}
metadata_str = json.dumps(metadata)
assert self.parser.is_metadata_matching(metadata_str) is True
def test_is_metadata_matching_negative(self):
"""Test that parser rejects non-SuiImage metadata."""
# Missing sui_image_params key
metadata = {
"other_params": {
"prompt": "test prompt"
}
}
metadata_str = json.dumps(metadata)
assert self.parser.is_metadata_matching(metadata_str) is False
def test_is_metadata_matching_invalid_json(self):
"""Test that parser handles invalid JSON gracefully."""
metadata_str = "not valid json"
assert self.parser.is_metadata_matching(metadata_str) is False
@pytest.mark.asyncio
async def test_parse_metadata_extracts_basic_fields(self):
"""Test parsing basic fields from SuiImage metadata."""
metadata = {
"sui_image_params": {
"prompt": "beautiful landscape",
"negativeprompt": "ugly, blurry",
"steps": 30,
"seed": 12345,
"cfgscale": 7.5,
"width": 512,
"height": 768,
"sampler": "Euler a",
"scheduler": "normal"
},
"sui_models": [],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
assert result.get('gen_params', {}).get('prompt') == "beautiful landscape"
assert result.get('gen_params', {}).get('negative_prompt') == "ugly, blurry"
assert result.get('gen_params', {}).get('steps') == 30
assert result.get('gen_params', {}).get('seed') == 12345
assert result.get('gen_params', {}).get('cfg_scale') == 7.5
assert result.get('gen_params', {}).get('width') == 512
assert result.get('gen_params', {}).get('height') == 768
assert result.get('gen_params', {}).get('size') == "512x768"
assert result.get('loras') == []
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint(self):
"""Test parsing checkpoint from sui_models."""
metadata = {
"sui_image_params": {
"prompt": "test prompt",
"model": "checkpoint_model"
},
"sui_models": [
{
"name": "test_checkpoint.safetensors",
"param": "model",
"hash": "0x1234567890abcdef"
}
],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
checkpoint = result.get('checkpoint')
assert checkpoint is not None
assert checkpoint['type'] == 'checkpoint'
assert checkpoint['name'] == 'test_checkpoint'
assert checkpoint['hash'] == '1234567890abcdef'
@pytest.mark.asyncio
async def test_parse_metadata_extracts_lora(self):
"""Test parsing LoRA from sui_models."""
metadata = {
"sui_image_params": {
"prompt": "test prompt"
},
"sui_models": [
{
"name": "test_lora.safetensors",
"param": "lora",
"hash": "0xabcdef1234567890"
}
],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
loras = result.get('loras')
assert len(loras) == 1
assert loras[0]['type'] == 'lora'
assert loras[0]['name'] == 'test_lora'
assert loras[0]['file_name'] == 'test_lora.safetensors'
assert loras[0]['hash'] == 'abcdef1234567890'
@pytest.mark.asyncio
async def test_parse_metadata_handles_lora_in_name(self):
"""Test that LoRA is detected by 'lora' in name."""
metadata = {
"sui_image_params": {
"prompt": "test prompt"
},
"sui_models": [
{
"name": "style_lora_v2.safetensors",
"param": "some_other_param",
"hash": "0x1111111111111111"
}
],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
loras = result.get('loras')
assert len(loras) == 1
assert loras[0]['type'] == 'lora'
@pytest.mark.asyncio
async def test_parse_metadata_empty_models(self):
"""Test parsing with empty sui_models array."""
metadata = {
"sui_image_params": {
"prompt": "test prompt",
"steps": 20
},
"sui_models": [],
"sui_extra_data": {
"date": "2024-01-01"
}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
assert result.get('loras') == []
assert result.get('checkpoint') is None
assert result.get('gen_params', {}).get('prompt') == "test prompt"
assert result.get('gen_params', {}).get('steps') == 20
@pytest.mark.asyncio
async def test_parse_metadata_alternative_field_names(self):
"""Test parsing with alternative field names."""
metadata = {
"sui_image_params": {
"prompt": "test prompt",
"negative_prompt": "bad quality", # Using underscore variant
"cfg_scale": 6.0 # Using underscore variant
},
"sui_models": [],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
assert result.get('gen_params', {}).get('negative_prompt') == "bad quality"
assert result.get('gen_params', {}).get('cfg_scale') == 6.0
@pytest.mark.asyncio
async def test_parse_metadata_error_handling(self):
"""Test that parser handles malformed data gracefully."""
# Missing required fields
metadata = {
"sui_image_params": {},
"sui_models": [],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
assert 'error' not in result
assert result.get('loras') == []
# Empty params result in empty gen_params dict
assert result.get('gen_params') == {}

View File

@@ -1,30 +1,7 @@
from py.utils.preview_selection import select_preview_media import pytest
from py.utils.constants import NSFW_LEVELS
def test_select_preview_prefers_safe_media_when_blurred(): from py.utils.preview_selection import resolve_mature_threshold, select_preview_media
images = [
{"url": "nsfw", "type": "image", "nsfwLevel": 8},
{"url": "mid", "type": "image", "nsfwLevel": 4},
{"url": "safe", "type": "image", "nsfwLevel": 1},
]
selected, level = select_preview_media(images, blur_mature_content=True)
assert selected["url"] == "safe"
assert level == 1
def test_select_preview_returns_lowest_when_no_safe_media():
images = [
{"url": "x", "type": "image", "nsfwLevel": 16},
{"url": "r", "type": "image", "nsfwLevel": 4},
{"url": "xx", "type": "image", "nsfwLevel": 8},
]
selected, level = select_preview_media(images, blur_mature_content=True)
assert selected["url"] == "r"
assert level == 4
def test_select_preview_returns_first_when_blur_disabled(): def test_select_preview_returns_first_when_blur_disabled():
@@ -37,3 +14,36 @@ def test_select_preview_returns_first_when_blur_disabled():
assert selected["url"] == "nsfw" assert selected["url"] == "nsfw"
assert level == 32 assert level == 32
@pytest.mark.parametrize(
("threshold_name", "expected_url"),
[
("PG13", "pg"),
("R", "pg13"),
("X", "r"),
("XXX", "x"),
],
)
def test_select_preview_respects_configurable_threshold(threshold_name, expected_url):
images = [
{"url": "xxx", "type": "image", "nsfwLevel": NSFW_LEVELS["XXX"]},
{"url": "x", "type": "image", "nsfwLevel": NSFW_LEVELS["X"]},
{"url": "r", "type": "image", "nsfwLevel": NSFW_LEVELS["R"]},
{"url": "pg13", "type": "image", "nsfwLevel": NSFW_LEVELS["PG13"]},
{"url": "pg", "type": "image", "nsfwLevel": NSFW_LEVELS["PG"]},
]
selected, level = select_preview_media(
images,
blur_mature_content=True,
mature_threshold=NSFW_LEVELS[threshold_name],
)
assert selected["url"] == expected_url
assert level == next(item["nsfwLevel"] for item in images if item["url"] == expected_url)
def test_resolve_mature_threshold_falls_back_to_r_for_invalid_value():
assert resolve_mature_threshold({"mature_blur_level": "invalid"}) == NSFW_LEVELS["R"]
assert resolve_mature_threshold({}) == NSFW_LEVELS["R"]

View File

@@ -2,8 +2,8 @@
<div class="lora-cycler-widget"> <div class="lora-cycler-widget">
<LoraCyclerSettingsView <LoraCyclerSettingsView
:current-index="state.currentIndex.value" :current-index="state.currentIndex.value"
:total-count="state.totalCount.value" :total-count="displayTotalCount"
:current-lora-name="state.currentLoraName.value" :current-lora-name="displayLoraName"
:current-lora-filename="state.currentLoraFilename.value" :current-lora-filename="state.currentLoraFilename.value"
:model-strength="state.modelStrength.value" :model-strength="state.modelStrength.value"
:clip-strength="state.clipStrength.value" :clip-strength="state.clipStrength.value"
@@ -16,11 +16,14 @@
:is-pause-disabled="hasQueuedPrompts" :is-pause-disabled="hasQueuedPrompts"
:is-workflow-executing="state.isWorkflowExecuting.value" :is-workflow-executing="state.isWorkflowExecuting.value"
:executing-repeat-step="state.executingRepeatStep.value" :executing-repeat-step="state.executingRepeatStep.value"
:include-no-lora="state.includeNoLora.value"
:is-no-lora="isNoLora"
@update:current-index="handleIndexUpdate" @update:current-index="handleIndexUpdate"
@update:model-strength="state.modelStrength.value = $event" @update:model-strength="state.modelStrength.value = $event"
@update:clip-strength="state.clipStrength.value = $event" @update:clip-strength="state.clipStrength.value = $event"
@update:use-custom-clip-range="handleUseCustomClipRangeChange" @update:use-custom-clip-range="handleUseCustomClipRangeChange"
@update:repeat-count="handleRepeatCountChange" @update:repeat-count="handleRepeatCountChange"
@update:include-no-lora="handleIncludeNoLoraChange"
@toggle-pause="handleTogglePause" @toggle-pause="handleTogglePause"
@reset-index="handleResetIndex" @reset-index="handleResetIndex"
@open-lora-selector="isModalOpen = true" @open-lora-selector="isModalOpen = true"
@@ -30,6 +33,7 @@
:visible="isModalOpen" :visible="isModalOpen"
:lora-list="cachedLoraList" :lora-list="cachedLoraList"
:current-index="state.currentIndex.value" :current-index="state.currentIndex.value"
:include-no-lora="state.includeNoLora.value"
@close="isModalOpen = false" @close="isModalOpen = false"
@select="handleModalSelect" @select="handleModalSelect"
/> />
@@ -37,7 +41,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref, computed } from 'vue'
import LoraCyclerSettingsView from './lora-cycler/LoraCyclerSettingsView.vue' import LoraCyclerSettingsView from './lora-cycler/LoraCyclerSettingsView.vue'
import LoraListModal from './lora-cycler/LoraListModal.vue' import LoraListModal from './lora-cycler/LoraListModal.vue'
import { useLoraCyclerState } from '../composables/useLoraCyclerState' import { useLoraCyclerState } from '../composables/useLoraCyclerState'
@@ -102,6 +106,31 @@ const isModalOpen = ref(false)
// Cache for LoRA list (used by modal) // Cache for LoRA list (used by modal)
const cachedLoraList = ref<LoraItem[]>([]) const cachedLoraList = ref<LoraItem[]>([])
// Computed: display total count (includes no lora option if enabled)
const displayTotalCount = computed(() => {
const baseCount = state.totalCount.value
return state.includeNoLora.value ? baseCount + 1 : baseCount
})
// Computed: display LoRA name (shows "No LoRA" if on the last index and includeNoLora is enabled)
const displayLoraName = computed(() => {
const currentIndex = state.currentIndex.value
const totalCount = state.totalCount.value
// If includeNoLora is enabled and we're on the last position (no lora slot)
if (state.includeNoLora.value && currentIndex === totalCount + 1) {
return 'No LoRA'
}
// Otherwise show the normal LoRA name
return state.currentLoraName.value
})
// Computed: check if currently on "No LoRA" option
const isNoLora = computed(() => {
return state.includeNoLora.value && state.currentIndex.value === state.totalCount.value + 1
})
// Get pool config from connected node // Get pool config from connected node
const getPoolConfig = (): LoraPoolConfig | null => { const getPoolConfig = (): LoraPoolConfig | null => {
// Check if getPoolConfig method exists on node (added by main.ts) // Check if getPoolConfig method exists on node (added by main.ts)
@@ -113,7 +142,17 @@ const getPoolConfig = (): LoraPoolConfig | null => {
// Update display from LoRA list and index // Update display from LoRA list and index
const updateDisplayFromLoraList = (loraList: LoraItem[], index: number) => { const updateDisplayFromLoraList = (loraList: LoraItem[], index: number) => {
if (loraList.length > 0 && index > 0 && index <= loraList.length) { const actualLoraCount = loraList.length
// If index is beyond actual LoRA count, it means we're on the "no lora" option
if (state.includeNoLora.value && index === actualLoraCount + 1) {
state.currentLoraName.value = 'No LoRA'
state.currentLoraFilename.value = 'No LoRA'
return
}
// Otherwise, show normal LoRA info
if (actualLoraCount > 0 && index > 0 && index <= actualLoraCount) {
const currentLora = loraList[index - 1] const currentLora = loraList[index - 1]
if (currentLora) { if (currentLora) {
state.currentLoraName.value = currentLora.file_name state.currentLoraName.value = currentLora.file_name
@@ -124,6 +163,14 @@ const updateDisplayFromLoraList = (loraList: LoraItem[], index: number) => {
// Handle index update from user // Handle index update from user
const handleIndexUpdate = async (newIndex: number) => { const handleIndexUpdate = async (newIndex: number) => {
// Calculate max valid index (includes no lora slot if enabled)
const maxIndex = state.includeNoLora.value
? state.totalCount.value + 1
: state.totalCount.value
// Clamp index to valid range
const clampedIndex = Math.max(1, Math.min(newIndex, maxIndex || 1))
// Reset execution state when user manually changes index // Reset execution state when user manually changes index
// This ensures the next execution starts from the user-set index // This ensures the next execution starts from the user-set index
;(props.widget as any)[HAS_EXECUTED] = false ;(props.widget as any)[HAS_EXECUTED] = false
@@ -134,14 +181,14 @@ const handleIndexUpdate = async (newIndex: number) => {
executionQueue.length = 0 executionQueue.length = 0
hasQueuedPrompts.value = false hasQueuedPrompts.value = false
state.setIndex(newIndex) state.setIndex(clampedIndex)
// Refresh list to update current LoRA display // Refresh list to update current LoRA display
try { try {
const poolConfig = getPoolConfig() const poolConfig = getPoolConfig()
const loraList = await state.fetchCyclerList(poolConfig) const loraList = await state.fetchCyclerList(poolConfig)
cachedLoraList.value = loraList cachedLoraList.value = loraList
updateDisplayFromLoraList(loraList, newIndex) updateDisplayFromLoraList(loraList, clampedIndex)
} catch (error) { } catch (error) {
console.error('[LoraCyclerWidget] Error updating index:', error) console.error('[LoraCyclerWidget] Error updating index:', error)
} }
@@ -169,6 +216,17 @@ const handleRepeatCountChange = (newValue: number) => {
state.displayRepeatUsed.value = 0 state.displayRepeatUsed.value = 0
} }
// Handle include no lora toggle
const handleIncludeNoLoraChange = (newValue: boolean) => {
state.includeNoLora.value = newValue
// If turning off and current index is beyond the actual LoRA count,
// clamp it to the last valid LoRA index
if (!newValue && state.currentIndex.value > state.totalCount.value) {
state.currentIndex.value = Math.max(1, state.totalCount.value)
}
}
// Handle pause toggle // Handle pause toggle
const handleTogglePause = () => { const handleTogglePause = () => {
state.togglePause() state.togglePause()

View File

@@ -8,6 +8,9 @@
:exclude-tags="state.excludeTags.value" :exclude-tags="state.excludeTags.value"
:include-folders="state.includeFolders.value" :include-folders="state.includeFolders.value"
:exclude-folders="state.excludeFolders.value" :exclude-folders="state.excludeFolders.value"
:include-patterns="state.includePatterns.value"
:exclude-patterns="state.excludePatterns.value"
:use-regex="state.useRegex.value"
:no-credit-required="state.noCreditRequired.value" :no-credit-required="state.noCreditRequired.value"
:allow-selling="state.allowSelling.value" :allow-selling="state.allowSelling.value"
:preview-items="state.previewItems.value" :preview-items="state.previewItems.value"
@@ -16,6 +19,9 @@
@open-modal="openModal" @open-modal="openModal"
@update:include-folders="state.includeFolders.value = $event" @update:include-folders="state.includeFolders.value = $event"
@update:exclude-folders="state.excludeFolders.value = $event" @update:exclude-folders="state.excludeFolders.value = $event"
@update:include-patterns="state.includePatterns.value = $event"
@update:exclude-patterns="state.excludePatterns.value = $event"
@update:use-regex="state.useRegex.value = $event"
@update:no-credit-required="state.noCreditRequired.value = $event" @update:no-credit-required="state.noCreditRequired.value = $event"
@update:allow-selling="state.allowSelling.value = $event" @update:allow-selling="state.allowSelling.value = $event"
@refresh="state.refreshPreview" @refresh="state.refreshPreview"

View File

@@ -13,7 +13,9 @@
@click="handleOpenSelector" @click="handleOpenSelector"
> >
<span class="progress-label">{{ isWorkflowExecuting ? 'Using LoRA:' : 'Next LoRA:' }}</span> <span class="progress-label">{{ isWorkflowExecuting ? 'Using LoRA:' : 'Next LoRA:' }}</span>
<span class="progress-name clickable" :class="{ disabled: isPauseDisabled }" :title="currentLoraFilename"> <span class="progress-name clickable"
:class="{ disabled: isPauseDisabled, 'no-lora': isNoLora }"
:title="currentLoraFilename">
{{ currentLoraName || 'None' }} {{ currentLoraName || 'None' }}
<svg class="selector-icon" viewBox="0 0 24 24" fill="currentColor"> <svg class="selector-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 10l5 5 5-5z"/> <path d="M7 10l5 5 5-5z"/>
@@ -160,6 +162,27 @@
/> />
</div> </div>
</div> </div>
<!-- Include No LoRA Toggle -->
<div class="setting-section">
<div class="section-header-with-toggle">
<label class="setting-label">
Add "No LoRA" step
</label>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': includeNoLora }"
@click="$emit('update:includeNoLora', !includeNoLora)"
role="switch"
:aria-checked="includeNoLora"
title="Add an iteration without LoRA for comparison"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</div>
</div>
</div> </div>
</template> </template>
@@ -182,6 +205,8 @@ const props = defineProps<{
isPauseDisabled: boolean isPauseDisabled: boolean
isWorkflowExecuting: boolean isWorkflowExecuting: boolean
executingRepeatStep: number executingRepeatStep: number
includeNoLora: boolean
isNoLora?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -190,6 +215,7 @@ const emit = defineEmits<{
'update:clipStrength': [value: number] 'update:clipStrength': [value: number]
'update:useCustomClipRange': [value: boolean] 'update:useCustomClipRange': [value: boolean]
'update:repeatCount': [value: number] 'update:repeatCount': [value: number]
'update:includeNoLora': [value: boolean]
'toggle-pause': [] 'toggle-pause': []
'reset-index': [] 'reset-index': []
'open-lora-selector': [] 'open-lora-selector': []
@@ -346,6 +372,16 @@ const onRepeatBlur = (event: Event) => {
color: rgba(191, 219, 254, 1); color: rgba(191, 219, 254, 1);
} }
.progress-name.no-lora {
font-style: italic;
color: rgba(226, 232, 240, 0.6);
}
.progress-name.clickable.no-lora:hover:not(.disabled) {
background: rgba(160, 174, 192, 0.2);
color: rgba(226, 232, 240, 0.8);
}
.progress-name.clickable.disabled { .progress-name.clickable.disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;

View File

@@ -35,7 +35,10 @@
v-for="item in filteredList" v-for="item in filteredList"
:key="item.index" :key="item.index"
class="lora-item" class="lora-item"
:class="{ active: currentIndex === item.index }" :class="{
active: currentIndex === item.index,
'no-lora-item': item.lora.file_name === 'No LoRA'
}"
@mouseenter="showPreview(item.lora.file_name, $event)" @mouseenter="showPreview(item.lora.file_name, $event)"
@mouseleave="hidePreview" @mouseleave="hidePreview"
@click="selectLora(item.index)" @click="selectLora(item.index)"
@@ -65,6 +68,7 @@ const props = defineProps<{
visible: boolean visible: boolean
loraList: LoraItem[] loraList: LoraItem[]
currentIndex: number currentIndex: number
includeNoLora?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -79,7 +83,8 @@ const searchInputRef = ref<HTMLInputElement | null>(null)
let previewTooltip: any = null let previewTooltip: any = null
const subtitleText = computed(() => { const subtitleText = computed(() => {
const total = props.loraList.length const baseTotal = props.loraList.length
const total = props.includeNoLora ? baseTotal + 1 : baseTotal
const filtered = filteredList.value.length const filtered = filteredList.value.length
if (filtered === total) { if (filtered === total) {
return `Total: ${total} LoRA${total !== 1 ? 's' : ''}` return `Total: ${total} LoRA${total !== 1 ? 's' : ''}`
@@ -88,11 +93,19 @@ const subtitleText = computed(() => {
}) })
const filteredList = computed<LoraListItem[]>(() => { const filteredList = computed<LoraListItem[]>(() => {
const list = props.loraList.map((lora, idx) => ({ const list: LoraListItem[] = props.loraList.map((lora, idx) => ({
index: idx + 1, index: idx + 1,
lora lora
})) }))
// Add "No LoRA" option at the end if includeNoLora is enabled
if (props.includeNoLora) {
list.push({
index: list.length + 1,
lora: { file_name: 'No LoRA' } as LoraItem
})
}
if (!searchQuery.value.trim()) { if (!searchQuery.value.trim()) {
return list return list
} }
@@ -303,6 +316,15 @@ onUnmounted(() => {
font-weight: 500; font-weight: 500;
} }
.lora-item.no-lora-item .lora-name {
font-style: italic;
color: rgba(226, 232, 240, 0.6);
}
.lora-item.no-lora-item:hover .lora-name {
color: rgba(226, 232, 240, 0.8);
}
.no-results { .no-results {
padding: 32px 20px; padding: 32px 20px;
text-align: center; text-align: center;

View File

@@ -24,6 +24,15 @@
@edit-exclude="$emit('open-modal', 'excludeFolders')" @edit-exclude="$emit('open-modal', 'excludeFolders')"
/> />
<NamePatternsSection
:include-patterns="includePatterns"
:exclude-patterns="excludePatterns"
:use-regex="useRegex"
@update:include-patterns="$emit('update:includePatterns', $event)"
@update:exclude-patterns="$emit('update:excludePatterns', $event)"
@update:use-regex="$emit('update:useRegex', $event)"
/>
<LicenseSection <LicenseSection
:no-credit-required="noCreditRequired" :no-credit-required="noCreditRequired"
:allow-selling="allowSelling" :allow-selling="allowSelling"
@@ -46,6 +55,7 @@
import BaseModelSection from './sections/BaseModelSection.vue' import BaseModelSection from './sections/BaseModelSection.vue'
import TagsSection from './sections/TagsSection.vue' import TagsSection from './sections/TagsSection.vue'
import FoldersSection from './sections/FoldersSection.vue' import FoldersSection from './sections/FoldersSection.vue'
import NamePatternsSection from './sections/NamePatternsSection.vue'
import LicenseSection from './sections/LicenseSection.vue' import LicenseSection from './sections/LicenseSection.vue'
import LoraPoolPreview from './LoraPoolPreview.vue' import LoraPoolPreview from './LoraPoolPreview.vue'
import type { BaseModelOption, LoraItem } from '../../composables/types' import type { BaseModelOption, LoraItem } from '../../composables/types'
@@ -61,6 +71,10 @@ defineProps<{
// Folders // Folders
includeFolders: string[] includeFolders: string[]
excludeFolders: string[] excludeFolders: string[]
// Name patterns
includePatterns: string[]
excludePatterns: string[]
useRegex: boolean
// License // License
noCreditRequired: boolean noCreditRequired: boolean
allowSelling: boolean allowSelling: boolean
@@ -74,6 +88,9 @@ defineEmits<{
'open-modal': [modal: ModalType] 'open-modal': [modal: ModalType]
'update:includeFolders': [value: string[]] 'update:includeFolders': [value: string[]]
'update:excludeFolders': [value: string[]] 'update:excludeFolders': [value: string[]]
'update:includePatterns': [value: string[]]
'update:excludePatterns': [value: string[]]
'update:useRegex': [value: boolean]
'update:noCreditRequired': [value: boolean] 'update:noCreditRequired': [value: boolean]
'update:allowSelling': [value: boolean] 'update:allowSelling': [value: boolean]
refresh: [] refresh: []

View File

@@ -0,0 +1,255 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">NAME PATTERNS</span>
<label class="section__toggle">
<input
type="checkbox"
:checked="useRegex"
@change="$emit('update:useRegex', ($event.target as HTMLInputElement).checked)"
/>
<span class="section__toggle-label">Use Regex</span>
</label>
</div>
<div class="section__columns">
<!-- Include column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--include">INCLUDE</span>
</div>
<div class="section__input-wrapper">
<input
type="text"
v-model="includeInput"
:placeholder="useRegex ? 'Add regex pattern...' : 'Add text pattern...'"
class="section__input"
@keydown.enter="addInclude"
/>
<button type="button" class="section__add-btn" @click="addInclude">+</button>
</div>
<div class="section__patterns">
<FilterChip
v-for="pattern in includePatterns"
:key="pattern"
:label="pattern"
variant="include"
removable
@remove="removeInclude(pattern)"
/>
<div v-if="includePatterns.length === 0" class="section__empty">
{{ useRegex ? 'No regex patterns' : 'No text patterns' }}
</div>
</div>
</div>
<!-- Exclude column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
</div>
<div class="section__input-wrapper">
<input
type="text"
v-model="excludeInput"
:placeholder="useRegex ? 'Add regex pattern...' : 'Add text pattern...'"
class="section__input"
@keydown.enter="addExclude"
/>
<button type="button" class="section__add-btn" @click="addExclude">+</button>
</div>
<div class="section__patterns">
<FilterChip
v-for="pattern in excludePatterns"
:key="pattern"
:label="pattern"
variant="exclude"
removable
@remove="removeExclude(pattern)"
/>
<div v-if="excludePatterns.length === 0" class="section__empty">
{{ useRegex ? 'No regex patterns' : 'No text patterns' }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FilterChip from '../shared/FilterChip.vue'
const props = defineProps<{
includePatterns: string[]
excludePatterns: string[]
useRegex: boolean
}>()
const emit = defineEmits<{
'update:includePatterns': [value: string[]]
'update:excludePatterns': [value: string[]]
'update:useRegex': [value: boolean]
}>()
const includeInput = ref('')
const excludeInput = ref('')
const addInclude = () => {
const pattern = includeInput.value.trim()
if (pattern && !props.includePatterns.includes(pattern)) {
emit('update:includePatterns', [...props.includePatterns, pattern])
includeInput.value = ''
}
}
const addExclude = () => {
const pattern = excludeInput.value.trim()
if (pattern && !props.excludePatterns.includes(pattern)) {
emit('update:excludePatterns', [...props.excludePatterns, pattern])
excludeInput.value = ''
}
}
const removeInclude = (pattern: string) => {
emit('update:includePatterns', props.includePatterns.filter(p => p !== pattern))
}
const removeExclude = (pattern: string) => {
emit('update:excludePatterns', props.excludePatterns.filter(p => p !== pattern))
}
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.7;
}
.section__toggle input[type="checkbox"] {
margin: 0;
width: 14px;
height: 14px;
cursor: pointer;
}
.section__toggle-label {
font-weight: 500;
}
.section__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.section__column {
min-width: 0;
}
.section__column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.section__column-title {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.section__column-title--include {
color: #4299e1;
}
.section__column-title--exclude {
color: #ef4444;
}
.section__input-wrapper {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.section__input {
flex: 1;
min-width: 0;
padding: 6px 8px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--comfy-input-border, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 12px;
outline: none;
}
.section__input:focus {
border-color: #4299e1;
}
.section__add-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--comfy-input-border, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.section__add-btn:hover {
background: var(--comfy-input-bg-hover, #444);
border-color: #4299e1;
}
.section__patterns {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 22px;
}
.section__empty {
font-size: 10px;
color: var(--fg-color, #fff);
opacity: 0.3;
font-style: italic;
min-height: 22px;
display: flex;
align-items: center;
}
</style>

View File

@@ -10,6 +10,12 @@ export interface LoraPoolConfig {
noCreditRequired: boolean noCreditRequired: boolean
allowSelling: boolean allowSelling: boolean
} }
namePatterns: {
include: string[]
exclude: string[]
useRegex: boolean
}
includeEmptyLora?: boolean // Optional, deprecated (moved to Cycler)
} }
preview: { matchCount: number; lastUpdated: number } preview: { matchCount: number; lastUpdated: number }
} }
@@ -84,6 +90,8 @@ export interface CyclerConfig {
repeat_count: number // How many times each LoRA should repeat (default: 1) repeat_count: number // How many times each LoRA should repeat (default: 1)
repeat_used: number // How many times current index has been used repeat_used: number // How many times current index has been used
is_paused: boolean // Whether iteration is paused is_paused: boolean // Whether iteration is paused
// Include "no LoRA" option in cycle
include_no_lora: boolean // Whether to include empty LoRA option
} }
// Widget config union type // Widget config union type

View File

@@ -4,6 +4,7 @@ import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from './types'
export interface CyclerLoraItem { export interface CyclerLoraItem {
file_name: string file_name: string
model_name: string model_name: string
file_path: string
} }
export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) { export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
@@ -34,6 +35,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
const repeatUsed = ref(0) // How many times current index has been used (internal tracking) const repeatUsed = ref(0) // How many times current index has been used (internal tracking)
const displayRepeatUsed = ref(0) // For UI display, deferred updates like currentIndex const displayRepeatUsed = ref(0) // For UI display, deferred updates like currentIndex
const isPaused = ref(false) // Whether iteration is paused const isPaused = ref(false) // Whether iteration is paused
const includeNoLora = ref(false) // Whether to include empty LoRA option in cycle
// Execution progress tracking (visual feedback) // Execution progress tracking (visual feedback)
const isWorkflowExecuting = ref(false) // Workflow is currently running const isWorkflowExecuting = ref(false) // Workflow is currently running
@@ -58,6 +60,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
repeat_count: repeatCount.value, repeat_count: repeatCount.value,
repeat_used: repeatUsed.value, repeat_used: repeatUsed.value,
is_paused: isPaused.value, is_paused: isPaused.value,
include_no_lora: includeNoLora.value,
} }
} }
return { return {
@@ -75,6 +78,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
repeat_count: repeatCount.value, repeat_count: repeatCount.value,
repeat_used: repeatUsed.value, repeat_used: repeatUsed.value,
is_paused: isPaused.value, is_paused: isPaused.value,
include_no_lora: includeNoLora.value,
} }
} }
@@ -97,6 +101,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
repeatCount.value = config.repeat_count ?? 1 repeatCount.value = config.repeat_count ?? 1
repeatUsed.value = config.repeat_used ?? 0 repeatUsed.value = config.repeat_used ?? 0
isPaused.value = config.is_paused ?? false isPaused.value = config.is_paused ?? false
includeNoLora.value = config.include_no_lora ?? false
// Note: execution_index and next_index are not restored from config // Note: execution_index and next_index are not restored from config
// as they are transient values used only during batch execution // as they are transient values used only during batch execution
} finally { } finally {
@@ -111,7 +116,9 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
// Calculate the next index (wrap to 1 if at end) // Calculate the next index (wrap to 1 if at end)
const current = executionIndex.value ?? currentIndex.value const current = executionIndex.value ?? currentIndex.value
let next = current + 1 let next = current + 1
if (totalCount.value > 0 && next > totalCount.value) { // Total count includes no lora option if enabled
const effectiveTotalCount = includeNoLora.value ? totalCount.value + 1 : totalCount.value
if (effectiveTotalCount > 0 && next > effectiveTotalCount) {
next = 1 next = 1
} }
nextIndex.value = next nextIndex.value = next
@@ -122,7 +129,9 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
if (nextIndex.value === null) { if (nextIndex.value === null) {
// First execution uses current_index, so next is current + 1 // First execution uses current_index, so next is current + 1
let next = currentIndex.value + 1 let next = currentIndex.value + 1
if (totalCount.value > 0 && next > totalCount.value) { // Total count includes no lora option if enabled
const effectiveTotalCount = includeNoLora.value ? totalCount.value + 1 : totalCount.value
if (effectiveTotalCount > 0 && next > effectiveTotalCount) {
next = 1 next = 1
} }
nextIndex.value = next nextIndex.value = next
@@ -230,7 +239,9 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
// Set index manually // Set index manually
const setIndex = (index: number) => { const setIndex = (index: number) => {
if (index >= 1 && index <= totalCount.value) { // Total count includes no lora option if enabled
const effectiveTotalCount = includeNoLora.value ? totalCount.value + 1 : totalCount.value
if (index >= 1 && index <= effectiveTotalCount) {
currentIndex.value = index currentIndex.value = index
} }
} }
@@ -272,6 +283,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
repeatCount, repeatCount,
repeatUsed, repeatUsed,
isPaused, isPaused,
includeNoLora,
], () => { ], () => {
widget.value = buildConfig() widget.value = buildConfig()
}, { deep: true }) }, { deep: true })
@@ -294,6 +306,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
repeatUsed, repeatUsed,
displayRepeatUsed, displayRepeatUsed,
isPaused, isPaused,
includeNoLora,
isWorkflowExecuting, isWorkflowExecuting,
executingRepeatStep, executingRepeatStep,

View File

@@ -62,6 +62,9 @@ export function useLoraPoolApi() {
foldersExclude?: string[] foldersExclude?: string[]
noCreditRequired?: boolean noCreditRequired?: boolean
allowSelling?: boolean allowSelling?: boolean
namePatternsInclude?: string[]
namePatternsExclude?: string[]
namePatternsUseRegex?: boolean
page?: number page?: number
pageSize?: number pageSize?: number
} }
@@ -92,6 +95,13 @@ export function useLoraPoolApi() {
urlParams.set('allow_selling_generated_content', String(params.allowSelling)) urlParams.set('allow_selling_generated_content', String(params.allowSelling))
} }
// Name pattern filters
params.namePatternsInclude?.forEach(pattern => urlParams.append('name_pattern_include', pattern))
params.namePatternsExclude?.forEach(pattern => urlParams.append('name_pattern_exclude', pattern))
if (params.namePatternsUseRegex !== undefined) {
urlParams.set('name_pattern_use_regex', String(params.namePatternsUseRegex))
}
const response = await fetch(`/api/lm/loras/list?${urlParams}`) const response = await fetch(`/api/lm/loras/list?${urlParams}`)
const data = await response.json() const data = await response.json()

View File

@@ -24,6 +24,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
const excludeFolders = ref<string[]>([]) const excludeFolders = ref<string[]>([])
const noCreditRequired = ref(false) const noCreditRequired = ref(false)
const allowSelling = ref(false) const allowSelling = ref(false)
const includePatterns = ref<string[]>([])
const excludePatterns = ref<string[]>([])
const useRegex = ref(false)
// Available options from API // Available options from API
const availableBaseModels = ref<BaseModelOption[]>([]) const availableBaseModels = ref<BaseModelOption[]>([])
@@ -52,6 +55,11 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
license: { license: {
noCreditRequired: noCreditRequired.value, noCreditRequired: noCreditRequired.value,
allowSelling: allowSelling.value allowSelling: allowSelling.value
},
namePatterns: {
include: includePatterns.value,
exclude: excludePatterns.value,
useRegex: useRegex.value
} }
}, },
preview: { preview: {
@@ -94,6 +102,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
updateIfChanged(excludeFolders, filters.folders?.exclude || []) updateIfChanged(excludeFolders, filters.folders?.exclude || [])
updateIfChanged(noCreditRequired, filters.license?.noCreditRequired ?? false) updateIfChanged(noCreditRequired, filters.license?.noCreditRequired ?? false)
updateIfChanged(allowSelling, filters.license?.allowSelling ?? false) updateIfChanged(allowSelling, filters.license?.allowSelling ?? false)
updateIfChanged(includePatterns, filters.namePatterns?.include || [])
updateIfChanged(excludePatterns, filters.namePatterns?.exclude || [])
updateIfChanged(useRegex, filters.namePatterns?.useRegex ?? false)
// matchCount doesn't trigger watchers, so direct assignment is fine // matchCount doesn't trigger watchers, so direct assignment is fine
matchCount.value = preview?.matchCount || 0 matchCount.value = preview?.matchCount || 0
@@ -125,6 +136,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
foldersExclude: excludeFolders.value, foldersExclude: excludeFolders.value,
noCreditRequired: noCreditRequired.value || undefined, noCreditRequired: noCreditRequired.value || undefined,
allowSelling: allowSelling.value || undefined, allowSelling: allowSelling.value || undefined,
namePatternsInclude: includePatterns.value,
namePatternsExclude: excludePatterns.value,
namePatternsUseRegex: useRegex.value,
pageSize: 6 pageSize: 6
}) })
@@ -150,7 +164,10 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
includeFolders, includeFolders,
excludeFolders, excludeFolders,
noCreditRequired, noCreditRequired,
allowSelling allowSelling,
includePatterns,
excludePatterns,
useRegex
], onFilterChange, { deep: true }) ], onFilterChange, { deep: true })
return { return {
@@ -162,6 +179,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
excludeFolders, excludeFolders,
noCreditRequired, noCreditRequired,
allowSelling, allowSelling,
includePatterns,
excludePatterns,
useRegex,
// Available options // Available options
availableBaseModels, availableBaseModels,

View File

@@ -13,12 +13,12 @@ import {
} from './mode-change-handler' } from './mode-change-handler'
const LORA_POOL_WIDGET_MIN_WIDTH = 500 const LORA_POOL_WIDGET_MIN_WIDTH = 500
const LORA_POOL_WIDGET_MIN_HEIGHT = 400 const LORA_POOL_WIDGET_MIN_HEIGHT = 520
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500 const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448 const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
const LORA_CYCLER_WIDGET_MIN_WIDTH = 380 const LORA_CYCLER_WIDGET_MIN_WIDTH = 380
const LORA_CYCLER_WIDGET_MIN_HEIGHT = 314 const LORA_CYCLER_WIDGET_MIN_HEIGHT = 344
const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300 const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200 const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200

View File

@@ -84,7 +84,8 @@ describe('useLoraCyclerState', () => {
current_lora_filename: '', current_lora_filename: '',
repeat_count: 1, repeat_count: 1,
repeat_used: 0, repeat_used: 0,
is_paused: false is_paused: false,
include_no_lora: false
}) })
expect(state.currentIndex.value).toBe(5) expect(state.currentIndex.value).toBe(5)

View File

@@ -24,6 +24,7 @@ export function createMockCyclerConfig(overrides: Partial<CyclerConfig> = {}): C
repeat_count: 1, repeat_count: 1,
repeat_used: 0, repeat_used: 0,
is_paused: false, is_paused: false,
include_no_lora: false,
...overrides ...overrides
} }
} }
@@ -54,7 +55,8 @@ export function createMockPoolConfig(overrides: Partial<LoraPoolConfig> = {}): L
export function createMockLoraList(count: number = 5): CyclerLoraItem[] { export function createMockLoraList(count: number = 5): CyclerLoraItem[] {
return Array.from({ length: count }, (_, i) => ({ return Array.from({ length: count }, (_, i) => ({
file_name: `lora${i + 1}.safetensors`, file_name: `lora${i + 1}.safetensors`,
model_name: `LoRA Model ${i + 1}` model_name: `LoRA Model ${i + 1}`,
file_path: `/models/loras/lora${i + 1}.safetensors`
})) }))
} }

View File

@@ -14,6 +14,7 @@ import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKey
import { forwardMiddleMouseToCanvas } from "./utils.js"; import { forwardMiddleMouseToCanvas } from "./utils.js";
import { PreviewTooltip } from "./preview_tooltip.js"; import { PreviewTooltip } from "./preview_tooltip.js";
import { ensureLmStyles } from "./lm_styles_loader.js"; import { ensureLmStyles } from "./lm_styles_loader.js";
import { getStrengthStepPreference } from "./settings.js";
export function addLorasWidget(node, name, opts, callback) { export function addLorasWidget(node, name, opts, callback) {
ensureLmStyles(); ensureLmStyles();
@@ -416,7 +417,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name); const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) { if (loraIndex >= 0) {
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2); lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - getStrengthStepPreference()).toFixed(2);
// Sync clipStrength if collapsed // Sync clipStrength if collapsed
syncClipStrengthIfCollapsed(lorasData[loraIndex]); syncClipStrengthIfCollapsed(lorasData[loraIndex]);
@@ -488,7 +489,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name); const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) { if (loraIndex >= 0) {
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2); lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + getStrengthStepPreference()).toFixed(2);
// Sync clipStrength if collapsed // Sync clipStrength if collapsed
syncClipStrengthIfCollapsed(lorasData[loraIndex]); syncClipStrengthIfCollapsed(lorasData[loraIndex]);
@@ -541,7 +542,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name); const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) { if (loraIndex >= 0) {
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - 0.05).toFixed(2); lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - getStrengthStepPreference()).toFixed(2);
const newValue = formatLoraValue(lorasData); const newValue = formatLoraValue(lorasData);
updateWidgetValue(newValue); updateWidgetValue(newValue);
@@ -611,7 +612,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name); const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) { if (loraIndex >= 0) {
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + 0.05).toFixed(2); lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + getStrengthStepPreference()).toFixed(2);
const newValue = formatLoraValue(lorasData); const newValue = formatLoraValue(lorasData);
updateWidgetValue(newValue); updateWidgetValue(newValue);

View File

@@ -24,6 +24,9 @@ const NEW_TAB_TEMPLATE_DEFAULT = "Default";
const NEW_TAB_ZOOM_LEVEL = 0.8; const NEW_TAB_ZOOM_LEVEL = 0.8;
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
const STRENGTH_STEP_DEFAULT = 0.05;
// ============================================================================ // ============================================================================
// Helper Functions // Helper Functions
// ============================================================================ // ============================================================================
@@ -232,6 +235,32 @@ const getNewTabTemplatePreference = (() => {
}; };
})(); })();
const getStrengthStepPreference = (() => {
let settingsUnavailableLogged = false;
return () => {
const settingManager = app?.extensionManager?.setting;
if (!settingManager || typeof settingManager.get !== "function") {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: settings API unavailable, using default strength step.");
settingsUnavailableLogged = true;
}
return STRENGTH_STEP_DEFAULT;
}
try {
const value = settingManager.get(STRENGTH_STEP_SETTING_ID);
return value ?? STRENGTH_STEP_DEFAULT;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read strength step setting, using default.", error);
settingsUnavailableLogged = true;
}
return STRENGTH_STEP_DEFAULT;
}
};
})();
// ============================================================================ // ============================================================================
// Register Extension with All Settings // Register Extension with All Settings
// ============================================================================ // ============================================================================
@@ -293,6 +322,19 @@ app.registerExtension({
tooltip: "Choose a template workflow to load when creating a new workflow tab. 'Default (Blank)' keeps ComfyUI's original blank workflow behavior.", tooltip: "Choose a template workflow to load when creating a new workflow tab. 'Default (Blank)' keeps ComfyUI's original blank workflow behavior.",
category: ["LoRA Manager", "Workflow", "New Tab Template"], category: ["LoRA Manager", "Workflow", "New Tab Template"],
}, },
{
id: STRENGTH_STEP_SETTING_ID,
name: "Strength Adjustment Step",
type: "slider",
attrs: {
min: 0.01,
max: 0.1,
step: 0.01,
},
defaultValue: STRENGTH_STEP_DEFAULT,
tooltip: "Step size for adjusting LoRA strength via arrow buttons or keyboard (default: 0.05)",
category: ["LoRA Manager", "LoRA Widget", "Strength Step"],
},
], ],
async setup() { async setup() {
await loadWorkflowOptions(); await loadWorkflowOptions();
@@ -375,4 +417,5 @@ export {
getTagSpaceReplacementPreference, getTagSpaceReplacementPreference,
getUsageStatisticsPreference, getUsageStatisticsPreference,
getNewTabTemplatePreference, getNewTabTemplatePreference,
getStrengthStepPreference,
}; };

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long