mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-26 07:35:44 -03:00
Compare commits
34 Commits
b5a0725d2c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceeab0c998 | ||
|
|
3b001a6cd8 | ||
|
|
95e5bc26d1 | ||
|
|
de3d0571f8 | ||
|
|
6f2a01dc86 | ||
|
|
c5c1b8fd2a | ||
|
|
e97648c70b | ||
|
|
8b85e083e2 | ||
|
|
9112cd3b62 | ||
|
|
7df4e8d037 | ||
|
|
4000b7f7e7 | ||
|
|
76c15105e6 | ||
|
|
b11c90e19b | ||
|
|
9f5d2d0c18 | ||
|
|
a0dc5229f4 | ||
|
|
61c31ecbd0 | ||
|
|
1ae1b0d607 | ||
|
|
8dd849892d | ||
|
|
03e1fa75c5 | ||
|
|
fefcaa4a45 | ||
|
|
701a6a6c44 | ||
|
|
0ef414d17e | ||
|
|
75dccaef87 | ||
|
|
7e87ec9521 | ||
|
|
46522edb1b | ||
|
|
2dae4c1291 | ||
|
|
a32325402e | ||
|
|
70c150bd80 | ||
|
|
9e81c33f8a | ||
|
|
22c0dbd734 | ||
|
|
d0c58472be | ||
|
|
b3c530bf36 | ||
|
|
05ebd7493d | ||
|
|
90986bd795 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
20
__init__.py
20
__init__.py
@@ -1,6 +1,8 @@
|
|||||||
try: # pragma: no cover - import fallback for pytest collection
|
try: # pragma: no cover - import fallback for pytest collection
|
||||||
from .py.lora_manager import LoraManager
|
from .py.lora_manager import LoraManager
|
||||||
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
|
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
|
||||||
|
from .py.nodes.checkpoint_loader import CheckpointLoaderLM
|
||||||
|
from .py.nodes.unet_loader import UNETLoaderLM
|
||||||
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
|
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
|
||||||
from .py.nodes.prompt import PromptLM
|
from .py.nodes.prompt import PromptLM
|
||||||
from .py.nodes.text import TextLM
|
from .py.nodes.text import TextLM
|
||||||
@@ -27,12 +29,12 @@ except (
|
|||||||
PromptLM = importlib.import_module("py.nodes.prompt").PromptLM
|
PromptLM = importlib.import_module("py.nodes.prompt").PromptLM
|
||||||
TextLM = importlib.import_module("py.nodes.text").TextLM
|
TextLM = importlib.import_module("py.nodes.text").TextLM
|
||||||
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
||||||
LoraLoaderLM = importlib.import_module(
|
LoraLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraLoaderLM
|
||||||
"py.nodes.lora_loader"
|
LoraTextLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraTextLoaderLM
|
||||||
).LoraLoaderLM
|
CheckpointLoaderLM = importlib.import_module(
|
||||||
LoraTextLoaderLM = importlib.import_module(
|
"py.nodes.checkpoint_loader"
|
||||||
"py.nodes.lora_loader"
|
).CheckpointLoaderLM
|
||||||
).LoraTextLoaderLM
|
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
|
||||||
TriggerWordToggleLM = importlib.import_module(
|
TriggerWordToggleLM = importlib.import_module(
|
||||||
"py.nodes.trigger_word_toggle"
|
"py.nodes.trigger_word_toggle"
|
||||||
).TriggerWordToggleLM
|
).TriggerWordToggleLM
|
||||||
@@ -49,9 +51,7 @@ except (
|
|||||||
LoraRandomizerLM = importlib.import_module(
|
LoraRandomizerLM = importlib.import_module(
|
||||||
"py.nodes.lora_randomizer"
|
"py.nodes.lora_randomizer"
|
||||||
).LoraRandomizerLM
|
).LoraRandomizerLM
|
||||||
LoraCyclerLM = importlib.import_module(
|
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
|
||||||
"py.nodes.lora_cycler"
|
|
||||||
).LoraCyclerLM
|
|
||||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
@@ -59,6 +59,8 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
TextLM.NAME: TextLM,
|
TextLM.NAME: TextLM,
|
||||||
LoraLoaderLM.NAME: LoraLoaderLM,
|
LoraLoaderLM.NAME: LoraLoaderLM,
|
||||||
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
|
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
|
||||||
|
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
|
||||||
|
UNETLoaderLM.NAME: UNETLoaderLM,
|
||||||
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
|
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
|
||||||
LoraStackerLM.NAME: LoraStackerLM,
|
LoraStackerLM.NAME: LoraStackerLM,
|
||||||
SaveImageLM.NAME: SaveImageLM,
|
SaveImageLM.NAME: SaveImageLM,
|
||||||
|
|||||||
150
locales/de.json
150
locales/de.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Nach oben",
|
"backToTop": "Nach oben",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
"add": "Hinzufügen"
|
"add": "Hinzufügen",
|
||||||
|
"close": "Schließen"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
@@ -290,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": "[TODO: Translate] Mature Blur Threshold",
|
||||||
|
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||||
|
"matureBlurThresholdOptions": {
|
||||||
|
"pg13": "[TODO: Translate] PG13 and above",
|
||||||
|
"r": "[TODO: Translate] R and above (default)",
|
||||||
|
"x": "[TODO: Translate] X and above",
|
||||||
|
"xxx": "[TODO: Translate] XXX only"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"videoSettings": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
|
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
|
||||||
@@ -574,6 +583,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)",
|
||||||
@@ -644,6 +654,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)",
|
||||||
@@ -731,61 +743,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -980,6 +992,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:",
|
||||||
@@ -1494,16 +1514,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",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Back to top",
|
"backToTop": "Back to top",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"add": "Add"
|
"add": "Add",
|
||||||
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -290,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",
|
||||||
@@ -574,6 +583,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)",
|
||||||
@@ -644,6 +654,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)",
|
||||||
@@ -980,6 +992,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:",
|
||||||
@@ -1494,6 +1514,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",
|
||||||
@@ -1503,7 +1524,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",
|
||||||
|
|||||||
150
locales/es.json
150
locales/es.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Volver arriba",
|
"backToTop": "Volver arriba",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"help": "Ayuda",
|
"help": "Ayuda",
|
||||||
"add": "Añadir"
|
"add": "Añadir",
|
||||||
|
"close": "Cerrar"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -290,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": "[TODO: Translate] Mature Blur Threshold",
|
||||||
|
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||||
|
"matureBlurThresholdOptions": {
|
||||||
|
"pg13": "[TODO: Translate] PG13 and above",
|
||||||
|
"r": "[TODO: Translate] R and above (default)",
|
||||||
|
"x": "[TODO: Translate] X and above",
|
||||||
|
"xxx": "[TODO: Translate] XXX only"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"videoSettings": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
|
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
|
||||||
@@ -574,6 +583,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)",
|
||||||
@@ -644,6 +654,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)",
|
||||||
@@ -731,61 +743,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -980,6 +992,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:",
|
||||||
@@ -1494,16 +1514,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",
|
||||||
|
|||||||
150
locales/fr.json
150
locales/fr.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Retour en haut",
|
"backToTop": "Retour en haut",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"help": "Aide",
|
"help": "Aide",
|
||||||
"add": "Ajouter"
|
"add": "Ajouter",
|
||||||
|
"close": "Fermer"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -290,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": "[TODO: Translate] Mature Blur Threshold",
|
||||||
|
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||||
|
"matureBlurThresholdOptions": {
|
||||||
|
"pg13": "[TODO: Translate] PG13 and above",
|
||||||
|
"r": "[TODO: Translate] R and above (default)",
|
||||||
|
"x": "[TODO: Translate] X and above",
|
||||||
|
"xxx": "[TODO: Translate] XXX only"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"videoSettings": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "Lecture automatique vidéo au survol",
|
"autoplayOnHover": "Lecture automatique vidéo au survol",
|
||||||
@@ -574,6 +583,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)",
|
||||||
@@ -644,6 +654,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)",
|
||||||
@@ -731,61 +743,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -980,6 +992,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 :",
|
||||||
@@ -1494,16 +1514,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é",
|
||||||
|
|||||||
150
locales/he.json
150
locales/he.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "חזרה למעלה",
|
"backToTop": "חזרה למעלה",
|
||||||
"settings": "הגדרות",
|
"settings": "הגדרות",
|
||||||
"help": "עזרה",
|
"help": "עזרה",
|
||||||
"add": "הוספה"
|
"add": "הוספה",
|
||||||
|
"close": "סגור"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "טוען...",
|
"loading": "טוען...",
|
||||||
@@ -290,7 +291,15 @@
|
|||||||
"blurNsfwContent": "טשטש תוכן NSFW",
|
"blurNsfwContent": "טשטש תוכן NSFW",
|
||||||
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
|
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
|
||||||
"showOnlySfw": "הצג רק תוצאות SFW",
|
"showOnlySfw": "הצג רק תוצאות SFW",
|
||||||
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש"
|
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש",
|
||||||
|
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||||
|
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||||
|
"matureBlurThresholdOptions": {
|
||||||
|
"pg13": "[TODO: Translate] PG13 and above",
|
||||||
|
"r": "[TODO: Translate] R and above (default)",
|
||||||
|
"x": "[TODO: Translate] X and above",
|
||||||
|
"xxx": "[TODO: Translate] XXX only"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"videoSettings": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
|
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
|
||||||
@@ -574,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||||
"deleteAll": "מחק את כל המודלים",
|
"deleteAll": "מחק את כל המודלים",
|
||||||
|
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
||||||
"clear": "נקה בחירה",
|
"clear": "נקה בחירה",
|
||||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||||
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
||||||
@@ -644,6 +654,8 @@
|
|||||||
"root": "שורש",
|
"root": "שורש",
|
||||||
"browseFolders": "דפדף בתיקיות:",
|
"browseFolders": "דפדף בתיקיות:",
|
||||||
"downloadAndSaveRecipe": "הורד ושמור מתכון",
|
"downloadAndSaveRecipe": "הורד ושמור מתכון",
|
||||||
|
"importRecipeOnly": "יבא רק מתכון",
|
||||||
|
"importAndDownload": "יבא והורד",
|
||||||
"downloadMissingLoras": "הורד LoRAs חסרים",
|
"downloadMissingLoras": "הורד LoRAs חסרים",
|
||||||
"saveRecipe": "שמור מתכון",
|
"saveRecipe": "שמור מתכון",
|
||||||
"loraCountInfo": "({existing}/{total} בספרייה)",
|
"loraCountInfo": "({existing}/{total} בספרייה)",
|
||||||
@@ -731,61 +743,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -980,6 +992,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": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||||
@@ -1494,16 +1514,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": "לא נבחרו מודלים",
|
||||||
|
|||||||
150
locales/ja.json
150
locales/ja.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "トップへ戻る",
|
"backToTop": "トップへ戻る",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "ヘルプ",
|
"help": "ヘルプ",
|
||||||
"add": "追加"
|
"add": "追加",
|
||||||
|
"close": "閉じる"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
@@ -290,7 +291,15 @@
|
|||||||
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
||||||
"blurNsfwContentHelp": "成人向け(NSFW)コンテンツのプレビュー画像をぼかします",
|
"blurNsfwContentHelp": "成人向け(NSFW)コンテンツのプレビュー画像をぼかします",
|
||||||
"showOnlySfw": "SFWコンテンツのみ表示",
|
"showOnlySfw": "SFWコンテンツのみ表示",
|
||||||
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します"
|
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します",
|
||||||
|
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||||
|
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||||
|
"matureBlurThresholdOptions": {
|
||||||
|
"pg13": "[TODO: Translate] PG13 and above",
|
||||||
|
"r": "[TODO: Translate] R and above (default)",
|
||||||
|
"x": "[TODO: Translate] X and above",
|
||||||
|
"xxx": "[TODO: Translate] XXX only"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"videoSettings": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "ホバー時に動画を自動再生",
|
"autoplayOnHover": "ホバー時に動画を自動再生",
|
||||||
@@ -574,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||||
"deleteAll": "すべてのモデルを削除",
|
"deleteAll": "すべてのモデルを削除",
|
||||||
|
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
||||||
"clear": "選択をクリア",
|
"clear": "選択をクリア",
|
||||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||||
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
||||||
@@ -644,6 +654,8 @@
|
|||||||
"root": "ルート",
|
"root": "ルート",
|
||||||
"browseFolders": "フォルダを参照:",
|
"browseFolders": "フォルダを参照:",
|
||||||
"downloadAndSaveRecipe": "ダウンロード & レシピ保存",
|
"downloadAndSaveRecipe": "ダウンロード & レシピ保存",
|
||||||
|
"importRecipeOnly": "レシピのみインポート",
|
||||||
|
"importAndDownload": "インポートとダウンロード",
|
||||||
"downloadMissingLoras": "不足しているLoRAをダウンロード",
|
"downloadMissingLoras": "不足しているLoRAをダウンロード",
|
||||||
"saveRecipe": "レシピを保存",
|
"saveRecipe": "レシピを保存",
|
||||||
"loraCountInfo": "({existing}/{total} ライブラリ内)",
|
"loraCountInfo": "({existing}/{total} ライブラリ内)",
|
||||||
@@ -731,61 +743,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -980,6 +992,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": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||||
@@ -1494,16 +1514,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": "モデルが選択されていません",
|
||||||
|
|||||||
150
locales/ko.json
150
locales/ko.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "맨 위로",
|
"backToTop": "맨 위로",
|
||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"help": "도움말",
|
"help": "도움말",
|
||||||
"add": "추가"
|
"add": "추가",
|
||||||
|
"close": "닫기"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
@@ -290,7 +291,15 @@
|
|||||||
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
||||||
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
|
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
|
||||||
"showOnlySfw": "SFW 결과만 표시",
|
"showOnlySfw": "SFW 결과만 표시",
|
||||||
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다"
|
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다",
|
||||||
|
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||||
|
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||||
|
"matureBlurThresholdOptions": {
|
||||||
|
"pg13": "[TODO: Translate] PG13 and above",
|
||||||
|
"r": "[TODO: Translate] R and above (default)",
|
||||||
|
"x": "[TODO: Translate] X and above",
|
||||||
|
"xxx": "[TODO: Translate] XXX only"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"videoSettings": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "호버 시 비디오 자동 재생",
|
"autoplayOnHover": "호버 시 비디오 자동 재생",
|
||||||
@@ -574,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||||
"deleteAll": "모든 모델 삭제",
|
"deleteAll": "모든 모델 삭제",
|
||||||
|
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||||
"clear": "선택 지우기",
|
"clear": "선택 지우기",
|
||||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||||
@@ -644,6 +654,8 @@
|
|||||||
"root": "루트",
|
"root": "루트",
|
||||||
"browseFolders": "폴더 탐색:",
|
"browseFolders": "폴더 탐색:",
|
||||||
"downloadAndSaveRecipe": "다운로드 및 레시피 저장",
|
"downloadAndSaveRecipe": "다운로드 및 레시피 저장",
|
||||||
|
"importRecipeOnly": "레시피만 가져오기",
|
||||||
|
"importAndDownload": "가져오기 및 다운로드",
|
||||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||||
"saveRecipe": "레시피 저장",
|
"saveRecipe": "레시피 저장",
|
||||||
"loraCountInfo": "({existing}/{total} 라이브러리에 있음)",
|
"loraCountInfo": "({existing}/{total} 라이브러리에 있음)",
|
||||||
@@ -731,61 +743,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -980,6 +992,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": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||||
@@ -1494,16 +1514,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": "선택된 모델이 없습니다",
|
||||||
|
|||||||
150
locales/ru.json
150
locales/ru.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Наверх",
|
"backToTop": "Наверх",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"help": "Справка",
|
"help": "Справка",
|
||||||
"add": "Добавить"
|
"add": "Добавить",
|
||||||
|
"close": "Закрыть"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -290,7 +291,15 @@
|
|||||||
"blurNsfwContent": "Размывать NSFW контент",
|
"blurNsfwContent": "Размывать NSFW контент",
|
||||||
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
|
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
|
||||||
"showOnlySfw": "Показывать только SFW результаты",
|
"showOnlySfw": "Показывать только SFW результаты",
|
||||||
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске"
|
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске",
|
||||||
|
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||||
|
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||||
|
"matureBlurThresholdOptions": {
|
||||||
|
"pg13": "[TODO: Translate] PG13 and above",
|
||||||
|
"r": "[TODO: Translate] R and above (default)",
|
||||||
|
"x": "[TODO: Translate] X and above",
|
||||||
|
"xxx": "[TODO: Translate] XXX only"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"videoSettings": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "Автовоспроизведение видео при наведении",
|
"autoplayOnHover": "Автовоспроизведение видео при наведении",
|
||||||
@@ -574,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||||
"deleteAll": "Удалить все модели",
|
"deleteAll": "Удалить все модели",
|
||||||
|
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||||
"clear": "Очистить выбор",
|
"clear": "Очистить выбор",
|
||||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||||
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
||||||
@@ -644,6 +654,8 @@
|
|||||||
"root": "Корень",
|
"root": "Корень",
|
||||||
"browseFolders": "Обзор папок:",
|
"browseFolders": "Обзор папок:",
|
||||||
"downloadAndSaveRecipe": "Скачать и сохранить рецепт",
|
"downloadAndSaveRecipe": "Скачать и сохранить рецепт",
|
||||||
|
"importRecipeOnly": "Импортировать только рецепт",
|
||||||
|
"importAndDownload": "Импорт и скачивание",
|
||||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||||
"saveRecipe": "Сохранить рецепт",
|
"saveRecipe": "Сохранить рецепт",
|
||||||
"loraCountInfo": "({existing}/{total} в библиотеке)",
|
"loraCountInfo": "({existing}/{total} в библиотеке)",
|
||||||
@@ -731,61 +743,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -980,6 +992,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": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||||
@@ -1494,16 +1514,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": "Модели не выбраны",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "返回顶部",
|
"backToTop": "返回顶部",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"add": "添加"
|
"add": "添加",
|
||||||
|
"close": "关闭"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
@@ -290,7 +291,15 @@
|
|||||||
"blurNsfwContent": "模糊 NSFW 内容",
|
"blurNsfwContent": "模糊 NSFW 内容",
|
||||||
"blurNsfwContentHelp": "模糊成熟(NSFW)内容预览图片",
|
"blurNsfwContentHelp": "模糊成熟(NSFW)内容预览图片",
|
||||||
"showOnlySfw": "仅显示 SFW 结果",
|
"showOnlySfw": "仅显示 SFW 结果",
|
||||||
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容"
|
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容",
|
||||||
|
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||||
|
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||||
|
"matureBlurThresholdOptions": {
|
||||||
|
"pg13": "[TODO: Translate] PG13 and above",
|
||||||
|
"r": "[TODO: Translate] R and above (default)",
|
||||||
|
"x": "[TODO: Translate] X and above",
|
||||||
|
"xxx": "[TODO: Translate] XXX only"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"videoSettings": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "悬停时自动播放视频",
|
"autoplayOnHover": "悬停时自动播放视频",
|
||||||
@@ -574,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||||
"deleteAll": "删除选中模型",
|
"deleteAll": "删除选中模型",
|
||||||
|
"downloadMissingLoras": "下载缺失的 LoRAs",
|
||||||
"clear": "清除选择",
|
"clear": "清除选择",
|
||||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||||
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
||||||
@@ -644,6 +654,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)",
|
||||||
@@ -733,55 +745,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": "请输入目录路径",
|
||||||
@@ -980,6 +992,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": "未找到此模型的本地示例图片。可选操作:",
|
||||||
@@ -1494,16 +1514,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": "未选中模型",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "回到頂部",
|
"backToTop": "回到頂部",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "說明",
|
"help": "說明",
|
||||||
"add": "新增"
|
"add": "新增",
|
||||||
|
"close": "關閉"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
@@ -290,7 +291,15 @@
|
|||||||
"blurNsfwContent": "模糊 NSFW 內容",
|
"blurNsfwContent": "模糊 NSFW 內容",
|
||||||
"blurNsfwContentHelp": "模糊成熟(NSFW)內容預覽圖片",
|
"blurNsfwContentHelp": "模糊成熟(NSFW)內容預覽圖片",
|
||||||
"showOnlySfw": "僅顯示 SFW 結果",
|
"showOnlySfw": "僅顯示 SFW 結果",
|
||||||
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容"
|
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容",
|
||||||
|
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||||
|
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||||
|
"matureBlurThresholdOptions": {
|
||||||
|
"pg13": "[TODO: Translate] PG13 and above",
|
||||||
|
"r": "[TODO: Translate] R and above (default)",
|
||||||
|
"x": "[TODO: Translate] X and above",
|
||||||
|
"xxx": "[TODO: Translate] XXX only"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"videoSettings": {
|
"videoSettings": {
|
||||||
"autoplayOnHover": "滑鼠懸停自動播放影片",
|
"autoplayOnHover": "滑鼠懸停自動播放影片",
|
||||||
@@ -574,6 +583,7 @@
|
|||||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||||
"deleteAll": "刪除全部模型",
|
"deleteAll": "刪除全部模型",
|
||||||
|
"downloadMissingLoras": "下載缺失的 LoRAs",
|
||||||
"clear": "清除選取",
|
"clear": "清除選取",
|
||||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||||
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
||||||
@@ -644,6 +654,8 @@
|
|||||||
"root": "根目錄",
|
"root": "根目錄",
|
||||||
"browseFolders": "瀏覽資料夾:",
|
"browseFolders": "瀏覽資料夾:",
|
||||||
"downloadAndSaveRecipe": "下載並儲存配方",
|
"downloadAndSaveRecipe": "下載並儲存配方",
|
||||||
|
"importRecipeOnly": "僅匯入配方",
|
||||||
|
"importAndDownload": "匯入並下載",
|
||||||
"downloadMissingLoras": "下載缺少的 LoRA",
|
"downloadMissingLoras": "下載缺少的 LoRA",
|
||||||
"saveRecipe": "儲存配方",
|
"saveRecipe": "儲存配方",
|
||||||
"loraCountInfo": "(庫存 {existing}/{total})",
|
"loraCountInfo": "(庫存 {existing}/{total})",
|
||||||
@@ -731,61 +743,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -980,6 +992,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": "此模型未找到本機範例圖片。可選擇:",
|
||||||
@@ -1494,16 +1514,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
3
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
47
py/config.py
47
py/config.py
@@ -707,7 +707,13 @@ class Config:
|
|||||||
|
|
||||||
def _prepare_checkpoint_paths(
|
def _prepare_checkpoint_paths(
|
||||||
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
|
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
|
||||||
) -> List[str]:
|
) -> Tuple[List[str], List[str], List[str]]:
|
||||||
|
"""Prepare checkpoint paths and return (all_roots, checkpoint_roots, unet_roots).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (all_unique_paths, checkpoint_only_paths, unet_only_paths)
|
||||||
|
This method does NOT modify instance variables - callers must set them.
|
||||||
|
"""
|
||||||
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
|
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
|
||||||
unet_map = self._dedupe_existing_paths(unet_paths)
|
unet_map = self._dedupe_existing_paths(unet_paths)
|
||||||
|
|
||||||
@@ -737,8 +743,8 @@ class Config:
|
|||||||
|
|
||||||
checkpoint_values = set(checkpoint_map.values())
|
checkpoint_values = set(checkpoint_map.values())
|
||||||
unet_values = set(unet_map.values())
|
unet_values = set(unet_map.values())
|
||||||
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_values]
|
checkpoint_roots = [p for p in unique_paths if p in checkpoint_values]
|
||||||
self.unet_roots = [p for p in unique_paths if p in unet_values]
|
unet_roots = [p for p in unique_paths if p in unet_values]
|
||||||
|
|
||||||
for original_path in unique_paths:
|
for original_path in unique_paths:
|
||||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(
|
real_path = os.path.normpath(os.path.realpath(original_path)).replace(
|
||||||
@@ -747,7 +753,7 @@ class Config:
|
|||||||
if real_path != original_path:
|
if real_path != original_path:
|
||||||
self.add_path_mapping(original_path, real_path)
|
self.add_path_mapping(original_path, real_path)
|
||||||
|
|
||||||
return unique_paths
|
return unique_paths, checkpoint_roots, unet_roots
|
||||||
|
|
||||||
def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]:
|
def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]:
|
||||||
path_map = self._dedupe_existing_paths(raw_paths)
|
path_map = self._dedupe_existing_paths(raw_paths)
|
||||||
@@ -776,9 +782,11 @@ class Config:
|
|||||||
embedding_paths = folder_paths.get("embeddings", []) or []
|
embedding_paths = folder_paths.get("embeddings", []) or []
|
||||||
|
|
||||||
self.loras_roots = self._prepare_lora_paths(lora_paths)
|
self.loras_roots = self._prepare_lora_paths(lora_paths)
|
||||||
self.base_models_roots = self._prepare_checkpoint_paths(
|
(
|
||||||
checkpoint_paths, unet_paths
|
self.base_models_roots,
|
||||||
)
|
self.checkpoints_roots,
|
||||||
|
self.unet_roots,
|
||||||
|
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
||||||
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
||||||
|
|
||||||
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
|
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||||
@@ -789,18 +797,11 @@ class Config:
|
|||||||
extra_embedding_paths = extra_paths.get("embeddings", []) or []
|
extra_embedding_paths = extra_paths.get("embeddings", []) or []
|
||||||
|
|
||||||
self.extra_loras_roots = self._prepare_lora_paths(extra_lora_paths)
|
self.extra_loras_roots = self._prepare_lora_paths(extra_lora_paths)
|
||||||
# Save main paths before processing extra paths ( _prepare_checkpoint_paths overwrites them)
|
(
|
||||||
saved_checkpoints_roots = self.checkpoints_roots
|
_,
|
||||||
saved_unet_roots = self.unet_roots
|
self.extra_checkpoints_roots,
|
||||||
self.extra_checkpoints_roots = self._prepare_checkpoint_paths(
|
self.extra_unet_roots,
|
||||||
extra_checkpoint_paths, extra_unet_paths
|
) = self._prepare_checkpoint_paths(extra_checkpoint_paths, extra_unet_paths)
|
||||||
)
|
|
||||||
self.extra_unet_roots = (
|
|
||||||
self.unet_roots if self.unet_roots is not None else []
|
|
||||||
) # unet_roots was set by _prepare_checkpoint_paths
|
|
||||||
# Restore main paths
|
|
||||||
self.checkpoints_roots = saved_checkpoints_roots
|
|
||||||
self.unet_roots = saved_unet_roots
|
|
||||||
self.extra_embeddings_roots = self._prepare_embedding_paths(
|
self.extra_embeddings_roots = self._prepare_embedding_paths(
|
||||||
extra_embedding_paths
|
extra_embedding_paths
|
||||||
)
|
)
|
||||||
@@ -857,9 +858,11 @@ class Config:
|
|||||||
try:
|
try:
|
||||||
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
||||||
raw_unet_paths = folder_paths.get_folder_paths("unet")
|
raw_unet_paths = folder_paths.get_folder_paths("unet")
|
||||||
unique_paths = self._prepare_checkpoint_paths(
|
(
|
||||||
raw_checkpoint_paths, raw_unet_paths
|
unique_paths,
|
||||||
)
|
self.checkpoints_roots,
|
||||||
|
self.unet_roots,
|
||||||
|
) = self._prepare_checkpoint_paths(raw_checkpoint_paths, raw_unet_paths)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Found checkpoint roots:"
|
"Found checkpoint roots:"
|
||||||
|
|||||||
@@ -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__'):
|
||||||
|
|||||||
118
py/nodes/checkpoint_loader.py
Normal file
118
py/nodes/checkpoint_loader.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import logging
|
||||||
|
from typing import List, Tuple
|
||||||
|
import comfy.sd # type: ignore
|
||||||
|
import folder_paths # type: ignore
|
||||||
|
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointLoaderLM:
|
||||||
|
"""Checkpoint Loader with support for extra folder paths
|
||||||
|
|
||||||
|
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
|
||||||
|
extra folder paths, providing a unified interface for checkpoint loading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NAME = "Checkpoint Loader (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/loaders"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(s):
|
||||||
|
# Get list of checkpoint names from scanner (includes extra folder paths)
|
||||||
|
checkpoint_names = s._get_checkpoint_names()
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"ckpt_name": (
|
||||||
|
checkpoint_names,
|
||||||
|
{"tooltip": "The name of the checkpoint (model) to load."},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("MODEL", "CLIP", "VAE")
|
||||||
|
RETURN_NAMES = ("MODEL", "CLIP", "VAE")
|
||||||
|
OUTPUT_TOOLTIPS = (
|
||||||
|
"The model used for denoising latents.",
|
||||||
|
"The CLIP model used for encoding text prompts.",
|
||||||
|
"The VAE model used for encoding and decoding images to and from latent space.",
|
||||||
|
)
|
||||||
|
FUNCTION = "load_checkpoint"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_checkpoint_names(cls) -> List[str]:
|
||||||
|
"""Get list of checkpoint names from scanner cache in ComfyUI format (relative path with extension)"""
|
||||||
|
try:
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def _get_names():
|
||||||
|
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Get all model roots for calculating relative paths
|
||||||
|
model_roots = scanner.get_model_roots()
|
||||||
|
|
||||||
|
# Filter only checkpoint type (not diffusion_model) and format names
|
||||||
|
names = []
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get("sub_type") == "checkpoint":
|
||||||
|
file_path = item.get("file_path", "")
|
||||||
|
if file_path:
|
||||||
|
# Format using relative path with OS-native separator
|
||||||
|
formatted_name = _format_model_name_for_comfyui(
|
||||||
|
file_path, model_roots
|
||||||
|
)
|
||||||
|
if formatted_name:
|
||||||
|
names.append(formatted_name)
|
||||||
|
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
def run_in_thread():
|
||||||
|
new_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(new_loop)
|
||||||
|
try:
|
||||||
|
return new_loop.run_until_complete(_get_names())
|
||||||
|
finally:
|
||||||
|
new_loop.close()
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
future = executor.submit(run_in_thread)
|
||||||
|
return future.result()
|
||||||
|
except RuntimeError:
|
||||||
|
return asyncio.run(_get_names())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting checkpoint names: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def load_checkpoint(self, ckpt_name: str) -> Tuple:
|
||||||
|
"""Load a checkpoint by name, supporting extra folder paths
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ckpt_name: The name of the checkpoint to load (relative path with extension)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (MODEL, CLIP, VAE)
|
||||||
|
"""
|
||||||
|
# Get absolute path from cache using ComfyUI-style name
|
||||||
|
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
|
||||||
|
|
||||||
|
if metadata is None:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Checkpoint '{ckpt_name}' not found in LoRA Manager cache. "
|
||||||
|
"Make sure the checkpoint is indexed and try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load regular checkpoint using ComfyUI's API
|
||||||
|
logger.info(f"Loading checkpoint from: {ckpt_path}")
|
||||||
|
out = comfy.sd.load_checkpoint_guess_config(
|
||||||
|
ckpt_path,
|
||||||
|
output_vae=True,
|
||||||
|
output_clip=True,
|
||||||
|
embedding_directory=folder_paths.get_folder_paths("embeddings"),
|
||||||
|
)
|
||||||
|
return out[:3]
|
||||||
161
py/nodes/gguf_import_helper.py
Normal file
161
py/nodes/gguf_import_helper.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Helper module to safely import ComfyUI-GGUF modules.
|
||||||
|
|
||||||
|
This module provides a robust way to import ComfyUI-GGUF functionality
|
||||||
|
regardless of how ComfyUI loaded it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Tuple, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_gguf_path() -> str:
|
||||||
|
"""Get the path to ComfyUI-GGUF based on this file's location.
|
||||||
|
|
||||||
|
Since ComfyUI-Lora-Manager and ComfyUI-GGUF are both in custom_nodes/,
|
||||||
|
we can derive the GGUF path from our own location.
|
||||||
|
"""
|
||||||
|
# This file is at: custom_nodes/ComfyUI-Lora-Manager/py/nodes/gguf_import_helper.py
|
||||||
|
# ComfyUI-GGUF is at: custom_nodes/ComfyUI-GGUF
|
||||||
|
current_file = os.path.abspath(__file__)
|
||||||
|
# Go up 4 levels: nodes -> py -> ComfyUI-Lora-Manager -> custom_nodes
|
||||||
|
custom_nodes_dir = os.path.dirname(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
|
||||||
|
)
|
||||||
|
return os.path.join(custom_nodes_dir, "ComfyUI-GGUF")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_gguf_module() -> Optional[Any]:
|
||||||
|
"""Find ComfyUI-GGUF module in sys.modules.
|
||||||
|
|
||||||
|
ComfyUI registers modules using the full path with dots replaced by _x_.
|
||||||
|
"""
|
||||||
|
gguf_path = _get_gguf_path()
|
||||||
|
sys_module_name = gguf_path.replace(".", "_x_")
|
||||||
|
|
||||||
|
logger.debug(f"[GGUF Import] Looking for module '{sys_module_name}' in sys.modules")
|
||||||
|
if sys_module_name in sys.modules:
|
||||||
|
logger.info(f"[GGUF Import] Found module: '{sys_module_name}'")
|
||||||
|
return sys.modules[sys_module_name]
|
||||||
|
|
||||||
|
logger.debug(f"[GGUF Import] Module not found: '{sys_module_name}'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_gguf_modules_directly() -> Optional[Any]:
|
||||||
|
"""Load ComfyUI-GGUF modules directly from file paths."""
|
||||||
|
gguf_path = _get_gguf_path()
|
||||||
|
|
||||||
|
logger.info(f"[GGUF Import] Direct Load: Attempting to load from '{gguf_path}'")
|
||||||
|
|
||||||
|
if not os.path.exists(gguf_path):
|
||||||
|
logger.warning(f"[GGUF Import] Path does not exist: {gguf_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
namespace = "ComfyUI_GGUF_Dynamic"
|
||||||
|
init_path = os.path.join(gguf_path, "__init__.py")
|
||||||
|
|
||||||
|
if not os.path.exists(init_path):
|
||||||
|
logger.warning(f"[GGUF Import] __init__.py not found at '{init_path}'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug(f"[GGUF Import] Loading from '{init_path}'")
|
||||||
|
spec = importlib.util.spec_from_file_location(namespace, init_path)
|
||||||
|
if not spec or not spec.loader:
|
||||||
|
logger.error(f"[GGUF Import] Failed to create spec for '{init_path}'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
package = importlib.util.module_from_spec(spec)
|
||||||
|
package.__path__ = [gguf_path]
|
||||||
|
sys.modules[namespace] = package
|
||||||
|
spec.loader.exec_module(package)
|
||||||
|
logger.debug(f"[GGUF Import] Loaded main package '{namespace}'")
|
||||||
|
|
||||||
|
# Load submodules
|
||||||
|
loaded = []
|
||||||
|
for submod_name in ["loader", "ops", "nodes"]:
|
||||||
|
submod_path = os.path.join(gguf_path, f"{submod_name}.py")
|
||||||
|
if os.path.exists(submod_path):
|
||||||
|
submod_spec = importlib.util.spec_from_file_location(
|
||||||
|
f"{namespace}.{submod_name}", submod_path
|
||||||
|
)
|
||||||
|
if submod_spec and submod_spec.loader:
|
||||||
|
submod = importlib.util.module_from_spec(submod_spec)
|
||||||
|
submod.__package__ = namespace
|
||||||
|
sys.modules[f"{namespace}.{submod_name}"] = submod
|
||||||
|
submod_spec.loader.exec_module(submod)
|
||||||
|
setattr(package, submod_name, submod)
|
||||||
|
loaded.append(submod_name)
|
||||||
|
logger.debug(f"[GGUF Import] Loaded submodule '{submod_name}'")
|
||||||
|
|
||||||
|
logger.info(f"[GGUF Import] Direct Load success: {loaded}")
|
||||||
|
return package
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[GGUF Import] Direct Load failed: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_gguf_modules() -> Tuple[Any, Any, Any]:
|
||||||
|
"""Get ComfyUI-GGUF modules (loader, ops, nodes).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (loader_module, ops_module, nodes_module)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If ComfyUI-GGUF cannot be found or loaded.
|
||||||
|
"""
|
||||||
|
logger.debug("[GGUF Import] Starting module search...")
|
||||||
|
|
||||||
|
# Try to find already loaded module first
|
||||||
|
gguf_module = _find_gguf_module()
|
||||||
|
|
||||||
|
if gguf_module is None:
|
||||||
|
logger.info("[GGUF Import] Not found in sys.modules, trying direct load...")
|
||||||
|
gguf_module = _load_gguf_modules_directly()
|
||||||
|
|
||||||
|
if gguf_module is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"ComfyUI-GGUF is not installed. "
|
||||||
|
"Please install from https://github.com/city96/ComfyUI-GGUF"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract submodules
|
||||||
|
loader = getattr(gguf_module, "loader", None)
|
||||||
|
ops = getattr(gguf_module, "ops", None)
|
||||||
|
nodes = getattr(gguf_module, "nodes", None)
|
||||||
|
|
||||||
|
if loader is None or ops is None or nodes is None:
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, mod in [("loader", loader), ("ops", ops), ("nodes", nodes)]
|
||||||
|
if mod is None
|
||||||
|
]
|
||||||
|
raise RuntimeError(f"ComfyUI-GGUF missing submodules: {missing}")
|
||||||
|
|
||||||
|
logger.debug("[GGUF Import] All modules loaded successfully")
|
||||||
|
return loader, ops, nodes
|
||||||
|
|
||||||
|
|
||||||
|
def get_gguf_sd_loader():
|
||||||
|
"""Get the gguf_sd_loader function from ComfyUI-GGUF."""
|
||||||
|
loader, _, _ = get_gguf_modules()
|
||||||
|
return getattr(loader, "gguf_sd_loader")
|
||||||
|
|
||||||
|
|
||||||
|
def get_ggml_ops():
|
||||||
|
"""Get the GGMLOps class from ComfyUI-GGUF."""
|
||||||
|
_, ops, _ = get_gguf_modules()
|
||||||
|
return getattr(ops, "GGMLOps")
|
||||||
|
|
||||||
|
|
||||||
|
def get_gguf_model_patcher():
|
||||||
|
"""Get the GGUFModelPatcher class from ComfyUI-GGUF."""
|
||||||
|
_, _, nodes = get_gguf_modules()
|
||||||
|
return getattr(nodes, "GGUFModelPatcher")
|
||||||
@@ -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],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
205
py/nodes/unet_loader.py
Normal file
205
py/nodes/unet_loader.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import List, Tuple
|
||||||
|
import comfy.sd # type: ignore
|
||||||
|
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UNETLoaderLM:
|
||||||
|
"""UNET Loader with support for extra folder paths
|
||||||
|
|
||||||
|
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
|
||||||
|
extra folder paths, providing a unified interface for UNET loading.
|
||||||
|
Supports both regular diffusion models and GGUF format models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NAME = "Unet Loader (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/loaders"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(s):
|
||||||
|
# Get list of unet names from scanner (includes extra folder paths)
|
||||||
|
unet_names = s._get_unet_names()
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"unet_name": (
|
||||||
|
unet_names,
|
||||||
|
{"tooltip": "The name of the diffusion model to load."},
|
||||||
|
),
|
||||||
|
"weight_dtype": (
|
||||||
|
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
|
||||||
|
{"tooltip": "The dtype to use for the model weights."},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("MODEL",)
|
||||||
|
RETURN_NAMES = ("MODEL",)
|
||||||
|
OUTPUT_TOOLTIPS = ("The model used for denoising latents.",)
|
||||||
|
FUNCTION = "load_unet"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_unet_names(cls) -> List[str]:
|
||||||
|
"""Get list of diffusion model names from scanner cache in ComfyUI format (relative path with extension)"""
|
||||||
|
try:
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def _get_names():
|
||||||
|
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Get all model roots for calculating relative paths
|
||||||
|
model_roots = scanner.get_model_roots()
|
||||||
|
|
||||||
|
# Filter only diffusion_model type and format names
|
||||||
|
names = []
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get("sub_type") == "diffusion_model":
|
||||||
|
file_path = item.get("file_path", "")
|
||||||
|
if file_path:
|
||||||
|
# Format using relative path with OS-native separator
|
||||||
|
formatted_name = _format_model_name_for_comfyui(
|
||||||
|
file_path, model_roots
|
||||||
|
)
|
||||||
|
if formatted_name:
|
||||||
|
names.append(formatted_name)
|
||||||
|
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
def run_in_thread():
|
||||||
|
new_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(new_loop)
|
||||||
|
try:
|
||||||
|
return new_loop.run_until_complete(_get_names())
|
||||||
|
finally:
|
||||||
|
new_loop.close()
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
future = executor.submit(run_in_thread)
|
||||||
|
return future.result()
|
||||||
|
except RuntimeError:
|
||||||
|
return asyncio.run(_get_names())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting unet names: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple:
|
||||||
|
"""Load a diffusion model by name, supporting extra folder paths
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unet_name: The name of the diffusion model to load (relative path with extension)
|
||||||
|
weight_dtype: The dtype to use for model weights
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (MODEL,)
|
||||||
|
"""
|
||||||
|
import torch
|
||||||
|
|
||||||
|
# Get absolute path from cache using ComfyUI-style name
|
||||||
|
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
|
||||||
|
|
||||||
|
if metadata is None:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Diffusion model '{unet_name}' not found in LoRA Manager cache. "
|
||||||
|
"Make sure the model is indexed and try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if it's a GGUF model
|
||||||
|
if unet_path.endswith(".gguf"):
|
||||||
|
return self._load_gguf_unet(unet_path, unet_name, weight_dtype)
|
||||||
|
|
||||||
|
# Load regular diffusion model using ComfyUI's API
|
||||||
|
logger.info(f"Loading diffusion model from: {unet_path}")
|
||||||
|
|
||||||
|
# Build model options based on weight_dtype
|
||||||
|
model_options = {}
|
||||||
|
if weight_dtype == "fp8_e4m3fn":
|
||||||
|
model_options["dtype"] = torch.float8_e4m3fn
|
||||||
|
elif weight_dtype == "fp8_e4m3fn_fast":
|
||||||
|
model_options["dtype"] = torch.float8_e4m3fn
|
||||||
|
model_options["fp8_optimizations"] = True
|
||||||
|
elif weight_dtype == "fp8_e5m2":
|
||||||
|
model_options["dtype"] = torch.float8_e5m2
|
||||||
|
|
||||||
|
model = comfy.sd.load_diffusion_model(unet_path, model_options=model_options)
|
||||||
|
return (model,)
|
||||||
|
|
||||||
|
def _load_gguf_unet(
|
||||||
|
self, unet_path: str, unet_name: str, weight_dtype: str
|
||||||
|
) -> Tuple:
|
||||||
|
"""Load a GGUF format diffusion model
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unet_path: Absolute path to the GGUF file
|
||||||
|
unet_name: Name of the model for error messages
|
||||||
|
weight_dtype: The dtype to use for model weights
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (MODEL,)
|
||||||
|
"""
|
||||||
|
import torch
|
||||||
|
from .gguf_import_helper import get_gguf_modules
|
||||||
|
|
||||||
|
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)
|
||||||
|
try:
|
||||||
|
loader_module, ops_module, nodes_module = get_gguf_modules()
|
||||||
|
gguf_sd_loader = getattr(loader_module, "gguf_sd_loader")
|
||||||
|
GGMLOps = getattr(ops_module, "GGMLOps")
|
||||||
|
GGUFModelPatcher = getattr(nodes_module, "GGUFModelPatcher")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise RuntimeError(f"Cannot load GGUF model '{unet_name}'. {str(e)}")
|
||||||
|
|
||||||
|
logger.info(f"Loading GGUF diffusion model from: {unet_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load GGUF state dict
|
||||||
|
sd, extra = gguf_sd_loader(unet_path)
|
||||||
|
|
||||||
|
# Prepare kwargs for metadata if supported
|
||||||
|
kwargs = {}
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
valid_params = inspect.signature(
|
||||||
|
comfy.sd.load_diffusion_model_state_dict
|
||||||
|
).parameters
|
||||||
|
if "metadata" in valid_params:
|
||||||
|
kwargs["metadata"] = extra.get("metadata", {})
|
||||||
|
|
||||||
|
# Setup custom operations with GGUF support
|
||||||
|
ops = GGMLOps()
|
||||||
|
|
||||||
|
# Handle weight_dtype for GGUF models
|
||||||
|
if weight_dtype in ("default", None):
|
||||||
|
ops.Linear.dequant_dtype = None
|
||||||
|
elif weight_dtype in ["target"]:
|
||||||
|
ops.Linear.dequant_dtype = weight_dtype
|
||||||
|
else:
|
||||||
|
ops.Linear.dequant_dtype = getattr(torch, weight_dtype, None)
|
||||||
|
|
||||||
|
# Load the model
|
||||||
|
model = comfy.sd.load_diffusion_model_state_dict(
|
||||||
|
sd, model_options={"custom_operations": ops}, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Could not detect model type for GGUF diffusion model: {unet_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wrap with GGUFModelPatcher
|
||||||
|
model = GGUFModelPatcher.clone(model)
|
||||||
|
|
||||||
|
return (model,)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading GGUF diffusion model '{unet_name}': {e}")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to load GGUF diffusion model '{unet_name}': {str(e)}"
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
188
py/recipes/parsers/sui_image_params.py
Normal file
188
py/recipes/parsers/sui_image_params.py
Normal 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": []}
|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,11 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
reverse = sort_params.order == "desc"
|
reverse = sort_params.order == "desc"
|
||||||
annotated.sort(
|
annotated.sort(
|
||||||
key=lambda x: (x.get("usage_count", 0), x.get("model_name", "").lower()),
|
key=lambda x: (
|
||||||
|
x.get("usage_count", 0),
|
||||||
|
x.get("model_name", "").lower(),
|
||||||
|
x.get("file_path", "").lower()
|
||||||
|
),
|
||||||
reverse=reverse,
|
reverse=reverse,
|
||||||
)
|
)
|
||||||
return annotated
|
return annotated
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class CacheEntryValidator:
|
|||||||
'preview_nsfw_level': (0, False),
|
'preview_nsfw_level': (0, False),
|
||||||
'notes': ('', False),
|
'notes': ('', False),
|
||||||
'usage_tips': ('', False),
|
'usage_tips': ('', False),
|
||||||
|
'hash_status': ('completed', False),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -90,13 +91,31 @@ class CacheEntryValidator:
|
|||||||
|
|
||||||
errors: List[str] = []
|
errors: List[str] = []
|
||||||
repaired = False
|
repaired = False
|
||||||
|
|
||||||
|
# If auto_repair is on, we work on a copy. If not, we still need a safe way to check fields.
|
||||||
working_entry = dict(entry) if auto_repair else entry
|
working_entry = dict(entry) if auto_repair else entry
|
||||||
|
|
||||||
|
# Determine effective hash_status for validation logic
|
||||||
|
hash_status = entry.get('hash_status')
|
||||||
|
if hash_status is None:
|
||||||
|
if auto_repair:
|
||||||
|
working_entry['hash_status'] = 'completed'
|
||||||
|
repaired = True
|
||||||
|
hash_status = 'completed'
|
||||||
|
|
||||||
for field_name, (default_value, is_required) in cls.CORE_FIELDS.items():
|
for field_name, (default_value, is_required) in cls.CORE_FIELDS.items():
|
||||||
value = working_entry.get(field_name)
|
# Get current value from the original entry to avoid side effects during validation
|
||||||
|
value = entry.get(field_name)
|
||||||
|
|
||||||
# Check if field is missing or None
|
# Check if field is missing or None
|
||||||
if value is None:
|
if value is None:
|
||||||
|
# Special case: sha256 can be None/empty if hash_status is pending
|
||||||
|
if field_name == 'sha256' and hash_status == 'pending':
|
||||||
|
if auto_repair:
|
||||||
|
working_entry[field_name] = ''
|
||||||
|
repaired = True
|
||||||
|
continue
|
||||||
|
|
||||||
if is_required:
|
if is_required:
|
||||||
errors.append(f"Required field '{field_name}' is missing or None")
|
errors.append(f"Required field '{field_name}' is missing or None")
|
||||||
if auto_repair:
|
if auto_repair:
|
||||||
@@ -107,6 +126,10 @@ class CacheEntryValidator:
|
|||||||
# Validate field type and value
|
# Validate field type and value
|
||||||
field_error = cls._validate_field(field_name, value, default_value)
|
field_error = cls._validate_field(field_name, value, default_value)
|
||||||
if field_error:
|
if field_error:
|
||||||
|
# Special case: allow empty string for sha256 if pending
|
||||||
|
if field_name == 'sha256' and hash_status == 'pending' and value == '':
|
||||||
|
continue
|
||||||
|
|
||||||
errors.append(field_error)
|
errors.append(field_error)
|
||||||
if auto_repair:
|
if auto_repair:
|
||||||
working_entry[field_name] = cls._get_default_copy(default_value)
|
working_entry[field_name] = cls._get_default_copy(default_value)
|
||||||
@@ -127,7 +150,7 @@ class CacheEntryValidator:
|
|||||||
# Special validation: sha256 must not be empty for required field
|
# Special validation: sha256 must not be empty for required field
|
||||||
# BUT allow empty sha256 when hash_status is pending (lazy hash calculation)
|
# BUT allow empty sha256 when hash_status is pending (lazy hash calculation)
|
||||||
sha256 = working_entry.get('sha256', '')
|
sha256 = working_entry.get('sha256', '')
|
||||||
hash_status = working_entry.get('hash_status', 'completed')
|
# Use the effective hash_status we determined earlier
|
||||||
if not sha256 or (isinstance(sha256, str) and not sha256.strip()):
|
if not sha256 or (isinstance(sha256, str) and not sha256.strip()):
|
||||||
# Allow empty sha256 for lazy hash calculation (checkpoints)
|
# Allow empty sha256 for lazy hash calculation (checkpoints)
|
||||||
if hash_status != 'pending':
|
if hash_status != 'pending':
|
||||||
@@ -144,8 +167,13 @@ class CacheEntryValidator:
|
|||||||
if isinstance(sha256, str):
|
if isinstance(sha256, str):
|
||||||
normalized_sha = sha256.lower().strip()
|
normalized_sha = sha256.lower().strip()
|
||||||
if normalized_sha != sha256:
|
if normalized_sha != sha256:
|
||||||
|
if auto_repair:
|
||||||
working_entry['sha256'] = normalized_sha
|
working_entry['sha256'] = normalized_sha
|
||||||
repaired = True
|
repaired = True
|
||||||
|
else:
|
||||||
|
# If not auto-repairing, we don't consider case difference as a "critical error"
|
||||||
|
# that invalidates the entry, but we also don't mark it repaired.
|
||||||
|
pass
|
||||||
|
|
||||||
# Determine if entry is valid
|
# Determine if entry is valid
|
||||||
# Entry is valid if no critical required field errors remain after repair
|
# Entry is valid if no critical required field errors remain after repair
|
||||||
|
|||||||
@@ -13,20 +13,33 @@ from .model_hash_index import ModelHashIndex
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CheckpointScanner(ModelScanner):
|
class CheckpointScanner(ModelScanner):
|
||||||
"""Service for scanning and managing checkpoint files"""
|
"""Service for scanning and managing checkpoint files"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Define supported file extensions
|
# Define supported file extensions
|
||||||
file_extensions = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft', '.gguf'}
|
file_extensions = {
|
||||||
|
".ckpt",
|
||||||
|
".pt",
|
||||||
|
".pt2",
|
||||||
|
".bin",
|
||||||
|
".pth",
|
||||||
|
".safetensors",
|
||||||
|
".pkl",
|
||||||
|
".sft",
|
||||||
|
".gguf",
|
||||||
|
}
|
||||||
super().__init__(
|
super().__init__(
|
||||||
model_type="checkpoint",
|
model_type="checkpoint",
|
||||||
model_class=CheckpointMetadata,
|
model_class=CheckpointMetadata,
|
||||||
file_extensions=file_extensions,
|
file_extensions=file_extensions,
|
||||||
hash_index=ModelHashIndex()
|
hash_index=ModelHashIndex(),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _create_default_metadata(self, file_path: str) -> Optional[CheckpointMetadata]:
|
async def _create_default_metadata(
|
||||||
|
self, file_path: str
|
||||||
|
) -> Optional[CheckpointMetadata]:
|
||||||
"""Create default metadata for checkpoint without calculating hash (lazy hash).
|
"""Create default metadata for checkpoint without calculating hash (lazy hash).
|
||||||
|
|
||||||
Checkpoints are typically large (10GB+), so we skip hash calculation during initial
|
Checkpoints are typically large (10GB+), so we skip hash calculation during initial
|
||||||
@@ -59,7 +72,7 @@ class CheckpointScanner(ModelScanner):
|
|||||||
modelDescription="",
|
modelDescription="",
|
||||||
sub_type="checkpoint",
|
sub_type="checkpoint",
|
||||||
from_civitai=False, # Mark as local model since no hash yet
|
from_civitai=False, # Mark as local model since no hash yet
|
||||||
hash_status="pending" # Mark hash as pending
|
hash_status="pending", # Mark hash as pending
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save the created metadata
|
# Save the created metadata
|
||||||
@@ -69,7 +82,9 @@ class CheckpointScanner(ModelScanner):
|
|||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating default checkpoint metadata for {file_path}: {e}")
|
logger.error(
|
||||||
|
f"Error creating default checkpoint metadata for {file_path}: {e}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
|
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
|
||||||
@@ -90,7 +105,9 @@ class CheckpointScanner(ModelScanner):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Load current metadata
|
# Load current metadata
|
||||||
metadata, _ = await MetadataManager.load_metadata(file_path, self.model_class)
|
metadata, _ = await MetadataManager.load_metadata(
|
||||||
|
file_path, self.model_class
|
||||||
|
)
|
||||||
if metadata is None:
|
if metadata is None:
|
||||||
logger.error(f"No metadata found for {file_path}")
|
logger.error(f"No metadata found for {file_path}")
|
||||||
return None
|
return None
|
||||||
@@ -122,7 +139,9 @@ class CheckpointScanner(ModelScanner):
|
|||||||
logger.error(f"Error calculating hash for {file_path}: {e}")
|
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||||
# Update status to failed
|
# Update status to failed
|
||||||
try:
|
try:
|
||||||
metadata, _ = await MetadataManager.load_metadata(file_path, self.model_class)
|
metadata, _ = await MetadataManager.load_metadata(
|
||||||
|
file_path, self.model_class
|
||||||
|
)
|
||||||
if metadata:
|
if metadata:
|
||||||
metadata.hash_status = "failed"
|
metadata.hash_status = "failed"
|
||||||
await MetadataManager.save_metadata(file_path, metadata)
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
@@ -130,7 +149,9 @@ class CheckpointScanner(ModelScanner):
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def calculate_all_pending_hashes(self, progress_callback=None) -> Dict[str, int]:
|
async def calculate_all_pending_hashes(
|
||||||
|
self, progress_callback=None
|
||||||
|
) -> Dict[str, int]:
|
||||||
"""Calculate hashes for all checkpoints with pending hash status.
|
"""Calculate hashes for all checkpoints with pending hash status.
|
||||||
|
|
||||||
If cache is not initialized, scans filesystem directly for metadata files
|
If cache is not initialized, scans filesystem directly for metadata files
|
||||||
@@ -148,22 +169,23 @@ class CheckpointScanner(ModelScanner):
|
|||||||
if cache and cache.raw_data:
|
if cache and cache.raw_data:
|
||||||
# Use cache if available
|
# Use cache if available
|
||||||
pending_models = [
|
pending_models = [
|
||||||
item for item in cache.raw_data
|
item
|
||||||
if item.get('hash_status') != 'completed' or not item.get('sha256')
|
for item in cache.raw_data
|
||||||
|
if item.get("hash_status") != "completed" or not item.get("sha256")
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# Cache not initialized, scan filesystem directly
|
# Cache not initialized, scan filesystem directly
|
||||||
pending_models = await self._find_pending_models_from_filesystem()
|
pending_models = await self._find_pending_models_from_filesystem()
|
||||||
|
|
||||||
if not pending_models:
|
if not pending_models:
|
||||||
return {'completed': 0, 'failed': 0, 'total': 0}
|
return {"completed": 0, "failed": 0, "total": 0}
|
||||||
|
|
||||||
total = len(pending_models)
|
total = len(pending_models)
|
||||||
completed = 0
|
completed = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
|
||||||
for i, model_data in enumerate(pending_models):
|
for i, model_data in enumerate(pending_models):
|
||||||
file_path = model_data.get('file_path')
|
file_path = model_data.get("file_path")
|
||||||
if not file_path:
|
if not file_path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -183,11 +205,7 @@ class CheckpointScanner(ModelScanner):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {
|
return {"completed": completed, "failed": failed, "total": total}
|
||||||
'completed': completed,
|
|
||||||
'failed': failed,
|
|
||||||
'total': total
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
||||||
"""Scan filesystem for checkpoint metadata files with pending hash status."""
|
"""Scan filesystem for checkpoint metadata files with pending hash status."""
|
||||||
@@ -199,21 +217,21 @@ class CheckpointScanner(ModelScanner):
|
|||||||
|
|
||||||
for dirpath, _dirnames, filenames in os.walk(root_path):
|
for dirpath, _dirnames, filenames in os.walk(root_path):
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
if not filename.endswith('.metadata.json'):
|
if not filename.endswith(".metadata.json"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
metadata_path = os.path.join(dirpath, filename)
|
metadata_path = os.path.join(dirpath, filename)
|
||||||
try:
|
try:
|
||||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
# Check if hash is pending
|
# Check if hash is pending
|
||||||
hash_status = data.get('hash_status', 'completed')
|
hash_status = data.get("hash_status", "completed")
|
||||||
sha256 = data.get('sha256', '')
|
sha256 = data.get("sha256", "")
|
||||||
|
|
||||||
if hash_status != 'completed' or not sha256:
|
if hash_status != "completed" or not sha256:
|
||||||
# Find corresponding model file
|
# Find corresponding model file
|
||||||
model_name = filename.replace('.metadata.json', '')
|
model_name = filename.replace(".metadata.json", "")
|
||||||
model_path = None
|
model_path = None
|
||||||
|
|
||||||
# Look for model file with matching name
|
# Look for model file with matching name
|
||||||
@@ -224,29 +242,58 @@ class CheckpointScanner(ModelScanner):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if model_path:
|
if model_path:
|
||||||
pending_models.append({
|
pending_models.append(
|
||||||
'file_path': model_path.replace(os.sep, '/'),
|
{
|
||||||
'hash_status': hash_status,
|
"file_path": model_path.replace(os.sep, "/"),
|
||||||
'sha256': sha256,
|
"hash_status": hash_status,
|
||||||
**{k: v for k, v in data.items() if k not in ['file_path', 'hash_status', 'sha256']}
|
"sha256": sha256,
|
||||||
})
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in data.items()
|
||||||
|
if k
|
||||||
|
not in [
|
||||||
|
"file_path",
|
||||||
|
"hash_status",
|
||||||
|
"sha256",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
except (json.JSONDecodeError, Exception) as e:
|
except (json.JSONDecodeError, Exception) as e:
|
||||||
logger.debug(f"Error reading metadata file {metadata_path}: {e}")
|
logger.debug(
|
||||||
|
f"Error reading metadata file {metadata_path}: {e}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return pending_models
|
return pending_models
|
||||||
|
|
||||||
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
|
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
|
||||||
"""Resolve the sub-type based on the root path."""
|
"""Resolve the sub-type based on the root path.
|
||||||
|
|
||||||
|
Checks both standard ComfyUI paths and LoRA Manager's extra folder paths.
|
||||||
|
"""
|
||||||
if not root_path:
|
if not root_path:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Check standard ComfyUI checkpoint paths
|
||||||
if config.checkpoints_roots and root_path in config.checkpoints_roots:
|
if config.checkpoints_roots and root_path in config.checkpoints_roots:
|
||||||
return "checkpoint"
|
return "checkpoint"
|
||||||
|
|
||||||
|
# Check extra checkpoint paths
|
||||||
|
if (
|
||||||
|
config.extra_checkpoints_roots
|
||||||
|
and root_path in config.extra_checkpoints_roots
|
||||||
|
):
|
||||||
|
return "checkpoint"
|
||||||
|
|
||||||
|
# Check standard ComfyUI unet paths
|
||||||
if config.unet_roots and root_path in config.unet_roots:
|
if config.unet_roots and root_path in config.unet_roots:
|
||||||
return "diffusion_model"
|
return "diffusion_model"
|
||||||
|
|
||||||
|
# Check extra unet paths
|
||||||
|
if config.extra_unet_roots and root_path in config.extra_unet_roots:
|
||||||
|
return "diffusion_model"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def adjust_metadata(self, metadata, file_path, root_path):
|
def adjust_metadata(self, metadata, file_path, root_path):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ from ..utils.constants import (
|
|||||||
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
|
||||||
@@ -847,9 +846,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 +968,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 +1242,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -516,12 +635,18 @@ class LoraService(BaseModelService):
|
|||||||
if sort_by == "model_name":
|
if sort_by == "model_name":
|
||||||
available_loras = sorted(
|
available_loras = sorted(
|
||||||
available_loras,
|
available_loras,
|
||||||
key=lambda x: (x.get("model_name") or x.get("file_name", "")).lower()
|
key=lambda x: (
|
||||||
|
(x.get("model_name") or x.get("file_name", "")).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: x.get("file_name", "").lower()
|
key=lambda x: (
|
||||||
|
x.get("file_name", "").lower(),
|
||||||
|
x.get("file_path", "").lower(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return minimal data needed for cycling
|
# Return minimal data needed for cycling
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -221,33 +221,45 @@ class ModelCache:
|
|||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
reverse = (order == 'desc')
|
reverse = (order == 'desc')
|
||||||
if sort_key == 'name':
|
if sort_key == 'name':
|
||||||
# Natural sort by configured display name, case-insensitive
|
# Natural sort by configured display name, case-insensitive, with file_path as tie-breaker
|
||||||
result = natsorted(
|
result = natsorted(
|
||||||
data,
|
data,
|
||||||
key=lambda x: self._get_display_name(x).lower(),
|
key=lambda x: (
|
||||||
|
self._get_display_name(x).lower(),
|
||||||
|
x.get('file_path', '').lower()
|
||||||
|
),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'date':
|
elif sort_key == 'date':
|
||||||
# Sort by modified timestamp (use .get() with default to handle missing fields)
|
# Sort by modified timestamp, fallback to name and path for stability
|
||||||
result = sorted(
|
result = sorted(
|
||||||
data,
|
data,
|
||||||
key=lambda x: x.get('modified', 0.0),
|
key=lambda x: (
|
||||||
|
x.get('modified', 0.0),
|
||||||
|
self._get_display_name(x).lower(),
|
||||||
|
x.get('file_path', '').lower()
|
||||||
|
),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'size':
|
elif sort_key == 'size':
|
||||||
# Sort by file size (use .get() with default to handle missing fields)
|
# Sort by file size, fallback to name and path for stability
|
||||||
result = sorted(
|
result = sorted(
|
||||||
data,
|
data,
|
||||||
key=lambda x: x.get('size', 0),
|
key=lambda x: (
|
||||||
|
x.get('size', 0),
|
||||||
|
self._get_display_name(x).lower(),
|
||||||
|
x.get('file_path', '').lower()
|
||||||
|
),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'usage':
|
elif sort_key == 'usage':
|
||||||
# Sort by usage count, fallback to 0, then name for stability
|
# Sort by usage count, fallback to 0, then name and path for stability
|
||||||
return sorted(
|
return sorted(
|
||||||
data,
|
data,
|
||||||
key=lambda x: (
|
key=lambda x: (
|
||||||
x.get('usage_count', 0),
|
x.get('usage_count', 0),
|
||||||
self._get_display_name(x).lower()
|
self._get_display_name(x).lower(),
|
||||||
|
x.get('file_path', '').lower()
|
||||||
),
|
),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class PersistentModelCache:
|
|||||||
"exclude",
|
"exclude",
|
||||||
"db_checked",
|
"db_checked",
|
||||||
"last_checked_at",
|
"last_checked_at",
|
||||||
|
"hash_status",
|
||||||
)
|
)
|
||||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||||
_instances: Dict[str, "PersistentModelCache"] = {}
|
_instances: Dict[str, "PersistentModelCache"] = {}
|
||||||
@@ -186,6 +187,7 @@ class PersistentModelCache:
|
|||||||
"civitai_deleted": bool(row["civitai_deleted"]),
|
"civitai_deleted": bool(row["civitai_deleted"]),
|
||||||
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
||||||
"license_flags": int(license_value),
|
"license_flags": int(license_value),
|
||||||
|
"hash_status": row["hash_status"] or "completed",
|
||||||
}
|
}
|
||||||
raw_data.append(item)
|
raw_data.append(item)
|
||||||
|
|
||||||
@@ -449,6 +451,7 @@ class PersistentModelCache:
|
|||||||
exclude INTEGER,
|
exclude INTEGER,
|
||||||
db_checked INTEGER,
|
db_checked INTEGER,
|
||||||
last_checked_at REAL,
|
last_checked_at REAL,
|
||||||
|
hash_status TEXT,
|
||||||
PRIMARY KEY (model_type, file_path)
|
PRIMARY KEY (model_type, file_path)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -496,6 +499,7 @@ class PersistentModelCache:
|
|||||||
"skip_metadata_refresh": "INTEGER DEFAULT 0",
|
"skip_metadata_refresh": "INTEGER DEFAULT 0",
|
||||||
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
||||||
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
||||||
|
"hash_status": "TEXT DEFAULT 'completed'",
|
||||||
}
|
}
|
||||||
|
|
||||||
for column, definition in required_columns.items():
|
for column, definition in required_columns.items():
|
||||||
@@ -570,6 +574,7 @@ class PersistentModelCache:
|
|||||||
1 if item.get("exclude") else 0,
|
1 if item.get("exclude") else 0,
|
||||||
1 if item.get("db_checked") else 0,
|
1 if item.get("db_checked") else 0,
|
||||||
float(item.get("last_checked_at") or 0.0),
|
float(item.get("last_checked_at") or 0.0),
|
||||||
|
item.get("hash_status", "completed"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _insert_model_sql(self) -> str:
|
def _insert_model_sql(self) -> str:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ class RecipeCache:
|
|||||||
"""Sort cached views. Caller must hold ``_lock``."""
|
"""Sort cached views. Caller must hold ``_lock``."""
|
||||||
|
|
||||||
self.sorted_by_name = natsorted(
|
self.sorted_by_name = natsorted(
|
||||||
self.raw_data, key=lambda x: x.get("title", "").lower()
|
self.raw_data,
|
||||||
|
key=lambda x: (x.get("title", "").lower(), x.get("file_path", "").lower()),
|
||||||
)
|
)
|
||||||
if not name_only:
|
if not name_only:
|
||||||
self.sorted_by_date = sorted(
|
self.sorted_by_date = sorted(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Services responsible for recipe metadata analysis."""
|
"""Services responsible for recipe metadata analysis."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -69,7 +70,9 @@ class RecipeAnalysisService:
|
|||||||
try:
|
try:
|
||||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return AnalysisResult({"error": "No metadata found in this image", "loras": []})
|
return AnalysisResult(
|
||||||
|
{"error": "No metadata found in this image", "loras": []}
|
||||||
|
)
|
||||||
|
|
||||||
return await self._parse_metadata(
|
return await self._parse_metadata(
|
||||||
metadata,
|
metadata,
|
||||||
@@ -105,7 +108,9 @@ class RecipeAnalysisService:
|
|||||||
if civitai_match:
|
if civitai_match:
|
||||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||||
if not image_info:
|
if not image_info:
|
||||||
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
raise RecipeDownloadError(
|
||||||
|
"Failed to fetch image information from Civitai"
|
||||||
|
)
|
||||||
|
|
||||||
image_url = image_info.get("url")
|
image_url = image_info.get("url")
|
||||||
if not image_url:
|
if not image_url:
|
||||||
@@ -114,13 +119,15 @@ class RecipeAnalysisService:
|
|||||||
is_video = image_info.get("type") == "video"
|
is_video = image_info.get("type") == "video"
|
||||||
|
|
||||||
# Use optimized preview URLs if possible
|
# Use optimized preview URLs if possible
|
||||||
rewritten_url, _ = rewrite_preview_url(image_url, media_type=image_info.get("type"))
|
rewritten_url, _ = rewrite_preview_url(
|
||||||
|
image_url, media_type=image_info.get("type")
|
||||||
|
)
|
||||||
if rewritten_url:
|
if rewritten_url:
|
||||||
image_url = rewritten_url
|
image_url = rewritten_url
|
||||||
|
|
||||||
if is_video:
|
if is_video:
|
||||||
# Extract extension from URL
|
# Extract extension from URL
|
||||||
url_path = image_url.split('?')[0].split('#')[0]
|
url_path = image_url.split("?")[0].split("#")[0]
|
||||||
extension = os.path.splitext(url_path)[1].lower() or ".mp4"
|
extension = os.path.splitext(url_path)[1].lower() or ".mp4"
|
||||||
else:
|
else:
|
||||||
extension = ".jpg"
|
extension = ".jpg"
|
||||||
@@ -135,9 +142,17 @@ class RecipeAnalysisService:
|
|||||||
and isinstance(metadata["meta"], dict)
|
and isinstance(metadata["meta"], dict)
|
||||||
):
|
):
|
||||||
metadata = metadata["meta"]
|
metadata = metadata["meta"]
|
||||||
|
|
||||||
|
# Validate that metadata contains meaningful recipe fields
|
||||||
|
# If not, treat as None to trigger EXIF extraction from downloaded image
|
||||||
|
if isinstance(metadata, dict) and not self._has_recipe_fields(metadata):
|
||||||
|
self._logger.debug(
|
||||||
|
"Civitai API metadata lacks recipe fields, will extract from EXIF"
|
||||||
|
)
|
||||||
|
metadata = None
|
||||||
else:
|
else:
|
||||||
# Basic extension detection for non-Civitai URLs
|
# Basic extension detection for non-Civitai URLs
|
||||||
url_path = url.split('?')[0].split('#')[0]
|
url_path = url.split("?")[0].split("#")[0]
|
||||||
extension = os.path.splitext(url_path)[1].lower()
|
extension = os.path.splitext(url_path)[1].lower()
|
||||||
if extension in [".mp4", ".webm"]:
|
if extension in [".mp4", ".webm"]:
|
||||||
is_video = True
|
is_video = True
|
||||||
@@ -211,7 +226,9 @@ class RecipeAnalysisService:
|
|||||||
|
|
||||||
image_bytes = self._convert_tensor_to_png_bytes(latest_image)
|
image_bytes = self._convert_tensor_to_png_bytes(latest_image)
|
||||||
if image_bytes is None:
|
if image_bytes is None:
|
||||||
raise RecipeValidationError("Cannot handle this data shape from metadata registry")
|
raise RecipeValidationError(
|
||||||
|
"Cannot handle this data shape from metadata registry"
|
||||||
|
)
|
||||||
|
|
||||||
return AnalysisResult(
|
return AnalysisResult(
|
||||||
{
|
{
|
||||||
@@ -222,6 +239,22 @@ class RecipeAnalysisService:
|
|||||||
|
|
||||||
# Internal helpers -------------------------------------------------
|
# Internal helpers -------------------------------------------------
|
||||||
|
|
||||||
|
def _has_recipe_fields(self, metadata: dict[str, Any]) -> bool:
|
||||||
|
"""Check if metadata contains meaningful recipe-related fields."""
|
||||||
|
recipe_fields = {
|
||||||
|
"prompt",
|
||||||
|
"negative_prompt",
|
||||||
|
"resources",
|
||||||
|
"hashes",
|
||||||
|
"params",
|
||||||
|
"generationData",
|
||||||
|
"Workflow",
|
||||||
|
"prompt_type",
|
||||||
|
"positive",
|
||||||
|
"negative",
|
||||||
|
}
|
||||||
|
return any(field in metadata for field in recipe_fields)
|
||||||
|
|
||||||
async def _parse_metadata(
|
async def _parse_metadata(
|
||||||
self,
|
self,
|
||||||
metadata: dict[str, Any],
|
metadata: dict[str, Any],
|
||||||
@@ -234,7 +267,12 @@ class RecipeAnalysisService:
|
|||||||
) -> AnalysisResult:
|
) -> AnalysisResult:
|
||||||
parser = self._recipe_parser_factory.create_parser(metadata)
|
parser = self._recipe_parser_factory.create_parser(metadata)
|
||||||
if parser is None:
|
if parser is None:
|
||||||
payload = {"error": "No parser found for this image", "loras": []}
|
# Provide more specific error message based on metadata source
|
||||||
|
if not metadata:
|
||||||
|
error_msg = "This image does not contain any generation metadata (prompt, models, or parameters)"
|
||||||
|
else:
|
||||||
|
error_msg = "No parser found for this image"
|
||||||
|
payload = {"error": error_msg, "loras": []}
|
||||||
if include_image_base64 and image_path:
|
if include_image_base64 and image_path:
|
||||||
payload["image_base64"] = self._encode_file(image_path)
|
payload["image_base64"] = self._encode_file(image_path)
|
||||||
payload["is_video"] = is_video
|
payload["is_video"] = is_video
|
||||||
@@ -257,7 +295,9 @@ class RecipeAnalysisService:
|
|||||||
|
|
||||||
matching_recipes: list[str] = []
|
matching_recipes: list[str] = []
|
||||||
if fingerprint:
|
if fingerprint:
|
||||||
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(fingerprint)
|
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
|
||||||
|
fingerprint
|
||||||
|
)
|
||||||
result["matching_recipes"] = matching_recipes
|
result["matching_recipes"] = matching_recipes
|
||||||
|
|
||||||
return AnalysisResult(result)
|
return AnalysisResult(result)
|
||||||
@@ -269,7 +309,10 @@ class RecipeAnalysisService:
|
|||||||
raise RecipeDownloadError(f"Failed to download image from URL: {result}")
|
raise RecipeDownloadError(f"Failed to download image from URL: {result}")
|
||||||
|
|
||||||
def _metadata_not_found_response(self, path: str) -> AnalysisResult:
|
def _metadata_not_found_response(self, path: str) -> AnalysisResult:
|
||||||
payload: dict[str, Any] = {"error": "No metadata found in this image", "loras": []}
|
payload: dict[str, Any] = {
|
||||||
|
"error": "No metadata found in this image",
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
payload["image_base64"] = self._encode_file(path)
|
payload["image_base64"] = self._encode_file(path)
|
||||||
return AnalysisResult(payload)
|
return AnalysisResult(payload)
|
||||||
@@ -305,7 +348,9 @@ class RecipeAnalysisService:
|
|||||||
|
|
||||||
if hasattr(tensor_image, "shape"):
|
if hasattr(tensor_image, "shape"):
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
"Tensor shape: %s, dtype: %s", tensor_image.shape, getattr(tensor_image, "dtype", None)
|
"Tensor shape: %s, dtype: %s",
|
||||||
|
tensor_image.shape,
|
||||||
|
getattr(tensor_image, "dtype", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
import torch # type: ignore[import-not-found]
|
import torch # type: ignore[import-not-found]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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
|
||||||
|
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 +60,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",
|
||||||
@@ -274,6 +276,16 @@ class SettingsManager:
|
|||||||
self.settings["metadata_refresh_skip_paths"] = []
|
self.settings["metadata_refresh_skip_paths"] = []
|
||||||
inserted_defaults = True
|
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 +620,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 +873,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 []
|
||||||
@@ -1012,6 +1032,8 @@ 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 == "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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -112,6 +112,115 @@ def get_lora_info_absolute(lora_name):
|
|||||||
return asyncio.run(_get_lora_info_absolute_async())
|
return asyncio.run(_get_lora_info_absolute_async())
|
||||||
|
|
||||||
|
|
||||||
|
def get_checkpoint_info_absolute(checkpoint_name):
|
||||||
|
"""Get the absolute checkpoint path and metadata from cache
|
||||||
|
|
||||||
|
Supports ComfyUI-style model names (e.g., "folder/model_name.ext")
|
||||||
|
|
||||||
|
Args:
|
||||||
|
checkpoint_name: The model name, can be:
|
||||||
|
- ComfyUI format: "folder/model_name.safetensors"
|
||||||
|
- Simple name: "model_name"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (absolute_path, metadata) where absolute_path is the full
|
||||||
|
file system path to the checkpoint file, or original checkpoint_name if not found,
|
||||||
|
metadata is the full model metadata dict or None
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _get_checkpoint_info_absolute_async():
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Get model roots for matching
|
||||||
|
model_roots = scanner.get_model_roots()
|
||||||
|
|
||||||
|
# Normalize the checkpoint name
|
||||||
|
normalized_name = checkpoint_name.replace(os.sep, "/")
|
||||||
|
|
||||||
|
for item in cache.raw_data:
|
||||||
|
file_path = item.get("file_path", "")
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Format the stored path as ComfyUI-style name
|
||||||
|
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
|
||||||
|
|
||||||
|
# Match by formatted name (normalize separators for robust comparison)
|
||||||
|
if formatted_name.replace(os.sep, "/") == normalized_name or formatted_name == checkpoint_name:
|
||||||
|
return file_path, item
|
||||||
|
|
||||||
|
# Also try matching by basename only (for backward compatibility)
|
||||||
|
file_name = item.get("file_name", "")
|
||||||
|
if (
|
||||||
|
file_name == checkpoint_name
|
||||||
|
or file_name == os.path.splitext(normalized_name)[0]
|
||||||
|
):
|
||||||
|
return file_path, item
|
||||||
|
|
||||||
|
return checkpoint_name, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if we're already in an event loop
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
# If we're in a running loop, we need to use a different approach
|
||||||
|
# Create a new thread to run the async code
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
def run_in_thread():
|
||||||
|
new_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(new_loop)
|
||||||
|
try:
|
||||||
|
return new_loop.run_until_complete(
|
||||||
|
_get_checkpoint_info_absolute_async()
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
new_loop.close()
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
future = executor.submit(run_in_thread)
|
||||||
|
return future.result()
|
||||||
|
|
||||||
|
except RuntimeError:
|
||||||
|
# No event loop is running, we can use asyncio.run()
|
||||||
|
return asyncio.run(_get_checkpoint_info_absolute_async())
|
||||||
|
|
||||||
|
|
||||||
|
def _format_model_name_for_comfyui(file_path: str, model_roots: list) -> str:
|
||||||
|
"""Format file path to ComfyUI-style model name (relative path with extension)
|
||||||
|
|
||||||
|
Example: /path/to/checkpoints/Illustrious/model.safetensors -> Illustrious/model.safetensors
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the model file
|
||||||
|
model_roots: List of model root directories
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ComfyUI-style model name with relative path and extension
|
||||||
|
"""
|
||||||
|
# Find the matching root and get relative path
|
||||||
|
for root in model_roots:
|
||||||
|
try:
|
||||||
|
# Normalize paths for comparison
|
||||||
|
norm_file = os.path.normcase(os.path.abspath(file_path))
|
||||||
|
norm_root = os.path.normcase(os.path.abspath(root))
|
||||||
|
|
||||||
|
# Add trailing separator for prefix check
|
||||||
|
if not norm_root.endswith(os.sep):
|
||||||
|
norm_root += os.sep
|
||||||
|
|
||||||
|
if norm_file.startswith(norm_root):
|
||||||
|
# Use os.path.relpath to get relative path with OS-native separator
|
||||||
|
return os.path.relpath(file_path, root)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If no root matches, just return the basename with extension
|
||||||
|
return os.path.basename(file_path)
|
||||||
|
|
||||||
|
|
||||||
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if text matches pattern using fuzzy matching.
|
Check if text matches pattern using fuzzy matching.
|
||||||
@@ -173,10 +282,13 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
|||||||
# Collapse repeated replacement characters to a single instance
|
# Collapse repeated replacement characters to a single instance
|
||||||
if replacement:
|
if replacement:
|
||||||
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
|
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
|
||||||
sanitized = sanitized.strip(replacement)
|
# Combine stripping to be idempotent:
|
||||||
|
# Right side: strip replacement, space, and dot (Windows restriction)
|
||||||
# Remove trailing spaces or periods which are invalid on Windows
|
# Left side: strip replacement and space (leading dots are allowed)
|
||||||
sanitized = sanitized.rstrip(" .")
|
sanitized = sanitized.rstrip(" ." + replacement).lstrip(" " + replacement)
|
||||||
|
else:
|
||||||
|
# If no replacement, just strip spaces and dots from right, spaces from left
|
||||||
|
sanitized = sanitized.rstrip(" .").lstrip(" ")
|
||||||
|
|
||||||
if not sanitized:
|
if not sanitized:
|
||||||
return "unnamed"
|
return "unnamed"
|
||||||
|
|||||||
@@ -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*
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js';
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { WS_ENDPOINTS } from '../api/apiConfig.js';
|
import { WS_ENDPOINTS } from '../api/apiConfig.js';
|
||||||
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manager for batch importing recipes from multiple images
|
* Manager for batch importing recipes from multiple images
|
||||||
@@ -34,6 +35,14 @@ export class BatchImportManager {
|
|||||||
*/
|
*/
|
||||||
initialize() {
|
initialize() {
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
|
|
||||||
|
// Add event listener for persisting "Skip images without metadata" choice
|
||||||
|
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
|
||||||
|
if (skipNoMetadata) {
|
||||||
|
skipNoMetadata.addEventListener('change', (e) => {
|
||||||
|
setStorageItem('batch_import_skip_no_metadata', e.target.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,7 +70,10 @@ export class BatchImportManager {
|
|||||||
if (tagsInput) tagsInput.value = '';
|
if (tagsInput) tagsInput.value = '';
|
||||||
|
|
||||||
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
|
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
|
||||||
if (skipNoMetadata) skipNoMetadata.checked = true;
|
if (skipNoMetadata) {
|
||||||
|
// Load preference from storage, defaulting to true
|
||||||
|
skipNoMetadata.checked = getStorageItem('batch_import_skip_no_metadata', true);
|
||||||
|
}
|
||||||
|
|
||||||
const recursiveCheck = document.getElementById('batchRecursiveCheck');
|
const recursiveCheck = document.getElementById('batchRecursiveCheck');
|
||||||
if (recursiveCheck) recursiveCheck.checked = true;
|
if (recursiveCheck) recursiveCheck.checked = true;
|
||||||
@@ -92,6 +104,14 @@ export class BatchImportManager {
|
|||||||
|
|
||||||
// Clean up any existing connections
|
// Clean up any existing connections
|
||||||
this.cleanupConnections();
|
this.cleanupConnections();
|
||||||
|
|
||||||
|
// Focus on the URL input field for better UX
|
||||||
|
setTimeout(() => {
|
||||||
|
const urlInput = document.getElementById('batchUrlInput');
|
||||||
|
if (urlInput) {
|
||||||
|
urlInput.focus();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
357
static/js/managers/BulkMissingLoraDownloadManager.js
Normal file
357
static/js/managers/BulkMissingLoraDownloadManager.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,25 @@ export class SettingsManager {
|
|||||||
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
|
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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
|
||||||
@@ -682,6 +698,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;
|
||||||
@@ -1811,7 +1834,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 +1859,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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')">×</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>
|
||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ class TestCheckpointPathOverlap:
|
|||||||
config._preview_root_paths = set()
|
config._preview_root_paths = set()
|
||||||
config._cached_fingerprint = None
|
config._cached_fingerprint = None
|
||||||
|
|
||||||
# Call the method under test
|
# Call the method under test - now returns a tuple
|
||||||
result = config._prepare_checkpoint_paths(
|
all_paths, checkpoint_roots, unet_roots = config._prepare_checkpoint_paths(
|
||||||
[str(checkpoints_link)], [str(unet_link)]
|
[str(checkpoints_link)], [str(unet_link)]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,21 +50,27 @@ class TestCheckpointPathOverlap:
|
|||||||
]
|
]
|
||||||
assert len(warning_messages) == 1
|
assert len(warning_messages) == 1
|
||||||
assert "checkpoints" in warning_messages[0].lower()
|
assert "checkpoints" in warning_messages[0].lower()
|
||||||
assert "diffusion_models" in warning_messages[0].lower() or "unet" in warning_messages[0].lower()
|
assert (
|
||||||
|
"diffusion_models" in warning_messages[0].lower()
|
||||||
|
or "unet" in warning_messages[0].lower()
|
||||||
|
)
|
||||||
# Verify warning mentions backward compatibility fallback
|
# Verify warning mentions backward compatibility fallback
|
||||||
assert "falling back" in warning_messages[0].lower() or "backward compatibility" in warning_messages[0].lower()
|
assert (
|
||||||
|
"falling back" in warning_messages[0].lower()
|
||||||
|
or "backward compatibility" in warning_messages[0].lower()
|
||||||
|
)
|
||||||
|
|
||||||
# Verify only one path is returned (deduplication still works)
|
# Verify only one path is returned (deduplication still works)
|
||||||
assert len(result) == 1
|
assert len(all_paths) == 1
|
||||||
# Prioritizes checkpoints path for backward compatibility
|
# Prioritizes checkpoints path for backward compatibility
|
||||||
assert _normalize(result[0]) == _normalize(str(checkpoints_link))
|
assert _normalize(all_paths[0]) == _normalize(str(checkpoints_link))
|
||||||
|
|
||||||
# Verify checkpoints_roots has the path (prioritized)
|
# Verify checkpoint_roots has the path (prioritized)
|
||||||
assert len(config.checkpoints_roots) == 1
|
assert len(checkpoint_roots) == 1
|
||||||
assert _normalize(config.checkpoints_roots[0]) == _normalize(str(checkpoints_link))
|
assert _normalize(checkpoint_roots[0]) == _normalize(str(checkpoints_link))
|
||||||
|
|
||||||
# Verify unet_roots is empty (overlapping paths removed)
|
# Verify unet_roots is empty (overlapping paths removed)
|
||||||
assert config.unet_roots == []
|
assert unet_roots == []
|
||||||
|
|
||||||
def test_non_overlapping_paths_no_warning(
|
def test_non_overlapping_paths_no_warning(
|
||||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path, caplog
|
self, monkeypatch: pytest.MonkeyPatch, tmp_path, caplog
|
||||||
@@ -83,7 +89,7 @@ class TestCheckpointPathOverlap:
|
|||||||
config._preview_root_paths = set()
|
config._preview_root_paths = set()
|
||||||
config._cached_fingerprint = None
|
config._cached_fingerprint = None
|
||||||
|
|
||||||
result = config._prepare_checkpoint_paths(
|
all_paths, checkpoint_roots, unet_roots = config._prepare_checkpoint_paths(
|
||||||
[str(checkpoints_dir)], [str(unet_dir)]
|
[str(checkpoints_dir)], [str(unet_dir)]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,14 +103,14 @@ class TestCheckpointPathOverlap:
|
|||||||
assert len(warning_messages) == 0
|
assert len(warning_messages) == 0
|
||||||
|
|
||||||
# Verify both paths are returned
|
# Verify both paths are returned
|
||||||
assert len(result) == 2
|
assert len(all_paths) == 2
|
||||||
normalized_result = [_normalize(p) for p in result]
|
normalized_result = [_normalize(p) for p in all_paths]
|
||||||
assert _normalize(str(checkpoints_dir)) in normalized_result
|
assert _normalize(str(checkpoints_dir)) in normalized_result
|
||||||
assert _normalize(str(unet_dir)) in normalized_result
|
assert _normalize(str(unet_dir)) in normalized_result
|
||||||
|
|
||||||
# Verify both roots are properly set
|
# Verify both roots are properly set
|
||||||
assert len(config.checkpoints_roots) == 1
|
assert len(checkpoint_roots) == 1
|
||||||
assert len(config.unet_roots) == 1
|
assert len(unet_roots) == 1
|
||||||
|
|
||||||
def test_partial_overlap_prioritizes_checkpoints(
|
def test_partial_overlap_prioritizes_checkpoints(
|
||||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path, caplog
|
self, monkeypatch: pytest.MonkeyPatch, tmp_path, caplog
|
||||||
@@ -129,9 +135,9 @@ class TestCheckpointPathOverlap:
|
|||||||
config._cached_fingerprint = None
|
config._cached_fingerprint = None
|
||||||
|
|
||||||
# One checkpoint path overlaps with one unet path
|
# One checkpoint path overlaps with one unet path
|
||||||
result = config._prepare_checkpoint_paths(
|
all_paths, checkpoint_roots, unet_roots = config._prepare_checkpoint_paths(
|
||||||
[str(shared_link), str(separate_checkpoint)],
|
[str(shared_link), str(separate_checkpoint)],
|
||||||
[str(shared_link), str(separate_unet)]
|
[str(shared_link), str(separate_unet)],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify warning was logged for the overlapping path
|
# Verify warning was logged for the overlapping path
|
||||||
@@ -144,17 +150,20 @@ class TestCheckpointPathOverlap:
|
|||||||
assert len(warning_messages) == 1
|
assert len(warning_messages) == 1
|
||||||
|
|
||||||
# Verify 3 unique paths (shared counted once as checkpoint, plus separate ones)
|
# Verify 3 unique paths (shared counted once as checkpoint, plus separate ones)
|
||||||
assert len(result) == 3
|
assert len(all_paths) == 3
|
||||||
|
|
||||||
# Verify the overlapping path appears in warning message
|
# Verify the overlapping path appears in warning message
|
||||||
assert str(shared_link.name) in warning_messages[0] or str(shared_dir.name) in warning_messages[0]
|
assert (
|
||||||
|
str(shared_link.name) in warning_messages[0]
|
||||||
|
or str(shared_dir.name) in warning_messages[0]
|
||||||
|
)
|
||||||
|
|
||||||
# Verify checkpoints_roots includes both checkpoint paths (including the shared one)
|
# Verify checkpoint_roots includes both checkpoint paths (including the shared one)
|
||||||
assert len(config.checkpoints_roots) == 2
|
assert len(checkpoint_roots) == 2
|
||||||
checkpoint_normalized = [_normalize(p) for p in config.checkpoints_roots]
|
checkpoint_normalized = [_normalize(p) for p in checkpoint_roots]
|
||||||
assert _normalize(str(shared_link)) in checkpoint_normalized
|
assert _normalize(str(shared_link)) in checkpoint_normalized
|
||||||
assert _normalize(str(separate_checkpoint)) in checkpoint_normalized
|
assert _normalize(str(separate_checkpoint)) in checkpoint_normalized
|
||||||
|
|
||||||
# Verify unet_roots only includes the non-overlapping unet path
|
# Verify unet_roots only includes the non-overlapping unet path
|
||||||
assert len(config.unet_roots) == 1
|
assert len(unet_roots) == 1
|
||||||
assert _normalize(config.unet_roots[0]) == _normalize(str(separate_unet))
|
assert _normalize(unet_roots[0]) == _normalize(str(separate_unet))
|
||||||
|
|||||||
@@ -267,4 +267,431 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0;
|
const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0;
|
||||||
expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore);
|
expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replaces entire multi-word phrase when it matches selected tag (Danbooru convention)', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
||||||
|
{ tag_name: 'looking_away', category: 0, post_count: 5678 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'looking to the side';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = null;
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('looking_to_the_side');
|
||||||
|
|
||||||
|
expect(input.value).toBe('looking_to_the_side, ');
|
||||||
|
expect(autoComplete.dropdown.style.display).toBe('none');
|
||||||
|
expect(input.focus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces only last token when typing partial match (e.g., "hello 1gi" -> "1girl")', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: '1girl', category: 4, post_count: 500000 },
|
||||||
|
{ tag_name: '1boy', category: 4, post_count: 300000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('hello 1gi');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'hello 1gi';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = null;
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.currentSearchTerm = 'hello 1gi';
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('1girl');
|
||||||
|
|
||||||
|
expect(input.value).toBe('hello 1girl, ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
||||||
|
{ tag_name: 'blue_eyes', category: 0, post_count: 80000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'blue hair';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = null;
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.currentSearchTerm = 'blue hair';
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('blue_hair');
|
||||||
|
|
||||||
|
expect(input.value).toBe('blue_hair, ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multi-word phrase with preceding text correctly', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl, looking to the side');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '1girl, looking to the side';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = null;
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.currentSearchTerm = 'looking to the side';
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('looking_to_the_side');
|
||||||
|
|
||||||
|
expect(input.value).toBe('1girl, looking_to_the_side, ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces entire command and search term when using command mode with multi-word phrase', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: 'looking_to_the_side', category: 4, post_count: 1234 },
|
||||||
|
{ tag_name: 'looking_away', category: 4, post_count: 5678 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate "/char looking to the side" input
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/char looking to the side';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up command mode state
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.currentSearchTerm = '/char looking to the side';
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('looking_to_the_side');
|
||||||
|
|
||||||
|
// Command part should be replaced along with search term
|
||||||
|
expect(input.value).toBe('looking_to_the_side, ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces only last token when multi-word query does not exactly match selected tag', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
||||||
|
{ tag_name: 'blue_eyes', category: 0, post_count: 80000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// User types "looking to the blue" but selects "blue_hair" (doesn't match entire phrase)
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the blue');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'looking to the blue';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = null;
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.currentSearchTerm = 'looking to the blue';
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('blue_hair');
|
||||||
|
|
||||||
|
// Only "blue" should be replaced, not the entire phrase
|
||||||
|
expect(input.value).toBe('looking to the blue_hair, ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple consecutive spaces in multi-word phrase correctly', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Input with multiple spaces between words
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'looking to the side';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = null;
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.currentSearchTerm = 'looking to the side';
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('looking_to_the_side');
|
||||||
|
|
||||||
|
// Multiple spaces should be normalized to single underscores for matching
|
||||||
|
expect(input.value).toBe('looking_to_the_side, ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles command mode with partial match replacing only last token', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command mode but selected tag doesn't match entire search phrase
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/general looking to the blue');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/general looking to the blue';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command mode with activeCommand
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = { categories: [0, 7], label: 'General' };
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.currentSearchTerm = '/general looking to the blue';
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('blue_hair');
|
||||||
|
|
||||||
|
// In command mode, the entire command + search term should be replaced
|
||||||
|
expect(input.value).toBe('blue_hair, ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// User types partial phrase "looking to the" and selects "looking_to_the_side"
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'looking to the';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = null;
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.currentSearchTerm = 'looking to the';
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('looking_to_the_side');
|
||||||
|
|
||||||
|
// Entire phrase should be replaced with selected tag (with underscores)
|
||||||
|
expect(input.value).toBe('looking_to_the_side, ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts tag with underscores regardless of space replacement setting', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('blue');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'blue';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = null;
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('blue_hair');
|
||||||
|
|
||||||
|
// Tag should be inserted with underscores, not spaces
|
||||||
|
expect(input.value).toBe('blue_hair, ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// User types suffix "to the side" and selects "looking_to_the_side"
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('to the side');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'to the side';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.activeCommand = null;
|
||||||
|
autoComplete.items = mockTags;
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.currentSearchTerm = 'to the side';
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('looking_to_the_side');
|
||||||
|
|
||||||
|
// Entire phrase should be replaced with selected tag
|
||||||
|
expect(input.value).toBe('looking_to_the_side, ');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
18
tests/frontend/utils/constants.matureBlurThreshold.test.js
Normal file
18
tests/frontend/utils/constants.matureBlurThreshold.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -194,6 +194,7 @@ class TestCacheHealthMonitor:
|
|||||||
'preview_nsfw_level': 0,
|
'preview_nsfw_level': 0,
|
||||||
'notes': '',
|
'notes': '',
|
||||||
'usage_tips': '',
|
'usage_tips': '',
|
||||||
|
'hash_status': 'completed',
|
||||||
}
|
}
|
||||||
incomplete_entry = {
|
incomplete_entry = {
|
||||||
'file_path': '/models/test2.safetensors',
|
'file_path': '/models/test2.safetensors',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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": ""}},
|
||||||
|
|||||||
202
tests/services/test_sui_image_params_parser.py
Normal file
202
tests/services/test_sui_image_params_parser.py
Normal 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') == {}
|
||||||
158
tests/test_checkpoint_loaders.py
Normal file
158
tests/test_checkpoint_loaders.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Tests for checkpoint and unet loaders with extra folder paths support"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# Get project root directory (ComfyUI-Lora-Manager folder)
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckpointLoaderLM:
|
||||||
|
"""Test CheckpointLoaderLM node"""
|
||||||
|
|
||||||
|
def test_class_attributes(self):
|
||||||
|
"""Test that CheckpointLoaderLM has required class attributes"""
|
||||||
|
# Import in a way that doesn't require ComfyUI
|
||||||
|
import ast
|
||||||
|
|
||||||
|
filepath = os.path.join(PROJECT_ROOT, "py", "nodes", "checkpoint_loader.py")
|
||||||
|
|
||||||
|
with open(filepath, "r") as f:
|
||||||
|
tree = ast.parse(f.read())
|
||||||
|
|
||||||
|
# Find CheckpointLoaderLM class
|
||||||
|
classes = {
|
||||||
|
node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
|
||||||
|
}
|
||||||
|
assert "CheckpointLoaderLM" in classes
|
||||||
|
|
||||||
|
cls = classes["CheckpointLoaderLM"]
|
||||||
|
|
||||||
|
# Check for NAME attribute
|
||||||
|
name_attr = [
|
||||||
|
n
|
||||||
|
for n in cls.body
|
||||||
|
if isinstance(n, ast.Assign)
|
||||||
|
and any(t.id == "NAME" for t in n.targets if isinstance(t, ast.Name))
|
||||||
|
]
|
||||||
|
assert len(name_attr) > 0, "CheckpointLoaderLM should have NAME attribute"
|
||||||
|
|
||||||
|
# Check for CATEGORY attribute
|
||||||
|
cat_attr = [
|
||||||
|
n
|
||||||
|
for n in cls.body
|
||||||
|
if isinstance(n, ast.Assign)
|
||||||
|
and any(t.id == "CATEGORY" for t in n.targets if isinstance(t, ast.Name))
|
||||||
|
]
|
||||||
|
assert len(cat_attr) > 0, "CheckpointLoaderLM should have CATEGORY attribute"
|
||||||
|
|
||||||
|
# Check for INPUT_TYPES method
|
||||||
|
input_types = [
|
||||||
|
n
|
||||||
|
for n in cls.body
|
||||||
|
if isinstance(n, ast.FunctionDef) and n.name == "INPUT_TYPES"
|
||||||
|
]
|
||||||
|
assert len(input_types) > 0, "CheckpointLoaderLM should have INPUT_TYPES method"
|
||||||
|
|
||||||
|
# Check for load_checkpoint method
|
||||||
|
load_method = [
|
||||||
|
n
|
||||||
|
for n in cls.body
|
||||||
|
if isinstance(n, ast.FunctionDef) and n.name == "load_checkpoint"
|
||||||
|
]
|
||||||
|
assert len(load_method) > 0, (
|
||||||
|
"CheckpointLoaderLM should have load_checkpoint method"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUNETLoaderLM:
|
||||||
|
"""Test UNETLoaderLM node"""
|
||||||
|
|
||||||
|
def test_class_attributes(self):
|
||||||
|
"""Test that UNETLoaderLM has required class attributes"""
|
||||||
|
# Import in a way that doesn't require ComfyUI
|
||||||
|
import ast
|
||||||
|
|
||||||
|
filepath = os.path.join(PROJECT_ROOT, "py", "nodes", "unet_loader.py")
|
||||||
|
|
||||||
|
with open(filepath, "r") as f:
|
||||||
|
tree = ast.parse(f.read())
|
||||||
|
|
||||||
|
# Find UNETLoaderLM class
|
||||||
|
classes = {
|
||||||
|
node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
|
||||||
|
}
|
||||||
|
assert "UNETLoaderLM" in classes
|
||||||
|
|
||||||
|
cls = classes["UNETLoaderLM"]
|
||||||
|
|
||||||
|
# Check for NAME attribute
|
||||||
|
name_attr = [
|
||||||
|
n
|
||||||
|
for n in cls.body
|
||||||
|
if isinstance(n, ast.Assign)
|
||||||
|
and any(t.id == "NAME" for t in n.targets if isinstance(t, ast.Name))
|
||||||
|
]
|
||||||
|
assert len(name_attr) > 0, "UNETLoaderLM should have NAME attribute"
|
||||||
|
|
||||||
|
# Check for CATEGORY attribute
|
||||||
|
cat_attr = [
|
||||||
|
n
|
||||||
|
for n in cls.body
|
||||||
|
if isinstance(n, ast.Assign)
|
||||||
|
and any(t.id == "CATEGORY" for t in n.targets if isinstance(t, ast.Name))
|
||||||
|
]
|
||||||
|
assert len(cat_attr) > 0, "UNETLoaderLM should have CATEGORY attribute"
|
||||||
|
|
||||||
|
# Check for INPUT_TYPES method
|
||||||
|
input_types = [
|
||||||
|
n
|
||||||
|
for n in cls.body
|
||||||
|
if isinstance(n, ast.FunctionDef) and n.name == "INPUT_TYPES"
|
||||||
|
]
|
||||||
|
assert len(input_types) > 0, "UNETLoaderLM should have INPUT_TYPES method"
|
||||||
|
|
||||||
|
# Check for load_unet method
|
||||||
|
load_method = [
|
||||||
|
n
|
||||||
|
for n in cls.body
|
||||||
|
if isinstance(n, ast.FunctionDef) and n.name == "load_unet"
|
||||||
|
]
|
||||||
|
assert len(load_method) > 0, "UNETLoaderLM should have load_unet method"
|
||||||
|
|
||||||
|
|
||||||
|
class TestUtils:
|
||||||
|
"""Test utility functions"""
|
||||||
|
|
||||||
|
def test_get_checkpoint_info_absolute_exists(self):
|
||||||
|
"""Test that get_checkpoint_info_absolute function exists in utils"""
|
||||||
|
import ast
|
||||||
|
|
||||||
|
filepath = os.path.join(PROJECT_ROOT, "py", "utils", "utils.py")
|
||||||
|
|
||||||
|
with open(filepath, "r") as f:
|
||||||
|
tree = ast.parse(f.read())
|
||||||
|
|
||||||
|
functions = [
|
||||||
|
node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)
|
||||||
|
]
|
||||||
|
assert "get_checkpoint_info_absolute" in functions, (
|
||||||
|
"get_checkpoint_info_absolute should exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_format_model_name_for_comfyui_exists(self):
|
||||||
|
"""Test that _format_model_name_for_comfyui function exists in utils"""
|
||||||
|
import ast
|
||||||
|
|
||||||
|
filepath = os.path.join(PROJECT_ROOT, "py", "utils", "utils.py")
|
||||||
|
|
||||||
|
with open(filepath, "r") as f:
|
||||||
|
tree = ast.parse(f.read())
|
||||||
|
|
||||||
|
functions = [
|
||||||
|
node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)
|
||||||
|
]
|
||||||
|
assert "_format_model_name_for_comfyui" in functions, (
|
||||||
|
"_format_model_name_for_comfyui should exist"
|
||||||
|
)
|
||||||
@@ -242,36 +242,70 @@ class TestTagFTSIndexSearch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_search_pagination_ordering_consistency(self, populated_fts):
|
def test_search_pagination_ordering_consistency(self, populated_fts):
|
||||||
"""Test that pagination maintains consistent ordering."""
|
"""Test that pagination maintains consistent ordering by post_count."""
|
||||||
page1 = populated_fts.search("1", limit=10, offset=0)
|
page1 = populated_fts.search("1", limit=10, offset=0)
|
||||||
page2 = populated_fts.search("1", limit=10, offset=10)
|
page2 = populated_fts.search("1", limit=10, offset=10)
|
||||||
|
|
||||||
assert len(page1) > 0, "Page 1 should have results"
|
assert len(page1) > 0, "Page 1 should have results"
|
||||||
assert len(page2) > 0, "Page 2 should have results"
|
assert len(page2) > 0, "Page 2 should have results"
|
||||||
|
|
||||||
# Page 2 scores should all be <= Page 1 min score
|
# Page 2 max post_count should be <= Page 1 min post_count
|
||||||
page1_min_score = min(r["rank_score"] for r in page1)
|
page1_min_posts = min(r["post_count"] for r in page1)
|
||||||
page2_max_score = max(r["rank_score"] for r in page2)
|
page2_max_posts = max(r["post_count"] for r in page2)
|
||||||
|
|
||||||
assert page2_max_score <= page1_min_score, (
|
assert page2_max_posts <= page1_min_posts, (
|
||||||
f"Page 2 max score ({page2_max_score}) should be <= Page 1 min score ({page1_min_score})"
|
f"Page 2 max post_count ({page2_max_posts}) should be <= Page 1 min post_count ({page1_min_posts})"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_search_rank_score_includes_popularity_weight(self, populated_fts):
|
def test_search_returns_popular_tags_higher(self, populated_fts):
|
||||||
"""Test that rank_score includes post_count popularity weighting."""
|
"""Test that search returns popular tags (higher post_count) first."""
|
||||||
results = populated_fts.search("1", limit=5)
|
results = populated_fts.search("1", limit=5)
|
||||||
|
|
||||||
assert len(results) >= 2, "Need at least 2 results to compare"
|
assert len(results) >= 2, "Need at least 2 results to compare"
|
||||||
|
|
||||||
# 1girl has 6M posts, should have higher rank_score than tags with fewer posts
|
# 1girl has 6M posts, should be ranked first
|
||||||
girl_result = next((r for r in results if r["tag_name"] == "1girl"), None)
|
girl_result = next((r for r in results if r["tag_name"] == "1girl"), None)
|
||||||
assert girl_result is not None, "1girl should be in results"
|
assert girl_result is not None, "1girl should be in results"
|
||||||
|
assert results[0]["tag_name"] == "1girl", (
|
||||||
|
"1girl should be first due to highest post_count"
|
||||||
|
)
|
||||||
|
|
||||||
# Find a tag with significantly fewer posts
|
# Find a tag with significantly fewer posts
|
||||||
low_post_result = next((r for r in results if r["post_count"] < 10000), None)
|
low_post_result = next((r for r in results if r["post_count"] < 10000), None)
|
||||||
if low_post_result:
|
if low_post_result:
|
||||||
assert girl_result["rank_score"] > low_post_result["rank_score"], (
|
assert girl_result["post_count"] > low_post_result["post_count"], (
|
||||||
f"1girl (6M posts) should have higher score than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)"
|
f"1girl (6M posts) should have higher post_count than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_popularity_ordering(self, populated_fts):
|
||||||
|
"""Test that results are ordered by post_count (popularity)."""
|
||||||
|
results = populated_fts.search("1", limit=20)
|
||||||
|
|
||||||
|
# Get 1girl and 1boy results for comparison
|
||||||
|
girl_result = next((r for r in results if r["tag_name"] == "1girl"), None)
|
||||||
|
boy_result = next((r for r in results if r["tag_name"] == "1boy"), None)
|
||||||
|
|
||||||
|
assert girl_result is not None, "1girl should be in results"
|
||||||
|
assert boy_result is not None, "1boy should be in results"
|
||||||
|
|
||||||
|
# 1girl: 6M posts, 1boy: 1.4M posts
|
||||||
|
assert girl_result["post_count"] == 6008644, "1girl should have 6M posts"
|
||||||
|
assert boy_result["post_count"] == 1405457, "1boy should have 1.4M posts"
|
||||||
|
|
||||||
|
# 1girl should rank higher due to higher post_count
|
||||||
|
girl_rank = results.index(girl_result)
|
||||||
|
boy_rank = results.index(boy_result)
|
||||||
|
assert girl_rank < boy_rank, (
|
||||||
|
f"1girl should rank higher than 1boy due to higher post_count "
|
||||||
|
f"(girl rank: {girl_rank}, boy rank: {boy_rank})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify results are sorted by post_count descending
|
||||||
|
for i in range(len(results) - 1):
|
||||||
|
assert results[i]["post_count"] >= results[i + 1]["post_count"], (
|
||||||
|
f"Results should be sorted by post_count descending: "
|
||||||
|
f"{results[i]['tag_name']} ({results[i]['post_count']}) >= "
|
||||||
|
f"{results[i + 1]['tag_name']} ({results[i + 1]['post_count']})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: []
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
4
vue-widgets/tests/fixtures/mockConfigs.ts
vendored
4
vue-widgets/tests/fixtures/mockConfigs.ts
vendored
@@ -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`
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1905,11 +1905,39 @@ class AutoComplete {
|
|||||||
|
|
||||||
// For regular tag autocomplete (no command), only replace the last space-separated token
|
// For regular tag autocomplete (no command), only replace the last space-separated token
|
||||||
// This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, "
|
// This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, "
|
||||||
|
// However, if the user typed a multi-word phrase that matches a tag (e.g., "looking to the side"
|
||||||
|
// matching "looking_to_the_side"), replace the entire phrase instead of just the last word.
|
||||||
// Command mode (e.g., "/char miku") should replace the entire command+search
|
// Command mode (e.g., "/char miku") should replace the entire command+search
|
||||||
let searchTerm = fullSearchTerm;
|
let searchTerm = fullSearchTerm;
|
||||||
if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) {
|
if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) {
|
||||||
|
// Check if the selectedItem exists and its tag_name matches the full search term
|
||||||
|
// when converted to underscore format (Danbooru convention)
|
||||||
|
const selectedItem = this.selectedIndex >= 0 ? this.items[this.selectedIndex] : null;
|
||||||
|
const selectedTagName = selectedItem && typeof selectedItem === 'object' && 'tag_name'
|
||||||
|
? selectedItem.tag_name
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Convert full search term to underscore format and check if it matches selected tag
|
||||||
|
// Normalize multiple spaces to single underscore for matching (e.g., "looking to the side" -> "looking_to_the_side")
|
||||||
|
const underscoreVersion = fullSearchTerm.replace(/ +/g, '_').toLowerCase();
|
||||||
|
const selectedTagLower = selectedTagName?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
// If multi-word search term is a prefix or suffix of the selected tag,
|
||||||
|
// replace the entire phrase. This handles cases where user types partial tag name.
|
||||||
|
// Examples:
|
||||||
|
// - "looking to the" -> "looking_to_the_side" (prefix match)
|
||||||
|
// - "to the side" -> "looking_to_the_side" (suffix match)
|
||||||
|
// - "looking to the side" -> "looking_to_the_side" (exact match)
|
||||||
|
if (fullSearchTerm.includes(' ') && (
|
||||||
|
selectedTagLower.startsWith(underscoreVersion) ||
|
||||||
|
selectedTagLower.endsWith(underscoreVersion) ||
|
||||||
|
underscoreVersion === selectedTagLower
|
||||||
|
)) {
|
||||||
|
searchTerm = fullSearchTerm;
|
||||||
|
} else {
|
||||||
searchTerm = this._getLastSpaceToken(fullSearchTerm);
|
searchTerm = this._getLastSpaceToken(fullSearchTerm);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const searchStartPos = caretPos - searchTerm.length;
|
const searchStartPos = caretPos - searchTerm.length;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user