mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 22:10:14 -03:00
Compare commits
7 Commits
fix/recipe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f11b6def9 | ||
|
|
7df83f44b8 | ||
|
|
169fa7bed6 | ||
|
|
027b504fe8 | ||
|
|
186ef4da78 | ||
|
|
dc674098e7 | ||
|
|
9087b4b07c |
4452
locales/de.json
4452
locales/de.json
File diff suppressed because it is too large
Load Diff
@@ -773,6 +773,8 @@
|
||||
"deleteAll": "Delete Selected",
|
||||
"downloadMissingLoras": "Download Missing LoRAs",
|
||||
"downloadExamples": "Download Example Images",
|
||||
"downloadMissingExamples": "Download Missing",
|
||||
"reprocessExamples": "Re-process All",
|
||||
"clear": "Clear Selection",
|
||||
"skipMetadataRefreshCount": "Skip ({count} models)",
|
||||
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
||||
@@ -808,6 +810,8 @@
|
||||
"sendToWorkflowReplace": "Send to Workflow (Replace)",
|
||||
"openExamples": "Open Examples Folder",
|
||||
"downloadExamples": "Download Example Images",
|
||||
"downloadMissingExamples": "Download Missing",
|
||||
"reprocessExamples": "Re-process All",
|
||||
"replacePreview": "Replace Preview",
|
||||
"setContentRating": "Set Content Rating",
|
||||
"moveToFolder": "Move to Folder",
|
||||
|
||||
4452
locales/es.json
4452
locales/es.json
File diff suppressed because it is too large
Load Diff
4452
locales/fr.json
4452
locales/fr.json
File diff suppressed because it is too large
Load Diff
4452
locales/he.json
4452
locales/he.json
File diff suppressed because it is too large
Load Diff
4452
locales/ja.json
4452
locales/ja.json
File diff suppressed because it is too large
Load Diff
4452
locales/ko.json
4452
locales/ko.json
File diff suppressed because it is too large
Load Diff
4452
locales/ru.json
4452
locales/ru.json
File diff suppressed because it is too large
Load Diff
4452
locales/zh-CN.json
4452
locales/zh-CN.json
File diff suppressed because it is too large
Load Diff
4452
locales/zh-TW.json
4452
locales/zh-TW.json
File diff suppressed because it is too large
Load Diff
@@ -252,6 +252,13 @@ class SaveImageLM:
|
||||
"tooltip": "When enabled, embeds generation parameters into the saved image metadata. Disable to skip writing generation metadata.",
|
||||
},
|
||||
),
|
||||
"add_loras_to_prompt": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "When enabled, appends the LoRA syntax line (e.g. <lora:name:strength>) after the positive prompt in the saved metadata.",
|
||||
},
|
||||
),
|
||||
"add_counter_to_filename": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
@@ -348,7 +355,7 @@ class SaveImageLM:
|
||||
type_lower = model_type.lower() if model_type else "other"
|
||||
return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}"
|
||||
|
||||
def format_metadata(self, metadata_dict: dict) -> str:
|
||||
def format_metadata(self, metadata_dict: dict, add_loras_to_prompt: bool = False) -> str:
|
||||
"""Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources."""
|
||||
if not metadata_dict: return ""
|
||||
|
||||
@@ -458,7 +465,10 @@ class SaveImageLM:
|
||||
scheduler_name = scheduler_mapping.get(scheduler, scheduler) if scheduler else None
|
||||
|
||||
# Build output lines
|
||||
lines = [prompt] if prompt else [""]
|
||||
prompt_line = prompt if prompt else ""
|
||||
if add_loras_to_prompt and loras_text:
|
||||
prompt_line = f"{prompt_line}\n{loras_text}" if prompt_line else loras_text
|
||||
lines = [prompt_line] if prompt_line else [""]
|
||||
if negative_prompt:
|
||||
lines.append(f"Negative prompt: {negative_prompt}")
|
||||
|
||||
@@ -793,6 +803,7 @@ class SaveImageLM:
|
||||
save_with_metadata=True,
|
||||
add_counter_to_filename=True,
|
||||
save_as_recipe=False,
|
||||
add_loras_to_prompt=False,
|
||||
):
|
||||
"""Save images with metadata"""
|
||||
results = []
|
||||
@@ -801,7 +812,7 @@ class SaveImageLM:
|
||||
raw_metadata = get_metadata()
|
||||
metadata_dict = MetadataProcessor.to_dict(raw_metadata, id)
|
||||
|
||||
metadata = self.format_metadata(metadata_dict)
|
||||
metadata = self.format_metadata(metadata_dict, add_loras_to_prompt)
|
||||
|
||||
# Process filename_prefix with pattern substitution
|
||||
filename_prefix = self.format_filename(filename_prefix, metadata_dict)
|
||||
@@ -943,6 +954,7 @@ class SaveImageLM:
|
||||
save_with_metadata=True,
|
||||
add_counter_to_filename=True,
|
||||
save_as_recipe=False,
|
||||
add_loras_to_prompt=False,
|
||||
):
|
||||
"""Process and save image with metadata"""
|
||||
# Make sure the output directory exists
|
||||
@@ -974,6 +986,7 @@ class SaveImageLM:
|
||||
save_with_metadata,
|
||||
add_counter_to_filename,
|
||||
save_as_recipe,
|
||||
add_loras_to_prompt,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1473,10 +1473,12 @@ class SettingsManager:
|
||||
|
||||
try:
|
||||
common_root = os.path.commonpath([source, target])
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid recipes path change") from exc
|
||||
except ValueError:
|
||||
# Windows: paths on different drives share no common root.
|
||||
# A cross-drive move is valid, so treat it as no common root.
|
||||
common_root = None
|
||||
|
||||
if common_root == source:
|
||||
if common_root is not None and common_root == source:
|
||||
raise ValueError("Recipes path cannot be moved into a nested directory")
|
||||
|
||||
planned_recipe_updates: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@@ -172,6 +172,7 @@ class DownloadManager:
|
||||
model_types = data.get("model_types", ["lora", "checkpoint"])
|
||||
delay = float(data.get("delay", 0.2))
|
||||
force = data.get("force", False)
|
||||
model_hashes = data.get("model_hashes", [])
|
||||
|
||||
# Step 2: Validate configuration (fast lookup)
|
||||
settings_manager = get_settings_manager()
|
||||
@@ -241,6 +242,7 @@ class DownloadManager:
|
||||
delay,
|
||||
active_library,
|
||||
force,
|
||||
model_hashes,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -577,8 +579,9 @@ class DownloadManager:
|
||||
delay,
|
||||
library_name,
|
||||
force: bool = False,
|
||||
model_hashes: list[str] | None = None,
|
||||
):
|
||||
"""Download example images for all models."""
|
||||
"""Download example images for all models (or only the given hashes)."""
|
||||
|
||||
downloader = await get_downloader()
|
||||
|
||||
@@ -606,6 +609,18 @@ class DownloadManager:
|
||||
if model.get("sha256"):
|
||||
all_models.append((scanner_type, model, scanner))
|
||||
|
||||
# Restrict to the requested hashes when provided (empty = all models).
|
||||
# Explicit targets are a directed user request, so previously failed
|
||||
# models are retried instead of skipped.
|
||||
explicit_targets = bool(model_hashes)
|
||||
if model_hashes:
|
||||
hash_set = {h.lower() for h in model_hashes}
|
||||
all_models = [
|
||||
(scanner_type, model, scanner)
|
||||
for scanner_type, model, scanner in all_models
|
||||
if model.get("sha256", "").lower() in hash_set
|
||||
]
|
||||
|
||||
# Update total count
|
||||
self._progress["total"] = len(all_models)
|
||||
logger.debug(f"Found {self._progress['total']} models to process")
|
||||
@@ -629,6 +644,7 @@ class DownloadManager:
|
||||
downloader,
|
||||
library_name,
|
||||
force,
|
||||
explicit_targets,
|
||||
)
|
||||
|
||||
# Update progress
|
||||
@@ -725,6 +741,7 @@ class DownloadManager:
|
||||
downloader,
|
||||
library_name,
|
||||
force: bool = False,
|
||||
explicit_targets: bool = False,
|
||||
):
|
||||
"""Process a single model download."""
|
||||
|
||||
@@ -747,8 +764,9 @@ class DownloadManager:
|
||||
self._progress["current_model"] = f"{model_name} ({model_hash[:8]})"
|
||||
await self._broadcast_progress(status="running")
|
||||
|
||||
# Skip if already in failed models (unless force mode is enabled)
|
||||
if not force and model_hash in self._progress["failed_models"]:
|
||||
# Skip if already in failed models (unless force mode is enabled or
|
||||
# the model was explicitly targeted by hash)
|
||||
if not force and not explicit_targets and model_hash in self._progress["failed_models"]:
|
||||
logger.debug(f"Skipping known failed model: {model_name}")
|
||||
return False
|
||||
|
||||
@@ -757,30 +775,34 @@ class DownloadManager:
|
||||
)
|
||||
existing_files = _model_directory_has_files(model_dir)
|
||||
|
||||
# Skip if already processed AND directory exists with files
|
||||
if model_hash in self._progress["processed_models"]:
|
||||
if existing_files:
|
||||
logger.debug(f"Skipping already processed model: {model_name}")
|
||||
# Model-level guard: a populated folder counts as done. Explicitly
|
||||
# targeted models bypass it so the per-image existence pre-check can
|
||||
# fill individual gaps without re-fetching existing files.
|
||||
if not explicit_targets:
|
||||
# Skip if already processed AND directory exists with files
|
||||
if model_hash in self._progress["processed_models"]:
|
||||
if existing_files:
|
||||
logger.debug(f"Skipping already processed model: {model_name}")
|
||||
return False
|
||||
|
||||
logger.debug(
|
||||
"Model %s (%s) marked as processed but folder empty or missing, reprocessing triggered",
|
||||
model_name,
|
||||
model_hash,
|
||||
)
|
||||
# Track that we are reprocessing this model for summary logging
|
||||
self._progress["reprocessed_models"].add(model_hash)
|
||||
# Remove from processed models since we need to reprocess
|
||||
self._progress["processed_models"].discard(model_hash)
|
||||
|
||||
if existing_files and model_hash not in self._progress["processed_models"]:
|
||||
logger.debug(
|
||||
"Model folder already populated for %s, marking as processed without download",
|
||||
model_name,
|
||||
)
|
||||
self._progress["processed_models"].add(model_hash)
|
||||
return False
|
||||
|
||||
logger.debug(
|
||||
"Model %s (%s) marked as processed but folder empty or missing, reprocessing triggered",
|
||||
model_name,
|
||||
model_hash,
|
||||
)
|
||||
# Track that we are reprocessing this model for summary logging
|
||||
self._progress["reprocessed_models"].add(model_hash)
|
||||
# Remove from processed models since we need to reprocess
|
||||
self._progress["processed_models"].discard(model_hash)
|
||||
|
||||
if existing_files and model_hash not in self._progress["processed_models"]:
|
||||
logger.debug(
|
||||
"Model folder already populated for %s, marking as processed without download",
|
||||
model_name,
|
||||
)
|
||||
self._progress["processed_models"].add(model_hash)
|
||||
return False
|
||||
|
||||
if not model_dir:
|
||||
logger.warning(
|
||||
"Unable to resolve example images folder for model %s (%s)",
|
||||
@@ -884,7 +906,7 @@ class DownloadManager:
|
||||
model_name,
|
||||
)
|
||||
# Clear failed_models so non-force runs can retry
|
||||
if force and model_hash in self._progress["failed_models"]:
|
||||
if (force or explicit_targets) and model_hash in self._progress["failed_models"]:
|
||||
self._progress["failed_models"].discard(model_hash)
|
||||
logger.info(
|
||||
f"Removed {model_name} from failed_models after force retry with rate-limited images"
|
||||
@@ -904,7 +926,7 @@ class DownloadManager:
|
||||
)
|
||||
elif success:
|
||||
self._progress["processed_models"].add(model_hash)
|
||||
if force and model_hash in self._progress["failed_models"]:
|
||||
if (force or explicit_targets) and model_hash in self._progress["failed_models"]:
|
||||
self._progress["failed_models"].discard(model_hash)
|
||||
logger.info(
|
||||
f"Removed {model_name} from failed_models after successful force retry"
|
||||
|
||||
@@ -113,6 +113,26 @@ class ExampleImagesProcessor:
|
||||
message = str(error).lower()
|
||||
return '404' in message or 'file not found' in message
|
||||
|
||||
@staticmethod
|
||||
def _example_image_file_exists(model_dir: str, index: int, media_type_hint: str | None = None) -> bool:
|
||||
"""Return True when the file that would be written for a media index already exists.
|
||||
|
||||
The final filename (``image_{index}{extension}``) depends on the downloaded
|
||||
content, so the extension cannot be known ahead of time. The post-download
|
||||
check skips the write when the exact target file exists; this pre-check
|
||||
approximates that with the candidate extensions for the media type (videos
|
||||
only when the metadata hints at a video) so the network request is avoided
|
||||
for files that already exist on disk.
|
||||
"""
|
||||
if media_type_hint == "video":
|
||||
extensions = SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||
else:
|
||||
extensions = SUPPORTED_MEDIA_EXTENSIONS['images']
|
||||
return any(
|
||||
os.path.exists(os.path.join(model_dir, f"image_{index}{ext}"))
|
||||
for ext in extensions
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, downloader):
|
||||
"""Download images for a single model
|
||||
@@ -139,7 +159,12 @@ class ExampleImagesProcessor:
|
||||
original_url = image_url
|
||||
if optimize and 'civitai.com' in image_url:
|
||||
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
|
||||
|
||||
|
||||
# Skip the download when the file already exists on disk
|
||||
if ExampleImagesProcessor._example_image_file_exists(model_dir, i, image.get("type")):
|
||||
logger.debug("File already exists, skipping download for %s", image_url)
|
||||
continue
|
||||
|
||||
# Download the file first to determine the actual file type
|
||||
try:
|
||||
logger.debug(f"Downloading media file {i} for {model_name}")
|
||||
@@ -229,6 +254,11 @@ class ExampleImagesProcessor:
|
||||
if optimize and 'civitai.com' in image_url:
|
||||
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
|
||||
|
||||
# Skip the download when the file already exists on disk
|
||||
if ExampleImagesProcessor._example_image_file_exists(model_dir, i, image.get("type")):
|
||||
logger.debug("File already exists, skipping download for %s", image_url)
|
||||
continue
|
||||
|
||||
async def _attempt_download() -> tuple:
|
||||
logger.debug("Downloading media file %s for %s", i, model_name)
|
||||
return await downloader.download_to_memory(
|
||||
|
||||
@@ -184,7 +184,8 @@ export const DOWNLOAD_ENDPOINTS = {
|
||||
downloadGet: '/api/lm/download-model-get',
|
||||
cancelGet: '/api/lm/cancel-download-get',
|
||||
progress: '/api/lm/download-progress',
|
||||
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
|
||||
exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
|
||||
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
|
||||
};
|
||||
|
||||
// Hugging Face API endpoints
|
||||
|
||||
@@ -1641,7 +1641,7 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async downloadExampleImages(modelHashes, modelTypes = null) {
|
||||
async downloadExampleImages(modelHashes, modelTypes = null, { force = true } = {}) {
|
||||
let ws = null;
|
||||
|
||||
await state.loadingManager.showWithProgress(async (loading) => {
|
||||
@@ -1700,8 +1700,13 @@ export class BaseModelApiClient {
|
||||
// Determine optimize setting
|
||||
const optimize = state.global?.settings?.optimize_example_images ?? true;
|
||||
|
||||
// force=false routes to the regular endpoint, which skips already-processed models
|
||||
const endpoint = force
|
||||
? DOWNLOAD_ENDPOINTS.exampleImages
|
||||
: DOWNLOAD_ENDPOINTS.exampleImagesMissing;
|
||||
|
||||
// Make the API request to start the download process
|
||||
const response = await fetch(DOWNLOAD_ENDPOINTS.exampleImages, {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -1710,6 +1715,7 @@ export class BaseModelApiClient {
|
||||
model_hashes: modelHashes,
|
||||
output_dir: outputDir,
|
||||
optimize: optimize,
|
||||
force: force,
|
||||
model_types: modelTypes || [this.apiConfig.config.singularName]
|
||||
})
|
||||
});
|
||||
|
||||
@@ -137,11 +137,10 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const downloadExampleImagesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||
if (downloadExampleImagesItem) {
|
||||
const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]');
|
||||
if (downloadExampleImagesSubmenu) {
|
||||
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
|
||||
const modelPages = ['loras', 'checkpoints', 'embeddings'];
|
||||
downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none';
|
||||
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||
@@ -294,8 +293,11 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
case 'download-missing-loras':
|
||||
this.handleDownloadMissingLoras();
|
||||
break;
|
||||
case 'download-missing-example-images':
|
||||
this.handleDownloadExampleImages({ force: false });
|
||||
break;
|
||||
case 'download-example-images':
|
||||
this.handleDownloadExampleImages();
|
||||
this.handleDownloadExampleImages({ force: true });
|
||||
break;
|
||||
case 'clear':
|
||||
bulkManager.clearSelection();
|
||||
@@ -340,7 +342,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
|
||||
}
|
||||
|
||||
async handleDownloadExampleImages() {
|
||||
async handleDownloadExampleImages({ force = true } = {}) {
|
||||
if (state.selectedModels.size === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -361,7 +363,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
|
||||
try {
|
||||
const apiClient = getModelApiClient();
|
||||
await apiClient.downloadExampleImages([...hashes]);
|
||||
await apiClient.downloadExampleImages([...hashes], null, { force });
|
||||
} catch (error) {
|
||||
console.error('Bulk download example images failed:', error);
|
||||
}
|
||||
|
||||
@@ -347,7 +347,10 @@ export const ModelContextMenuMixin = {
|
||||
openExampleImagesFolder(this.currentCard.dataset.sha256);
|
||||
return true;
|
||||
case 'download-examples':
|
||||
this.downloadExampleImages();
|
||||
this.downloadExampleImages(false);
|
||||
return true;
|
||||
case 'download-examples-force':
|
||||
this.downloadExampleImages(true);
|
||||
return true;
|
||||
case 'civitai':
|
||||
if (this.currentCard.dataset.from_civitai === 'true') {
|
||||
@@ -378,7 +381,7 @@ export const ModelContextMenuMixin = {
|
||||
},
|
||||
|
||||
// Download example images method
|
||||
async downloadExampleImages() {
|
||||
async downloadExampleImages(force = false) {
|
||||
const modelHash = this.currentCard.dataset.sha256;
|
||||
if (!modelHash) {
|
||||
showToast('toast.contextMenu.missingHash', {}, 'error');
|
||||
@@ -387,7 +390,7 @@ export const ModelContextMenuMixin = {
|
||||
|
||||
try {
|
||||
const apiClient = getModelApiClient();
|
||||
await apiClient.downloadExampleImages([modelHash]);
|
||||
await apiClient.downloadExampleImages([modelHash], null, { force });
|
||||
} catch (error) {
|
||||
console.error('Error downloading example images:', error);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,12 @@
|
||||
<div class="context-menu-separator menu-section-break"></div>
|
||||
<!-- Media / Preview -->
|
||||
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
|
||||
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div>
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }} <i class="fas fa-chevron-right submenu-arrow"></i>
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingExamples') }}</div>
|
||||
<div class="context-menu-item" data-action="download-examples-force"><i class="fas fa-redo-alt"></i> {{ t('loras.contextMenu.reprocessExamples') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
|
||||
<div class="context-menu-separator menu-section-break"></div>
|
||||
<!-- Attributes -->
|
||||
|
||||
@@ -44,8 +44,18 @@
|
||||
<div class="context-menu-item" data-action="preview">
|
||||
<i class="fas fa-folder-open"></i> <span>{{ t('loras.contextMenu.openExamples') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="download-examples">
|
||||
<i class="fas fa-download"></i> <span>{{ t('loras.contextMenu.downloadExamples') }}</span>
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="download-examples">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>{{ t('loras.contextMenu.downloadExamples') }}</span>
|
||||
<i class="fas fa-chevron-right submenu-arrow"></i>
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="download-examples">
|
||||
<i class="fas fa-download"></i> <span>{{ t('loras.contextMenu.downloadMissingExamples') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="download-examples-force">
|
||||
<i class="fas fa-redo-alt"></i> <span>{{ t('loras.contextMenu.reprocessExamples') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="replace-preview">
|
||||
<i class="fas fa-image"></i> <span>{{ t('loras.contextMenu.replacePreview') }}</span>
|
||||
@@ -136,8 +146,18 @@
|
||||
</div>
|
||||
<div class="context-menu-section" data-section="download">
|
||||
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div>
|
||||
<div class="context-menu-item" data-action="download-example-images">
|
||||
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="download-example-images">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
|
||||
<i class="fas fa-chevron-right submenu-arrow"></i>
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="download-missing-example-images">
|
||||
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingExamples') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="download-example-images">
|
||||
<i class="fas fa-redo-alt"></i> <span>{{ t('loras.bulkOperations.reprocessExamples') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="download-missing-loras">
|
||||
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
|
||||
|
||||
@@ -32,7 +32,12 @@
|
||||
<div class="context-menu-separator menu-section-break"></div>
|
||||
<!-- Media / Preview -->
|
||||
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
|
||||
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div>
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }} <i class="fas fa-chevron-right submenu-arrow"></i>
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingExamples') }}</div>
|
||||
<div class="context-menu-item" data-action="download-examples-force"><i class="fas fa-redo-alt"></i> {{ t('loras.contextMenu.reprocessExamples') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
|
||||
<div class="context-menu-separator menu-section-break"></div>
|
||||
<!-- Attributes -->
|
||||
|
||||
@@ -2155,4 +2155,35 @@ describe('Interaction-level regression coverage', () => {
|
||||
excludedItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('routes single-model example downloads to missing-only and force paths', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu">
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="download-examples">
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="download-examples"></div>
|
||||
<div class="context-menu-item" data-action="download-examples-force"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
card.dataset.sha256 = 'abc123hash';
|
||||
document.body.appendChild(card);
|
||||
|
||||
contextMenu.showMenu(100, 100, card);
|
||||
|
||||
document.querySelector('[data-action="download-examples"]').dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: false });
|
||||
|
||||
contextMenu.showMenu(100, 100, card);
|
||||
document.querySelector('[data-action="download-examples-force"]').dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,41 @@ def test_save_image_skips_png_parameters_when_metadata_disabled_and_keeps_workfl
|
||||
assert img.info["workflow"] == json.dumps(workflow)
|
||||
|
||||
|
||||
def test_save_image_does_not_append_loras_to_prompt_by_default(monkeypatch, tmp_path):
|
||||
_configure_save_paths(monkeypatch, tmp_path)
|
||||
_configure_metadata(
|
||||
monkeypatch,
|
||||
{"prompt": "prompt text", "seed": 123, "loras": "<lora:foo:0.7>"},
|
||||
)
|
||||
|
||||
node = SaveImageLM()
|
||||
node.save_images([_make_image()], "ComfyUI", "png", id="node-1")
|
||||
|
||||
image_path = tmp_path / "sample_00001_.png"
|
||||
with Image.open(image_path) as img:
|
||||
assert "<lora:" not in img.info["parameters"]
|
||||
assert img.info["parameters"] == "prompt text\nSeed: 123, Version: ComfyUI"
|
||||
|
||||
|
||||
def test_save_image_appends_loras_to_prompt_when_enabled(monkeypatch, tmp_path):
|
||||
_configure_save_paths(monkeypatch, tmp_path)
|
||||
_configure_metadata(
|
||||
monkeypatch,
|
||||
{"prompt": "prompt text", "seed": 123, "loras": "<lora:foo:0.7>"},
|
||||
)
|
||||
|
||||
node = SaveImageLM()
|
||||
node.save_images(
|
||||
[_make_image()], "ComfyUI", "png", id="node-1", add_loras_to_prompt=True
|
||||
)
|
||||
|
||||
image_path = tmp_path / "sample_00001_.png"
|
||||
with Image.open(image_path) as img:
|
||||
assert img.info["parameters"] == (
|
||||
"prompt text\n<lora:foo:0.7>\nSeed: 123, Version: ComfyUI"
|
||||
)
|
||||
|
||||
|
||||
def test_save_image_skips_jpeg_metadata_when_disabled(monkeypatch, tmp_path):
|
||||
_configure_save_paths(monkeypatch, tmp_path)
|
||||
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
|
||||
@@ -451,6 +486,14 @@ class TestParameterDefaultConsistency:
|
||||
assert SaveImageLM.save_images.__defaults__[5] == 0
|
||||
assert SaveImageLM.process_image.__defaults__[7] == 0
|
||||
|
||||
def test_add_loras_to_prompt_defaults_are_consistent(self):
|
||||
input_types = SaveImageLM.INPUT_TYPES()
|
||||
optional = input_types["optional"]
|
||||
|
||||
assert optional["add_loras_to_prompt"][1]["default"] is False
|
||||
assert SaveImageLM.save_images.__defaults__[-1] is False
|
||||
assert SaveImageLM.process_image.__defaults__[-1] is False
|
||||
|
||||
|
||||
def test_png_does_not_pass_webp_method_or_jpeg_subsampling(monkeypatch, tmp_path):
|
||||
_configure_save_paths(monkeypatch, tmp_path)
|
||||
|
||||
@@ -529,7 +529,8 @@ async def test_not_found_example_images_are_cleaned(
|
||||
|
||||
model_dir = images_root / model_hash
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
(model_dir / "image_0.png").write_bytes(b"first")
|
||||
# Pre-existing file collides with the valid image index (1) so the
|
||||
# pre-download existence check must skip it without a network request
|
||||
(model_dir / "image_1.png").write_bytes(b"second")
|
||||
|
||||
async def fake_process_local_examples(*_args, **_kwargs):
|
||||
@@ -608,11 +609,188 @@ async def test_not_found_example_images_are_cleaned(
|
||||
]
|
||||
|
||||
files = sorted(p.name for p in model_dir.iterdir())
|
||||
assert files == ["image_0.png", "image_1.png"]
|
||||
assert (model_dir / "image_0.png").read_bytes() == b"first"
|
||||
assert files == ["image_1.png"]
|
||||
assert (model_dir / "image_1.png").read_bytes() == b"second"
|
||||
|
||||
|
||||
async def test_failed_models_retried_when_explicitly_targeted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
settings_manager,
|
||||
):
|
||||
ws_manager = RecordingWebSocketManager()
|
||||
manager = download_module.DownloadManager(ws_manager=ws_manager)
|
||||
|
||||
images_root = tmp_path / "examples"
|
||||
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(images_root))
|
||||
|
||||
model_hash = "a" * 64
|
||||
model_path = tmp_path / "model.safetensors"
|
||||
model_path.write_text("data", encoding="utf-8")
|
||||
|
||||
model_metadata = {
|
||||
"sha256": model_hash,
|
||||
"model_name": "Failed Example",
|
||||
"file_path": str(model_path),
|
||||
"file_name": "model.safetensors",
|
||||
"civitai": {"images": [{"url": "https://example.com/valid.png"}]},
|
||||
}
|
||||
|
||||
scanner = StubScanner([model_metadata.copy()])
|
||||
_patch_scanner(monkeypatch, scanner)
|
||||
|
||||
# Persist a previous failure so the skip path is exercised
|
||||
images_root.mkdir(parents=True, exist_ok=True)
|
||||
(images_root / ".download_progress.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"failed_models": [model_hash],
|
||||
"processed_models": [],
|
||||
"rate_limited_models": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
async def fake_process_local_examples(*_args, **_kwargs):
|
||||
return False
|
||||
|
||||
async def fake_get_updated_model(model_hash_arg, _scanner):
|
||||
return model_metadata
|
||||
|
||||
class DownloaderStub:
|
||||
def __init__(self):
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def download_to_memory(self, url, *_args, **_kwargs):
|
||||
self.calls.append(url)
|
||||
return True, b"\x89PNG\r\n\x1a\n", {"content-type": "image/png"}
|
||||
|
||||
downloader = DownloaderStub()
|
||||
|
||||
async def fake_get_downloader():
|
||||
return downloader
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_module.ExampleImagesProcessor,
|
||||
"process_local_examples",
|
||||
staticmethod(fake_process_local_examples),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
download_module.MetadataUpdater,
|
||||
"get_updated_model",
|
||||
staticmethod(fake_get_updated_model),
|
||||
)
|
||||
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
|
||||
|
||||
# Without explicit hashes the previously failed model is skipped
|
||||
skipped_manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
|
||||
result = await skipped_manager.start_download({"model_types": ["lora"], "delay": 0})
|
||||
assert result["success"] is True
|
||||
if skipped_manager._download_task is not None:
|
||||
await asyncio.wait_for(skipped_manager._download_task, timeout=1)
|
||||
assert downloader.calls == []
|
||||
|
||||
# With explicit hashes the previously failed model is retried and cleared
|
||||
result = await manager.start_download(
|
||||
{"model_types": ["lora"], "delay": 0, "model_hashes": [model_hash]}
|
||||
)
|
||||
assert result["success"] is True
|
||||
if manager._download_task is not None:
|
||||
await asyncio.wait_for(manager._download_task, timeout=1)
|
||||
assert downloader.calls == ["https://example.com/valid.png"]
|
||||
assert manager._progress["failed_models"] == set()
|
||||
assert model_hash in manager._progress["processed_models"]
|
||||
|
||||
|
||||
async def test_explicit_targets_fill_partial_example_gaps(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
settings_manager,
|
||||
):
|
||||
ws_manager = RecordingWebSocketManager()
|
||||
|
||||
images_root = tmp_path / "examples"
|
||||
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(images_root))
|
||||
|
||||
model_hash = "b" * 64
|
||||
model_path = tmp_path / "model.safetensors"
|
||||
model_path.write_text("data", encoding="utf-8")
|
||||
|
||||
model_metadata = {
|
||||
"sha256": model_hash,
|
||||
"model_name": "Partial Example",
|
||||
"file_path": str(model_path),
|
||||
"file_name": "model.safetensors",
|
||||
"civitai": {
|
||||
"images": [
|
||||
{"url": "https://example.com/first.png"},
|
||||
{"url": "https://example.com/second.png"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
scanner = StubScanner([model_metadata.copy()])
|
||||
_patch_scanner(monkeypatch, scanner)
|
||||
|
||||
# Simulate a partially populated folder: index 0 already downloaded
|
||||
model_dir = images_root / model_hash
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
(model_dir / "image_0.png").write_bytes(b"existing")
|
||||
|
||||
async def fake_process_local_examples(*_args, **_kwargs):
|
||||
return False
|
||||
|
||||
async def fake_get_updated_model(model_hash_arg, _scanner):
|
||||
return model_metadata
|
||||
|
||||
class DownloaderStub:
|
||||
def __init__(self):
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def download_to_memory(self, url, *_args, **_kwargs):
|
||||
self.calls.append(url)
|
||||
return True, b"\x89PNG\r\n\x1a\n", {"content-type": "image/png"}
|
||||
|
||||
downloader = DownloaderStub()
|
||||
|
||||
async def fake_get_downloader():
|
||||
return downloader
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_module.ExampleImagesProcessor,
|
||||
"process_local_examples",
|
||||
staticmethod(fake_process_local_examples),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
download_module.MetadataUpdater,
|
||||
"get_updated_model",
|
||||
staticmethod(fake_get_updated_model),
|
||||
)
|
||||
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
|
||||
|
||||
# Untargeted run treats the populated folder as done
|
||||
untargeted = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
|
||||
result = await untargeted.start_download({"model_types": ["lora"], "delay": 0})
|
||||
assert result["success"] is True
|
||||
if untargeted._download_task is not None:
|
||||
await asyncio.wait_for(untargeted._download_task, timeout=1)
|
||||
assert downloader.calls == []
|
||||
|
||||
# Explicitly targeted run fills only the missing index, skipping the
|
||||
# existing file without a network request
|
||||
targeted = download_module.DownloadManager(ws_manager=ws_manager)
|
||||
result = await targeted.start_download(
|
||||
{"model_types": ["lora"], "delay": 0, "model_hashes": [model_hash]}
|
||||
)
|
||||
assert result["success"] is True
|
||||
if targeted._download_task is not None:
|
||||
await asyncio.wait_for(targeted._download_task, timeout=1)
|
||||
assert downloader.calls == ["https://example.com/second.png"]
|
||||
assert (model_dir / "image_1.png").exists()
|
||||
assert (model_dir / "image_0.png").read_bytes() == b"existing"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings_manager():
|
||||
return get_settings_manager()
|
||||
|
||||
@@ -860,6 +860,59 @@ def test_set_recipes_path_rewrites_symlinked_recipe_metadata(manager, tmp_path):
|
||||
assert not old_json_path.exists()
|
||||
|
||||
|
||||
def test_set_recipes_path_allows_cross_drive_migration(manager, tmp_path, monkeypatch):
|
||||
# Windows regression: os.path.commonpath raises ValueError for paths on
|
||||
# different drives (ntpath semantics). Cross-drive moves must succeed.
|
||||
lora_root = tmp_path / "loras"
|
||||
old_recipes_dir = lora_root / "recipes" / "nested"
|
||||
old_recipes_dir.mkdir(parents=True)
|
||||
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
||||
|
||||
recipe_id = "recipe-cross-drive"
|
||||
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
|
||||
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
|
||||
old_image_path.write_bytes(b"image-bytes")
|
||||
old_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": str(old_image_path),
|
||||
"title": "Recipe Cross Drive",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
new_recipes_dir = tmp_path / "N_drive" / "AI" / "Library" / "Recipes"
|
||||
|
||||
# The effective current recipes dir (source of the migration) is
|
||||
# lora_root/recipes — the nested subdirectory holds the recipe files.
|
||||
source = str(lora_root / "recipes")
|
||||
target = str(new_recipes_dir)
|
||||
real_commonpath = os.path.commonpath
|
||||
|
||||
def fake_commonpath(paths):
|
||||
# Simulate ntpath on Windows: a source/target pair on different
|
||||
# drives shares no common root and raises ValueError.
|
||||
if {source, target} <= set(paths):
|
||||
raise ValueError("Paths don't have the same drive")
|
||||
return real_commonpath(paths)
|
||||
|
||||
monkeypatch.setattr(os.path, "commonpath", fake_commonpath)
|
||||
|
||||
manager.set("recipes_path", str(new_recipes_dir))
|
||||
|
||||
migrated_image_path = new_recipes_dir / "nested" / f"{recipe_id}.webp"
|
||||
migrated_json_path = new_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
|
||||
|
||||
assert manager.get("recipes_path") == str(new_recipes_dir.resolve())
|
||||
assert migrated_image_path.read_bytes() == b"image-bytes"
|
||||
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
||||
assert migrated_payload["file_path"] == str(migrated_image_path)
|
||||
assert not old_image_path.exists()
|
||||
assert not old_json_path.exists()
|
||||
|
||||
|
||||
def test_set_recipes_path_rejects_file_target(manager, tmp_path):
|
||||
lora_root = tmp_path / "loras"
|
||||
lora_root.mkdir()
|
||||
|
||||
@@ -63,7 +63,7 @@ async def test_start_download_bootstraps_progress_and_task(
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_download(
|
||||
self, output_dir, optimize, model_types, delay, library_name, force=False
|
||||
self, output_dir, optimize, model_types, delay, library_name, force=False, model_hashes=None
|
||||
):
|
||||
started.set()
|
||||
await release.wait()
|
||||
@@ -93,6 +93,44 @@ async def test_start_download_bootstraps_progress_and_task(
|
||||
assert manager._progress["status"] == "completed"
|
||||
|
||||
|
||||
async def test_start_download_forwards_model_hashes(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.settings["example_images_path"] = str(tmp_path)
|
||||
settings_manager.settings["libraries"] = {"default": {}}
|
||||
settings_manager.settings["active_library"] = "default"
|
||||
|
||||
manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
|
||||
|
||||
received: Dict[str, Any] = {}
|
||||
|
||||
async def fake_download(
|
||||
self, output_dir, optimize, model_types, delay, library_name, force=False, model_hashes=None
|
||||
):
|
||||
received["model_hashes"] = model_hashes
|
||||
async with self._state_lock:
|
||||
self._is_downloading = False
|
||||
self._download_task = None
|
||||
self._progress["status"] = "completed"
|
||||
|
||||
monkeypatch.setattr(
|
||||
download_module.DownloadManager,
|
||||
"_download_all_example_images",
|
||||
fake_download,
|
||||
)
|
||||
|
||||
result = await manager.start_download(
|
||||
{"model_types": ["lora"], "delay": 0, "model_hashes": ["abc123", "def456"]}
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
task = manager._download_task
|
||||
assert task is not None
|
||||
await asyncio.wait_for(task, timeout=1)
|
||||
assert received["model_hashes"] == ["abc123", "def456"]
|
||||
|
||||
|
||||
async def test_pause_and_resume_flow(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.settings["example_images_path"] = str(tmp_path)
|
||||
|
||||
@@ -100,6 +100,54 @@ def test_get_file_extension_media_type_hint_low_priority() -> None:
|
||||
assert ext == ".mp4"
|
||||
|
||||
|
||||
def test_example_image_file_exists_checks_plausible_extensions(tmp_path) -> None:
|
||||
proc = processor_module.ExampleImagesProcessor
|
||||
assert proc._example_image_file_exists(str(tmp_path), 0) is False
|
||||
Path(tmp_path, "image_0.webp").write_bytes(b"x")
|
||||
assert proc._example_image_file_exists(str(tmp_path), 0) is True
|
||||
assert proc._example_image_file_exists(str(tmp_path), 1) is False
|
||||
|
||||
|
||||
def test_example_image_file_exists_video_hint_only_checks_video_extensions(tmp_path) -> None:
|
||||
proc = processor_module.ExampleImagesProcessor
|
||||
Path(tmp_path, "image_2.jpg").write_bytes(b"x")
|
||||
# An existing image file must not satisfy a video-hinted lookup
|
||||
assert proc._example_image_file_exists(str(tmp_path), 2, "video") is False
|
||||
Path(tmp_path, "image_2.mp4").write_bytes(b"x")
|
||||
assert proc._example_image_file_exists(str(tmp_path), 2, "video") is True
|
||||
|
||||
|
||||
async def test_download_model_images_with_tracking_skips_existing_files(tmp_path) -> None:
|
||||
proc = processor_module.ExampleImagesProcessor
|
||||
images = [
|
||||
{"url": "https://image.civitai.com/a/b", "type": "image"},
|
||||
{"url": "https://image.civitai.com/c/d", "type": "image"},
|
||||
]
|
||||
Path(tmp_path, "image_0.jpg").write_bytes(b"existing")
|
||||
|
||||
class RecordingDownloader:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def download_to_memory(self, url, use_auth=False, return_headers=False):
|
||||
self.calls.append(url)
|
||||
return True, b"\xff\xd8\xff" + b"data", {}
|
||||
|
||||
downloader = RecordingDownloader()
|
||||
success, is_stale, failed, rate_limited = await proc.download_model_images_with_tracking(
|
||||
"hash", "model", images, str(tmp_path), False, downloader
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert is_stale is False
|
||||
assert failed == []
|
||||
assert rate_limited == []
|
||||
# Only the missing image is requested; the existing one is skipped without a network call
|
||||
assert len(downloader.calls) == 1
|
||||
assert "c/d" in downloader.calls[0]
|
||||
assert Path(tmp_path, "image_1.jpg").exists()
|
||||
|
||||
|
||||
class StubScanner:
|
||||
def __init__(self, models: list[Dict[str, Any]]) -> None:
|
||||
self._cache = SimpleNamespace(raw_data=models)
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface AutocompleteTextWidgetInterface {
|
||||
const props = defineProps<{
|
||||
widget: AutocompleteTextWidgetInterface
|
||||
node: { id: number }
|
||||
modelType?: 'loras' | 'embeddings' | 'custom_words' | 'prompt'
|
||||
modelType?: 'loras' | 'prompt'
|
||||
placeholder?: string
|
||||
showPreview?: boolean
|
||||
spellcheck?: boolean
|
||||
|
||||
@@ -98,7 +98,7 @@ interface LoraInfoWidget {
|
||||
onSetValue?: (v: unknown) => void
|
||||
callback?: unknown
|
||||
options?: {
|
||||
getValue?: () => LoraInfoWidgetValue
|
||||
getValue?: () => unknown
|
||||
setValue?: (v: unknown) => void
|
||||
}
|
||||
node?: { widgets?: Array<{ id?: string }>; widgets_values?: Array<unknown> }
|
||||
@@ -299,8 +299,12 @@ onMounted(() => {
|
||||
|
||||
// ComponentWidgetImpl.value getter/setter delegates to options.getValue/options.setValue.
|
||||
// These must be set for workflow JSON persistence (LGraphNode.serialize/configure) to work.
|
||||
props.widget.options.getValue = buildValue
|
||||
props.widget.options.setValue = applyValue
|
||||
if (props.widget.options) {
|
||||
props.widget.options.getValue = buildValue
|
||||
props.widget.options.setValue = applyValue
|
||||
} else {
|
||||
console.warn('[LoraInfoWidget] widget.options missing, value persistence disabled')
|
||||
}
|
||||
|
||||
// Also set serializeValue for prompt/API serialization path (executionUtil.ts)
|
||||
props.widget.serializeValue = async () => buildValue()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
||||
// Dynamic import type for AutoComplete class
|
||||
type AutoCompleteClass = new (
|
||||
inputElement: HTMLTextAreaElement,
|
||||
modelType: 'loras' | 'embeddings' | 'custom_words' | 'prompt',
|
||||
modelType: 'loras' | 'prompt',
|
||||
options?: AutocompleteOptions
|
||||
) => AutoCompleteInstance
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface UseAutocompleteOptions {
|
||||
|
||||
export function useAutocomplete(
|
||||
textareaRef: Ref<HTMLTextAreaElement | null>,
|
||||
modelType: 'loras' | 'embeddings' | 'custom_words' | 'prompt' = 'loras',
|
||||
modelType: 'loras' | 'prompt' = 'loras',
|
||||
options: UseAutocompleteOptions = {}
|
||||
) {
|
||||
const autocompleteInstance = ref<AutoCompleteInstance | null>(null)
|
||||
|
||||
@@ -36,6 +36,9 @@ const AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT = 300
|
||||
const AUTOCOMPLETE_METADATA_VERSION = 1
|
||||
const LORA_MANAGER_WIDGET_IDS_PROPERTY = '__lm_widget_ids'
|
||||
|
||||
// Access LiteGraph global for Vue DOM mode detection (matches AutocompleteTextWidget.vue)
|
||||
declare const LiteGraph: { vueNodesMode?: boolean } | undefined
|
||||
|
||||
// @ts-ignore - ComfyUI external module
|
||||
import { app } from '../../../scripts/app.js'
|
||||
// @ts-ignore - ComfyUI external module
|
||||
@@ -718,7 +721,7 @@ function createLoraInfoWidget(node: any) {
|
||||
function createAutocompleteTextWidgetFactory(
|
||||
node: any,
|
||||
widgetName: string,
|
||||
modelType: 'loras' | 'embeddings' | 'prompt',
|
||||
modelType: 'loras' | 'prompt',
|
||||
inputOptions: { placeholder?: string } = {}
|
||||
) {
|
||||
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`
|
||||
@@ -835,7 +838,7 @@ function createAutocompleteTextWidgetFactory(
|
||||
applyAutocompleteTextLayoutFix(
|
||||
widget,
|
||||
container,
|
||||
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
|
||||
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -964,13 +967,7 @@ app.registerExtension({
|
||||
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
|
||||
return createAutocompleteTextWidgetFactory(node, 'text', 'loras', options)
|
||||
},
|
||||
// Autocomplete text widget for embeddings (used by Prompt node)
|
||||
// @ts-ignore
|
||||
AUTOCOMPLETE_TEXT_EMBEDDINGS(node) {
|
||||
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
|
||||
return createAutocompleteTextWidgetFactory(node, 'text', 'embeddings', options)
|
||||
},
|
||||
// Autocomplete text widget for prompt (supports both embeddings and custom words)
|
||||
// Autocomplete text widget for prompt (used by Prompt and Text nodes)
|
||||
// @ts-ignore
|
||||
AUTOCOMPLETE_TEXT_PROMPT(node) {
|
||||
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
|
||||
|
||||
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.autocomplete-text-widget[data-v-3f3d7a1a] {
|
||||
.autocomplete-text-widget[data-v-55e3316e] {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input-wrapper[data-v-3f3d7a1a] {
|
||||
.input-wrapper[data-v-55e3316e] {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
|
||||
.text-input[data-v-3f3d7a1a] {
|
||||
.text-input[data-v-55e3316e] {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background-color: var(--comfy-input-bg, #222);
|
||||
@@ -2152,7 +2152,7 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
|
||||
.text-input.vue-dom-mode[data-v-3f3d7a1a] {
|
||||
.text-input.vue-dom-mode[data-v-55e3316e] {
|
||||
background-color: var(--color-charcoal-400, #313235);
|
||||
color: #fff;
|
||||
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
|
||||
@@ -2161,12 +2161,12 @@ to { transform: rotate(360deg);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.text-input[data-v-3f3d7a1a]:focus {
|
||||
.text-input[data-v-55e3316e]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Clear button styles */
|
||||
.clear-button[data-v-3f3d7a1a] {
|
||||
.clear-button[data-v-55e3316e] {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px; /* Changed from top to bottom */
|
||||
@@ -2189,31 +2189,31 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* Show clear button when hovering over input wrapper */
|
||||
.input-wrapper:hover .clear-button[data-v-3f3d7a1a] {
|
||||
.input-wrapper:hover .clear-button[data-v-55e3316e] {
|
||||
opacity: 0.7;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.clear-button[data-v-3f3d7a1a]:hover {
|
||||
.clear-button[data-v-55e3316e]:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 100, 100, 0.8);
|
||||
}
|
||||
.clear-button svg[data-v-3f3d7a1a] {
|
||||
.clear-button svg[data-v-55e3316e] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Vue DOM mode adjustments for clear button */
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-3f3d7a1a] {
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-55e3316e] {
|
||||
right: 8px;
|
||||
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(107, 114, 128, 0.6);
|
||||
}
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-3f3d7a1a]:hover {
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-55e3316e]:hover {
|
||||
background: oklch(62% 0.18 25);
|
||||
}
|
||||
.text-input.vue-dom-mode ~ .clear-button svg[data-v-3f3d7a1a] {
|
||||
.text-input.vue-dom-mode ~ .clear-button svg[data-v-55e3316e] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
@@ -2224,7 +2224,7 @@ to { transform: rotate(360deg);
|
||||
resize: vertical !important;
|
||||
}
|
||||
|
||||
.lora-info-widget[data-v-a99cc1ab] {
|
||||
.lora-info-widget[data-v-d7692b6f] {
|
||||
padding: 12px;
|
||||
background: rgba(40, 44, 52, 0.6);
|
||||
border-radius: 4px;
|
||||
@@ -2240,45 +2240,45 @@ to { transform: rotate(360deg);
|
||||
determined solely by CSS — not by descendant content. This breaks the
|
||||
feedback loop where content grows → ResizeObserver resizes → content
|
||||
reflows → repeat. Same technique used by tags_widget.js + lm_styles.css. */
|
||||
.lora-info-widget.lm-vue-node[data-v-a99cc1ab] {
|
||||
.lora-info-widget.lm-vue-node[data-v-d7692b6f] {
|
||||
contain: layout size;
|
||||
}
|
||||
|
||||
/* ── Tab bar ── */
|
||||
.lora-info-tabs[data-v-a99cc1ab] {
|
||||
.lora-info-tabs[data-v-d7692b6f] {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color, #444);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lora-info-tab[data-v-a99cc1ab] {
|
||||
.lora-info-tab[data-v-d7692b6f] {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
position: relative;
|
||||
}
|
||||
.lora-info-tab-input[data-v-a99cc1ab] {
|
||||
.lora-info-tab-input[data-v-d7692b6f] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.lora-info-tab-label[data-v-a99cc1ab] {
|
||||
.lora-info-tab-label[data-v-d7692b6f] {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.lora-info-tab:hover .lora-info-tab-label[data-v-a99cc1ab] {
|
||||
.lora-info-tab:hover .lora-info-tab-label[data-v-d7692b6f] {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.lora-info-tab.active .lora-info-tab-label[data-v-a99cc1ab] {
|
||||
.lora-info-tab.active .lora-info-tab-label[data-v-d7692b6f] {
|
||||
opacity: 1;
|
||||
}
|
||||
.lora-info-tab.active[data-v-a99cc1ab]::after {
|
||||
.lora-info-tab.active[data-v-d7692b6f]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
@@ -2290,16 +2290,16 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* ── Tab content ── */
|
||||
.tab-content[data-v-a99cc1ab] {
|
||||
.tab-content[data-v-d7692b6f] {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notes-tab[data-v-a99cc1ab] {
|
||||
.notes-tab[data-v-d7692b6f] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.description-tab[data-v-a99cc1ab] {
|
||||
.description-tab[data-v-d7692b6f] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
@@ -2307,12 +2307,12 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* ── Info fields (shared) ── */
|
||||
.info-field[data-v-a99cc1ab] {
|
||||
.info-field[data-v-d7692b6f] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.info-label[data-v-a99cc1ab] {
|
||||
.info-label[data-v-d7692b6f] {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -2320,7 +2320,7 @@ to { transform: rotate(360deg);
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.lora-filename[data-v-a99cc1ab] {
|
||||
.lora-filename[data-v-d7692b6f] {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-color, #fff);
|
||||
@@ -2331,11 +2331,11 @@ to { transform: rotate(360deg);
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
.notes-field[data-v-a99cc1ab] {
|
||||
.notes-field[data-v-d7692b6f] {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.lora-notes[data-v-a99cc1ab] {
|
||||
.lora-notes[data-v-d7692b6f] {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
@@ -2350,14 +2350,14 @@ to { transform: rotate(360deg);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.lora-notes[data-v-a99cc1ab]:focus {
|
||||
.lora-notes[data-v-d7692b6f]:focus {
|
||||
border-color: var(--comfy-input-border, #444);
|
||||
}
|
||||
.lora-notes[data-v-a99cc1ab]:disabled {
|
||||
.lora-notes[data-v-d7692b6f]:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.save-btn[data-v-a99cc1ab] {
|
||||
.save-btn[data-v-d7692b6f] {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
@@ -2371,11 +2371,11 @@ to { transform: rotate(360deg);
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.save-btn[data-v-a99cc1ab]:hover:not(:disabled) {
|
||||
.save-btn[data-v-d7692b6f]:hover:not(:disabled) {
|
||||
background: rgba(66, 153, 225, 0.25);
|
||||
border-color: rgba(66, 153, 225, 0.6);
|
||||
}
|
||||
.save-btn[data-v-a99cc1ab]:disabled {
|
||||
.save-btn[data-v-d7692b6f]:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
background: rgba(66, 153, 225, 0.05);
|
||||
@@ -2383,7 +2383,7 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* ── Description states ── */
|
||||
.description-state[data-v-a99cc1ab] {
|
||||
.description-state[data-v-d7692b6f] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -2395,22 +2395,22 @@ to { transform: rotate(360deg);
|
||||
min-height: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.description-state.error[data-v-a99cc1ab] {
|
||||
.description-state.error[data-v-d7692b6f] {
|
||||
opacity: 0.7;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* ── Description content ── */
|
||||
.description-content[data-v-a99cc1ab] {
|
||||
.description-content[data-v-d7692b6f] {
|
||||
min-height: 0;
|
||||
}
|
||||
.description-section[data-v-a99cc1ab] {
|
||||
.description-section[data-v-d7692b6f] {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.description-section[data-v-a99cc1ab]:last-child {
|
||||
.description-section[data-v-d7692b6f]:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.description-text[data-v-a99cc1ab] {
|
||||
.description-text[data-v-d7692b6f] {
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
@@ -2422,41 +2422,41 @@ to { transform: rotate(360deg);
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
.description-text[data-v-a99cc1ab] p {
|
||||
.description-text[data-v-d7692b6f] p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.description-text[data-v-a99cc1ab] p:last-child {
|
||||
.description-text[data-v-d7692b6f] p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.description-text[data-v-a99cc1ab] a {
|
||||
.description-text[data-v-d7692b6f] a {
|
||||
color: rgba(66, 153, 225, 0.9);
|
||||
}
|
||||
.description-text[data-v-a99cc1ab] ul,
|
||||
.description-text[data-v-a99cc1ab] ol {
|
||||
.description-text[data-v-d7692b6f] ul,
|
||||
.description-text[data-v-d7692b6f] ol {
|
||||
padding-left: 20px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.description-text[data-v-a99cc1ab] h1,
|
||||
.description-text[data-v-a99cc1ab] h2,
|
||||
.description-text[data-v-a99cc1ab] h3 {
|
||||
.description-text[data-v-d7692b6f] h1,
|
||||
.description-text[data-v-d7692b6f] h2,
|
||||
.description-text[data-v-d7692b6f] h3 {
|
||||
font-size: 13px;
|
||||
margin: 10px 0 4px 0;
|
||||
font-weight: 600;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.description-text[data-v-a99cc1ab] code {
|
||||
.description-text[data-v-d7692b6f] code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.description-text[data-v-a99cc1ab] img {
|
||||
.description-text[data-v-d7692b6f] img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Placeholder (shared) ── */
|
||||
.placeholder[data-v-a99cc1ab] {
|
||||
.placeholder[data-v-d7692b6f] {
|
||||
font-style: italic;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
text-align: center;
|
||||
@@ -2465,10 +2465,10 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* ── Spinner (Font Awesome) ── */
|
||||
.fa-spinner[data-v-a99cc1ab] {
|
||||
animation: fa-spin-a99cc1ab 1s linear infinite;
|
||||
.fa-spinner[data-v-d7692b6f] {
|
||||
animation: fa-spin-d7692b6f 1s linear infinite;
|
||||
}
|
||||
@keyframes fa-spin-a99cc1ab {
|
||||
@keyframes fa-spin-d7692b6f {
|
||||
0% { transform: rotate(0deg);
|
||||
}
|
||||
100% { transform: rotate(360deg);
|
||||
@@ -15316,7 +15316,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
|
||||
};
|
||||
}
|
||||
});
|
||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-3f3d7a1a"]]);
|
||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-55e3316e"]]);
|
||||
const _hoisted_1 = { class: "lora-info-tabs" };
|
||||
const _hoisted_2 = { class: "tab-content notes-tab" };
|
||||
const _hoisted_3 = { class: "info-field" };
|
||||
@@ -15511,8 +15511,12 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
if (data.filePath !== void 0) filePath.value = data.filePath;
|
||||
}
|
||||
};
|
||||
props.widget.options.getValue = buildValue;
|
||||
props.widget.options.setValue = applyValue;
|
||||
if (props.widget.options) {
|
||||
props.widget.options.getValue = buildValue;
|
||||
props.widget.options.setValue = applyValue;
|
||||
} else {
|
||||
console.warn("[LoraInfoWidget] widget.options missing, value persistence disabled");
|
||||
}
|
||||
props.widget.serializeValue = async () => buildValue();
|
||||
props.widget.onSetValue = applyValue;
|
||||
const widgetIndex = (_b = (_a2 = props.widget.node) == null ? void 0 : _a2.widgets) == null ? void 0 : _b.findIndex(
|
||||
@@ -15641,7 +15645,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
};
|
||||
}
|
||||
});
|
||||
const LoraInfoWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-a99cc1ab"]]);
|
||||
const LoraInfoWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-d7692b6f"]]);
|
||||
function createVueWidgetCleanup(vueApp, onCleanup) {
|
||||
let didUnmount = false;
|
||||
return () => {
|
||||
@@ -16637,7 +16641,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
|
||||
applyAutocompleteTextLayoutFix(
|
||||
widget,
|
||||
container,
|
||||
typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode
|
||||
typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode === true
|
||||
);
|
||||
}
|
||||
const vueCleanup = createVueWidgetCleanup(vueApp, () => {
|
||||
@@ -16747,13 +16751,7 @@ app$1.registerExtension({
|
||||
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
|
||||
return createAutocompleteTextWidgetFactory(node, "text", "loras", options);
|
||||
},
|
||||
// Autocomplete text widget for embeddings (used by Prompt node)
|
||||
// @ts-ignore
|
||||
AUTOCOMPLETE_TEXT_EMBEDDINGS(node) {
|
||||
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
|
||||
return createAutocompleteTextWidgetFactory(node, "text", "embeddings", options);
|
||||
},
|
||||
// Autocomplete text widget for prompt (supports both embeddings and custom words)
|
||||
// Autocomplete text widget for prompt (used by Prompt and Text nodes)
|
||||
// @ts-ignore
|
||||
AUTOCOMPLETE_TEXT_PROMPT(node) {
|
||||
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user