mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 15:15:44 -03:00
Compare commits
31 Commits
b5a0725d2c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/
|
||||||
|
|||||||
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,
|
||||||
|
|||||||
128
locales/de.json
128
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...",
|
||||||
@@ -644,6 +645,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 +734,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1494,16 +1497,17 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
"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...",
|
||||||
@@ -644,6 +645,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)",
|
||||||
@@ -1494,6 +1497,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",
|
||||||
|
|||||||
128
locales/es.json
128
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...",
|
||||||
@@ -644,6 +645,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 +734,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1494,16 +1497,17 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "No hay modelos seleccionados",
|
"noModelsSelected": "No hay modelos seleccionados",
|
||||||
|
|||||||
128
locales/fr.json
128
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...",
|
||||||
@@ -644,6 +645,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 +734,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1494,16 +1497,17 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "Aucun modèle sélectionné",
|
"noModelsSelected": "Aucun modèle sélectionné",
|
||||||
|
|||||||
128
locales/he.json
128
locales/he.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "חזרה למעלה",
|
"backToTop": "חזרה למעלה",
|
||||||
"settings": "הגדרות",
|
"settings": "הגדרות",
|
||||||
"help": "עזרה",
|
"help": "עזרה",
|
||||||
"add": "הוספה"
|
"add": "הוספה",
|
||||||
|
"close": "סגור"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "טוען...",
|
"loading": "טוען...",
|
||||||
@@ -644,6 +645,8 @@
|
|||||||
"root": "שורש",
|
"root": "שורש",
|
||||||
"browseFolders": "דפדף בתיקיות:",
|
"browseFolders": "דפדף בתיקיות:",
|
||||||
"downloadAndSaveRecipe": "הורד ושמור מתכון",
|
"downloadAndSaveRecipe": "הורד ושמור מתכון",
|
||||||
|
"importRecipeOnly": "יבא רק מתכון",
|
||||||
|
"importAndDownload": "יבא והורד",
|
||||||
"downloadMissingLoras": "הורד LoRAs חסרים",
|
"downloadMissingLoras": "הורד LoRAs חסרים",
|
||||||
"saveRecipe": "שמור מתכון",
|
"saveRecipe": "שמור מתכון",
|
||||||
"loraCountInfo": "({existing}/{total} בספרייה)",
|
"loraCountInfo": "({existing}/{total} בספרייה)",
|
||||||
@@ -731,61 +734,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1494,16 +1497,17 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "לא נבחרו מודלים",
|
"noModelsSelected": "לא נבחרו מודלים",
|
||||||
|
|||||||
128
locales/ja.json
128
locales/ja.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "トップへ戻る",
|
"backToTop": "トップへ戻る",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "ヘルプ",
|
"help": "ヘルプ",
|
||||||
"add": "追加"
|
"add": "追加",
|
||||||
|
"close": "閉じる"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
@@ -644,6 +645,8 @@
|
|||||||
"root": "ルート",
|
"root": "ルート",
|
||||||
"browseFolders": "フォルダを参照:",
|
"browseFolders": "フォルダを参照:",
|
||||||
"downloadAndSaveRecipe": "ダウンロード & レシピ保存",
|
"downloadAndSaveRecipe": "ダウンロード & レシピ保存",
|
||||||
|
"importRecipeOnly": "レシピのみインポート",
|
||||||
|
"importAndDownload": "インポートとダウンロード",
|
||||||
"downloadMissingLoras": "不足しているLoRAをダウンロード",
|
"downloadMissingLoras": "不足しているLoRAをダウンロード",
|
||||||
"saveRecipe": "レシピを保存",
|
"saveRecipe": "レシピを保存",
|
||||||
"loraCountInfo": "({existing}/{total} ライブラリ内)",
|
"loraCountInfo": "({existing}/{total} ライブラリ内)",
|
||||||
@@ -731,61 +734,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1494,16 +1497,17 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "モデルが選択されていません",
|
"noModelsSelected": "モデルが選択されていません",
|
||||||
|
|||||||
128
locales/ko.json
128
locales/ko.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "맨 위로",
|
"backToTop": "맨 위로",
|
||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"help": "도움말",
|
"help": "도움말",
|
||||||
"add": "추가"
|
"add": "추가",
|
||||||
|
"close": "닫기"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
@@ -644,6 +645,8 @@
|
|||||||
"root": "루트",
|
"root": "루트",
|
||||||
"browseFolders": "폴더 탐색:",
|
"browseFolders": "폴더 탐색:",
|
||||||
"downloadAndSaveRecipe": "다운로드 및 레시피 저장",
|
"downloadAndSaveRecipe": "다운로드 및 레시피 저장",
|
||||||
|
"importRecipeOnly": "레시피만 가져오기",
|
||||||
|
"importAndDownload": "가져오기 및 다운로드",
|
||||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||||
"saveRecipe": "레시피 저장",
|
"saveRecipe": "레시피 저장",
|
||||||
"loraCountInfo": "({existing}/{total} 라이브러리에 있음)",
|
"loraCountInfo": "({existing}/{total} 라이브러리에 있음)",
|
||||||
@@ -731,61 +734,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1494,16 +1497,17 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "선택된 모델이 없습니다",
|
"noModelsSelected": "선택된 모델이 없습니다",
|
||||||
|
|||||||
128
locales/ru.json
128
locales/ru.json
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "Наверх",
|
"backToTop": "Наверх",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"help": "Справка",
|
"help": "Справка",
|
||||||
"add": "Добавить"
|
"add": "Добавить",
|
||||||
|
"close": "Закрыть"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -644,6 +645,8 @@
|
|||||||
"root": "Корень",
|
"root": "Корень",
|
||||||
"browseFolders": "Обзор папок:",
|
"browseFolders": "Обзор папок:",
|
||||||
"downloadAndSaveRecipe": "Скачать и сохранить рецепт",
|
"downloadAndSaveRecipe": "Скачать и сохранить рецепт",
|
||||||
|
"importRecipeOnly": "Импортировать только рецепт",
|
||||||
|
"importAndDownload": "Импорт и скачивание",
|
||||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||||
"saveRecipe": "Сохранить рецепт",
|
"saveRecipe": "Сохранить рецепт",
|
||||||
"loraCountInfo": "({existing}/{total} в библиотеке)",
|
"loraCountInfo": "({existing}/{total} в библиотеке)",
|
||||||
@@ -731,61 +734,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1494,16 +1497,17 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "Модели не выбраны",
|
"noModelsSelected": "Модели не выбраны",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "返回顶部",
|
"backToTop": "返回顶部",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"add": "添加"
|
"add": "添加",
|
||||||
|
"close": "关闭"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
@@ -644,6 +645,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 +736,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": "请输入目录路径",
|
||||||
@@ -1494,16 +1497,17 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"noModelsSelected": "未选中模型",
|
"noModelsSelected": "未选中模型",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"backToTop": "回到頂部",
|
"backToTop": "回到頂部",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "說明",
|
"help": "說明",
|
||||||
"add": "新增"
|
"add": "新增",
|
||||||
|
"close": "關閉"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
@@ -644,6 +645,8 @@
|
|||||||
"root": "根目錄",
|
"root": "根目錄",
|
||||||
"browseFolders": "瀏覽資料夾:",
|
"browseFolders": "瀏覽資料夾:",
|
||||||
"downloadAndSaveRecipe": "下載並儲存配方",
|
"downloadAndSaveRecipe": "下載並儲存配方",
|
||||||
|
"importRecipeOnly": "僅匯入配方",
|
||||||
|
"importAndDownload": "匯入並下載",
|
||||||
"downloadMissingLoras": "下載缺少的 LoRA",
|
"downloadMissingLoras": "下載缺少的 LoRA",
|
||||||
"saveRecipe": "儲存配方",
|
"saveRecipe": "儲存配方",
|
||||||
"loraCountInfo": "(庫存 {existing}/{total})",
|
"loraCountInfo": "(庫存 {existing}/{total})",
|
||||||
@@ -731,61 +734,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1494,16 +1497,17 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
"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,42 +99,66 @@ 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))
|
||||||
|
|
||||||
# Get LoRA at current index (convert to 0-based for list access)
|
# Check if current index is the "no lora" option (last position when include_no_lora is True)
|
||||||
current_lora = lora_list[clamped_index - 1]
|
is_no_lora = include_no_lora and clamped_index == effective_total_count
|
||||||
|
|
||||||
# Build LORA_STACK with single LoRA
|
if is_no_lora:
|
||||||
lora_path, _ = get_lora_info(current_lora["file_name"])
|
# "No LoRA" option - return empty stack
|
||||||
if not lora_path:
|
|
||||||
logger.warning(
|
|
||||||
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
|
|
||||||
)
|
|
||||||
lora_stack = []
|
lora_stack = []
|
||||||
|
current_lora_name = "No LoRA"
|
||||||
|
current_lora_filename = "No LoRA"
|
||||||
else:
|
else:
|
||||||
# Normalize path separators
|
# Get LoRA at current index (convert to 0-based for list access)
|
||||||
lora_path = lora_path.replace("/", os.sep)
|
current_lora = lora_list[clamped_index - 1]
|
||||||
lora_stack = [(lora_path, model_strength, clip_strength)]
|
current_lora_name = current_lora["file_name"]
|
||||||
|
current_lora_filename = current_lora["file_name"]
|
||||||
|
|
||||||
|
# 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"])
|
||||||
|
|
||||||
|
if not lora_path:
|
||||||
|
if current_lora["file_name"] != "None":
|
||||||
|
logger.warning(
|
||||||
|
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
|
||||||
|
)
|
||||||
|
lora_stack = []
|
||||||
|
else:
|
||||||
|
# Normalize path separators
|
||||||
|
lora_path = lora_path.replace("/", os.sep)
|
||||||
|
lora_stack = [(lora_path, model_strength, clip_strength)]
|
||||||
|
|
||||||
# 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)
|
||||||
next_lora = lora_list[next_index - 1]
|
is_next_no_lora = include_no_lora and next_index == effective_total_count
|
||||||
next_display_name = next_lora["file_name"]
|
if is_next_no_lora:
|
||||||
|
next_display_name = "No LoRA"
|
||||||
|
next_lora_filename = "No LoRA"
|
||||||
|
else:
|
||||||
|
next_lora = lora_list[next_index - 1]
|
||||||
|
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:
|
||||||
working_entry['sha256'] = normalized_sha
|
if auto_repair:
|
||||||
repaired = True
|
working_entry['sha256'] = normalized_sha
|
||||||
|
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):
|
||||||
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
items = result["items"]
|
||||||
return result["items"][0]
|
|
||||||
|
# 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}")
|
||||||
|
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)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from ..utils.civitai_utils import rewrite_preview_url
|
|||||||
from ..utils.preview_selection import select_preview_media
|
from ..utils.preview_selection import 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
|
||||||
@@ -965,11 +964,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 +1238,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
|
||||||
@@ -209,7 +217,7 @@ class Downloader:
|
|||||||
if self._session is not None:
|
if self._session is not None:
|
||||||
try:
|
try:
|
||||||
await self._session.close()
|
await self._session.close()
|
||||||
except Exception as e: # pragma: no cover
|
except Exception as e: # pragma: no cover
|
||||||
logger.warning(f"Error closing previous session: {e}")
|
logger.warning(f"Error closing previous session: {e}")
|
||||||
finally:
|
finally:
|
||||||
self._session = None
|
self._session = None
|
||||||
@@ -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()
|
||||||
|
|
||||||
provider = (
|
try:
|
||||||
provider_manager._get_provider(provider_name)
|
provider = (
|
||||||
if provider_name
|
provider_manager._get_provider(provider_name)
|
||||||
else provider_manager._get_provider()
|
if provider_name
|
||||||
)
|
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,12 +1442,11 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, ');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
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']})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,12 +97,13 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
|||||||
sortBy.value = config.sort_by || 'filename'
|
sortBy.value = config.sort_by || 'filename'
|
||||||
currentLoraName.value = config.current_lora_name || ''
|
currentLoraName.value = config.current_lora_name || ''
|
||||||
currentLoraFilename.value = config.current_lora_filename || ''
|
currentLoraFilename.value = config.current_lora_filename || ''
|
||||||
// Advanced index control features
|
// Advanced index control features
|
||||||
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
|
||||||
// Note: execution_index and next_index are not restored from config
|
includeNoLora.value = config.include_no_lora ?? false
|
||||||
// as they are transient values used only during batch execution
|
// Note: execution_index and next_index are not restored from config
|
||||||
|
// as they are transient values used only during batch execution
|
||||||
} finally {
|
} finally {
|
||||||
isRestoring = false
|
isRestoring = false
|
||||||
}
|
}
|
||||||
@@ -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,10 +1905,38 @@ 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) {
|
||||||
searchTerm = this._getLastSpaceToken(fullSearchTerm);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user