Compare commits

...

37 Commits

Author SHA1 Message Date
Will Miao
59b1abb719 Update version to 0.8.4 and add release notes for node layout improvements and bug fixes 2025-04-07 14:49:34 +08:00
Will Miao
3e2cfb552b Refactor image saving logic for batch processing and unique filename generation. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/79 2025-04-07 14:37:39 +08:00
Will Miao
779be1b8d0 Refactor loras_widget styles for improved layout consistency 2025-04-07 13:42:31 +08:00
Will Miao
faf74de238 Enhance model move functionality with detailed error handling and user feedback 2025-04-07 11:14:56 +08:00
Will Miao
50a51c2e79 Refactor Lora widget and dynamic module loading
- Updated lora_loader.js to dynamically import the appropriate loras widget based on ComfyUI version, enhancing compatibility and maintainability.
- Enhanced loras_widget.js with improved height management and styling for better user experience.
- Introduced utility functions in utils.js for version checking and dynamic imports, streamlining widget loading processes.
- Improved overall structure and readability of the code, ensuring better performance and easier future updates.
2025-04-07 09:02:36 +08:00
Will Miao
d31e641496 Add dynamic tags widget selection based on ComfyUI version
- Introduced a mechanism to dynamically import either the legacy or modern tags widget based on the ComfyUI frontend version.
- Updated the `addTagsWidget` function in both `tags_widget.js` and `legacy_tags_widget.js` to enhance tag rendering and widget height management.
- Improved styling and layout for tags, ensuring better alignment and responsiveness.
- Added a new serialization method to handle potential issues with ComfyUI's serialization process.
- Enhanced the overall user experience by providing a more modern and flexible tags widget implementation.
2025-04-07 08:42:20 +08:00
Will Miao
f2d36f5be9 Refactor DownloadManager and LoraFileHandler for improved file monitoring
- Simplified the path handling in DownloadManager by directly adding normalized paths to the ignore list.
- Updated LoraFileHandler to utilize a set for ignore paths, enhancing performance and clarity.
- Implemented debouncing for modified file events to prevent duplicate processing and improve efficiency.
- Enhanced the handling of file creation, modification, and deletion events for .safetensors files, ensuring accurate processing and logging.
- Adjusted cache operations to streamline the addition and removal of files based on real paths.
2025-04-06 22:27:55 +08:00
Will Miao
0b55f61fac Refactor LoraFileHandler to use real file paths for monitoring
- Updated the file monitoring logic to store and verify real file paths instead of mapped paths, ensuring accurate existence checks.
- Enhanced logging for error handling and processing actions, including detailed error messages with exception info.
- Adjusted cache operations to reflect the use of normalized paths for consistency in add/remove actions.
- Improved handling of ignore paths by removing successfully processed files from the ignore list.
2025-04-05 12:10:46 +08:00
pixelpaws
4156dcbafd Merge pull request #83 from willmiao/dev
Dev
2025-04-05 05:28:22 +08:00
Will Miao
36e6ac2362 Add CheckpointMetadata class for enhanced model metadata management
- Introduced a new CheckpointMetadata dataclass to encapsulate metadata for checkpoint models.
- Included fields for file details, model specifications, and additional attributes such as resolution and architecture.
- Implemented a __post_init__ method to initialize tags as an empty list if not provided, ensuring consistent data handling.
2025-04-05 05:16:52 +08:00
Will Miao
9613199152 Enhance SaveImage functionality with custom prompt support
- Added a new optional parameter `custom_prompt` to the SaveImage class methods to allow users to override the default prompt.
- Updated the `format_metadata` method to utilize the custom prompt if provided.
- Modified the `save_images` and `process_image` methods to accept and pass the custom prompt through the workflow processing.
2025-04-04 07:47:46 +08:00
pixelpaws
14328d7496 Merge pull request #77 from willmiao/dev
Add reconnect functionality for deleted LoRAs in recipe modal
2025-04-03 16:56:04 +08:00
Will Miao
6af12d1acc Add reconnect functionality for deleted LoRAs in recipe modal
- Introduced a new API endpoint to reconnect deleted LoRAs to local files.
- Updated RecipeModal to include UI elements for reconnecting LoRAs, including input fields and buttons.
- Enhanced CSS styles for deleted badges and reconnect containers to improve user experience.
- Implemented event handling for reconnect actions, including input validation and API calls.
- Updated recipe data handling to reflect changes after reconnecting LoRAs.
2025-04-03 16:55:19 +08:00
pixelpaws
9b44e49879 Merge pull request #75 from willmiao/dev
Enhance file monitoring for LoRA files
2025-04-03 11:10:29 +08:00
Will Miao
afee18f146 Enhance file monitoring for LoRA files
- Added a method to map symbolic links back to actual paths in the Config class.
- Improved file creation handling in LoraFileHandler to check for file size and existence before processing.
- Introduced handling for file modification events to update the ignore list and schedule updates.
- Increased debounce delay in _process_changes to allow for file downloads to complete.
- Enhanced action processing to prioritize 'add' actions and verify file existence before adding to cache.
2025-04-03 11:09:30 +08:00
Will Miao
f007369a66 Bump version to v0.8.3 2025-04-02 20:18:51 +08:00
pixelpaws
9a9c166dbe Merge pull request #74 from willmiao/dev
Dev
2025-04-02 20:15:11 +08:00
Will Miao
2f90e32dbf Delete unused files 2025-04-02 20:11:41 +08:00
Will Miao
26355ccb79 chore: remove .vscode from git 2025-04-02 20:09:58 +08:00
Will Miao
27ea3c0c8e chore: add .vscode to gitignore 2025-04-02 20:09:08 +08:00
Will Miao
5aa35b211a Update README and update_logs 2025-04-02 20:03:18 +08:00
Will Miao
92450385d2 Update README 2025-04-02 20:00:04 +08:00
Will Miao
8d15e23f3c Add markdown support for changelog in modal
- Introduced a simple markdown parser to convert markdown syntax in changelog items to HTML.
- Updated modal CSS to style markdown elements, enhancing the presentation of changelog items.
- Improved user experience by allowing formatted text in changelog, including bold, italic, code, and links.
2025-04-02 19:36:52 +08:00
Will Miao
73686d4146 Enhance modal and settings functionality with default LoRA root selection
- Updated modal styles for improved layout and added select control for default LoRA root.
- Modified DownloadManager, ImportManager, MoveManager, and SettingsManager to retrieve and set the default LoRA root from storage.
- Introduced asynchronous loading of LoRA roots in SettingsManager to dynamically populate the select options.
- Improved user experience by allowing users to set a default LoRA root for downloads, imports, and moves.
2025-04-02 17:37:16 +08:00
Will Miao
0499ca1300 Update process_node function to ignore type checking
- Added a type: ignore comment to the process_node function to suppress type checking errors.
- Removed the README.md file as it is no longer needed.
2025-04-02 17:02:11 +08:00
Will Miao
234c942f34 Refactor transform functions and update node mappers
- Moved and redefined transform functions for KSampler, EmptyLatentImage, CLIPTextEncode, and FluxGuidance to improve organization and maintainability.
- Updated NODE_MAPPERS to include new input tracking for clip_skip in KSampler and added new transform functions for LatentUpscale and CLIPSetLastLayer.
- Enhanced the transform_sampler_custom_advanced function to handle clip_skip extraction from model inputs.
2025-04-02 17:01:10 +08:00
Will Miao
aec218ba00 Enhance SaveImage class with filename formatting and multiple image support
- Updated the INPUT_TYPES to accept multiple images and modified the corresponding processing methods.
- Introduced a new format_filename method to handle dynamic filename generation using metadata patterns.
- Replaced save_workflow_json with embed_workflow for better clarity in saving workflow metadata.
- Improved directory handling and filename generation logic to ensure proper file saving.
2025-04-02 15:08:36 +08:00
Will Miao
b508f51fcf checkpoint 2025-04-02 14:13:53 +08:00
Will Miao
435628ea59 Refactor WorkflowParser by removing unused methods 2025-04-02 14:13:24 +08:00
Will Miao
4933dbfb87 Refactor ExifUtils by removing unused methods and imports
- Removed the extract_user_comment and update_user_comment methods to streamline the ExifUtils class.
- Cleaned up unnecessary imports and reduced code complexity, focusing on essential functionality for image metadata extraction.
2025-04-02 11:14:05 +08:00
Will Miao
5a93c40b79 Refactor logging levels and improve mapper registration
- Changed warning logs to debug logs in CivitaiClient and RecipeScanner for better log granularity.
- Updated the mapper registration function name for clarity and adjusted related logging messages.
- Enhanced extension loading process to automatically register mappers from NODE_MAPPERS_EXT, improving modularity and maintainability.
2025-04-02 10:29:31 +08:00
Will Miao
a8ec5af037 checkpoint 2025-04-02 06:05:24 +08:00
Will Miao
27db60ce68 checkpoint 2025-04-01 19:17:43 +08:00
Will Miao
195866b00d Implement KJNodes extension with new mappers and transform functions
- Added KJNodes mappers for JoinStrings, StringConstantMultiline, and EmptyLatentImagePresets.
- Introduced transform functions to handle string joining, string constants, and dimension extraction with optional inversion.
- Registered new mappers and logged successful registration for better traceability.
2025-04-01 16:22:57 +08:00
Will Miao
60575b6546 checkpoint 2025-04-01 08:38:49 +08:00
pixelpaws
350b81d678 Merge pull request #64 from richardhristov/main
Remember sort by name/date in LoRAs page
2025-03-31 20:16:29 +08:00
Richard Hristov
e7871bf843 Remember sort by name/date in LoRAs page 2025-03-29 17:11:53 +02:00
47 changed files with 4404 additions and 1837 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
__pycache__/
settings.json
output/*
py/run_test.py
py/run_test.py
.vscode/

View File

@@ -20,6 +20,18 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
## Release Notes
### v0.8.4
* **Node Layout Improvements** - Fixed layout issues with LoRA Loader and Trigger Words Toggle nodes in newer ComfyUI frontend versions
* **Recipe LoRA Reconnection** - Added ability to reconnect deleted LoRAs in recipes by clicking the "deleted" badge in recipe details
* **Bug Fixes & Stability** - Resolved various issues for improved reliability
### v0.8.3
* **Enhanced Workflow Parser** - Rebuilt workflow analysis engine with improved support for ComfyUI core nodes and easier extensibility
* **Improved Recipe System** - Refined the experimental Save Recipe functionality with better workflow integration
* **New Save Image Node** - Added experimental node with metadata support for perfect CivitAI compatibility
* Supports dynamic filename prefixes with variables [1](https://github.com/nkchocoai/ComfyUI-SaveImageWithMetaData?tab=readme-ov-file#filename_prefix)
* **Default LoRA Root Setting** - Added configuration option for setting your preferred LoRA directory
### v0.8.2
* **Faster Initialization for Forge Users** - Improved first-run efficiency by utilizing existing `.json` and `.civitai.info` files from Forges CivitAI helper extension, making migration smoother.
* **LoRA Filename Editing** - Added support for renaming LoRA files directly within LoRA Manager.
@@ -42,52 +54,6 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
* **Enhanced UI & UX** - Improved interface design and user experience
* **Bug Fixes & Stability** - Resolved various issues and enhanced overall performance
### v0.7.37
* Added NSFW content control settings (blur mature content and SFW-only filter)
* Implemented intelligent blur effects for previews and showcase media
* Added manual content rating option through context menu
* Enhanced user experience with configurable content visibility
* Fixed various bugs and improved stability
### v0.7.36
* Enhanced LoRA details view with model descriptions and tags display
* Added tag filtering system for improved model discovery
* Implemented editable trigger words functionality
* Improved TriggerWord Toggle node with new group mode option for granular control
* Added new Lora Stacker node with cross-compatibility support (works with efficiency nodes, ComfyRoll, easy-use, etc.)
* Fixed several bugs
### v0.7.35-beta
* Added base model filtering
* Implemented bulk operations (copy syntax, move multiple LoRAs)
* Added ability to edit LoRA model names in details view
* Added update checker with notification system
* Added support modal for user feedback and community links
### v0.7.33
* Enhanced LoRA Loader node with visual strength adjustment widgets
* Added toggle switches for LoRA enable/disable
* Implemented image tooltips for LoRA preview
* Added TriggerWord Toggle node with visual word selection
* Fixed various bugs and improved stability
### v0.7.3
* Added "Lora Loader (LoraManager)" custom node for workflows
* Implemented one-click LoRA integration
* Added direct copying of LoRA syntax from manager interface
* Added automatic preset strength value application
* Added automatic trigger word loading
### v0.7.0
* Added direct CivitAI integration for downloading LoRAs
* Implemented version selection for model downloads
* Added target folder selection for downloads
* Added context menu with quick actions
* Added force refresh for CivitAI data
* Implemented LoRA movement between folders
* Added personal usage tips and notes for LoRAs
* Improved performance for details window
[View Update History](./update_logs.md)
---
@@ -171,6 +137,15 @@ pip install requirements.txt
---
## Credits
This project has been inspired by and benefited from other excellent ComfyUI extensions:
- [ComfyUI-SaveImageWithMetaData](https://github.com/Comfy-Community/ComfyUI-SaveImageWithMetaData) - For the image metadata functionality
- [rgthree-comfy](https://github.com/rgthree/rgthree-comfy) - For the lora loader functionality
---
## Contributing
If you have suggestions, bug reports, or improvements, feel free to open an issue or contribute directly to the codebase. Pull requests are always welcome!

View File

@@ -2,13 +2,13 @@ from .py.lora_manager import LoraManager
from .py.nodes.lora_loader import LoraManagerLoader
from .py.nodes.trigger_word_toggle import TriggerWordToggle
from .py.nodes.lora_stacker import LoraStacker
# from .py.nodes.save_image import SaveImage
from .py.nodes.save_image import SaveImage
NODE_CLASS_MAPPINGS = {
LoraManagerLoader.NAME: LoraManagerLoader,
TriggerWordToggle.NAME: TriggerWordToggle,
LoraStacker.NAME: LoraStacker,
# SaveImage.NAME: SaveImage
SaveImage.NAME: SaveImage
}
WEB_DIRECTORY = "./web/comfyui"

View File

@@ -85,6 +85,17 @@ class Config:
mapped_path = normalized_path.replace(target_path, link_path, 1)
return mapped_path
return path
def map_link_to_path(self, link_path: str) -> str:
"""将符号链接路径映射回实际路径"""
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
# 检查路径是否包含在任何映射的目标路径中
for target_path, link_path in self._path_mappings.items():
if normalized_link.startswith(target_path):
# 如果路径以目标路径开头,则替换为实际路径
mapped_path = normalized_link.replace(target_path, link_path, 1)
return mapped_path
return link_path
def _init_lora_paths(self) -> List[str]:
"""Initialize and validate LoRA paths from ComfyUI settings"""

View File

@@ -1,16 +1,44 @@
import json
from server import PromptServer # type: ignore
import os
import asyncio
import re
import numpy as np
import folder_paths # type: ignore
from ..services.lora_scanner import LoraScanner
from ..workflow.parser import WorkflowParser
from PIL import Image, PngImagePlugin
import piexif
from io import BytesIO
class SaveImage:
NAME = "Save Image (LoraManager)"
CATEGORY = "Lora Manager/utils"
DESCRIPTION = "Experimental node to display image preview and print prompt and extra_pnginfo"
DESCRIPTION = "Save images with embedded generation metadata in compatible format"
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.prefix_append = ""
self.compress_level = 4
self.counter = 0
# Add pattern format regex for filename substitution
pattern_format = re.compile(r"(%[^%]+%)")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"images": ("IMAGE",),
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
"file_format": (["png", "jpeg", "webp"],),
},
"optional": {
"custom_prompt": ("STRING", {"default": "", "forceInput": True}),
"lossless_webp": ("BOOLEAN", {"default": True}),
"quality": ("INT", {"default": 100, "min": 1, "max": 100}),
"embed_workflow": ("BOOLEAN", {"default": False}),
"add_counter_to_filename": ("BOOLEAN", {"default": True}),
},
"hidden": {
"prompt": "PROMPT",
@@ -19,23 +47,329 @@ class SaveImage:
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
RETURN_NAMES = ("images",)
FUNCTION = "process_image"
OUTPUT_NODE = True
def process_image(self, image, prompt=None, extra_pnginfo=None):
# Print the prompt information
print("SaveImage Node - Prompt:")
async def get_lora_hash(self, lora_name):
"""Get the lora hash from cache"""
scanner = await LoraScanner.get_instance()
cache = await scanner.get_cached_data()
for item in cache.raw_data:
if item.get('file_name') == lora_name:
return item.get('sha256')
return None
async def format_metadata(self, parsed_workflow, custom_prompt=None):
"""Format metadata in the requested format similar to userComment example"""
if not parsed_workflow:
return ""
# Extract the prompt and negative prompt
prompt = parsed_workflow.get('prompt', '')
negative_prompt = parsed_workflow.get('negative_prompt', '')
# Override prompt with custom_prompt if provided
if custom_prompt:
prompt = custom_prompt
# Extract loras from the prompt if present
loras_text = parsed_workflow.get('loras', '')
lora_hashes = {}
# If loras are found, add them on a new line after the prompt
if loras_text:
prompt_with_loras = f"{prompt}\n{loras_text}"
# Extract lora names from the format <lora:name:strength>
lora_matches = re.findall(r'<lora:([^:]+):([^>]+)>', loras_text)
# Get hash for each lora
for lora_name, strength in lora_matches:
hash_value = await self.get_lora_hash(lora_name)
if hash_value:
lora_hashes[lora_name] = hash_value
else:
prompt_with_loras = prompt
# Format the first part (prompt and loras)
metadata_parts = [prompt_with_loras]
# Add negative prompt
if negative_prompt:
metadata_parts.append(f"Negative prompt: {negative_prompt}")
# Format the second part (generation parameters)
params = []
# Add standard parameters in the correct order
if 'steps' in parsed_workflow:
params.append(f"Steps: {parsed_workflow.get('steps')}")
if 'sampler' in parsed_workflow:
sampler = parsed_workflow.get('sampler')
# Convert ComfyUI sampler names to user-friendly names
sampler_mapping = {
'euler': 'Euler',
'euler_ancestral': 'Euler a',
'dpm_2': 'DPM2',
'dpm_2_ancestral': 'DPM2 a',
'heun': 'Heun',
'dpm_fast': 'DPM fast',
'dpm_adaptive': 'DPM adaptive',
'lms': 'LMS',
'dpmpp_2s_ancestral': 'DPM++ 2S a',
'dpmpp_sde': 'DPM++ SDE',
'dpmpp_sde_gpu': 'DPM++ SDE',
'dpmpp_2m': 'DPM++ 2M',
'dpmpp_2m_sde': 'DPM++ 2M SDE',
'dpmpp_2m_sde_gpu': 'DPM++ 2M SDE',
'ddim': 'DDIM'
}
sampler_name = sampler_mapping.get(sampler, sampler)
params.append(f"Sampler: {sampler_name}")
if 'scheduler' in parsed_workflow:
scheduler = parsed_workflow.get('scheduler')
scheduler_mapping = {
'normal': 'Simple',
'karras': 'Karras',
'exponential': 'Exponential',
'sgm_uniform': 'SGM Uniform',
'sgm_quadratic': 'SGM Quadratic'
}
scheduler_name = scheduler_mapping.get(scheduler, scheduler)
params.append(f"Schedule type: {scheduler_name}")
# CFG scale (cfg in parsed_workflow)
if 'cfg_scale' in parsed_workflow:
params.append(f"CFG scale: {parsed_workflow.get('cfg_scale')}")
elif 'cfg' in parsed_workflow:
params.append(f"CFG scale: {parsed_workflow.get('cfg')}")
# Seed
if 'seed' in parsed_workflow:
params.append(f"Seed: {parsed_workflow.get('seed')}")
# Size
if 'size' in parsed_workflow:
params.append(f"Size: {parsed_workflow.get('size')}")
# Model info
if 'checkpoint' in parsed_workflow:
# Extract basename without path
checkpoint = os.path.basename(parsed_workflow.get('checkpoint', ''))
# Remove extension if present
checkpoint = os.path.splitext(checkpoint)[0]
params.append(f"Model: {checkpoint}")
# Add LoRA hashes if available
if lora_hashes:
lora_hash_parts = []
for lora_name, hash_value in lora_hashes.items():
lora_hash_parts.append(f"{lora_name}: {hash_value}")
if lora_hash_parts:
params.append(f"Lora hashes: \"{', '.join(lora_hash_parts)}\"")
# Combine all parameters with commas
metadata_parts.append(", ".join(params))
# Join all parts with a new line
return "\n".join(metadata_parts)
# credit to nkchocoai
# Add format_filename method to handle pattern substitution
def format_filename(self, filename, parsed_workflow):
"""Format filename with metadata values"""
if not parsed_workflow:
return filename
result = re.findall(self.pattern_format, filename)
for segment in result:
parts = segment.replace("%", "").split(":")
key = parts[0]
if key == "seed" and 'seed' in parsed_workflow:
filename = filename.replace(segment, str(parsed_workflow.get('seed', '')))
elif key == "width" and 'size' in parsed_workflow:
size = parsed_workflow.get('size', 'x')
w = size.split('x')[0] if isinstance(size, str) else size[0]
filename = filename.replace(segment, str(w))
elif key == "height" and 'size' in parsed_workflow:
size = parsed_workflow.get('size', 'x')
h = size.split('x')[1] if isinstance(size, str) else size[1]
filename = filename.replace(segment, str(h))
elif key == "pprompt" and 'prompt' in parsed_workflow:
prompt = parsed_workflow.get('prompt', '').replace("\n", " ")
if len(parts) >= 2:
length = int(parts[1])
prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip())
elif key == "nprompt" and 'negative_prompt' in parsed_workflow:
prompt = parsed_workflow.get('negative_prompt', '').replace("\n", " ")
if len(parts) >= 2:
length = int(parts[1])
prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip())
elif key == "model" and 'checkpoint' in parsed_workflow:
model = parsed_workflow.get('checkpoint', '')
model = os.path.splitext(os.path.basename(model))[0]
if len(parts) >= 2:
length = int(parts[1])
model = model[:length]
filename = filename.replace(segment, model)
elif key == "date":
from datetime import datetime
now = datetime.now()
date_table = {
"yyyy": str(now.year),
"MM": str(now.month).zfill(2),
"dd": str(now.day).zfill(2),
"hh": str(now.hour).zfill(2),
"mm": str(now.minute).zfill(2),
"ss": str(now.second).zfill(2),
}
if len(parts) >= 2:
date_format = parts[1]
for k, v in date_table.items():
date_format = date_format.replace(k, v)
filename = filename.replace(segment, date_format)
else:
date_format = "yyyyMMddhhmmss"
for k, v in date_table.items():
date_format = date_format.replace(k, v)
filename = filename.replace(segment, date_format)
return filename
def save_images(self, images, filename_prefix, file_format, prompt=None, extra_pnginfo=None,
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True,
custom_prompt=None):
"""Save images with metadata"""
results = []
# Parse the workflow using the WorkflowParser
parser = WorkflowParser()
if prompt:
print(json.dumps(prompt, indent=2))
parsed_workflow = parser.parse_workflow(prompt)
else:
print("No prompt information available")
parsed_workflow = {}
# Get or create metadata asynchronously
metadata = asyncio.run(self.format_metadata(parsed_workflow, custom_prompt))
# Print the extra_pnginfo
print("\nSaveImage Node - Extra PNG Info:")
if extra_pnginfo:
print(json.dumps(extra_pnginfo, indent=2))
else:
print("No extra PNG info available")
# Process filename_prefix with pattern substitution
filename_prefix = self.format_filename(filename_prefix, parsed_workflow)
# Return the image unchanged
return (image,)
# Get initial save path info once for the batch
full_output_folder, filename, counter, subfolder, processed_prefix = folder_paths.get_save_image_path(
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
)
# Create directory if it doesn't exist
if not os.path.exists(full_output_folder):
os.makedirs(full_output_folder, exist_ok=True)
# Process each image with incrementing counter
for i, image in enumerate(images):
# Convert the tensor image to numpy array
img = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
# Generate filename with counter if needed
base_filename = filename
if add_counter_to_filename:
# Use counter + i to ensure unique filenames for all images in batch
current_counter = counter + i
base_filename += f"_{current_counter:05}"
# Set file extension and prepare saving parameters
if file_format == "png":
file = base_filename + ".png"
file_extension = ".png"
save_kwargs = {"optimize": True, "compress_level": self.compress_level}
pnginfo = PngImagePlugin.PngInfo()
elif file_format == "jpeg":
file = base_filename + ".jpg"
file_extension = ".jpg"
save_kwargs = {"quality": quality, "optimize": True}
elif file_format == "webp":
file = base_filename + ".webp"
file_extension = ".webp"
save_kwargs = {"quality": quality, "lossless": lossless_webp}
# Full save path
file_path = os.path.join(full_output_folder, file)
# Save the image with metadata
try:
if file_format == "png":
if metadata:
pnginfo.add_text("parameters", metadata)
if embed_workflow and extra_pnginfo is not None:
workflow_json = json.dumps(extra_pnginfo["workflow"])
pnginfo.add_text("workflow", workflow_json)
save_kwargs["pnginfo"] = pnginfo
img.save(file_path, format="PNG", **save_kwargs)
elif file_format == "jpeg":
# For JPEG, use piexif
if metadata:
try:
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes
except Exception as e:
print(f"Error adding EXIF data: {e}")
img.save(file_path, format="JPEG", **save_kwargs)
elif file_format == "webp":
# For WebP, also use piexif for metadata
if metadata:
try:
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes
except Exception as e:
print(f"Error adding EXIF data: {e}")
img.save(file_path, format="WEBP", **save_kwargs)
results.append({
"filename": file,
"subfolder": subfolder,
"type": self.type
})
except Exception as e:
print(f"Error saving image: {e}")
return results
def process_image(self, images, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True,
custom_prompt=""):
"""Process and save image with metadata"""
# Make sure the output directory exists
os.makedirs(self.output_dir, exist_ok=True)
# Ensure images is always a list of images
if len(images.shape) == 3: # Single image (height, width, channels)
images = [images]
else: # Multiple images (batch, height, width, channels)
images = [img for img in images]
# Save all images
results = self.save_images(
images,
filename_prefix,
file_format,
prompt,
extra_pnginfo,
lossless_webp,
quality,
embed_workflow,
add_counter_to_filename,
custom_prompt if custom_prompt.strip() else None
)
return (images,)

View File

@@ -667,12 +667,28 @@ class ApiRoutes:
"""Handle model move request"""
try:
data = await request.json()
file_path = data.get('file_path')
target_path = data.get('target_path')
file_path = data.get('file_path') # full path of the model file, e.g. /path/to/model.safetensors
target_path = data.get('target_path') # folder path to move the model to, e.g. /path/to/target_folder
if not file_path or not target_path:
return web.Response(text='File path and target path are required', status=400)
# Check if source and destination are the same
source_dir = os.path.dirname(file_path)
if os.path.normpath(source_dir) == os.path.normpath(target_path):
logger.info(f"Source and target directories are the same: {source_dir}")
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
# Check if target file already exists
file_name = os.path.basename(file_path)
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
if os.path.exists(target_file_path):
return web.json_response({
'success': False,
'error': f"Target file already exists: {target_file_path}"
}, status=409) # 409 Conflict
# Call scanner to handle the move operation
success = await self.scanner.move_model(file_path, target_path)
@@ -821,33 +837,55 @@ class ApiRoutes:
"""Handle bulk model move request"""
try:
data = await request.json()
file_paths = data.get('file_paths', [])
target_path = data.get('target_path')
file_paths = data.get('file_paths', []) # list of full paths of the model files, e.g. ["/path/to/model1.safetensors", "/path/to/model2.safetensors"]
target_path = data.get('target_path') # folder path to move the models to, e.g. "/path/to/target_folder"
if not file_paths or not target_path:
return web.Response(text='File paths and target path are required', status=400)
results = []
for file_path in file_paths:
# Check if source and destination are the same
source_dir = os.path.dirname(file_path)
if os.path.normpath(source_dir) == os.path.normpath(target_path):
results.append({
"path": file_path,
"success": True,
"message": "Source and target directories are the same"
})
continue
# Check if target file already exists
file_name = os.path.basename(file_path)
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
if os.path.exists(target_file_path):
results.append({
"path": file_path,
"success": False,
"message": f"Target file already exists: {target_file_path}"
})
continue
# Try to move the model
success = await self.scanner.move_model(file_path, target_path)
results.append({"path": file_path, "success": success})
results.append({
"path": file_path,
"success": success,
"message": "Success" if success else "Failed to move model"
})
# Count successes
# Count successes and failures
success_count = sum(1 for r in results if r["success"])
failure_count = len(results) - success_count
if success_count == len(file_paths):
return web.json_response({
'success': True,
'message': f'Successfully moved {success_count} models'
})
elif success_count > 0:
return web.json_response({
'success': True,
'message': f'Moved {success_count} of {len(file_paths)} models',
'results': results
})
else:
return web.Response(text='Failed to move any models', status=500)
return web.json_response({
'success': True,
'message': f'Moved {success_count} of {len(file_paths)} models',
'results': results,
'success_count': success_count,
'failure_count': failure_count
})
except Exception as e:
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
@@ -962,7 +1000,7 @@ class ApiRoutes:
'base_models': base_models
})
except Exception as e:
logger.error(f"Error retrieving base models: {e}", exc_info=True)
logger.error(f"Error retrieving base models: {e}")
return web.json_response({
'success': False,
'error': str(e)

View File

@@ -89,7 +89,7 @@ class LoraRoutes:
settings=settings, # Pass settings to template
request=request # Pass the request object to the template
)
logger.info(f"Loras page loaded successfully with {len(cache.raw_data)} items")
logger.debug(f"Loras page loaded successfully with {len(cache.raw_data)} items")
except Exception as cache_error:
logger.error(f"Error loading cache data: {cache_error}")
# 如果获取缓存失败,也显示初始化页面

View File

@@ -53,6 +53,9 @@ class RecipeRoutes:
# Add new endpoint for updating recipe metadata (name and tags)
app.router.add_put('/api/recipe/{recipe_id}/update', routes.update_recipe)
# Add new endpoint for reconnecting deleted LoRAs
app.router.add_post('/api/recipe/lora/reconnect', routes.reconnect_lora)
# Start cache initialization
app.on_startup.append(routes._init_cache)
@@ -762,7 +765,7 @@ class RecipeRoutes:
return web.json_response({"error": "Invalid workflow JSON"}, status=400)
if not workflow_json:
return web.json_response({"error": "Missing required workflow_json field"}, status=400)
return web.json_response({"error": "Missing workflow JSON"}, status=400)
# Find the latest image in the temp directory
temp_dir = config.temp_directory
@@ -783,8 +786,8 @@ class RecipeRoutes:
# Parse the workflow to extract generation parameters and loras
parsed_workflow = self.parser.parse_workflow(workflow_json)
if not parsed_workflow or not parsed_workflow.get("gen_params"):
return web.json_response({"error": "Could not extract generation parameters from workflow"}, status=400)
if not parsed_workflow:
return web.json_response({"error": "Could not extract parameters from workflow"}, status=400)
# Get the lora stack from the parsed workflow
lora_stack = parsed_workflow.get("loras", "")
@@ -880,7 +883,9 @@ class RecipeRoutes:
"created_date": time.time(),
"base_model": most_common_base_model,
"loras": loras_data,
"gen_params": parsed_workflow.get("gen_params", {}), # Use the parsed workflow parameters
"checkpoint": parsed_workflow.get("checkpoint", ""),
"gen_params": {key: value for key, value in parsed_workflow.items()
if key not in ['checkpoint', 'loras']},
"loras_stack": lora_stack # Include the original lora stack string
}
@@ -1019,3 +1024,113 @@ class RecipeRoutes:
except Exception as e:
logger.error(f"Error updating recipe: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
async def reconnect_lora(self, request: web.Request) -> web.Response:
"""Reconnect a deleted LoRA in a recipe to a local LoRA file"""
try:
# Parse request data
data = await request.json()
# Validate required fields
required_fields = ['recipe_id', 'lora_data', 'target_name']
for field in required_fields:
if field not in data:
return web.json_response({
"error": f"Missing required field: {field}"
}, status=400)
recipe_id = data['recipe_id']
lora_data = data['lora_data']
target_name = data['target_name']
# Get recipe scanner
scanner = self.recipe_scanner
lora_scanner = scanner._lora_scanner
# Check if recipe exists
recipe_path = os.path.join(scanner.recipes_dir, f"{recipe_id}.recipe.json")
if not os.path.exists(recipe_path):
return web.json_response({"error": "Recipe not found"}, status=404)
# Find target LoRA by name
target_lora = await lora_scanner.get_lora_info_by_name(target_name)
if not target_lora:
return web.json_response({"error": f"Local LoRA not found with name: {target_name}"}, status=404)
# Load recipe data
with open(recipe_path, 'r', encoding='utf-8') as f:
recipe_data = json.load(f)
# Find the deleted LoRA in the recipe
found = False
updated_lora = None
# Identification can be by hash, modelVersionId, or modelName
for i, lora in enumerate(recipe_data.get('loras', [])):
match_found = False
# Try to match by available identifiers
if 'hash' in lora and 'hash' in lora_data and lora['hash'] == lora_data['hash']:
match_found = True
elif 'modelVersionId' in lora and 'modelVersionId' in lora_data and lora['modelVersionId'] == lora_data['modelVersionId']:
match_found = True
elif 'modelName' in lora and 'modelName' in lora_data and lora['modelName'] == lora_data['modelName']:
match_found = True
if match_found:
# Update LoRA data
lora['isDeleted'] = False
lora['file_name'] = target_name
# Update with information from the target LoRA
if 'sha256' in target_lora:
lora['hash'] = target_lora['sha256'].lower()
if target_lora.get("civitai"):
lora['modelName'] = target_lora['civitai']['model']['name']
lora['modelVersionName'] = target_lora['civitai']['name']
lora['modelVersionId'] = target_lora['civitai']['id']
# Keep original fields for identification
# Mark as found and store updated lora
found = True
updated_lora = dict(lora) # Make a copy for response
break
if not found:
return web.json_response({"error": "Could not find matching deleted LoRA in recipe"}, status=404)
# Save updated recipe
with open(recipe_path, 'w', encoding='utf-8') as f:
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
updated_lora['inLibrary'] = True
updated_lora['preview_url'] = target_lora['preview_url']
updated_lora['localPath'] = target_lora['file_path']
# Update in cache if it exists
if scanner._cache is not None:
for cache_item in scanner._cache.raw_data:
if cache_item.get('id') == recipe_id:
# Replace loras array with updated version
cache_item['loras'] = recipe_data['loras']
# Resort the cache
asyncio.create_task(scanner._cache.resort())
break
# Update EXIF metadata if image exists
image_path = recipe_data.get('file_path')
if image_path and os.path.exists(image_path):
from ..utils.exif_utils import ExifUtils
ExifUtils.append_recipe_metadata(image_path, recipe_data)
return web.json_response({
"success": True,
"recipe_id": recipe_id,
"updated_lora": updated_lora
})
except Exception as e:
logger.error(f"Error reconnecting LoRA: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)

View File

@@ -234,11 +234,9 @@ class CivitaiClient:
if not self._session:
return None
logger.info(f"Fetching model version info from Civitai for ID: {model_version_id}")
version_info = await self._session.get(f"{self.base_url}/model-versions/{model_version_id}")
if not version_info or not version_info.json().get('files'):
logger.warning(f"No files found in version info for ID: {model_version_id}")
return None
# Get hash from the first file
@@ -248,7 +246,6 @@ class CivitaiClient:
hash_value = file_info['hashes']['SHA256'].lower()
return hash_value
logger.warning(f"No SHA256 hash found in version info for ID: {model_version_id}")
return None
except Exception as e:
logger.error(f"Error getting hash from Civitai: {e}")

View File

@@ -75,17 +75,10 @@ class DownloadManager:
file_size = file_info.get('sizeKB', 0) * 1024
# 4. 通知文件监控系统 - 使用规范化路径和文件大小
if self.file_monitor and self.file_monitor.handler:
# Add both the normalized path and potential alternative paths
normalized_path = save_path.replace(os.sep, '/')
self.file_monitor.handler.add_ignore_path(normalized_path, file_size)
# Also add the path with file extension variations (.safetensors)
if not normalized_path.endswith('.safetensors'):
safetensors_path = os.path.splitext(normalized_path)[0] + '.safetensors'
self.file_monitor.handler.add_ignore_path(safetensors_path, file_size)
logger.debug(f"Added download path to ignore list: {normalized_path} (size: {file_size} bytes)")
self.file_monitor.handler.add_ignore_path(
save_path.replace(os.sep, '/'),
file_size
)
# 5. 准备元数据
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)

View File

@@ -2,9 +2,10 @@ from operator import itemgetter
import os
import logging
import asyncio
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileDeletedEvent
from typing import List
from watchdog.events import FileSystemEventHandler
from typing import List, Dict, Set
from threading import Lock
from .lora_scanner import LoraScanner
from ..config import config
@@ -20,91 +21,167 @@ class LoraFileHandler(FileSystemEventHandler):
self.pending_changes = set() # 待处理的变更
self.lock = Lock() # 线程安全锁
self.update_task = None # 异步更新任务
self._ignore_paths = {} # Change to dictionary to store expiration times
self._ignore_paths = set() # Add ignore paths set
self._min_ignore_timeout = 5 # minimum timeout in seconds
self._download_speed = 1024 * 1024 # assume 1MB/s as base speed
# Track modified files with timestamps for debouncing
self.modified_files: Dict[str, float] = {}
self.debounce_timer = None
self.debounce_delay = 3.0 # seconds to wait after last modification
# Track files that are already scheduled for processing
self.scheduled_files: Set[str] = set()
def _should_ignore(self, path: str) -> bool:
"""Check if path should be ignored"""
real_path = os.path.realpath(path) # Resolve any symbolic links
normalized_path = real_path.replace(os.sep, '/')
# Also check with backslashes for Windows compatibility
alt_path = real_path.replace('/', '\\')
# 使用传入的事件循环而不是尝试获取当前线程的事件循环
current_time = self.loop.time()
# Check if path is in ignore list and not expired
if normalized_path in self._ignore_paths and self._ignore_paths[normalized_path] > current_time:
return True
# Also check alternative path format
if alt_path in self._ignore_paths and self._ignore_paths[alt_path] > current_time:
return True
return False
return real_path.replace(os.sep, '/') in self._ignore_paths
def add_ignore_path(self, path: str, file_size: int = 0):
"""Add path to ignore list with dynamic timeout based on file size"""
real_path = os.path.realpath(path) # Resolve any symbolic links
normalized_path = real_path.replace(os.sep, '/')
self._ignore_paths.add(real_path.replace(os.sep, '/'))
# Calculate timeout based on file size
# For small files, use minimum timeout
# For larger files, estimate download time + buffer
if file_size > 0:
# Estimate download time in seconds (size / speed) + buffer
estimated_time = (file_size / self._download_speed) + 10
timeout = max(self._min_ignore_timeout, estimated_time)
else:
timeout = self._min_ignore_timeout
current_time = self.loop.time()
expiration_time = current_time + timeout
# Store both normalized and alternative path formats
self._ignore_paths[normalized_path] = expiration_time
# Also store with backslashes for Windows compatibility
alt_path = real_path.replace('/', '\\')
self._ignore_paths[alt_path] = expiration_time
logger.debug(f"Added ignore path: {normalized_path} (expires in {timeout:.1f}s)")
# Short timeout (e.g. 5 seconds) is sufficient to ignore the CREATE event
timeout = 5
self.loop.call_later(
timeout,
self._remove_ignore_path,
normalized_path
self._ignore_paths.discard,
real_path.replace(os.sep, '/')
)
def _remove_ignore_path(self, path: str):
"""Remove path from ignore list after timeout"""
if path in self._ignore_paths:
del self._ignore_paths[path]
logger.debug(f"Removed ignore path: {path}")
# Also remove alternative path format
alt_path = path.replace('/', '\\')
if alt_path in self._ignore_paths:
del self._ignore_paths[alt_path]
def on_created(self, event):
if event.is_directory or not event.src_path.endswith('.safetensors'):
if event.is_directory:
return
if self._should_ignore(event.src_path):
# Handle safetensors files directly
if event.src_path.endswith('.safetensors'):
if self._should_ignore(event.src_path):
return
# We'll process this file directly and ignore subsequent modifications
# to prevent duplicate processing
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
if normalized_path not in self.scheduled_files:
logger.info(f"LoRA file created: {event.src_path}")
self.scheduled_files.add(normalized_path)
self._schedule_update('add', event.src_path)
# Ignore modifications for a short period after creation
# This helps avoid duplicate processing
self.loop.call_later(
self.debounce_delay * 2,
self.scheduled_files.discard,
normalized_path
)
# For browser downloads, we'll catch them when they're renamed to .safetensors
def on_modified(self, event):
if event.is_directory:
return
logger.info(f"LoRA file created: {event.src_path}")
self._schedule_update('add', event.src_path)
# Only process safetensors files
if event.src_path.endswith('.safetensors'):
if self._should_ignore(event.src_path):
return
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
# Skip if this file is already scheduled for processing
if normalized_path in self.scheduled_files:
return
# Update the timestamp for this file
self.modified_files[normalized_path] = time.time()
# Cancel any existing timer
if self.debounce_timer:
self.debounce_timer.cancel()
# Set a new timer to process modified files after debounce period
self.debounce_timer = self.loop.call_later(
self.debounce_delay,
self.loop.call_soon_threadsafe,
self._process_modified_files
)
def _process_modified_files(self):
"""Process files that have been modified after debounce period"""
current_time = time.time()
files_to_process = []
# Find files that haven't been modified for debounce_delay seconds
for file_path, last_modified in list(self.modified_files.items()):
if current_time - last_modified >= self.debounce_delay:
# Only process if not already scheduled
if file_path not in self.scheduled_files:
files_to_process.append(file_path)
self.scheduled_files.add(file_path)
# Auto-remove from scheduled list after reasonable time
self.loop.call_later(
self.debounce_delay * 2,
self.scheduled_files.discard,
file_path
)
del self.modified_files[file_path]
# Process stable files
for file_path in files_to_process:
logger.info(f"Processing modified LoRA file: {file_path}")
self._schedule_update('add', file_path)
def on_deleted(self, event):
if event.is_directory or not event.src_path.endswith('.safetensors'):
return
if self._should_ignore(event.src_path):
return
# Remove from scheduled files if present
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
self.scheduled_files.discard(normalized_path)
logger.info(f"LoRA file deleted: {event.src_path}")
self._schedule_update('remove', event.src_path)
def on_moved(self, event):
"""Handle file move/rename events"""
# If destination is a safetensors file, treat it as a new file
if event.dest_path.endswith('.safetensors'):
if self._should_ignore(event.dest_path):
return
normalized_path = os.path.realpath(event.dest_path).replace(os.sep, '/')
# Only process if not already scheduled
if normalized_path not in self.scheduled_files:
logger.info(f"LoRA file renamed/moved to: {event.dest_path}")
self.scheduled_files.add(normalized_path)
self._schedule_update('add', event.dest_path)
# Auto-remove from scheduled list after reasonable time
self.loop.call_later(
self.debounce_delay * 2,
self.scheduled_files.discard,
normalized_path
)
# If source was a safetensors file, treat it as deleted
if event.src_path.endswith('.safetensors'):
if self._should_ignore(event.src_path):
return
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
self.scheduled_files.discard(normalized_path)
logger.info(f"LoRA file moved/renamed from: {event.src_path}")
self._schedule_update('remove', event.src_path)
def _schedule_update(self, action: str, file_path: str): #file_path is a real path
"""Schedule a cache update"""
with self.lock:
@@ -141,6 +218,12 @@ class LoraFileHandler(FileSystemEventHandler):
for action, file_path in changes:
try:
if action == 'add':
# Check if file already exists in cache
existing = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
if existing:
logger.info(f"File {file_path} already in cache, skipping")
continue
# Scan new file
lora_data = await self.scanner.scan_single_lora(file_path)
if lora_data:

View File

@@ -211,7 +211,7 @@ class RecipeScanner:
lora['hash'] = hash_from_civitai
metadata_updated = True
else:
logger.warning(f"Could not get hash for modelVersionId {model_version_id}")
logger.debug(f"Could not get hash for modelVersionId {model_version_id}")
# If has hash but no file_name, look up in lora library
if 'hash' in lora and (not lora.get('file_name') or not lora['file_name']):
@@ -261,7 +261,7 @@ class RecipeScanner:
version_info = await self._civitai_client.get_model_version_info(model_version_id)
if not version_info or not version_info.get('files'):
logger.warning(f"No files found in version info for ID: {model_version_id}")
logger.debug(f"No files found in version info for ID: {model_version_id}")
return None
# Get hash from the first file
@@ -269,7 +269,7 @@ class RecipeScanner:
if file_info.get('hashes', {}).get('SHA256'):
return file_info['hashes']['SHA256']
logger.warning(f"No SHA256 hash found in version info for ID: {model_version_id}")
logger.debug(f"No SHA256 hash found in version info for ID: {model_version_id}")
return None
except Exception as e:
logger.error(f"Error getting hash from Civitai: {e}")
@@ -286,7 +286,7 @@ class RecipeScanner:
if version_info and 'name' in version_info:
return version_info['name']
logger.warning(f"No version name found for modelVersionId {model_version_id}")
logger.debug(f"No version name found for modelVersionId {model_version_id}")
return None
except Exception as e:
logger.error(f"Error getting model version name from Civitai: {e}")

View File

@@ -1,51 +1,16 @@
import piexif
import json
import logging
from typing import Dict, Optional, Any
from typing import Optional
from io import BytesIO
import os
from PIL import Image
import re
logger = logging.getLogger(__name__)
class ExifUtils:
"""Utility functions for working with EXIF data in images"""
@staticmethod
def extract_user_comment(image_path: str) -> Optional[str]:
"""Extract UserComment field from image EXIF data"""
try:
# First try to open as image to check format
with Image.open(image_path) as img:
if img.format not in ['JPEG', 'TIFF', 'WEBP']:
# For non-JPEG/TIFF/WEBP images, try to get EXIF through PIL
exif = img._getexif()
if exif and piexif.ExifIFD.UserComment in exif:
user_comment = exif[piexif.ExifIFD.UserComment]
if isinstance(user_comment, bytes):
if user_comment.startswith(b'UNICODE\0'):
return user_comment[8:].decode('utf-16be')
return user_comment.decode('utf-8', errors='ignore')
return user_comment
return None
# For JPEG/TIFF/WEBP, use piexif
exif_dict = piexif.load(image_path)
if piexif.ExifIFD.UserComment in exif_dict.get('Exif', {}):
user_comment = exif_dict['Exif'][piexif.ExifIFD.UserComment]
if isinstance(user_comment, bytes):
if user_comment.startswith(b'UNICODE\0'):
user_comment = user_comment[8:].decode('utf-16be')
else:
user_comment = user_comment.decode('utf-8', errors='ignore')
return user_comment
return None
except Exception as e:
return None
@staticmethod
def extract_image_metadata(image_path: str) -> Optional[str]:
"""Extract metadata from image including UserComment or parameters field
@@ -103,53 +68,6 @@ class ExifUtils:
logger.error(f"Error extracting image metadata: {e}", exc_info=True)
return None
@staticmethod
def update_user_comment(image_path: str, user_comment: str) -> str:
"""Update UserComment field in image EXIF data"""
try:
# Load the image and its EXIF data
with Image.open(image_path) as img:
# Get original format
img_format = img.format
# For WebP format, we need a different approach
if img_format == 'WEBP':
# WebP doesn't support standard EXIF through piexif
# We'll use PIL's exif parameter directly
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + user_comment.encode('utf-16be')}}
exif_bytes = piexif.dump(exif_dict)
# Save with the exif data
img.save(image_path, format='WEBP', exif=exif_bytes, quality=85)
return image_path
# For other formats, use the standard approach
try:
exif_dict = piexif.load(img.info.get('exif', b''))
except:
exif_dict = {'0th':{}, 'Exif':{}, 'GPS':{}, 'Interop':{}, '1st':{}}
# If no Exif dictionary exists, create one
if 'Exif' not in exif_dict:
exif_dict['Exif'] = {}
# Update the UserComment field - use UNICODE format
unicode_bytes = user_comment.encode('utf-16be')
user_comment_bytes = b'UNICODE\0' + unicode_bytes
exif_dict['Exif'][piexif.ExifIFD.UserComment] = user_comment_bytes
# Convert EXIF dict back to bytes
exif_bytes = piexif.dump(exif_dict)
# Save the image with updated EXIF data
img.save(image_path, exif=exif_bytes)
return image_path
except Exception as e:
logger.error(f"Error updating EXIF data in {image_path}: {e}")
return image_path
@staticmethod
def update_image_metadata(image_path: str, metadata: str) -> str:
"""Update metadata in image's EXIF data or parameters fields
@@ -394,210 +312,4 @@ class ExifUtils:
if isinstance(image_data, str) and os.path.exists(image_data):
with open(image_data, 'rb') as f:
return f.read(), os.path.splitext(image_data)[1]
return image_data, '.jpg'
@staticmethod
def _parse_comfyui_workflow(workflow_data: Any) -> Dict[str, Any]:
"""
Parse ComfyUI workflow data and extract relevant generation parameters
Args:
workflow_data: Raw workflow data (string or dict)
Returns:
Formatted generation parameters dictionary
"""
try:
# If workflow_data is a string, try to parse it as JSON
if isinstance(workflow_data, str):
try:
workflow_data = json.loads(workflow_data)
except json.JSONDecodeError:
logger.error("Failed to parse workflow data as JSON")
return {}
# Now workflow_data should be a dictionary
if not isinstance(workflow_data, dict):
logger.error(f"Workflow data is not a dictionary: {type(workflow_data)}")
return {}
# Initialize parameters dictionary with only the required fields
gen_params = {
"prompt": "",
"negative_prompt": "",
"steps": "",
"sampler": "",
"cfg_scale": "",
"seed": "",
"size": "",
"clip_skip": ""
}
# First pass: find the KSampler node to get basic parameters and node references
# Store node references to follow for prompts
positive_ref = None
negative_ref = None
for node_id, node_data in workflow_data.items():
if not isinstance(node_data, dict):
continue
# Extract node inputs if available
inputs = node_data.get("inputs", {})
if not inputs:
continue
# KSampler nodes contain most generation parameters and references to prompt nodes
if "KSampler" in node_data.get("class_type", ""):
# Extract basic sampling parameters
gen_params["steps"] = inputs.get("steps", "")
gen_params["cfg_scale"] = inputs.get("cfg", "")
gen_params["sampler"] = inputs.get("sampler_name", "")
gen_params["seed"] = inputs.get("seed", "")
if isinstance(gen_params["seed"], list) and len(gen_params["seed"]) > 1:
gen_params["seed"] = gen_params["seed"][1] # Use the actual value if it's a list
# Get references to positive and negative prompt nodes
positive_ref = inputs.get("positive", "")
negative_ref = inputs.get("negative", "")
# CLIPSetLastLayer contains clip_skip information
elif "CLIPSetLastLayer" in node_data.get("class_type", ""):
gen_params["clip_skip"] = inputs.get("stop_at_clip_layer", "")
if isinstance(gen_params["clip_skip"], int) and gen_params["clip_skip"] < 0:
# Convert negative layer index to positive clip skip value
gen_params["clip_skip"] = abs(gen_params["clip_skip"])
# Look for resolution information
elif "LatentImage" in node_data.get("class_type", "") or "Empty" in node_data.get("class_type", ""):
width = inputs.get("width", 0)
height = inputs.get("height", 0)
if width and height:
gen_params["size"] = f"{width}x{height}"
# Some nodes have resolution as a string like "832x1216 (0.68)"
resolution = inputs.get("resolution", "")
if isinstance(resolution, str) and "x" in resolution:
gen_params["size"] = resolution.split(" ")[0] # Extract just the dimensions
# Helper function to follow node references and extract text content
def get_text_from_node_ref(node_ref, workflow_data):
if not node_ref or not isinstance(node_ref, list) or len(node_ref) < 2:
return ""
node_id, slot_idx = node_ref
# If we can't find the node, return empty string
if node_id not in workflow_data:
return ""
node = workflow_data[node_id]
inputs = node.get("inputs", {})
# Direct text input in CLIP Text Encode nodes
if "CLIPTextEncode" in node.get("class_type", ""):
text = inputs.get("text", "")
if isinstance(text, str):
return text
elif isinstance(text, list) and len(text) >= 2:
# If text is a reference to another node, follow it
return get_text_from_node_ref(text, workflow_data)
# Other nodes might have text input with different field names
for field_name, field_value in inputs.items():
if field_name == "text" and isinstance(field_value, str):
return field_value
elif isinstance(field_value, list) and len(field_value) >= 2 and field_name in ["text"]:
# If it's a reference to another node, follow it
return get_text_from_node_ref(field_value, workflow_data)
return ""
# Extract prompts by following references from KSampler node
if positive_ref:
gen_params["prompt"] = get_text_from_node_ref(positive_ref, workflow_data)
if negative_ref:
gen_params["negative_prompt"] = get_text_from_node_ref(negative_ref, workflow_data)
# Fallback: if we couldn't extract prompts via references, use the traditional method
if not gen_params["prompt"] or not gen_params["negative_prompt"]:
for node_id, node_data in workflow_data.items():
if not isinstance(node_data, dict):
continue
inputs = node_data.get("inputs", {})
if not inputs:
continue
if "CLIPTextEncode" in node_data.get("class_type", ""):
# Check for negative prompt nodes
title = node_data.get("_meta", {}).get("title", "").lower()
prompt_text = inputs.get("text", "")
if isinstance(prompt_text, str):
if "negative" in title and not gen_params["negative_prompt"]:
gen_params["negative_prompt"] = prompt_text
elif prompt_text and not "negative" in title and not gen_params["prompt"]:
gen_params["prompt"] = prompt_text
return gen_params
except Exception as e:
logger.error(f"Error parsing ComfyUI workflow: {e}", exc_info=True)
return {}
@staticmethod
def extract_comfyui_gen_params(image_path: str) -> Dict[str, Any]:
"""
Extract ComfyUI workflow data from PNG images and format for recipe data
Only extracts the specific generation parameters needed for recipes.
Args:
image_path: Path to the ComfyUI-generated PNG image
Returns:
Dictionary containing formatted generation parameters
"""
try:
# Check if the file exists and is accessible
if not os.path.exists(image_path):
logger.error(f"Image file not found: {image_path}")
return {}
# Open the image to extract embedded workflow data
with Image.open(image_path) as img:
workflow_data = None
# For PNG images, look for the ComfyUI workflow data in PNG chunks
if img.format == 'PNG':
# Check standard metadata fields that might contain workflow
if 'parameters' in img.info:
workflow_data = img.info['parameters']
elif 'prompt' in img.info:
workflow_data = img.info['prompt']
else:
# Look for other potential field names that might contain workflow data
for key in img.info:
if isinstance(key, str) and ('workflow' in key.lower() or 'comfy' in key.lower()):
workflow_data = img.info[key]
break
# If no workflow data found in PNG chunks, try extract_image_metadata as fallback
if not workflow_data:
metadata = ExifUtils.extract_image_metadata(image_path)
if metadata and '{' in metadata and '}' in metadata:
# Try to extract JSON part
json_start = metadata.find('{')
json_end = metadata.rfind('}') + 1
workflow_data = metadata[json_start:json_end]
# Parse workflow data if found
if workflow_data:
return ExifUtils._parse_comfyui_workflow(workflow_data)
return {}
except Exception as e:
logger.error(f"Error extracting ComfyUI gen params from {image_path}: {e}", exc_info=True)
return {}
return image_data, '.jpg'

View File

@@ -75,3 +75,31 @@ class LoraMetadata:
self.modified = os.path.getmtime(file_path)
self.file_path = file_path.replace(os.sep, '/')
@dataclass
class CheckpointMetadata:
"""Represents the metadata structure for a Checkpoint model"""
file_name: str # The filename without extension
model_name: str # The checkpoint's name defined by the creator
file_path: str # Full path to the model file
size: int # File size in bytes
modified: float # Last modified timestamp
sha256: str # SHA256 hash of the file
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
preview_url: str # Preview image URL
preview_nsfw_level: int = 0 # NSFW level of the preview image
model_type: str = "checkpoint" # Model type (checkpoint, inpainting, etc.)
notes: str = "" # Additional notes
from_civitai: bool = True # Whether from Civitai
civitai: Optional[Dict] = None # Civitai API data if available
tags: List[str] = None # Model tags
modelDescription: str = "" # Full model description
# Additional checkpoint-specific fields
resolution: Optional[str] = None # Native resolution (e.g., 512x512, 1024x1024)
vae_included: bool = False # Whether VAE is included in the checkpoint
architecture: str = "" # Model architecture (if known)
def __post_init__(self):
if self.tags is None:
self.tags = []

View File

@@ -1,149 +0,0 @@
# ComfyUI Workflow Parser
本模块提供了一个灵活的解析系统可以从ComfyUI工作流中提取生成参数和LoRA信息。
## 设计理念
工作流解析器基于以下设计原则:
1. **模块化**: 每种节点类型由独立的mapper处理
2. **可扩展性**: 通过扩展系统轻松添加新的节点类型支持
3. **回溯**: 通过工作流图的模型输入路径跟踪LoRA节点
4. **灵活性**: 适应不同的ComfyUI工作流结构
## 主要组件
### 1. NodeMapper
`NodeMapper`是所有节点映射器的基类,定义了如何从工作流中提取节点信息:
```python
class NodeMapper:
def __init__(self, node_type: str, inputs_to_track: List[str]):
self.node_type = node_type
self.inputs_to_track = inputs_to_track
def process(self, node_id: str, node_data: Dict, workflow: Dict, parser) -> Any:
# 处理节点的通用逻辑
...
def transform(self, inputs: Dict) -> Any:
# 由子类覆盖以提供特定转换
return inputs
```
### 2. WorkflowParser
主要解析类,通过跟踪工作流图来提取参数:
```python
parser = WorkflowParser()
result = parser.parse_workflow("workflow.json")
```
### 3. 扩展系统
允许通过添加新的自定义mapper来扩展支持的节点类型:
```python
# 在py/workflow/ext/中添加自定义mapper模块
load_extensions() # 自动加载所有扩展
```
## 使用方法
### 基本用法
```python
from workflow.parser import parse_workflow
# 解析工作流并保存结果
result = parse_workflow("workflow.json", "output.json")
```
### 自定义解析
```python
from workflow.parser import WorkflowParser
from workflow.mappers import register_mapper, load_extensions
# 加载扩展
load_extensions()
# 创建解析器
parser = WorkflowParser(load_extensions_on_init=False) # 不自动加载扩展
# 解析工作流
result = parser.parse_workflow(workflow_data)
```
## 扩展系统
### 添加新的节点映射器
`py/workflow/ext/`目录中创建Python文件定义从`NodeMapper`继承的类:
```python
# example_mapper.py
from ..mappers import NodeMapper
class MyCustomNodeMapper(NodeMapper):
def __init__(self):
super().__init__(
node_type="MyCustomNode", # 节点的class_type
inputs_to_track=["param1", "param2"] # 要提取的参数
)
def transform(self, inputs: Dict) -> Any:
# 处理提取的参数
return {
"custom_param": inputs.get("param1", "default")
}
```
扩展系统会自动加载和注册这些映射器。
### LoraManager节点说明
LoraManager相关节点的处理方式:
1. **Lora Loader**: 处理`loras`数组,过滤出`active=true`的条目,和`lora_stack`输入
2. **Lora Stacker**: 处理`loras`数组和已有的`lora_stack`构建叠加的LoRA
3. **TriggerWord Toggle**: 从`toggle_trigger_words`中提取`active=true`的条目
## 输出格式
解析器生成的输出格式如下:
```json
{
"gen_params": {
"prompt": "...",
"negative_prompt": "",
"steps": "25",
"sampler": "dpmpp_2m",
"scheduler": "beta",
"cfg": "1",
"seed": "48",
"guidance": 3.5,
"size": "896x1152",
"clip_skip": "2"
},
"loras": "<lora:name1:0.9> <lora:name2:0.8>"
}
```
## 高级用法
### 直接注册映射器
```python
from workflow.mappers import register_mapper
from workflow.mappers import NodeMapper
# 创建自定义映射器
class CustomMapper(NodeMapper):
# ...实现映射器
# 注册映射器
register_mapper(CustomMapper())

View File

@@ -0,0 +1,285 @@
"""
ComfyUI Core nodes mappers extension for workflow parsing
"""
import logging
from typing import Dict, Any, List
logger = logging.getLogger(__name__)
# =============================================================================
# Transform Functions
# =============================================================================
def transform_random_noise(inputs: Dict) -> Dict:
"""Transform function for RandomNoise node"""
return {"seed": str(inputs.get("noise_seed", ""))}
def transform_ksampler_select(inputs: Dict) -> Dict:
"""Transform function for KSamplerSelect node"""
return {"sampler": inputs.get("sampler_name", "")}
def transform_basic_scheduler(inputs: Dict) -> Dict:
"""Transform function for BasicScheduler node"""
result = {
"scheduler": inputs.get("scheduler", ""),
"denoise": str(inputs.get("denoise", "1.0"))
}
# Get steps from inputs or steps input
if "steps" in inputs:
if isinstance(inputs["steps"], str):
result["steps"] = inputs["steps"]
elif isinstance(inputs["steps"], dict) and "value" in inputs["steps"]:
result["steps"] = str(inputs["steps"]["value"])
else:
result["steps"] = str(inputs["steps"])
return result
def transform_basic_guider(inputs: Dict) -> Dict:
"""Transform function for BasicGuider node"""
result = {}
# Process conditioning
if "conditioning" in inputs:
if isinstance(inputs["conditioning"], str):
result["prompt"] = inputs["conditioning"]
elif isinstance(inputs["conditioning"], dict):
result["conditioning"] = inputs["conditioning"]
# Get model information if needed
if "model" in inputs and isinstance(inputs["model"], dict):
result["model"] = inputs["model"]
return result
def transform_model_sampling_flux(inputs: Dict) -> Dict:
"""Transform function for ModelSamplingFlux - mostly a pass-through node"""
# This node is primarily used for routing, so we mostly pass through values
return inputs["model"]
def transform_sampler_custom_advanced(inputs: Dict) -> Dict:
"""Transform function for SamplerCustomAdvanced node"""
result = {}
# Extract seed from noise
if "noise" in inputs and isinstance(inputs["noise"], dict):
result["seed"] = str(inputs["noise"].get("seed", ""))
# Extract sampler info
if "sampler" in inputs and isinstance(inputs["sampler"], dict):
sampler = inputs["sampler"].get("sampler", "")
if sampler:
result["sampler"] = sampler
# Extract scheduler, steps, denoise from sigmas
if "sigmas" in inputs and isinstance(inputs["sigmas"], dict):
sigmas = inputs["sigmas"]
result["scheduler"] = sigmas.get("scheduler", "")
result["steps"] = str(sigmas.get("steps", ""))
result["denoise"] = str(sigmas.get("denoise", "1.0"))
# Extract prompt and guidance from guider
if "guider" in inputs and isinstance(inputs["guider"], dict):
guider = inputs["guider"]
# Get prompt from conditioning
if "conditioning" in guider and isinstance(guider["conditioning"], str):
result["prompt"] = guider["conditioning"]
elif "conditioning" in guider and isinstance(guider["conditioning"], dict):
result["guidance"] = guider["conditioning"].get("guidance", "")
result["prompt"] = guider["conditioning"].get("prompt", "")
if "model" in guider and isinstance(guider["model"], dict):
result["checkpoint"] = guider["model"].get("checkpoint", "")
result["loras"] = guider["model"].get("loras", "")
result["clip_skip"] = str(int(guider["model"].get("clip_skip", "-1")) * -1)
# Extract dimensions from latent_image
if "latent_image" in inputs and isinstance(inputs["latent_image"], dict):
latent = inputs["latent_image"]
width = latent.get("width", 0)
height = latent.get("height", 0)
if width and height:
result["width"] = width
result["height"] = height
result["size"] = f"{width}x{height}"
return result
def transform_ksampler(inputs: Dict) -> Dict:
"""Transform function for KSampler nodes"""
result = {
"seed": str(inputs.get("seed", "")),
"steps": str(inputs.get("steps", "")),
"cfg": str(inputs.get("cfg", "")),
"sampler": inputs.get("sampler_name", ""),
"scheduler": inputs.get("scheduler", ""),
}
# Process positive prompt
if "positive" in inputs:
result["prompt"] = inputs["positive"]
# Process negative prompt
if "negative" in inputs:
result["negative_prompt"] = inputs["negative"]
# Get dimensions from latent image
if "latent_image" in inputs and isinstance(inputs["latent_image"], dict):
width = inputs["latent_image"].get("width", 0)
height = inputs["latent_image"].get("height", 0)
if width and height:
result["size"] = f"{width}x{height}"
# Add clip_skip if present
if "clip_skip" in inputs:
result["clip_skip"] = str(inputs.get("clip_skip", ""))
# Add guidance if present
if "guidance" in inputs:
result["guidance"] = str(inputs.get("guidance", ""))
# Add model if present
if "model" in inputs:
result["checkpoint"] = inputs.get("model", {}).get("checkpoint", "")
result["loras"] = inputs.get("model", {}).get("loras", "")
result["clip_skip"] = str(inputs.get("model", {}).get("clip_skip", -1) * -1)
return result
def transform_empty_latent(inputs: Dict) -> Dict:
"""Transform function for EmptyLatentImage nodes"""
width = inputs.get("width", 0)
height = inputs.get("height", 0)
return {"width": width, "height": height, "size": f"{width}x{height}"}
def transform_clip_text(inputs: Dict) -> Any:
"""Transform function for CLIPTextEncode nodes"""
return inputs.get("text", "")
def transform_flux_guidance(inputs: Dict) -> Dict:
"""Transform function for FluxGuidance nodes"""
result = {}
if "guidance" in inputs:
result["guidance"] = inputs["guidance"]
if "conditioning" in inputs:
conditioning = inputs["conditioning"]
if isinstance(conditioning, str):
result["prompt"] = conditioning
else:
result["prompt"] = "Unknown prompt"
return result
def transform_unet_loader(inputs: Dict) -> Dict:
"""Transform function for UNETLoader node"""
unet_name = inputs.get("unet_name", "")
return {"checkpoint": unet_name} if unet_name else {}
def transform_checkpoint_loader(inputs: Dict) -> Dict:
"""Transform function for CheckpointLoaderSimple node"""
ckpt_name = inputs.get("ckpt_name", "")
return {"checkpoint": ckpt_name} if ckpt_name else {}
def transform_latent_upscale_by(inputs: Dict) -> Dict:
"""Transform function for LatentUpscaleBy node"""
result = {}
width = inputs["samples"].get("width", 0) * inputs["scale_by"]
height = inputs["samples"].get("height", 0) * inputs["scale_by"]
result["width"] = width
result["height"] = height
result["size"] = f"{width}x{height}"
return result
def transform_clip_set_last_layer(inputs: Dict) -> Dict:
"""Transform function for CLIPSetLastLayer node"""
result = {}
if "stop_at_clip_layer" in inputs:
result["clip_skip"] = inputs["stop_at_clip_layer"]
return result
# =============================================================================
# Node Mapper Definitions
# =============================================================================
# Define the mappers for ComfyUI core nodes not in main mapper
NODE_MAPPERS_EXT = {
# KSamplers
"SamplerCustomAdvanced": {
"inputs_to_track": ["noise", "guider", "sampler", "sigmas", "latent_image"],
"transform_func": transform_sampler_custom_advanced
},
"KSampler": {
"inputs_to_track": [
"seed", "steps", "cfg", "sampler_name", "scheduler",
"denoise", "positive", "negative", "latent_image",
"model", "clip_skip"
],
"transform_func": transform_ksampler
},
# ComfyUI core nodes
"EmptyLatentImage": {
"inputs_to_track": ["width", "height", "batch_size"],
"transform_func": transform_empty_latent
},
"EmptySD3LatentImage": {
"inputs_to_track": ["width", "height", "batch_size"],
"transform_func": transform_empty_latent
},
"CLIPTextEncode": {
"inputs_to_track": ["text", "clip"],
"transform_func": transform_clip_text
},
"FluxGuidance": {
"inputs_to_track": ["guidance", "conditioning"],
"transform_func": transform_flux_guidance
},
"RandomNoise": {
"inputs_to_track": ["noise_seed"],
"transform_func": transform_random_noise
},
"KSamplerSelect": {
"inputs_to_track": ["sampler_name"],
"transform_func": transform_ksampler_select
},
"BasicScheduler": {
"inputs_to_track": ["scheduler", "steps", "denoise", "model"],
"transform_func": transform_basic_scheduler
},
"BasicGuider": {
"inputs_to_track": ["model", "conditioning"],
"transform_func": transform_basic_guider
},
"ModelSamplingFlux": {
"inputs_to_track": ["max_shift", "base_shift", "width", "height", "model"],
"transform_func": transform_model_sampling_flux
},
"UNETLoader": {
"inputs_to_track": ["unet_name"],
"transform_func": transform_unet_loader
},
"CheckpointLoaderSimple": {
"inputs_to_track": ["ckpt_name"],
"transform_func": transform_checkpoint_loader
},
"LatentUpscale": {
"inputs_to_track": ["width", "height"],
"transform_func": transform_empty_latent
},
"LatentUpscaleBy": {
"inputs_to_track": ["samples", "scale_by"],
"transform_func": transform_latent_upscale_by
},
"CLIPSetLastLayer": {
"inputs_to_track": ["clip", "stop_at_clip_layer"],
"transform_func": transform_clip_set_last_layer
}
}

View File

@@ -1,54 +0,0 @@
"""
Example extension mapper for demonstrating the extension system
"""
from typing import Dict, Any
from ..mappers import NodeMapper
class ExampleNodeMapper(NodeMapper):
"""Example mapper for custom nodes"""
def __init__(self):
super().__init__(
node_type="ExampleCustomNode",
inputs_to_track=["param1", "param2", "image"]
)
def transform(self, inputs: Dict) -> Dict:
"""Transform extracted inputs into the desired output format"""
result = {}
# Extract interesting parameters
if "param1" in inputs:
result["example_param1"] = inputs["param1"]
if "param2" in inputs:
result["example_param2"] = inputs["param2"]
# You can process the data in any way needed
return result
class VAEMapperExtension(NodeMapper):
"""Extension mapper for VAE nodes"""
def __init__(self):
super().__init__(
node_type="VAELoader",
inputs_to_track=["vae_name"]
)
def transform(self, inputs: Dict) -> Dict:
"""Extract VAE information"""
vae_name = inputs.get("vae_name", "")
# Remove path prefix if present
if "/" in vae_name or "\\" in vae_name:
# Get just the filename without path or extension
vae_name = vae_name.replace("\\", "/").split("/")[-1]
vae_name = vae_name.split(".")[0] # Remove extension
return {"vae": vae_name}
# Note: No need to register manually - extensions are automatically registered
# when the extension system loads this file

View File

@@ -0,0 +1,74 @@
"""
KJNodes mappers extension for ComfyUI workflow parsing
"""
import logging
import re
from typing import Dict, Any
logger = logging.getLogger(__name__)
# =============================================================================
# Transform Functions
# =============================================================================
def transform_join_strings(inputs: Dict) -> str:
"""Transform function for JoinStrings nodes"""
string1 = inputs.get("string1", "")
string2 = inputs.get("string2", "")
delimiter = inputs.get("delimiter", "")
return f"{string1}{delimiter}{string2}"
def transform_string_constant(inputs: Dict) -> str:
"""Transform function for StringConstant nodes"""
return inputs.get("string", "")
def transform_empty_latent_presets(inputs: Dict) -> Dict:
"""Transform function for EmptyLatentImagePresets nodes"""
dimensions = inputs.get("dimensions", "")
invert = inputs.get("invert", False)
# Extract width and height from dimensions string
# Expected format: "width x height (ratio)" or similar
width = 0
height = 0
if dimensions:
# Try to extract dimensions using regex
match = re.search(r'(\d+)\s*x\s*(\d+)', dimensions)
if match:
width = int(match.group(1))
height = int(match.group(2))
# If invert is True, swap width and height
if invert and width and height:
width, height = height, width
return {"width": width, "height": height, "size": f"{width}x{height}"}
def transform_int_constant(inputs: Dict) -> int:
"""Transform function for INTConstant nodes"""
return inputs.get("value", 0)
# =============================================================================
# Node Mapper Definitions
# =============================================================================
# Define the mappers for KJNodes
NODE_MAPPERS_EXT = {
"JoinStrings": {
"inputs_to_track": ["string1", "string2", "delimiter"],
"transform_func": transform_join_strings
},
"StringConstantMultiline": {
"inputs_to_track": ["string"],
"transform_func": transform_string_constant
},
"EmptyLatentImagePresets": {
"inputs_to_track": ["dimensions", "invert", "batch_size"],
"transform_func": transform_empty_latent_presets
},
"INTConstant": {
"inputs_to_track": ["value"],
"transform_func": transform_int_constant
}
}

View File

@@ -5,375 +5,229 @@ import logging
import os
import importlib.util
import inspect
from typing import Dict, List, Any, Optional, Union, Type, Callable
from typing import Dict, List, Any, Optional, Union, Type, Callable, Tuple
logger = logging.getLogger(__name__)
# Global mapper registry
_MAPPER_REGISTRY: Dict[str, 'NodeMapper'] = {}
class NodeMapper:
"""Base class for node mappers that define how to extract information from a specific node type"""
def __init__(self, node_type: str, inputs_to_track: List[str]):
self.node_type = node_type
self.inputs_to_track = inputs_to_track
def process(self, node_id: str, node_data: Dict, workflow: Dict, parser: 'WorkflowParser') -> Any: # type: ignore
"""Process the node and extract relevant information"""
result = {}
for input_name in self.inputs_to_track:
if input_name in node_data.get("inputs", {}):
input_value = node_data["inputs"][input_name]
# Check if input is a reference to another node's output
if isinstance(input_value, list) and len(input_value) == 2:
# Format is [node_id, output_slot]
try:
ref_node_id, output_slot = input_value
# Convert node_id to string if it's an integer
if isinstance(ref_node_id, int):
ref_node_id = str(ref_node_id)
# Recursively process the referenced node
ref_value = parser.process_node(ref_node_id, workflow)
# Store the processed value
if ref_value is not None:
result[input_name] = ref_value
else:
# If we couldn't get a value from the reference, store the raw value
result[input_name] = input_value
except Exception as e:
logger.error(f"Error processing reference in node {node_id}, input {input_name}: {e}")
# If we couldn't process the reference, store the raw value
result[input_name] = input_value
else:
# Direct value
result[input_name] = input_value
# Apply any transformations
return self.transform(result)
def transform(self, inputs: Dict) -> Any:
"""Transform the extracted inputs - override in subclasses"""
return inputs
class KSamplerMapper(NodeMapper):
"""Mapper for KSampler nodes"""
def __init__(self):
super().__init__(
node_type="KSampler",
inputs_to_track=["seed", "steps", "cfg", "sampler_name", "scheduler",
"denoise", "positive", "negative", "latent_image",
"model", "clip_skip"]
)
def transform(self, inputs: Dict) -> Dict:
result = {
"seed": str(inputs.get("seed", "")),
"steps": str(inputs.get("steps", "")),
"cfg": str(inputs.get("cfg", "")),
"sampler": inputs.get("sampler_name", ""),
"scheduler": inputs.get("scheduler", ""),
}
# Process positive prompt
if "positive" in inputs:
result["prompt"] = inputs["positive"]
# Process negative prompt
if "negative" in inputs:
result["negative_prompt"] = inputs["negative"]
# Get dimensions from latent image
if "latent_image" in inputs and isinstance(inputs["latent_image"], dict):
width = inputs["latent_image"].get("width", 0)
height = inputs["latent_image"].get("height", 0)
if width and height:
result["size"] = f"{width}x{height}"
# Add clip_skip if present
if "clip_skip" in inputs:
result["clip_skip"] = str(inputs.get("clip_skip", ""))
return result
class EmptyLatentImageMapper(NodeMapper):
"""Mapper for EmptyLatentImage nodes"""
def __init__(self):
super().__init__(
node_type="EmptyLatentImage",
inputs_to_track=["width", "height", "batch_size"]
)
def transform(self, inputs: Dict) -> Dict:
width = inputs.get("width", 0)
height = inputs.get("height", 0)
return {"width": width, "height": height, "size": f"{width}x{height}"}
class EmptySD3LatentImageMapper(NodeMapper):
"""Mapper for EmptySD3LatentImage nodes"""
def __init__(self):
super().__init__(
node_type="EmptySD3LatentImage",
inputs_to_track=["width", "height", "batch_size"]
)
def transform(self, inputs: Dict) -> Dict:
width = inputs.get("width", 0)
height = inputs.get("height", 0)
return {"width": width, "height": height, "size": f"{width}x{height}"}
class CLIPTextEncodeMapper(NodeMapper):
"""Mapper for CLIPTextEncode nodes"""
def __init__(self):
super().__init__(
node_type="CLIPTextEncode",
inputs_to_track=["text", "clip"]
)
def transform(self, inputs: Dict) -> Any:
# Simply return the text
return inputs.get("text", "")
class LoraLoaderMapper(NodeMapper):
"""Mapper for LoraLoader nodes"""
def __init__(self):
super().__init__(
node_type="Lora Loader (LoraManager)",
inputs_to_track=["loras", "lora_stack"]
)
def transform(self, inputs: Dict) -> Dict:
# Fallback to loras array if text field doesn't exist or is invalid
loras_data = inputs.get("loras", [])
lora_stack = inputs.get("lora_stack", {}).get("lora_stack", [])
# Process loras array - filter active entries
lora_texts = []
# Check if loras_data is a list or a dict with __value__ key (new format)
if isinstance(loras_data, dict) and "__value__" in loras_data:
loras_list = loras_data["__value__"]
elif isinstance(loras_data, list):
loras_list = loras_data
else:
loras_list = []
# Process each active lora entry
for lora in loras_list:
logger.info(f"Lora: {lora}, active: {lora.get('active')}")
if isinstance(lora, dict) and lora.get("active", False):
lora_name = lora.get("name", "")
strength = lora.get("strength", 1.0)
lora_texts.append(f"<lora:{lora_name}:{strength}>")
# Process lora_stack if it exists and is a valid format (list of tuples)
if lora_stack and isinstance(lora_stack, list):
# If lora_stack is a reference to another node ([node_id, output_slot]),
# we don't process it here as it's already been processed recursively
if len(lora_stack) == 2 and isinstance(lora_stack[0], (str, int)) and isinstance(lora_stack[1], int):
# This is a reference to another node, already processed
pass
else:
# Format each entry from the stack (assuming it's a list of tuples)
for stack_entry in lora_stack:
lora_name = stack_entry[0]
strength = stack_entry[1]
lora_texts.append(f"<lora:{lora_name}:{strength}>")
# Join with spaces
combined_text = " ".join(lora_texts)
return {"loras": combined_text}
class LoraStackerMapper(NodeMapper):
"""Mapper for LoraStacker nodes"""
def __init__(self):
super().__init__(
node_type="Lora Stacker (LoraManager)",
inputs_to_track=["loras", "lora_stack"]
)
def transform(self, inputs: Dict) -> Dict:
loras_data = inputs.get("loras", [])
result_stack = []
# Handle existing stack entries
existing_stack = []
lora_stack_input = inputs.get("lora_stack", [])
# Handle different formats of lora_stack
if isinstance(lora_stack_input, dict) and "lora_stack" in lora_stack_input:
# Format from another LoraStacker node
existing_stack = lora_stack_input["lora_stack"]
elif isinstance(lora_stack_input, list):
# Direct list format or reference format [node_id, output_slot]
if len(lora_stack_input) == 2 and isinstance(lora_stack_input[0], (str, int)) and isinstance(lora_stack_input[1], int):
# This is likely a reference that was already processed
pass
else:
# Regular list of tuples/entries
existing_stack = lora_stack_input
# Add existing entries first
if existing_stack:
result_stack.extend(existing_stack)
# Process loras array - filter active entries
# Check if loras_data is a list or a dict with __value__ key (new format)
if isinstance(loras_data, dict) and "__value__" in loras_data:
loras_list = loras_data["__value__"]
elif isinstance(loras_data, list):
loras_list = loras_data
else:
loras_list = []
# Process each active lora entry
for lora in loras_list:
if isinstance(lora, dict) and lora.get("active", False):
lora_name = lora.get("name", "")
strength = float(lora.get("strength", 1.0))
result_stack.append((lora_name, strength))
return {"lora_stack": result_stack}
class JoinStringsMapper(NodeMapper):
"""Mapper for JoinStrings nodes"""
def __init__(self):
super().__init__(
node_type="JoinStrings",
inputs_to_track=["string1", "string2", "delimiter"]
)
def transform(self, inputs: Dict) -> str:
string1 = inputs.get("string1", "")
string2 = inputs.get("string2", "")
delimiter = inputs.get("delimiter", "")
return f"{string1}{delimiter}{string2}"
class StringConstantMapper(NodeMapper):
"""Mapper for StringConstant and StringConstantMultiline nodes"""
def __init__(self):
super().__init__(
node_type="StringConstantMultiline",
inputs_to_track=["string"]
)
def transform(self, inputs: Dict) -> str:
return inputs.get("string", "")
class TriggerWordToggleMapper(NodeMapper):
"""Mapper for TriggerWordToggle nodes"""
def __init__(self):
super().__init__(
node_type="TriggerWord Toggle (LoraManager)",
inputs_to_track=["toggle_trigger_words"]
)
def transform(self, inputs: Dict) -> str:
toggle_data = inputs.get("toggle_trigger_words", [])
# check if toggle_words is a list or a dict with __value__ key (new format)
if isinstance(toggle_data, dict) and "__value__" in toggle_data:
toggle_words = toggle_data["__value__"]
elif isinstance(toggle_data, list):
toggle_words = toggle_data
else:
toggle_words = []
# Filter active trigger words
active_words = []
for item in toggle_words:
if isinstance(item, dict) and item.get("active", False):
word = item.get("text", "")
if word and not word.startswith("__dummy"):
active_words.append(word)
# Join with commas
result = ", ".join(active_words)
return result
class FluxGuidanceMapper(NodeMapper):
"""Mapper for FluxGuidance nodes"""
def __init__(self):
super().__init__(
node_type="FluxGuidance",
inputs_to_track=["guidance", "conditioning"]
)
def transform(self, inputs: Dict) -> Dict:
result = {}
# Handle guidance parameter
if "guidance" in inputs:
result["guidance"] = inputs["guidance"]
# Handle conditioning (the prompt text)
if "conditioning" in inputs:
conditioning = inputs["conditioning"]
if isinstance(conditioning, str):
result["prompt"] = conditioning
else:
result["prompt"] = "Unknown prompt"
return result
_MAPPER_REGISTRY: Dict[str, Dict] = {}
# =============================================================================
# Mapper Registry Functions
# Mapper Definition Functions
# =============================================================================
def register_mapper(mapper: NodeMapper) -> None:
def create_mapper(
node_type: str,
inputs_to_track: List[str],
transform_func: Callable[[Dict], Any] = None
) -> Dict:
"""Create a mapper definition for a node type"""
mapper = {
"node_type": node_type,
"inputs_to_track": inputs_to_track,
"transform": transform_func or (lambda inputs: inputs)
}
return mapper
def register_mapper(mapper: Dict) -> None:
"""Register a node mapper in the global registry"""
_MAPPER_REGISTRY[mapper.node_type] = mapper
logger.debug(f"Registered mapper for node type: {mapper.node_type}")
_MAPPER_REGISTRY[mapper["node_type"]] = mapper
logger.debug(f"Registered mapper for node type: {mapper['node_type']}")
def get_mapper(node_type: str) -> Optional[NodeMapper]:
def get_mapper(node_type: str) -> Optional[Dict]:
"""Get a mapper for the specified node type"""
return _MAPPER_REGISTRY.get(node_type)
def get_all_mappers() -> Dict[str, NodeMapper]:
def get_all_mappers() -> Dict[str, Dict]:
"""Get all registered mappers"""
return _MAPPER_REGISTRY.copy()
def register_default_mappers() -> None:
"""Register all default mappers"""
default_mappers = [
KSamplerMapper(),
EmptyLatentImageMapper(),
EmptySD3LatentImageMapper(),
CLIPTextEncodeMapper(),
LoraLoaderMapper(),
LoraStackerMapper(),
JoinStringsMapper(),
StringConstantMapper(),
TriggerWordToggleMapper(),
FluxGuidanceMapper()
]
# =============================================================================
# Node Processing Function
# =============================================================================
def process_node(node_id: str, node_data: Dict, workflow: Dict, parser: 'WorkflowParser') -> Any: # type: ignore
"""Process a node using its mapper and extract relevant information"""
node_type = node_data.get("class_type")
mapper = get_mapper(node_type)
for mapper in default_mappers:
if not mapper:
logger.warning(f"No mapper found for node type: {node_type}")
return None
result = {}
# Extract inputs based on the mapper's tracked inputs
for input_name in mapper["inputs_to_track"]:
if input_name in node_data.get("inputs", {}):
input_value = node_data["inputs"][input_name]
# Check if input is a reference to another node's output
if isinstance(input_value, list) and len(input_value) == 2:
try:
# Format is [node_id, output_slot]
ref_node_id, output_slot = input_value
# Convert node_id to string if it's an integer
if isinstance(ref_node_id, int):
ref_node_id = str(ref_node_id)
# Recursively process the referenced node
ref_value = parser.process_node(ref_node_id, workflow)
if ref_value is not None:
result[input_name] = ref_value
else:
# If we couldn't get a value from the reference, store the raw value
result[input_name] = input_value
except Exception as e:
logger.error(f"Error processing reference in node {node_id}, input {input_name}: {e}")
result[input_name] = input_value
else:
# Direct value
result[input_name] = input_value
# Apply the transform function
try:
return mapper["transform"](result)
except Exception as e:
logger.error(f"Error in transform function for node {node_id} of type {node_type}: {e}")
return result
# =============================================================================
# Transform Functions
# =============================================================================
def transform_lora_loader(inputs: Dict) -> Dict:
"""Transform function for LoraLoader nodes"""
loras_data = inputs.get("loras", [])
lora_stack = inputs.get("lora_stack", {}).get("lora_stack", [])
lora_texts = []
# Process loras array
if isinstance(loras_data, dict) and "__value__" in loras_data:
loras_list = loras_data["__value__"]
elif isinstance(loras_data, list):
loras_list = loras_data
else:
loras_list = []
# Process each active lora entry
for lora in loras_list:
if isinstance(lora, dict) and lora.get("active", False):
lora_name = lora.get("name", "")
strength = lora.get("strength", 1.0)
lora_texts.append(f"<lora:{lora_name}:{strength}>")
# Process lora_stack if valid
if lora_stack and isinstance(lora_stack, list):
if not (len(lora_stack) == 2 and isinstance(lora_stack[0], (str, int)) and isinstance(lora_stack[1], int)):
for stack_entry in lora_stack:
lora_name = stack_entry[0]
strength = stack_entry[1]
lora_texts.append(f"<lora:{lora_name}:{strength}>")
result = {
"checkpoint": inputs.get("model", {}).get("checkpoint", ""),
"loras": " ".join(lora_texts)
}
if "clip" in inputs and isinstance(inputs["clip"], dict):
result["clip_skip"] = inputs["clip"].get("clip_skip", "-1")
return result
def transform_lora_stacker(inputs: Dict) -> Dict:
"""Transform function for LoraStacker nodes"""
loras_data = inputs.get("loras", [])
result_stack = []
# Handle existing stack entries
existing_stack = []
lora_stack_input = inputs.get("lora_stack", [])
if isinstance(lora_stack_input, dict) and "lora_stack" in lora_stack_input:
existing_stack = lora_stack_input["lora_stack"]
elif isinstance(lora_stack_input, list):
if not (len(lora_stack_input) == 2 and isinstance(lora_stack_input[0], (str, int)) and
isinstance(lora_stack_input[1], int)):
existing_stack = lora_stack_input
# Add existing entries
if existing_stack:
result_stack.extend(existing_stack)
# Process new loras
if isinstance(loras_data, dict) and "__value__" in loras_data:
loras_list = loras_data["__value__"]
elif isinstance(loras_data, list):
loras_list = loras_data
else:
loras_list = []
for lora in loras_list:
if isinstance(lora, dict) and lora.get("active", False):
lora_name = lora.get("name", "")
strength = float(lora.get("strength", 1.0))
result_stack.append((lora_name, strength))
return {"lora_stack": result_stack}
def transform_trigger_word_toggle(inputs: Dict) -> str:
"""Transform function for TriggerWordToggle nodes"""
toggle_data = inputs.get("toggle_trigger_words", [])
if isinstance(toggle_data, dict) and "__value__" in toggle_data:
toggle_words = toggle_data["__value__"]
elif isinstance(toggle_data, list):
toggle_words = toggle_data
else:
toggle_words = []
# Filter active trigger words
active_words = []
for item in toggle_words:
if isinstance(item, dict) and item.get("active", False):
word = item.get("text", "")
if word and not word.startswith("__dummy"):
active_words.append(word)
return ", ".join(active_words)
# =============================================================================
# Node Mapper Definitions
# =============================================================================
# Central definition of all supported node types and their configurations
NODE_MAPPERS = {
# LoraManager nodes
"Lora Loader (LoraManager)": {
"inputs_to_track": ["model", "clip", "loras", "lora_stack"],
"transform_func": transform_lora_loader
},
"Lora Stacker (LoraManager)": {
"inputs_to_track": ["loras", "lora_stack"],
"transform_func": transform_lora_stacker
},
"TriggerWord Toggle (LoraManager)": {
"inputs_to_track": ["toggle_trigger_words"],
"transform_func": transform_trigger_word_toggle
}
}
def register_all_mappers() -> None:
"""Register all mappers from the NODE_MAPPERS dictionary"""
for node_type, config in NODE_MAPPERS.items():
mapper = create_mapper(
node_type=node_type,
inputs_to_track=config["inputs_to_track"],
transform_func=config["transform_func"]
)
register_mapper(mapper)
logger.info(f"Registered {len(NODE_MAPPERS)} node mappers")
# =============================================================================
# Extension Loading
@@ -383,8 +237,8 @@ def load_extensions(ext_dir: str = None) -> None:
"""
Load mapper extensions from the specified directory
Each Python file in the directory will be loaded, and any NodeMapper subclasses
defined in those files will be automatically registered.
Extension files should define a NODE_MAPPERS_EXT dictionary containing mapper configurations.
These will be added to the global NODE_MAPPERS dictionary and registered automatically.
"""
# Use default path if none provided
if ext_dir is None:
@@ -411,18 +265,18 @@ def load_extensions(ext_dir: str = None) -> None:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Find all NodeMapper subclasses in the module
for name, obj in inspect.getmembers(module):
if (inspect.isclass(obj) and issubclass(obj, NodeMapper)
and obj != NodeMapper and hasattr(obj, 'node_type')):
# Instantiate and register the mapper
mapper = obj()
register_mapper(mapper)
logger.info(f"Loaded extension mapper: {mapper.node_type} from {filename}")
# Check if the module defines NODE_MAPPERS_EXT
if hasattr(module, 'NODE_MAPPERS_EXT'):
# Add the extension mappers to the global NODE_MAPPERS dictionary
NODE_MAPPERS.update(module.NODE_MAPPERS_EXT)
logger.info(f"Added {len(module.NODE_MAPPERS_EXT)} mappers from extension: {filename}")
else:
logger.warning(f"Extension {filename} does not define NODE_MAPPERS_EXT dictionary")
except Exception as e:
logger.warning(f"Error loading extension {filename}: {e}")
# Re-register all mappers after loading extensions
register_all_mappers()
# Initialize the registry with default mappers
register_default_mappers()
# register_default_mappers()

View File

@@ -4,7 +4,7 @@ Main workflow parser implementation for ComfyUI
import json
import logging
from typing import Dict, List, Any, Optional, Union, Set
from .mappers import get_mapper, get_all_mappers, load_extensions
from .mappers import get_mapper, get_all_mappers, load_extensions, process_node
from .utils import (
load_workflow, save_output, find_node_by_type,
trace_model_path
@@ -15,14 +15,13 @@ logger = logging.getLogger(__name__)
class WorkflowParser:
"""Parser for ComfyUI workflows"""
def __init__(self, load_extensions_on_init: bool = True):
def __init__(self):
"""Initialize the parser with mappers"""
self.processed_nodes: Set[str] = set() # Track processed nodes to avoid cycles
self.node_results_cache: Dict[str, Any] = {} # Cache for processed node results
# Load extensions if requested
if load_extensions_on_init:
load_extensions()
# Load extensions
load_extensions()
def process_node(self, node_id: str, workflow: Dict) -> Any:
"""Process a single node and extract relevant information"""
@@ -45,10 +44,9 @@ class WorkflowParser:
node_type = node_data.get("class_type")
result = None
mapper = get_mapper(node_type)
if mapper:
if get_mapper(node_type):
try:
result = mapper.process(node_id, node_data, workflow, self)
result = process_node(node_id, node_data, workflow, self)
# Cache the result
self.node_results_cache[node_id] = result
except Exception as e:
@@ -60,32 +58,58 @@ class WorkflowParser:
self.processed_nodes.remove(node_id)
return result
def collect_loras_from_model(self, model_input: List, workflow: Dict) -> str:
"""Collect loras information from the model node chain"""
if not isinstance(model_input, list) or len(model_input) != 2:
return ""
model_node_id, _ = model_input
# Convert node_id to string if it's an integer
if isinstance(model_node_id, int):
model_node_id = str(model_node_id)
# Process the model node
model_result = self.process_node(model_node_id, workflow)
def find_primary_sampler_node(self, workflow: Dict) -> Optional[str]:
"""
Find the primary sampler node in the workflow.
# If this is a Lora Loader node, return the loras text
if model_result and isinstance(model_result, dict) and "loras" in model_result:
return model_result["loras"]
# If not a lora loader, check the node's inputs for a model connection
node_data = workflow.get(model_node_id, {})
inputs = node_data.get("inputs", {})
Priority:
1. First try to find a SamplerCustomAdvanced node
2. If not found, look for KSampler nodes with denoise=1.0
3. If still not found, use the first KSampler node
# If this node has a model input, follow that path
if "model" in inputs and isinstance(inputs["model"], list):
return self.collect_loras_from_model(inputs["model"], workflow)
Args:
workflow: The workflow data as a dictionary
return ""
Returns:
The node ID of the primary sampler node, or None if not found
"""
# First check for SamplerCustomAdvanced nodes
sampler_advanced_nodes = []
ksampler_nodes = []
# Scan workflow for sampler nodes
for node_id, node_data in workflow.items():
node_type = node_data.get("class_type")
if node_type == "SamplerCustomAdvanced":
sampler_advanced_nodes.append(node_id)
elif node_type == "KSampler":
ksampler_nodes.append(node_id)
# If we found SamplerCustomAdvanced nodes, return the first one
if sampler_advanced_nodes:
logger.debug(f"Found SamplerCustomAdvanced node: {sampler_advanced_nodes[0]}")
return sampler_advanced_nodes[0]
# If we have KSampler nodes, look for one with denoise=1.0
if ksampler_nodes:
for node_id in ksampler_nodes:
node_data = workflow[node_id]
inputs = node_data.get("inputs", {})
denoise = inputs.get("denoise", 0)
# Check if denoise is 1.0 (allowing for small floating point differences)
if abs(float(denoise) - 1.0) < 0.001:
logger.debug(f"Found KSampler node with denoise=1.0: {node_id}")
return node_id
# If no KSampler with denoise=1.0 found, use the first one
logger.debug(f"No KSampler with denoise=1.0 found, using first KSampler: {ksampler_nodes[0]}")
return ksampler_nodes[0]
# No sampler nodes found
logger.warning("No sampler nodes found in workflow")
return None
def parse_workflow(self, workflow_data: Union[str, Dict], output_path: Optional[str] = None) -> Dict:
"""
@@ -108,77 +132,38 @@ class WorkflowParser:
self.processed_nodes = set()
self.node_results_cache = {}
# Find the KSampler node
ksampler_node_id = find_node_by_type(workflow, "KSampler")
if not ksampler_node_id:
logger.warning("No KSampler node found in workflow")
# Find the primary sampler node
sampler_node_id = self.find_primary_sampler_node(workflow)
if not sampler_node_id:
logger.warning("No suitable sampler node found in workflow")
return {}
# Start parsing from the KSampler node
result = {
"gen_params": {},
"loras": ""
}
# Process sampler node to extract parameters
sampler_result = self.process_node(sampler_node_id, workflow)
if not sampler_result:
return {}
# Process KSampler node to extract parameters
ksampler_result = self.process_node(ksampler_node_id, workflow)
if ksampler_result:
# Process the result
for key, value in ksampler_result.items():
# Special handling for the positive prompt from FluxGuidance
if key == "positive" and isinstance(value, dict):
# Extract guidance value
if "guidance" in value:
result["gen_params"]["guidance"] = value["guidance"]
# Extract prompt
if "prompt" in value:
result["gen_params"]["prompt"] = value["prompt"]
else:
# Normal handling for other values
result["gen_params"][key] = value
# Process the positive prompt node if it exists and we don't have a prompt yet
if "prompt" not in result["gen_params"] and "positive" in ksampler_result:
positive_value = ksampler_result.get("positive")
if isinstance(positive_value, str):
result["gen_params"]["prompt"] = positive_value
# Manually check for FluxGuidance if we don't have guidance value
if "guidance" not in result["gen_params"]:
flux_node_id = find_node_by_type(workflow, "FluxGuidance")
if flux_node_id:
# Get the direct input from the node
node_inputs = workflow[flux_node_id].get("inputs", {})
if "guidance" in node_inputs:
result["gen_params"]["guidance"] = node_inputs["guidance"]
# Extract loras from the model input of KSampler
ksampler_node = workflow.get(ksampler_node_id, {})
ksampler_inputs = ksampler_node.get("inputs", {})
if "model" in ksampler_inputs and isinstance(ksampler_inputs["model"], list):
loras_text = self.collect_loras_from_model(ksampler_inputs["model"], workflow)
if loras_text:
result["loras"] = loras_text
# Return the sampler result directly - it's already in the format we need
# This simplifies the structure and makes it easier to use in recipe_routes.py
# Handle standard ComfyUI names vs our output format
if "cfg" in result["gen_params"]:
result["gen_params"]["cfg_scale"] = result["gen_params"].pop("cfg")
if "cfg" in sampler_result:
sampler_result["cfg_scale"] = sampler_result.pop("cfg")
# Add clip_skip = 2 to match reference output if not already present
if "clip_skip" not in result["gen_params"]:
result["gen_params"]["clip_skip"] = "2"
# Add clip_skip = 1 to match reference output if not already present
if "clip_skip" not in sampler_result:
sampler_result["clip_skip"] = "1"
# Ensure the prompt is a string and not a nested dictionary
if "prompt" in result["gen_params"] and isinstance(result["gen_params"]["prompt"], dict):
if "prompt" in result["gen_params"]["prompt"]:
result["gen_params"]["prompt"] = result["gen_params"]["prompt"]["prompt"]
if "prompt" in sampler_result and isinstance(sampler_result["prompt"], dict):
if "prompt" in sampler_result["prompt"]:
sampler_result["prompt"] = sampler_result["prompt"]["prompt"]
# Save the result if requested
if output_path:
save_output(result, output_path)
save_output(sampler_result, output_path)
return result
return sampler_result
def parse_workflow(workflow_path: str, output_path: Optional[str] = None) -> Dict:
@@ -193,4 +178,4 @@ def parse_workflow(workflow_path: str, output_path: Optional[str] = None) -> Dic
Dictionary containing extracted parameters
"""
parser = WorkflowParser()
return parser.parse_workflow(workflow_path, output_path)
return parser.parse_workflow(workflow_path, output_path)

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
version = "0.8.2"
version = "0.8.4"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -2,13 +2,10 @@ a dynamic and dramatic digital artwork featuring a stylized anthropomorphic whit
Negative prompt:
Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {}
<lora:ck-shadow-circuit-IL:0.78>,
masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject,
dynamic angle, dutch angle, from below, epic half body portrait, gritty, wabi sabi, looking at viewer, woman is a geisha, parted lips,
holographic skin, holofoil glitter, faint, glowing, ethereal, neon hair, glowing hair, otherworldly glow, she is dangerous,
<lora:ck-nc-cyberpunk-IL-000011:0.4>
<lora:ck-neon-retrowave-IL:0.2>
<lora:ck-yoneyama-mai-IL-000014:0.4>
holographic skin, holofoil glitter, faint, glowing, ethereal, neon hair, glowing hair, otherworldly glow, she is dangerous
<lora:ck-shadow-circuit-IL:0.78>, <lora:ck-nc-cyberpunk-IL-000011:0.4>, <lora:ck-neon-retrowave-IL:0.2>, <lora:ck-yoneyama-mai-IL-000014:0.4>
Negative prompt: score_6, score_5, score_4, bad quality, worst quality, worst detail, sketch, censorship, furry, window, headphones,
Steps: 30, Sampler: Euler a, Schedule type: Simple, CFG scale: 7, Seed: 1405717592, Size: 832x1216, Model hash: 1ad6ca7f70, Model: waiNSFWIllustrious_v100, Denoising strength: 0.35, Hires CFG Scale: 5, Hires upscale: 1.3, Hires steps: 20, Hires upscaler: 4x-AnimeSharp, Lora hashes: "ck-shadow-circuit-IL: 88e247aa8c3d, ck-nc-cyberpunk-IL-000011: 935e6755554c, ck-neon-retrowave-IL: edafb9df7da1, ck-yoneyama-mai-IL-000014: 1b9305692a2e", Version: f2.0.1v1.10.1-1.10.1, Diffusion in Low Bits: Automatic (fp16 LoRA)

View File

@@ -1,13 +1,11 @@
{
"loras": "<lora:ck-neon-retrowave-IL-000012:0.8> <lora:aorunIllstrious:1> <lora:ck-shadow-circuit-IL-000012:0.78> <lora:MoriiMee_Gothic_Niji_Style_Illustrious_r1:0.45> <lora:ck-nc-cyberpunk-IL-000011:0.4>",
"gen_params": {
"prompt": "in the style of ck-rw, aorun, scales, makeup, bare shoulders, pointy ears, dress, claws, in the style of cksc, artist:moriimee, in the style of cknc, masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject, close up, stylized, in gold and neon shades, wabi sabi, 1girl, rainbow angel wings, looking at viewer, dynamic angle, from below, from side, relaxing",
"negative_prompt": "bad quality, worst quality, worst detail, sketch ,signature, watermark, patreon logo, nsfw",
"steps": "20",
"sampler": "euler_ancestral",
"cfg_scale": "8",
"seed": "241",
"size": "832x1216",
"clip_skip": "2"
}
"prompt": "in the style of ck-rw, aorun, scales, makeup, bare shoulders, pointy ears, dress, claws, in the style of cksc, artist:moriimee, in the style of cknc, masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject, close up, stylized, in gold and neon shades, wabi sabi, 1girl, rainbow angel wings, looking at viewer, dynamic angle, from below, from side, relaxing",
"negative_prompt": "bad quality, worst quality, worst detail, sketch ,signature, watermark, patreon logo, nsfw",
"steps": "20",
"sampler": "euler_ancestral",
"cfg_scale": "8",
"seed": "241",
"size": "832x1216",
"clip_skip": "2"
}

View File

@@ -1,75 +1,12 @@
{
"3": {
"inputs": {
"seed": 241,
"steps": 20,
"cfg": 8,
"sampler_name": "euler_ancestral",
"scheduler": "karras",
"denoise": 1,
"model": [
"56",
0
],
"positive": [
"6",
0
],
"negative": [
"7",
0
],
"latent_image": [
"5",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
},
"4": {
"inputs": {
"ckpt_name": "il\\waiNSFWIllustrious_v110.safetensors"
},
"class_type": "CheckpointLoaderSimple",
"_meta": {
"title": "Load Checkpoint"
}
},
"5": {
"inputs": {
"width": 832,
"height": 1216,
"batch_size": 1
},
"class_type": "EmptyLatentImage",
"_meta": {
"title": "Empty Latent Image"
}
},
"6": {
"inputs": {
"text": [
"22",
"301",
0
],
"clip": [
"56",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Prompt)"
}
},
"7": {
"inputs": {
"text": "bad quality, worst quality, worst detail, sketch ,signature, watermark, patreon logo, nsfw",
"clip": [
"56",
"299",
1
]
},
@@ -81,12 +18,12 @@
"8": {
"inputs": {
"samples": [
"3",
0
"13",
1
],
"vae": [
"4",
2
"10",
0
]
},
"class_type": "VAEDecode",
@@ -94,7 +31,230 @@
"title": "VAE Decode"
}
},
"14": {
"10": {
"inputs": {
"vae_name": "flux1\\ae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"11": {
"inputs": {
"clip_name1": "t5xxl_fp8_e4m3fn.safetensors",
"clip_name2": "ViT-L-14-TEXT-detail-improved-hiT-GmP-TE-only-HF.safetensors",
"type": "flux",
"device": "default"
},
"class_type": "DualCLIPLoader",
"_meta": {
"title": "DualCLIPLoader"
}
},
"13": {
"inputs": {
"noise": [
"147",
0
],
"guider": [
"22",
0
],
"sampler": [
"16",
0
],
"sigmas": [
"17",
0
],
"latent_image": [
"48",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"16": {
"inputs": {
"sampler_name": "dpmpp_2m"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"17": {
"inputs": {
"scheduler": "beta",
"steps": [
"246",
0
],
"denoise": 1,
"model": [
"28",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"22": {
"inputs": {
"model": [
"28",
0
],
"conditioning": [
"29",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "BasicGuider"
}
},
"28": {
"inputs": {
"max_shift": 1.1500000000000001,
"base_shift": 0.5,
"width": [
"48",
1
],
"height": [
"48",
2
],
"model": [
"299",
0
]
},
"class_type": "ModelSamplingFlux",
"_meta": {
"title": "ModelSamplingFlux"
}
},
"29": {
"inputs": {
"guidance": 3.5,
"conditioning": [
"6",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "FluxGuidance"
}
},
"48": {
"inputs": {
"resolution": "832x1216 (0.68)",
"batch_size": 1,
"width_override": 0,
"height_override": 0
},
"class_type": "SDXLEmptyLatentSizePicker+",
"_meta": {
"title": "🔧 SDXL Empty Latent Size Picker"
}
},
"65": {
"inputs": {
"unet_name": "flux\\flux1-dev-fp8-e4m3fn.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"147": {
"inputs": {
"noise_seed": 651532572596956
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"148": {
"inputs": {
"wildcard_text": "__some-prompts__",
"populated_text": "A surreal digital artwork showcases a forward-thinking inventor captivated by his intricate mechanical creation through a large magnifying glass. Viewed from an unconventional perspective, the scene reveals an eccentric assembly of gears, springs, and brass instruments within his workshop. Soft, ethereal light radiates from the invention, casting enigmatic shadows on the walls as time appears to bend around its metallic form, invoking a sense of curiosity, wonder, and exhilaration in discovery.",
"mode": "fixed",
"seed": 553084268162351,
"Select to add Wildcard": "Select the Wildcard to add to the text"
},
"class_type": "ImpactWildcardProcessor",
"_meta": {
"title": "ImpactWildcardProcessor"
}
},
"151": {
"inputs": {
"text": "A hyper-realistic close-up portrait of a young woman with shoulder-length black hair styled in edgy, futuristic layers, adorned with glowing tips. She wears mecha eyewear with a neon green visor that transitions into iridescent shades of teal and gold. The frame is sleek, with angular edges and fine mechanical detailing. Her expression is fierce and confident, with flawless skin highlighted by the neon reflections. She wears a high-tech bodysuit with integrated LED lines and metallic panels. The background depicts a hazy rendition of The Great Wave off Kanagawa by Hokusai, its powerful waves blending seamlessly with the neon tones, amplifying her intense, defiant aura."
},
"class_type": "Text Multiline",
"_meta": {
"title": "Text Multiline"
}
},
"191": {
"inputs": {
"text": "A cinematic, oil painting masterpiece captures the essence of impressionistic surrealism, inspired by Claude Monet. A mysterious woman in a flowing crimson dress stands at the edge of a tranquil lake, where lily pads shimmer under an ethereal, golden twilight. The waters surface reflects a dreamlike sky, its swirling hues of violet and sapphire melting together like liquid light. The thick, expressive brushstrokes lend depth to the scene, evoking a sense of nostalgia and quiet longing, as if the world itself is caught between reality and a fleeting dream. \nA mesmerizing oil painting masterpiece inspired by Salvador Dalí, blending surrealism with post-impressionist texture. A lone violinist plays atop a melting clock tower, his form distorted by the passage of time. The sky is a cascade of swirling, liquid oranges and deep blues, where floating staircases spiral endlessly into the horizon. The impasto technique gives depth and movement to the surreal elements, making time itself feel fluid, as if the world is dissolving into a dream. \nA stunning impressionistic oil painting evokes the spirit of Edvard Munch, capturing a solitary figure standing on a rain-soaked street, illuminated by the glow of flickering gas lamps. The swirling, chaotic strokes of deep blues and fiery reds reflect the turbulence of emotion, while the blurred reflections in the wet cobblestone suggest a merging of past and present. The faceless figure, draped in a dark overcoat, seems lost in thought, embodying the ephemeral nature of memory and time. \nA breathtaking oil painting masterpiece, inspired by Gustav Klimt, presents a celestial ballroom where faceless dancers swirl in an eternal waltz beneath a gilded, star-speckled sky. Their golden garments shimmer with intricate patterns, blending into the opulent mosaic floor that seems to stretch into infinity. The dreamlike composition, rich in warm amber and deep sapphire hues, captures an otherworldly elegance, as if the dancers are suspended in a moment that transcends time. \nA visionary oil painting inspired by Marc Chagall depicts a dreamlike cityscape where gravity ceases to exist. A couple floats above a crimson-tinted town, their forms dissolving into the swirling strokes of a vast, cerulean sky. The buildings below twist and bend in rhythmic motion, their windows glowing like tiny stars. The thick, textured brushwork conveys a sense of weightlessness and wonder, as if love itself has defied the laws of the universe. \nAn impressionistic oil painting in the style of J.M.W. Turner, depicting a ghostly ship sailing through a sea of swirling golden mist. The waves crash and dissolve into abstract, fiery strokes of orange and deep indigo, blurring the line between ocean and sky. The ship appears almost ethereal, as if drifting between worlds, lost in the ever-changing tides of memory and myth. The dynamic brushstrokes capture the relentless power of nature and the fleeting essence of time. \nA captivating oil painting masterpiece, infused with surrealist impressionism, portrays a grand library where books float midair, their pages unraveling into ribbons of light. The towering shelves twist into the heavens, vanishing into an infinite, starry void. A lone scholar, illuminated by the glow of a suspended lantern, reaches for a book that seems to pulse with life. The scene pulses with mystery, where the impasto textures bring depth to the interplay between knowledge and dreams. \nA luminous impressionistic oil painting captures the melancholic beauty of an abandoned carnival, its faded carousel horses frozen mid-gallop beneath a sky of swirling lavender and gold. The wind carries fragments of forgotten laughter through the empty fairground, where scattered ticket stubs and crumbling banners whisper tales of joy long past. The thick, textured brushstrokes blend nostalgia with an eerie dreamlike quality, as if the carnival exists only in the echoes of memory. \nA surreal oil painting in the spirit of René Magritte, featuring a towering lighthouse that emits not light, but cascading waterfalls from its peak. The swirling sky, painted in deep midnight blues, is punctuated by glowing, crescent moons that defy gravity. A lone figure stands at the waters edge, gazing up in quiet contemplation, as if caught between wonder and the unknown. The paintings rich textures and luminous colors create an enigmatic, dreamlike landscape. \nA striking impressionistic oil painting, reminiscent of Van Gogh, portrays a lone traveler on a winding cobblestone path, their silhouette bathed in the golden glow of lantern-lit cherry blossoms. The petals swirl through the night air like glowing embers, blending with the deep, rhythmic strokes of a star-filled indigo sky. The scene captures a feeling of wistful solitude, as if the traveler is walking not only through the city, but through the fleeting nature of time itself."
},
"class_type": "Text Multiline",
"_meta": {
"title": "Text Multiline"
}
},
"203": {
"inputs": {
"string1": [
"289",
0
],
"string2": [
"293",
0
],
"delimiter": ", "
},
"class_type": "JoinStrings",
"_meta": {
"title": "Join Strings"
}
},
"208": {
"inputs": {
"file_path": "",
"dictionary_name": "[filename]",
"label": "TextBatch",
"mode": "automatic",
"index": 0,
"multiline_text": [
"191",
0
]
},
"class_type": "Text Load Line From File",
"_meta": {
"title": "Text Load Line From File"
}
},
"226": {
"inputs": {
"images": [
"8",
@@ -106,60 +266,21 @@
"title": "Preview Image"
}
},
"19": {
"246": {
"inputs": {
"stop_at_clip_layer": -2,
"clip": [
"4",
1
]
"value": 25
},
"class_type": "CLIPSetLastLayer",
"class_type": "INTConstant",
"_meta": {
"title": "CLIP Set Last Layer"
"title": "Steps"
}
},
"21": {
"inputs": {
"string": "masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject, close up, stylized, in gold and neon shades, wabi sabi, 1girl, rainbow angel wings, looking at viewer, dynamic angle, from below, from side, relaxing",
"strip_newlines": false
},
"class_type": "StringConstantMultiline",
"_meta": {
"title": "positive"
}
},
"22": {
"inputs": {
"string1": [
"55",
0
],
"string2": [
"21",
0
],
"delimiter": ", "
},
"class_type": "JoinStrings",
"_meta": {
"title": "Join Strings"
}
},
"55": {
"289": {
"inputs": {
"group_mode": true,
"toggle_trigger_words": [
{
"text": "in the style of ck-rw",
"active": true
},
{
"text": "in the style of cksc",
"active": true
},
{
"text": "artist:moriimee",
"text": "bo-exposure",
"active": true
},
{
@@ -173,9 +294,9 @@
"_isDummy": true
}
],
"orinalMessage": "in the style of ck-rw,, in the style of cksc,, artist:moriimee",
"orinalMessage": "bo-exposure",
"trigger_words": [
"56",
"299",
2
]
},
@@ -184,25 +305,58 @@
"title": "TriggerWord Toggle (LoraManager)"
}
},
"56": {
"293": {
"inputs": {
"text": "<lora:ck-shadow-circuit-IL-000012:0.78> <lora:MoriiMee_Gothic_Niji_Style_Illustrious_r1:0.45> <lora:ck-nc-cyberpunk-IL-000011:0.4>",
"input": 1,
"text1": [
"208",
0
],
"text2": [
"151",
0
]
},
"class_type": "easy textSwitch",
"_meta": {
"title": "Text Switch"
}
},
"297": {
"inputs": {
"text": ""
},
"class_type": "Lora Stacker (LoraManager)",
"_meta": {
"title": "Lora Stacker (LoraManager)"
}
},
"298": {
"inputs": {
"anything": [
"297",
0
]
},
"class_type": "easy showAnything",
"_meta": {
"title": "Show Any"
}
},
"299": {
"inputs": {
"text": "<lora:boFLUX Double Exposure Magic v2:0.8> <lora:FluxDFaeTasticDetails:0.65>",
"loras": [
{
"name": "ck-shadow-circuit-IL-000012",
"strength": 0.78,
"name": "boFLUX Double Exposure Magic v2",
"strength": 0.8,
"active": true
},
{
"name": "MoriiMee_Gothic_Niji_Style_Illustrious_r1",
"strength": 0.45,
"name": "FluxDFaeTasticDetails",
"strength": 0.65,
"active": true
},
{
"name": "ck-nc-cyberpunk-IL-000011",
"strength": 0.4,
"active": false
},
{
"name": "__dummy_item1__",
"strength": 0,
@@ -217,15 +371,15 @@
}
],
"model": [
"4",
"65",
0
],
"clip": [
"4",
1
"11",
0
],
"lora_stack": [
"57",
"297",
0
]
},
@@ -234,64 +388,14 @@
"title": "Lora Loader (LoraManager)"
}
},
"57": {
"301": {
"inputs": {
"text": "<lora:aorunIllstrious:1>",
"loras": [
{
"name": "aorunIllstrious",
"strength": "0.90",
"active": false
},
{
"name": "__dummy_item1__",
"strength": 0,
"active": false,
"_isDummy": true
},
{
"name": "__dummy_item2__",
"strength": 0,
"active": false,
"_isDummy": true
}
],
"lora_stack": [
"59",
0
]
"string": "A hyper-realistic close-up portrait of a young woman with shoulder-length black hair styled in edgy, futuristic layers, adorned with glowing tips. She wears mecha eyewear with a neon green visor that transitions into iridescent shades of teal and gold. The frame is sleek, with angular edges and fine mechanical detailing. Her expression is fierce and confident, with flawless skin highlighted by the neon reflections. She wears a high-tech bodysuit with integrated LED lines and metallic panels. The background depicts a hazy rendition of The Great Wave off Kanagawa by Hokusai, its powerful waves blending seamlessly with the neon tones, amplifying her intense, defiant aura.",
"strip_newlines": true
},
"class_type": "Lora Stacker (LoraManager)",
"class_type": "StringConstantMultiline",
"_meta": {
"title": "Lora Stacker (LoraManager)"
}
},
"59": {
"inputs": {
"text": "<lora:ck-neon-retrowave-IL-000012:0.8>",
"loras": [
{
"name": "ck-neon-retrowave-IL-000012",
"strength": 0.8,
"active": true
},
{
"name": "__dummy_item1__",
"strength": 0,
"active": false,
"_isDummy": true
},
{
"name": "__dummy_item2__",
"strength": 0,
"active": false,
"_isDummy": true
}
]
},
"class_type": "Lora Stacker (LoraManager)",
"_meta": {
"title": "Lora Stacker (LoraManager)"
"title": "String Constant Multiline"
}
}
}

View File

@@ -1,25 +0,0 @@
import json
from py.workflow.parser import WorkflowParser
# Load workflow data
with open('refs/prompt.json', 'r') as f:
workflow_data = json.load(f)
# Parse workflow
parser = WorkflowParser()
try:
# Parse the workflow
result = parser.parse_workflow(workflow_data)
print("Parsing successful!")
# Print each component separately
print("\nGeneration Parameters:")
for k, v in result.get("gen_params", {}).items():
print(f" {k}: {v}")
print("\nLoRAs:")
print(result.get("loras", ""))
except Exception as e:
print(f"Error parsing workflow: {e}")
import traceback
traceback.print_exc()

View File

@@ -341,8 +341,7 @@ body.modal-open {
.setting-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-direction: column;
margin-bottom: var(--space-2);
padding: var(--space-1);
border-radius: var(--border-radius-xs);
@@ -357,7 +356,8 @@ body.modal-open {
}
.setting-info {
flex: 1;
margin-bottom: var(--space-1);
width: 100%;
}
.setting-info label {
@@ -367,7 +367,39 @@ body.modal-open {
}
.setting-control {
padding-left: var(--space-2);
width: 100%;
margin-bottom: var(--space-1);
}
/* Select Control Styles */
.select-control {
width: 100%;
}
.select-control select {
width: 100%;
padding: 6px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
}
/* Fix dark theme select dropdown text color */
[data-theme="dark"] .select-control select {
background-color: rgba(30, 30, 30, 0.9);
color: var(--text-color);
}
[data-theme="dark"] .select-control select option {
background-color: #2d2d2d;
color: var(--text-color);
}
.select-control select:focus {
border-color: var(--lora-accent);
outline: none;
}
/* Toggle Switch */
@@ -482,4 +514,44 @@ input:checked + .toggle-slider:before {
font-style: italic;
margin-top: var(--space-1);
text-align: center;
}
/* Add styles for markdown elements in changelog */
.changelog-item ul {
padding-left: 20px;
margin-top: 8px;
}
.changelog-item li {
margin-bottom: 6px;
line-height: 1.4;
}
.changelog-item strong {
font-weight: 600;
}
.changelog-item em {
font-style: italic;
}
.changelog-item code {
background: rgba(0, 0, 0, 0.05);
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
[data-theme="dark"] .changelog-item code {
background: rgba(255, 255, 255, 0.1);
}
.changelog-item a {
color: var(--lora-accent);
text-decoration: none;
}
.changelog-item a:hover {
text-decoration: underline;
}

View File

@@ -584,7 +584,7 @@
font-size: 0.9em;
}
/* Deleted badge */
/* Deleted badge with reconnect functionality */
.deleted-badge {
display: inline-flex;
align-items: center;
@@ -603,6 +603,138 @@
font-size: 0.9em;
}
/* Add reconnect functionality styles */
.deleted-badge.reconnectable {
position: relative;
cursor: pointer;
transition: background-color 0.2s ease;
}
.deleted-badge.reconnectable:hover {
background-color: var(--lora-accent);
}
.deleted-badge .reconnect-tooltip {
position: absolute;
display: none;
background-color: var(--card-bg);
color: var(--text-color);
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: var(--z-overlay);
width: max-content;
max-width: 200px;
font-size: 0.85rem;
font-weight: normal;
top: calc(100% + 5px);
left: 0;
margin-left: -100px;
}
.deleted-badge.reconnectable:hover .reconnect-tooltip {
display: block;
}
/* LoRA reconnect container */
.lora-reconnect-container {
display: none;
flex-direction: column;
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 12px;
margin-top: 10px;
gap: 10px;
}
.lora-reconnect-container.active {
display: flex;
}
.reconnect-instructions {
display: flex;
flex-direction: column;
gap: 5px;
}
.reconnect-instructions p {
margin: 0;
font-size: 0.95em;
font-weight: 500;
color: var(--text-color);
}
.reconnect-instructions small {
color: var(--text-color);
opacity: 0.7;
font-size: 0.85em;
}
.reconnect-instructions code {
background: rgba(0, 0, 0, 0.1);
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
[data-theme="dark"] .reconnect-instructions code {
background: rgba(255, 255, 255, 0.1);
}
.reconnect-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.reconnect-input {
width: calc(100% - 20px);
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9em;
}
.reconnect-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.reconnect-cancel-btn,
.reconnect-confirm-btn {
padding: 6px 12px;
border-radius: var(--border-radius-xs);
font-size: 0.85em;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.reconnect-cancel-btn {
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.reconnect-confirm-btn {
background: var(--lora-accent);
color: white;
}
.reconnect-cancel-btn:hover {
background: var(--lora-surface);
}
.reconnect-confirm-btn:hover {
background: color-mix(in oklch, var(--lora-accent), black 10%);
}
/* Recipe status partial state */
.recipe-status.partial {
background: rgba(127, 127, 127, 0.1);

View File

@@ -31,6 +31,16 @@ class RecipeModal {
!event.target.closest('.edit-icon')) {
this.saveTagsEdit();
}
// Handle reconnect input
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
reconnectContainers.forEach(container => {
if (container.classList.contains('active') &&
!container.contains(event.target) &&
!event.target.closest('.deleted-badge.reconnectable')) {
this.hideReconnectInput(container);
}
});
});
}
@@ -358,8 +368,9 @@ class RecipeModal {
</div>`;
} else if (isDeleted) {
localStatus = `
<div class="deleted-badge">
<i class="fas fa-trash-alt"></i> Deleted
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}">
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
</div>`;
} else {
localStatus = `
@@ -387,7 +398,7 @@ class RecipeModal {
}
return `
<div class="${loraItemClass}">
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}">
<div class="recipe-lora-thumbnail">
${previewMedia}
</div>
@@ -401,11 +412,29 @@ class RecipeModal {
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
</div>
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}">
<div class="reconnect-instructions">
<p>Enter LoRA Syntax or Name to Reconnect:</p>
<small>Example: <code>&lt;lora:Boris_Vallejo_BV_flux_D:1&gt;</code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
</div>
<div class="reconnect-form">
<input type="text" class="reconnect-input" placeholder="Enter LoRA name or syntax">
<div class="reconnect-actions">
<button class="reconnect-cancel-btn">Cancel</button>
<button class="reconnect-confirm-btn">Reconnect</button>
</div>
</div>
</div>
</div>
</div>
`;
}).join('');
// Add event listeners for reconnect functionality
setTimeout(() => {
this.setupReconnectButtons();
}, 100);
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
this.recipeLorasSyntax = '';
@@ -829,6 +858,155 @@ class RecipeModal {
state.loadingManager.hide();
}
}
// New methods for reconnecting LoRAs
setupReconnectButtons() {
// Add event listeners to all deleted badges
const deletedBadges = document.querySelectorAll('.deleted-badge.reconnectable');
deletedBadges.forEach(badge => {
badge.addEventListener('mouseenter', () => {
badge.querySelector('.badge-text').innerHTML = 'Reconnect';
});
badge.addEventListener('mouseleave', () => {
badge.querySelector('.badge-text').innerHTML = '<i class="fas fa-trash-alt"></i> Deleted';
});
badge.addEventListener('click', (e) => {
const loraIndex = badge.getAttribute('data-lora-index');
this.showReconnectInput(loraIndex);
});
});
// Add event listeners to reconnect cancel buttons
const cancelButtons = document.querySelectorAll('.reconnect-cancel-btn');
cancelButtons.forEach(button => {
button.addEventListener('click', (e) => {
const container = button.closest('.lora-reconnect-container');
this.hideReconnectInput(container);
});
});
// Add event listeners to reconnect confirm buttons
const confirmButtons = document.querySelectorAll('.reconnect-confirm-btn');
confirmButtons.forEach(button => {
button.addEventListener('click', (e) => {
const container = button.closest('.lora-reconnect-container');
const input = container.querySelector('.reconnect-input');
const loraIndex = container.getAttribute('data-lora-index');
this.reconnectLora(loraIndex, input.value);
});
});
// Add keydown handlers to reconnect inputs
const reconnectInputs = document.querySelectorAll('.reconnect-input');
reconnectInputs.forEach(input => {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const container = input.closest('.lora-reconnect-container');
const loraIndex = container.getAttribute('data-lora-index');
this.reconnectLora(loraIndex, input.value);
} else if (e.key === 'Escape') {
const container = input.closest('.lora-reconnect-container');
this.hideReconnectInput(container);
}
});
});
}
showReconnectInput(loraIndex) {
// Hide any currently active reconnect containers
document.querySelectorAll('.lora-reconnect-container.active').forEach(active => {
active.classList.remove('active');
});
// Show the reconnect container for this lora
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
if (container) {
container.classList.add('active');
const input = container.querySelector('.reconnect-input');
input.focus();
}
}
hideReconnectInput(container) {
if (container && container.classList.contains('active')) {
container.classList.remove('active');
const input = container.querySelector('.reconnect-input');
if (input) input.value = '';
}
}
async reconnectLora(loraIndex, inputValue) {
if (!inputValue || !inputValue.trim()) {
showToast('Please enter a LoRA name or syntax', 'error');
return;
}
try {
// Parse input value to extract file_name
let loraSyntaxMatch = inputValue.match(/<lora:([^:>]+)(?::[^>]+)?>/);
let fileName = loraSyntaxMatch ? loraSyntaxMatch[1] : inputValue.trim();
// Remove any file extension if present
fileName = fileName.replace(/\.\w+$/, '');
// Get the deleted lora data
const deletedLora = this.currentRecipe.loras[loraIndex];
if (!deletedLora) {
showToast('Error: Could not find the LoRA in the recipe', 'error');
return;
}
state.loadingManager.showSimpleLoading('Reconnecting LoRA...');
// Call API to reconnect the LoRA
const response = await fetch('/api/recipe/lora/reconnect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_id: this.recipeId,
lora_data: deletedLora,
target_name: fileName
})
});
const result = await response.json();
if (result.success) {
// Hide the reconnect input
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
this.hideReconnectInput(container);
// Update the current recipe with the updated lora data
this.currentRecipe.loras[loraIndex] = result.updated_lora;
// Show success message
showToast('LoRA reconnected successfully', 'success');
// Refresh modal to show updated content
setTimeout(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
// Refresh recipes list
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
setTimeout(() => {
window.recipeManager.loadRecipes(true);
}, 1000);
}
} else {
showToast(`Error: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error reconnecting LoRA:', error);
showToast(`Error reconnecting LoRA: ${error.message}`, 'error');
} finally {
state.loadingManager.hide();
}
}
}
export { RecipeModal };

View File

@@ -17,6 +17,7 @@ import { LoraContextMenu } from './components/ContextMenu.js';
import { moveManager } from './managers/MoveManager.js';
import { updateCardsForBulkMode } from './components/LoraCard.js';
import { bulkManager } from './managers/BulkManager.js';
import { setStorageItem, getStorageItem } from './utils/storageHelpers.js';
// Initialize the LoRA page
class LoraPageManager {
@@ -63,7 +64,7 @@ class LoraPageManager {
async initialize() {
// Initialize page-specific components
initializeEventListeners();
this.initEventListeners();
restoreFolderFilter();
initFolderTagsVisibility();
new LoraContextMenu();
@@ -77,22 +78,38 @@ class LoraPageManager {
// Initialize common page features (lazy loading, infinite scroll)
appCore.initializePageFeatures();
}
}
// Initialize event listeners
function initializeEventListeners() {
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = state.sortBy;
sortSelect.addEventListener('change', async (e) => {
state.sortBy = e.target.value;
await resetAndReload();
});
loadSortPreference() {
const savedSort = getStorageItem('loras_sort');
if (savedSort) {
state.sortBy = savedSort;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedSort;
}
}
}
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
tag.addEventListener('click', toggleFolder);
});
saveSortPreference(sortValue) {
setStorageItem('loras_sort', sortValue);
}
initEventListeners() {
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = state.sortBy;
this.loadSortPreference();
sortSelect.addEventListener('change', async (e) => {
state.sortBy = e.target.value;
this.saveSortPreference(e.target.value);
await resetAndReload();
});
}
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
tag.addEventListener('click', toggleFolder);
});
}
}
// Initialize everything when DOM is ready

View File

@@ -3,7 +3,7 @@ import { showToast } from '../utils/uiHelpers.js';
import { LoadingManager } from './LoadingManager.js';
import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
import { getStorageItem } from '../utils/storageHelpers.js';
export class DownloadManager {
constructor() {
this.currentVersion = null;
@@ -246,6 +246,12 @@ export class DownloadManager {
`<option value="${root}">${root}</option>`
).join('');
// Set default lora root if available
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
if (defaultRoot && data.roots.includes(defaultRoot)) {
loraRoot.value = defaultRoot;
}
// Initialize folder browser after loading roots
this.initializeFolderBrowser();
} catch (error) {

View File

@@ -1,6 +1,7 @@
import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { LoadingManager } from './LoadingManager.js';
import { getStorageItem } from '../utils/storageHelpers.js';
export class ImportManager {
constructor() {
@@ -779,6 +780,12 @@ export class ImportManager {
loraRoot.innerHTML = rootsData.roots.map(root =>
`<option value="${root}">${root}</option>`
).join('');
// Set default lora root if available
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
loraRoot.value = defaultRoot;
}
}
// Fetch folders

View File

@@ -2,6 +2,7 @@ import { showToast } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
import { modalManager } from './ModalManager.js';
import { getStorageItem } from '../utils/storageHelpers.js';
class MoveManager {
constructor() {
@@ -87,6 +88,12 @@ class MoveManager {
`<option value="${root}">${root}</option>`
).join('');
// Set default lora root if available
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
if (defaultRoot && data.roots.includes(defaultRoot)) {
this.loraRootSelect.value = defaultRoot;
}
this.updatePathPreview();
modalManager.showModal('moveModal');
@@ -166,11 +173,20 @@ class MoveManager {
})
});
const result = await response.json();
if (!response.ok) {
if (result && result.error) {
throw new Error(result.error);
}
throw new Error('Failed to move model');
}
showToast('Model moved successfully', 'success');
if (result && result.message) {
showToast(result.message, 'info');
} else {
showToast('Model moved successfully', 'success');
}
}
async moveBulkModels(filePaths, targetPath) {
@@ -195,11 +211,44 @@ class MoveManager {
})
});
const result = await response.json();
if (!response.ok) {
throw new Error('Failed to move models');
}
showToast(`Successfully moved ${movedPaths.length} models`, 'success');
// Display results with more details
if (result.success) {
if (result.failure_count > 0) {
// Some files failed to move
showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning');
// Log details about failures
console.log('Move operation results:', result.results);
// Get list of failed files with reasons
const failedFiles = result.results
.filter(r => !r.success)
.map(r => {
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
return `${fileName}: ${r.message}`;
});
// Show first few failures in a toast
if (failedFiles.length > 0) {
const failureMessage = failedFiles.length <= 3
? failedFiles.join('\n')
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
}
} else {
// All files moved successfully
showToast(`Successfully moved ${result.success_count} models`, 'success');
}
} else {
throw new Error(result.message || 'Failed to move models');
}
}
}

View File

@@ -53,7 +53,7 @@ export class SettingsManager {
this.initialized = true;
}
loadSettingsToUI() {
async loadSettingsToUI() {
// Set frontend settings from state
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
if (blurMatureContentCheckbox) {
@@ -65,10 +65,52 @@ export class SettingsManager {
// Sync with state (backend will set this via template)
state.global.settings.show_only_sfw = showOnlySFWCheckbox.checked;
}
// Load default lora root
await this.loadLoraRoots();
// Backend settings are loaded from the template directly
}
async loadLoraRoots() {
try {
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
if (!defaultLoraRootSelect) return;
// Fetch lora roots
const response = await fetch('/api/lora-roots');
if (!response.ok) {
throw new Error('Failed to fetch LoRA roots');
}
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No LoRA roots found');
}
// Clear existing options except the first one (No Default)
const noDefaultOption = defaultLoraRootSelect.querySelector('option[value=""]');
defaultLoraRootSelect.innerHTML = '';
defaultLoraRootSelect.appendChild(noDefaultOption);
// Add options for each root
data.roots.forEach(root => {
const option = document.createElement('option');
option.value = root;
option.textContent = root;
defaultLoraRootSelect.appendChild(option);
});
// Set selected value from settings
const defaultRoot = state.global.settings.default_loras_root || '';
defaultLoraRootSelect.value = defaultRoot;
} catch (error) {
console.error('Error loading LoRA roots:', error);
showToast('Failed to load LoRA roots: ' + error.message, 'error');
}
}
toggleSettings() {
if (this.isOpen) {
modalManager.closeModal('settingsModal');
@@ -81,14 +123,16 @@ export class SettingsManager {
async saveSettings() {
// Get frontend settings from UI
const blurMatureContent = document.getElementById('blurMatureContent').checked;
const showOnlySFW = document.getElementById('showOnlySFW').checked;
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
// Get backend settings
const apiKey = document.getElementById('civitaiApiKey').value;
const showOnlySFW = document.getElementById('showOnlySFW').checked;
// Update frontend state and save to localStorage
state.global.settings.blurMatureContent = blurMatureContent;
state.global.settings.show_only_sfw = showOnlySFW;
state.global.settings.default_loras_root = defaultLoraRoot;
// Save settings to localStorage
setStorageItem('settings', state.global.settings);

View File

@@ -178,7 +178,8 @@ export class UpdateService {
if (this.updateInfo.changelog && this.updateInfo.changelog.length > 0) {
this.updateInfo.changelog.forEach(item => {
const listItem = document.createElement('li');
listItem.textContent = item;
// Parse markdown in changelog items
listItem.innerHTML = this.parseMarkdown(item);
changelogList.appendChild(listItem);
});
} else {
@@ -201,6 +202,25 @@ export class UpdateService {
}
}
// Simple markdown parser for changelog items
parseMarkdown(text) {
if (!text) return '';
// Handle bold text (**text**)
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Handle italic text (*text*)
text = text.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Handle inline code (`code`)
text = text.replace(/`(.*?)`/g, '<code>$1</code>');
// Handle links [text](url)
text = text.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank">$1</a>');
return text;
}
toggleUpdateModal() {
const updateModal = modalManager.getModal('updateModal');

View File

@@ -66,6 +66,26 @@
</div>
</div>
</div>
<!-- Add Folder Settings Section -->
<div class="settings-section">
<h3>Folder Settings</h3>
<div class="setting-item">
<div class="setting-info">
<label for="defaultLoraRoot">Default LoRA Root</label>
</div>
<div class="setting-control select-control">
<select id="defaultLoraRoot">
<option value="">No Default</option>
<!-- Options will be loaded dynamically -->
</select>
</div>
<div class="input-help">
Set the default LoRA root directory for downloads, imports and moves
</div>
</div>
</div>
</div>
<div class="modal-actions">
<button class="primary-btn" onclick="settingsManager.saveSettings()">Save</button>

View File

@@ -1,45 +0,0 @@
import json
import sys
from py.workflow.parser import WorkflowParser
from py.workflow.utils import trace_model_path
# Load workflow data
with open('refs/prompt.json', 'r') as f:
workflow_data = json.load(f)
# Parse workflow
parser = WorkflowParser()
try:
# Find KSampler node
ksampler_node = None
for node_id, node in workflow_data.items():
if node.get("class_type") == "KSampler":
ksampler_node = node_id
break
if not ksampler_node:
print("KSampler node not found")
sys.exit(1)
# Trace all Lora nodes
print("Finding Lora nodes in the workflow...")
lora_nodes = trace_model_path(workflow_data, ksampler_node)
print(f"Found Lora nodes: {lora_nodes}")
# Print node details
for node_id in lora_nodes:
node = workflow_data[node_id]
print(f"\nNode {node_id}: {node.get('class_type')}")
for key, value in node.get("inputs", {}).items():
print(f" - {key}: {value}")
# Parse the workflow
result = parser.parse_workflow(workflow_data)
print("\nParsing successful!")
print(json.dumps(result, indent=2))
sys.exit(0)
except Exception as e:
print(f"Error parsing workflow: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@@ -2,6 +2,52 @@
---
### v0.7.37
* Added NSFW content control settings (blur mature content and SFW-only filter)
* Implemented intelligent blur effects for previews and showcase media
* Added manual content rating option through context menu
* Enhanced user experience with configurable content visibility
* Fixed various bugs and improved stability
### v0.7.36
* Enhanced LoRA details view with model descriptions and tags display
* Added tag filtering system for improved model discovery
* Implemented editable trigger words functionality
* Improved TriggerWord Toggle node with new group mode option for granular control
* Added new Lora Stacker node with cross-compatibility support (works with efficiency nodes, ComfyRoll, easy-use, etc.)
* Fixed several bugs
### v0.7.35-beta
* Added base model filtering
* Implemented bulk operations (copy syntax, move multiple LoRAs)
* Added ability to edit LoRA model names in details view
* Added update checker with notification system
* Added support modal for user feedback and community links
### v0.7.33
* Enhanced LoRA Loader node with visual strength adjustment widgets
* Added toggle switches for LoRA enable/disable
* Implemented image tooltips for LoRA preview
* Added TriggerWord Toggle node with visual word selection
* Fixed various bugs and improved stability
### v0.7.3
* Added "Lora Loader (LoraManager)" custom node for workflows
* Implemented one-click LoRA integration
* Added direct copying of LoRA syntax from manager interface
* Added automatic preset strength value application
* Added automatic trigger word loading
### v0.7.0
* Added direct CivitAI integration for downloading LoRAs
* Implemented version selection for model downloads
* Added target folder selection for downloads
* Added context menu with quick actions
* Added force refresh for CivitAI data
* Implemented LoRA movement between folders
* Added personal usage tips and notes for LoRAs
* Improved performance for details window
## [Update 0.5.9] Enhanced Search Capabilities
- 🔍 **Advanced Search Features**:

144
web/comfyui/DomWidget.vue Normal file
View File

@@ -0,0 +1,144 @@
<template>
<div
class="dom-widget"
:title="tooltip"
ref="widgetElement"
:style="style"
v-show="widgetState.visible"
>
<component
v-if="isComponentWidget(widget)"
:is="widget.component"
:modelValue="widget.value"
@update:modelValue="emit('update:widgetValue', $event)"
:widget="widget"
/>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useDomClipping } from '@/composables/element/useDomClipping'
import {
type BaseDOMWidget,
isComponentWidget,
isDOMWidget
} from '@/scripts/domWidget'
import { DomWidgetState } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
const { widget, widgetState } = defineProps<{
widget: BaseDOMWidget<string | object>
widgetState: DomWidgetState
}>()
const emit = defineEmits<{
(e: 'update:widgetValue', value: string | object): void
}>()
const widgetElement = ref<HTMLElement | undefined>()
const { style: positionStyle, updatePositionWithTransform } =
useAbsolutePosition()
const { style: clippingStyle, updateClipPath } = useDomClipping()
const style = computed<CSSProperties>(() => ({
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents: widgetState.readonly ? 'none' : 'auto'
}))
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const enableDomClipping = computed(() =>
settingStore.get('Comfy.DOMClippingEnabled')
)
const updateDomClipping = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas || !widgetElement.value) return
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
if (!selectedNode) return
const node = widget.node
const isSelected = selectedNode === node
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
const selectedAreaConfig = renderArea
? {
x: renderArea[0],
y: renderArea[1],
width: renderArea[2],
height: renderArea[3],
scale,
offset: [offset[0], offset[1]] as [number, number]
}
: undefined
updateClipPath(
widgetElement.value,
lgCanvas.canvas,
isSelected,
selectedAreaConfig
)
}
watch(
() => widgetState,
(newState) => {
updatePositionWithTransform(newState)
if (enableDomClipping.value) {
updateDomClipping()
}
},
{ deep: true }
)
watch(
() => widgetState.visible,
(newVisible, oldVisible) => {
if (!newVisible && oldVisible) {
widget.options.onHide?.(widget)
}
}
)
if (isDOMWidget(widget)) {
if (widget.element.blur) {
useEventListener(document, 'mousedown', (event) => {
if (!widget.element.contains(event.target as HTMLElement)) {
widget.element.blur()
}
})
}
for (const evt of widget.options.selectOn ?? ['focus', 'click']) {
useEventListener(widget.element, evt, () => {
const lgCanvas = canvasStore.canvas
lgCanvas?.selectNode(widget.node)
lgCanvas?.bringToFront(widget.node)
})
}
}
const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
onMounted(() => {
if (isDOMWidget(widget) && widgetElement.value) {
widgetElement.value.appendChild(widget.element)
}
})
</script>
<style scoped>
.dom-widget > * {
@apply h-full w-full;
}
</style>

View File

@@ -1,193 +1,121 @@
import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
import type { Size, Vector4 } from '@comfyorg/litegraph'
import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation'
import { LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type {
ICustomWidget,
IWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import _ from 'lodash'
import { type Component, toRaw } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { generateUUID } from '@/utils/formatUtil'
import { app } from './app'
export interface BaseDOMWidget<V extends object | string>
extends ICustomWidget {
// ICustomWidget properties
type: 'custom'
options: DOMWidgetOptions<V>
value: V
callback?: (value: V) => void
const SIZE = Symbol()
interface Rect {
height: number
width: number
x: number
y: number
// BaseDOMWidget properties
/** The unique ID of the widget. */
readonly id: string
/** The node that the widget belongs to. */
readonly node: LGraphNode
/** Whether the widget is visible. */
isVisible(): boolean
/** The margin of the widget. */
margin: number
}
/**
* A DOM widget that wraps a custom HTML element as a litegraph widget.
*/
export interface DOMWidget<T extends HTMLElement, V extends object | string>
extends ICustomWidget<T> {
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
type: 'custom'
name: string
extends BaseDOMWidget<V> {
element: T
options: DOMWidgetOptions<T, V>
value: V
y?: number
/**
* @deprecated Legacy property used by some extensions for customtext
* (textarea) widgets. Use `element` instead as it provides the same
* (textarea) widgets. Use {@link element} instead as it provides the same
* functionality and works for all DOMWidget types.
*/
inputEl?: T
callback?: (value: V) => void
/**
* Draw the widget on the canvas.
*/
draw?: (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number,
widgetHeight: number
) => void
/**
* TODO(huchenlei): Investigate when is this callback fired. `onRemove` is
* on litegraph's IBaseWidget definition, but not called in litegraph.
* Currently only called in widgetInputs.ts.
*/
onRemove?: () => void
}
export interface DOMWidgetOptions<
T extends HTMLElement,
V extends object | string
> extends IWidgetOptions {
/**
* A DOM widget that wraps a Vue component as a litegraph widget.
*/
export interface ComponentWidget<V extends object | string>
extends BaseDOMWidget<V> {
readonly component: Component
readonly inputSpec: InputSpec
}
export interface DOMWidgetOptions<V extends object | string>
extends IWidgetOptions {
/**
* Whether to render a placeholder rectangle when zoomed out.
*/
hideOnZoom?: boolean
selectOn?: string[]
onHide?: (widget: DOMWidget<T, V>) => void
onHide?: (widget: BaseDOMWidget<V>) => void
getValue?: () => V
setValue?: (value: V) => void
getMinHeight?: () => number
getMaxHeight?: () => number
getHeight?: () => string | number
onDraw?: (widget: DOMWidget<T, V>) => void
beforeResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
afterResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
onDraw?: (widget: BaseDOMWidget<V>) => void
margin?: number
/**
* @deprecated Use `afterResize` instead. This callback is a legacy API
* that fires before resize happens, but it is no longer supported. Now it
* fires after resize happens.
* The resize logic has been upstreamed to litegraph in
* https://github.com/Comfy-Org/ComfyUI_frontend/pull/2557
*/
beforeResize?: (this: BaseDOMWidget<V>, node: LGraphNode) => void
afterResize?: (this: BaseDOMWidget<V>, node: LGraphNode) => void
}
function intersect(a: Rect, b: Rect): Vector4 | null {
const x = Math.max(a.x, b.x)
const num1 = Math.min(a.x + a.width, b.x + b.width)
const y = Math.max(a.y, b.y)
const num2 = Math.min(a.y + a.height, b.y + b.height)
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
else return null
}
export const isDOMWidget = <T extends HTMLElement, V extends object | string>(
widget: IWidget
): widget is DOMWidget<T, V> => 'element' in widget && !!widget.element
function getClipPath(
node: LGraphNode,
element: HTMLElement,
canvasRect: DOMRect
): string {
const selectedNode: LGraphNode = Object.values(
app.canvas.selected_nodes ?? {}
)[0] as LGraphNode
if (selectedNode && selectedNode !== node) {
const elRect = element.getBoundingClientRect()
const MARGIN = 4
const { offset, scale } = app.canvas.ds
const { renderArea } = selectedNode
export const isComponentWidget = <V extends object | string>(
widget: IWidget
): widget is ComponentWidget<V> => 'component' in widget && !!widget.component
// Get intersection in browser space
const intersection = intersect(
{
x: elRect.left - canvasRect.left,
y: elRect.top - canvasRect.top,
width: elRect.width,
height: elRect.height
},
{
x: (renderArea[0] + offset[0] - MARGIN) * scale,
y: (renderArea[1] + offset[1] - MARGIN) * scale,
width: (renderArea[2] + 2 * MARGIN) * scale,
height: (renderArea[3] + 2 * MARGIN) * scale
}
)
if (!intersection) {
return ''
}
// Convert intersection to canvas scale (element has scale transform)
const clipX =
(intersection[0] - elRect.left + canvasRect.left) / scale + 'px'
const clipY = (intersection[1] - elRect.top + canvasRect.top) / scale + 'px'
const clipWidth = intersection[2] / scale + 'px'
const clipHeight = intersection[3] / scale + 'px'
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
return path
}
return ''
}
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const elementWidgets = new Set<LGraphNode>()
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
LGraphCanvas.prototype.computeVisibleNodes = function (
nodes?: LGraphNode[],
out?: LGraphNode[]
): LGraphNode[] {
const visibleNodes = computeVisibleNodes.call(this, nodes, out)
for (const node of app.graph.nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1
for (const w of node.widgets ?? []) {
if (w.element) {
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
const isCollapsed = w.element.dataset.collapsed === 'true'
const wasHidden = w.element.hidden
const actualHidden = hidden || shouldOtherwiseHide || isCollapsed
w.element.hidden = actualHidden
w.element.style.display = actualHidden ? 'none' : ''
if (actualHidden && !wasHidden) {
w.options.onHide?.(w as DOMWidget<HTMLElement, object>)
}
}
}
}
}
return visibleNodes
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
implements DOMWidget<T, V>
abstract class BaseDOMWidgetImpl<V extends object | string>
implements BaseDOMWidget<V>
{
type: 'custom'
name: string
element: T
options: DOMWidgetOptions<T, V>
static readonly DEFAULT_MARGIN = 10
readonly type: 'custom'
readonly name: string
readonly options: DOMWidgetOptions<V>
computedHeight?: number
y: number = 0
callback?: (value: V) => void
private mouseDownHandler?: (event: MouseEvent) => void
constructor(
name: string,
type: string,
element: T,
options: DOMWidgetOptions<T, V> = {}
) {
readonly id: string
readonly node: LGraphNode
constructor(obj: {
id: string
node: LGraphNode
name: string
type: string
options: DOMWidgetOptions<V>
}) {
// @ts-expect-error custom widget type
this.type = type
this.name = name
this.element = element
this.options = options
this.type = obj.type
this.name = obj.name
this.options = obj.options
if (element.blur) {
this.mouseDownHandler = (event) => {
if (!element.contains(event.target as HTMLElement)) {
element.blur()
}
}
document.addEventListener('mousedown', this.mouseDownHandler)
}
this.id = obj.id
this.node = obj.node
}
get value(): V {
@@ -199,6 +127,67 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
this.callback?.(this.value)
}
get margin(): number {
return this.options.margin ?? BaseDOMWidgetImpl.DEFAULT_MARGIN
}
isVisible(): boolean {
return (
!_.isNil(this.computedHeight) &&
this.computedHeight > 0 &&
!['converted-widget', 'hidden'].includes(this.type) &&
!this.node.collapsed
)
}
draw(
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
widget_width: number,
y: number,
widget_height: number,
lowQuality?: boolean
): void {
if (this.options.hideOnZoom && lowQuality && this.isVisible()) {
// Draw a placeholder rectangle
const originalFillStyle = ctx.fillStyle
ctx.beginPath()
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
ctx.rect(
this.margin,
y + this.margin,
widget_width - this.margin * 2,
(this.computedHeight ?? widget_height) - 2 * this.margin
)
ctx.fill()
ctx.fillStyle = originalFillStyle
}
this.options.onDraw?.(this)
}
onRemove(): void {
useDomWidgetStore().unregisterWidget(this.id)
}
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
extends BaseDOMWidgetImpl<V>
implements DOMWidget<T, V>
{
readonly element: T
constructor(obj: {
id: string
node: LGraphNode
name: string
type: string
element: T
options: DOMWidgetOptions<V>
}) {
super(obj)
this.element = obj.element
}
/** Extract DOM widget size info */
computeLayoutSize(node: LGraphNode) {
// @ts-expect-error custom widget type
@@ -241,69 +230,61 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
minWidth: 0
}
}
}
draw(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number
): void {
const { offset, scale } = app.canvas.ds
const hidden =
(!!this.options.hideOnZoom && app.canvas.low_quality) ||
(this.computedHeight ?? 0) <= 0 ||
// @ts-expect-error custom widget type
this.type === 'converted-widget' ||
// @ts-expect-error custom widget type
this.type === 'hidden'
export class ComponentWidgetImpl<V extends object | string>
extends BaseDOMWidgetImpl<V>
implements ComponentWidget<V>
{
readonly component: Component
readonly inputSpec: InputSpec
this.element.dataset.shouldHide = hidden ? 'true' : 'false'
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
const isCollapsed = this.element.dataset.collapsed === 'true'
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
const wasHidden = this.element.hidden
this.element.hidden = actualHidden
this.element.style.display = actualHidden ? 'none' : ''
if (actualHidden && !wasHidden) {
this.options.onHide?.(this)
}
if (actualHidden) {
return
}
const elRect = ctx.canvas.getBoundingClientRect()
const margin = 10
const top = node.pos[0] + offset[0] + margin
const left = node.pos[1] + offset[1] + margin + y
Object.assign(this.element.style, {
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${top * scale}px`,
top: `${left * scale}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(this.computedHeight ?? 50) - margin * 2}px`,
position: 'absolute',
zIndex: app.graph.nodes.indexOf(node),
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
constructor(obj: {
id: string
node: LGraphNode
name: string
component: Component
inputSpec: InputSpec
options: DOMWidgetOptions<V>
}) {
super({
...obj,
type: 'custom'
})
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
const clipPath = getClipPath(node, this.element, elRect)
this.element.style.clipPath = clipPath ?? 'none'
this.element.style.willChange = 'clip-path'
}
this.options.onDraw?.(this)
this.component = obj.component
this.inputSpec = obj.inputSpec
}
onRemove(): void {
if (this.mouseDownHandler) {
document.removeEventListener('mousedown', this.mouseDownHandler)
computeLayoutSize() {
const minHeight = this.options.getMinHeight?.() ?? 50
const maxHeight = this.options.getMaxHeight?.()
return {
minHeight,
maxHeight,
minWidth: 0
}
this.element.remove()
}
serializeValue(): V {
return toRaw(this.value)
}
}
export const addWidget = <W extends BaseDOMWidget<object | string>>(
node: LGraphNode,
widget: W
) => {
node.addCustomWidget(widget)
node.onRemoved = useChainCallback(node.onRemoved, () => {
widget.onRemove?.()
})
node.onResize = useChainCallback(node.onResize, () => {
widget.options.beforeResize?.call(widget, node)
widget.options.afterResize?.call(widget, node)
})
useDomWidgetStore().registerWidget(widget)
}
LGraphNode.prototype.addDOMWidget = function <
@@ -314,24 +295,19 @@ LGraphNode.prototype.addDOMWidget = function <
name: string,
type: string,
element: T,
options: DOMWidgetOptions<T, V> = {}
options: DOMWidgetOptions<V> = {}
): DOMWidget<T, V> {
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
const widget = new DOMWidgetImpl({
id: generateUUID(),
node: this,
name,
type,
element,
options: { hideOnZoom: true, ...options }
})
// Note: Before `LGraphNode.configure` is called, `this.id` is always `-1`.
addWidget(this, widget as unknown as BaseDOMWidget<object | string>)
if (!element.parentElement) {
app.canvasContainer.append(element)
}
element.hidden = true
element.style.display = 'none'
const { nodeData } = this.constructor
const tooltip = (nodeData?.input.required?.[name] ??
nodeData?.input.optional?.[name])?.[1]?.tooltip
if (tooltip && !element.title) {
element.title = tooltip
}
const widget = new DOMWidgetImpl(name, type, element, options)
// Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
// Some custom nodes are explicitly expecting getter and setter of `value`
// property to be on instance instead of prototype.
@@ -345,55 +321,5 @@ LGraphNode.prototype.addDOMWidget = function <
}
})
// Ensure selectOn exists before iteration
const selectEvents = options.selectOn ?? ['focus', 'click']
for (const evt of selectEvents) {
element.addEventListener(evt, () => {
app.canvas.selectNode(this)
app.canvas.bringToFront(this)
})
}
this.addCustomWidget(widget)
elementWidgets.add(this)
const collapse = this.collapse
this.collapse = function (this: LGraphNode, force?: boolean) {
collapse.call(this, force)
if (this.collapsed) {
element.hidden = true
element.style.display = 'none'
}
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
}
const { onConfigure } = this
this.onConfigure = function (
this: LGraphNode,
serializedNode: ISerialisedNode
) {
onConfigure?.call(this, serializedNode)
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
}
const onRemoved = this.onRemoved
this.onRemoved = function (this: LGraphNode) {
element.remove()
elementWidgets.delete(this)
onRemoved?.call(this)
}
// @ts-ignore index with symbol
if (!this[SIZE]) {
// @ts-ignore index with symbol
this[SIZE] = true
const onResize = this.onResize
this.onResize = function (this: LGraphNode, size: Size) {
options.beforeResize?.call(widget, this)
onResize?.call(this, size)
options.afterResize?.call(widget, this)
}
}
return widget
}

View File

@@ -0,0 +1,399 @@
import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
import type { Size, Vector4 } from '@comfyorg/litegraph'
import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation'
import type {
ICustomWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import { useSettingStore } from '@/stores/settingStore'
import { app } from './app'
const SIZE = Symbol()
interface Rect {
height: number
width: number
x: number
y: number
}
export interface DOMWidget<T extends HTMLElement, V extends object | string>
extends ICustomWidget<T> {
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
type: 'custom'
name: string
element: T
options: DOMWidgetOptions<T, V>
value: V
y?: number
/**
* @deprecated Legacy property used by some extensions for customtext
* (textarea) widgets. Use `element` instead as it provides the same
* functionality and works for all DOMWidget types.
*/
inputEl?: T
callback?: (value: V) => void
/**
* Draw the widget on the canvas.
*/
draw?: (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number,
widgetHeight: number
) => void
/**
* TODO(huchenlei): Investigate when is this callback fired. `onRemove` is
* on litegraph's IBaseWidget definition, but not called in litegraph.
* Currently only called in widgetInputs.ts.
*/
onRemove?: () => void
}
export interface DOMWidgetOptions<
T extends HTMLElement,
V extends object | string
> extends IWidgetOptions {
hideOnZoom?: boolean
selectOn?: string[]
onHide?: (widget: DOMWidget<T, V>) => void
getValue?: () => V
setValue?: (value: V) => void
getMinHeight?: () => number
getMaxHeight?: () => number
getHeight?: () => string | number
onDraw?: (widget: DOMWidget<T, V>) => void
beforeResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
afterResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
}
function intersect(a: Rect, b: Rect): Vector4 | null {
const x = Math.max(a.x, b.x)
const num1 = Math.min(a.x + a.width, b.x + b.width)
const y = Math.max(a.y, b.y)
const num2 = Math.min(a.y + a.height, b.y + b.height)
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
else return null
}
function getClipPath(
node: LGraphNode,
element: HTMLElement,
canvasRect: DOMRect
): string {
const selectedNode: LGraphNode = Object.values(
app.canvas.selected_nodes ?? {}
)[0] as LGraphNode
if (selectedNode && selectedNode !== node) {
const elRect = element.getBoundingClientRect()
const MARGIN = 4
const { offset, scale } = app.canvas.ds
const { renderArea } = selectedNode
// Get intersection in browser space
const intersection = intersect(
{
x: elRect.left - canvasRect.left,
y: elRect.top - canvasRect.top,
width: elRect.width,
height: elRect.height
},
{
x: (renderArea[0] + offset[0] - MARGIN) * scale,
y: (renderArea[1] + offset[1] - MARGIN) * scale,
width: (renderArea[2] + 2 * MARGIN) * scale,
height: (renderArea[3] + 2 * MARGIN) * scale
}
)
if (!intersection) {
return ''
}
// Convert intersection to canvas scale (element has scale transform)
const clipX =
(intersection[0] - elRect.left + canvasRect.left) / scale + 'px'
const clipY = (intersection[1] - elRect.top + canvasRect.top) / scale + 'px'
const clipWidth = intersection[2] / scale + 'px'
const clipHeight = intersection[3] / scale + 'px'
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
return path
}
return ''
}
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const elementWidgets = new Set<LGraphNode>()
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
LGraphCanvas.prototype.computeVisibleNodes = function (
nodes?: LGraphNode[],
out?: LGraphNode[]
): LGraphNode[] {
const visibleNodes = computeVisibleNodes.call(this, nodes, out)
for (const node of app.graph.nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1
for (const w of node.widgets ?? []) {
if (w.element) {
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
const isCollapsed = w.element.dataset.collapsed === 'true'
const wasHidden = w.element.hidden
const actualHidden = hidden || shouldOtherwiseHide || isCollapsed
w.element.hidden = actualHidden
w.element.style.display = actualHidden ? 'none' : ''
if (actualHidden && !wasHidden) {
w.options.onHide?.(w as DOMWidget<HTMLElement, object>)
}
}
}
}
}
return visibleNodes
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
implements DOMWidget<T, V>
{
type: 'custom'
name: string
element: T
options: DOMWidgetOptions<T, V>
computedHeight?: number
callback?: (value: V) => void
private mouseDownHandler?: (event: MouseEvent) => void
constructor(
name: string,
type: string,
element: T,
options: DOMWidgetOptions<T, V> = {}
) {
// @ts-expect-error custom widget type
this.type = type
this.name = name
this.element = element
this.options = options
if (element.blur) {
this.mouseDownHandler = (event) => {
if (!element.contains(event.target as HTMLElement)) {
element.blur()
}
}
document.addEventListener('mousedown', this.mouseDownHandler)
}
}
get value(): V {
return this.options.getValue?.() ?? ('' as V)
}
set value(v: V) {
this.options.setValue?.(v)
this.callback?.(this.value)
}
/** Extract DOM widget size info */
computeLayoutSize(node: LGraphNode) {
// @ts-expect-error custom widget type
if (this.type === 'hidden') {
return {
minHeight: 0,
maxHeight: 0,
minWidth: 0
}
}
const styles = getComputedStyle(this.element)
let minHeight =
this.options.getMinHeight?.() ??
parseInt(styles.getPropertyValue('--comfy-widget-min-height'))
let maxHeight =
this.options.getMaxHeight?.() ??
parseInt(styles.getPropertyValue('--comfy-widget-max-height'))
let prefHeight: string | number =
this.options.getHeight?.() ??
styles.getPropertyValue('--comfy-widget-height')
if (typeof prefHeight === 'string' && prefHeight.endsWith?.('%')) {
prefHeight =
node.size[1] *
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100)
} else {
prefHeight =
typeof prefHeight === 'number' ? prefHeight : parseInt(prefHeight)
if (isNaN(minHeight)) {
minHeight = prefHeight
}
}
return {
minHeight: isNaN(minHeight) ? 50 : minHeight,
maxHeight: isNaN(maxHeight) ? undefined : maxHeight,
minWidth: 0
}
}
draw(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number
): void {
const { offset, scale } = app.canvas.ds
const hidden =
(!!this.options.hideOnZoom && app.canvas.low_quality) ||
(this.computedHeight ?? 0) <= 0 ||
// @ts-expect-error custom widget type
this.type === 'converted-widget' ||
// @ts-expect-error custom widget type
this.type === 'hidden'
this.element.dataset.shouldHide = hidden ? 'true' : 'false'
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
const isCollapsed = this.element.dataset.collapsed === 'true'
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
const wasHidden = this.element.hidden
this.element.hidden = actualHidden
this.element.style.display = actualHidden ? 'none' : ''
if (actualHidden && !wasHidden) {
this.options.onHide?.(this)
}
if (actualHidden) {
return
}
const elRect = ctx.canvas.getBoundingClientRect()
const margin = 10
const top = node.pos[0] + offset[0] + margin
const left = node.pos[1] + offset[1] + margin + y
Object.assign(this.element.style, {
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${top * scale}px`,
top: `${left * scale}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(this.computedHeight ?? 50) - margin * 2}px`,
position: 'absolute',
zIndex: app.graph.nodes.indexOf(node),
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
})
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
const clipPath = getClipPath(node, this.element, elRect)
this.element.style.clipPath = clipPath ?? 'none'
this.element.style.willChange = 'clip-path'
}
this.options.onDraw?.(this)
}
onRemove(): void {
if (this.mouseDownHandler) {
document.removeEventListener('mousedown', this.mouseDownHandler)
}
this.element.remove()
}
}
LGraphNode.prototype.addDOMWidget = function <
T extends HTMLElement,
V extends object | string
>(
this: LGraphNode,
name: string,
type: string,
element: T,
options: DOMWidgetOptions<T, V> = {}
): DOMWidget<T, V> {
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
if (!element.parentElement) {
app.canvasContainer.append(element)
}
element.hidden = true
element.style.display = 'none'
const { nodeData } = this.constructor
const tooltip = (nodeData?.input.required?.[name] ??
nodeData?.input.optional?.[name])?.[1]?.tooltip
if (tooltip && !element.title) {
element.title = tooltip
}
const widget = new DOMWidgetImpl(name, type, element, options)
// Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
// Some custom nodes are explicitly expecting getter and setter of `value`
// property to be on instance instead of prototype.
Object.defineProperty(widget, 'value', {
get(this: DOMWidgetImpl<T, V>): V {
return this.options.getValue?.() ?? ('' as V)
},
set(this: DOMWidgetImpl<T, V>, v: V) {
this.options.setValue?.(v)
this.callback?.(this.value)
}
})
// Ensure selectOn exists before iteration
const selectEvents = options.selectOn ?? ['focus', 'click']
for (const evt of selectEvents) {
element.addEventListener(evt, () => {
app.canvas.selectNode(this)
app.canvas.bringToFront(this)
})
}
this.addCustomWidget(widget)
elementWidgets.add(this)
const collapse = this.collapse
this.collapse = function (this: LGraphNode, force?: boolean) {
collapse.call(this, force)
if (this.collapsed) {
element.hidden = true
element.style.display = 'none'
}
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
}
const { onConfigure } = this
this.onConfigure = function (
this: LGraphNode,
serializedNode: ISerialisedNode
) {
onConfigure?.call(this, serializedNode)
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
}
const onRemoved = this.onRemoved
this.onRemoved = function (this: LGraphNode) {
element.remove()
elementWidgets.delete(this)
onRemoved?.call(this)
}
// @ts-ignore index with symbol
if (!this[SIZE]) {
// @ts-ignore index with symbol
this[SIZE] = true
const onResize = this.onResize
this.onResize = function (this: LGraphNode, size: Size) {
options.beforeResize?.call(widget, this)
onResize?.call(this, size)
options.afterResize?.call(widget, this)
}
}
return widget
}

View File

@@ -0,0 +1,882 @@
import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
export function addLorasWidget(node, name, opts, callback) {
// Create container for loras
const container = document.createElement("div");
container.className = "comfy-loras-container";
Object.assign(container.style, {
display: "flex",
flexDirection: "column",
gap: "8px",
padding: "6px",
backgroundColor: "rgba(40, 44, 52, 0.6)",
borderRadius: "6px",
width: "100%",
});
// Initialize default value
const defaultValue = opts?.defaultVal || [];
// Parse LoRA entries from value
const parseLoraValue = (value) => {
if (!value) return [];
return Array.isArray(value) ? value : [];
};
// Format LoRA data
const formatLoraValue = (loras) => {
return loras;
};
// Function to create toggle element
const createToggle = (active, onChange) => {
const toggle = document.createElement("div");
toggle.className = "comfy-lora-toggle";
updateToggleStyle(toggle, active);
toggle.addEventListener("click", (e) => {
e.stopPropagation();
onChange(!active);
});
return toggle;
};
// Helper function to update toggle style
function updateToggleStyle(toggleEl, active) {
Object.assign(toggleEl.style, {
width: "18px",
height: "18px",
borderRadius: "4px",
cursor: "pointer",
transition: "all 0.2s ease",
backgroundColor: active ? "rgba(66, 153, 225, 0.9)" : "rgba(45, 55, 72, 0.7)",
border: `1px solid ${active ? "rgba(66, 153, 225, 0.9)" : "rgba(226, 232, 240, 0.2)"}`,
});
// Add hover effect
toggleEl.onmouseenter = () => {
toggleEl.style.transform = "scale(1.05)";
toggleEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)";
};
toggleEl.onmouseleave = () => {
toggleEl.style.transform = "scale(1)";
toggleEl.style.boxShadow = "none";
};
}
// Create arrow button for strength adjustment
const createArrowButton = (direction, onClick) => {
const button = document.createElement("div");
button.className = `comfy-lora-arrow comfy-lora-arrow-${direction}`;
Object.assign(button.style, {
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
userSelect: "none",
fontSize: "12px",
color: "rgba(226, 232, 240, 0.8)",
transition: "all 0.2s ease",
});
button.textContent = direction === "left" ? "◀" : "▶";
button.addEventListener("click", (e) => {
e.stopPropagation();
onClick();
});
// Add hover effect
button.onmouseenter = () => {
button.style.color = "white";
button.style.transform = "scale(1.2)";
};
button.onmouseleave = () => {
button.style.color = "rgba(226, 232, 240, 0.8)";
button.style.transform = "scale(1)";
};
return button;
};
// 添加预览弹窗组件
class PreviewTooltip {
constructor() {
this.element = document.createElement('div');
Object.assign(this.element.style, {
position: 'fixed',
zIndex: 9999,
background: 'rgba(0, 0, 0, 0.85)',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
display: 'none',
overflow: 'hidden',
maxWidth: '300px',
});
document.body.appendChild(this.element);
this.hideTimeout = null; // 添加超时处理变量
// 添加全局点击事件来隐藏tooltip
document.addEventListener('click', () => this.hide());
// 添加滚动事件监听
document.addEventListener('scroll', () => this.hide(), true);
}
async show(loraName, x, y) {
try {
// 清除之前的隐藏定时器
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
// 如果已经显示同一个lora的预览则不重复显示
if (this.element.style.display === 'block' && this.currentLora === loraName) {
return;
}
this.currentLora = loraName;
// 获取预览URL
const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, {
method: 'GET'
});
if (!response.ok) {
throw new Error('Failed to fetch preview URL');
}
const data = await response.json();
if (!data.success || !data.preview_url) {
throw new Error('No preview available');
}
// 清除现有内容
while (this.element.firstChild) {
this.element.removeChild(this.element.firstChild);
}
// Create media container with relative positioning
const mediaContainer = document.createElement('div');
Object.assign(mediaContainer.style, {
position: 'relative',
maxWidth: '300px',
maxHeight: '300px',
});
const isVideo = data.preview_url.endsWith('.mp4');
const mediaElement = isVideo ? document.createElement('video') : document.createElement('img');
Object.assign(mediaElement.style, {
maxWidth: '300px',
maxHeight: '300px',
objectFit: 'contain',
display: 'block',
});
if (isVideo) {
mediaElement.autoplay = true;
mediaElement.loop = true;
mediaElement.muted = true;
mediaElement.controls = false;
}
mediaElement.src = data.preview_url;
// Create name label with absolute positioning
const nameLabel = document.createElement('div');
nameLabel.textContent = loraName;
Object.assign(nameLabel.style, {
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
padding: '8px',
color: 'rgba(255, 255, 255, 0.95)',
fontSize: '13px',
fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
background: 'linear-gradient(transparent, rgba(0, 0, 0, 0.8))',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
textAlign: 'center',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
});
mediaContainer.appendChild(mediaElement);
mediaContainer.appendChild(nameLabel);
this.element.appendChild(mediaContainer);
// 添加淡入效果
this.element.style.opacity = '0';
this.element.style.display = 'block';
this.position(x, y);
requestAnimationFrame(() => {
this.element.style.transition = 'opacity 0.15s ease';
this.element.style.opacity = '1';
});
} catch (error) {
console.warn('Failed to load preview:', error);
}
}
position(x, y) {
// 确保预览框不超出视窗边界
const rect = this.element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x + 10; // 默认在鼠标右侧偏移10px
let top = y + 10; // 默认在鼠标下方偏移10px
// 检查右边界
if (left + rect.width > viewportWidth) {
left = x - rect.width - 10;
}
// 检查下边界
if (top + rect.height > viewportHeight) {
top = y - rect.height - 10;
}
Object.assign(this.element.style, {
left: `${left}px`,
top: `${top}px`
});
}
hide() {
// 使用淡出效果
if (this.element.style.display === 'block') {
this.element.style.opacity = '0';
this.hideTimeout = setTimeout(() => {
this.element.style.display = 'none';
this.currentLora = null;
// 停止视频播放
const video = this.element.querySelector('video');
if (video) {
video.pause();
}
this.hideTimeout = null;
}, 150);
}
}
cleanup() {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
}
// 移除所有事件监听器
document.removeEventListener('click', () => this.hide());
document.removeEventListener('scroll', () => this.hide(), true);
this.element.remove();
}
}
// 创建预览tooltip实例
const previewTooltip = new PreviewTooltip();
// Function to create menu item
const createMenuItem = (text, icon, onClick) => {
const menuItem = document.createElement('div');
Object.assign(menuItem.style, {
padding: '6px 20px',
cursor: 'pointer',
color: 'rgba(226, 232, 240, 0.9)',
fontSize: '13px',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
gap: '8px',
});
// Create icon element
const iconEl = document.createElement('div');
iconEl.innerHTML = icon;
Object.assign(iconEl.style, {
width: '14px',
height: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
// Create text element
const textEl = document.createElement('span');
textEl.textContent = text;
menuItem.appendChild(iconEl);
menuItem.appendChild(textEl);
menuItem.addEventListener('mouseenter', () => {
menuItem.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
});
menuItem.addEventListener('mouseleave', () => {
menuItem.style.backgroundColor = 'transparent';
});
if (onClick) {
menuItem.addEventListener('click', onClick);
}
return menuItem;
};
// Function to create context menu
const createContextMenu = (x, y, loraName, widget) => {
// Hide preview tooltip first
previewTooltip.hide();
// Remove existing context menu if any
const existingMenu = document.querySelector('.comfy-lora-context-menu');
if (existingMenu) {
existingMenu.remove();
}
const menu = document.createElement('div');
menu.className = 'comfy-lora-context-menu';
Object.assign(menu.style, {
position: 'fixed',
left: `${x}px`,
top: `${y}px`,
backgroundColor: 'rgba(30, 30, 30, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '4px',
padding: '4px 0',
zIndex: 1000,
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
minWidth: '180px',
});
// View on Civitai option with globe icon
const viewOnCivitaiOption = createMenuItem(
'View on Civitai',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>',
async () => {
menu.remove();
document.removeEventListener('click', closeMenu);
try {
// Get Civitai URL from API
const response = await api.fetchApi(`/lora-civitai-url?name=${encodeURIComponent(loraName)}`, {
method: 'GET'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to get Civitai URL');
}
const data = await response.json();
if (data.success && data.civitai_url) {
// Open the URL in a new tab
window.open(data.civitai_url, '_blank');
} else {
// Show error message if no Civitai URL
if (app && app.extensionManager && app.extensionManager.toast) {
app.extensionManager.toast.add({
severity: 'warning',
summary: 'Not Found',
detail: 'This LoRA has no associated Civitai URL',
life: 3000
});
} else {
alert('This LoRA has no associated Civitai URL');
}
}
} catch (error) {
console.error('Error getting Civitai URL:', error);
if (app && app.extensionManager && app.extensionManager.toast) {
app.extensionManager.toast.add({
severity: 'error',
summary: 'Error',
detail: error.message || 'Failed to get Civitai URL',
life: 5000
});
} else {
alert('Error: ' + (error.message || 'Failed to get Civitai URL'));
}
}
}
);
// Delete option with trash icon
const deleteOption = createMenuItem(
'Delete',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18m-2 0v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6m3 0V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>',
() => {
menu.remove();
document.removeEventListener('click', closeMenu);
const lorasData = parseLoraValue(widget.value).filter(l => l.name !== loraName);
widget.value = formatLoraValue(lorasData);
if (widget.callback) {
widget.callback(widget.value);
}
}
);
// Save recipe option with bookmark icon
const saveOption = createMenuItem(
'Save Recipe',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>',
() => {
menu.remove();
document.removeEventListener('click', closeMenu);
saveRecipeDirectly(widget);
}
);
// Add separator
const separator = document.createElement('div');
Object.assign(separator.style, {
margin: '4px 0',
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
});
menu.appendChild(viewOnCivitaiOption); // Add the new menu option
menu.appendChild(deleteOption);
menu.appendChild(separator);
menu.appendChild(saveOption);
document.body.appendChild(menu);
// Close menu when clicking outside
const closeMenu = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener('click', closeMenu);
}
};
setTimeout(() => document.addEventListener('click', closeMenu), 0);
};
// Function to render loras from data
const renderLoras = (value, widget) => {
// Clear existing content
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Parse the loras data
const lorasData = parseLoraValue(value);
if (lorasData.length === 0) {
// Show message when no loras are added
const emptyMessage = document.createElement("div");
emptyMessage.textContent = "No LoRAs added";
Object.assign(emptyMessage.style, {
textAlign: "center",
padding: "20px 0",
color: "rgba(226, 232, 240, 0.8)",
fontStyle: "italic",
userSelect: "none", // Add this line to prevent text selection
WebkitUserSelect: "none", // For Safari support
MozUserSelect: "none", // For Firefox support
msUserSelect: "none", // For IE/Edge support
});
container.appendChild(emptyMessage);
return;
}
// Create header
const header = document.createElement("div");
header.className = "comfy-loras-header";
Object.assign(header.style, {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
marginBottom: "8px"
});
// Add toggle all control
const allActive = lorasData.every(lora => lora.active);
const toggleAll = createToggle(allActive, (active) => {
// Update all loras active state
const lorasData = parseLoraValue(widget.value);
lorasData.forEach(lora => lora.active = active);
const newValue = formatLoraValue(lorasData);
widget.value = newValue;
});
// Add label to toggle all
const toggleLabel = document.createElement("div");
toggleLabel.textContent = "Toggle All";
Object.assign(toggleLabel.style, {
color: "rgba(226, 232, 240, 0.8)",
fontSize: "13px",
marginLeft: "8px",
userSelect: "none", // Add this line to prevent text selection
WebkitUserSelect: "none", // For Safari support
MozUserSelect: "none", // For Firefox support
msUserSelect: "none", // For IE/Edge support
});
const toggleContainer = document.createElement("div");
Object.assign(toggleContainer.style, {
display: "flex",
alignItems: "center",
});
toggleContainer.appendChild(toggleAll);
toggleContainer.appendChild(toggleLabel);
// Strength label
const strengthLabel = document.createElement("div");
strengthLabel.textContent = "Strength";
Object.assign(strengthLabel.style, {
color: "rgba(226, 232, 240, 0.8)",
fontSize: "13px",
marginRight: "8px",
userSelect: "none", // Add this line to prevent text selection
WebkitUserSelect: "none", // For Safari support
MozUserSelect: "none", // For Firefox support
msUserSelect: "none", // For IE/Edge support
});
header.appendChild(toggleContainer);
header.appendChild(strengthLabel);
container.appendChild(header);
// Render each lora entry
lorasData.forEach((loraData) => {
const { name, strength, active } = loraData;
const loraEl = document.createElement("div");
loraEl.className = "comfy-lora-entry";
Object.assign(loraEl.style, {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px",
borderRadius: "6px",
backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)",
transition: "all 0.2s ease",
marginBottom: "6px",
});
// Create toggle for this lora
const toggle = createToggle(active, (newActive) => {
// Update this lora's active state
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].active = newActive;
const newValue = formatLoraValue(lorasData);
widget.value = newValue;
}
});
// Create name display
const nameEl = document.createElement("div");
nameEl.textContent = name;
Object.assign(nameEl.style, {
marginLeft: "10px",
flex: "1",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
fontSize: "13px",
cursor: "pointer", // Add pointer cursor to indicate hoverable area
userSelect: "none", // Add this line to prevent text selection
WebkitUserSelect: "none", // For Safari support
MozUserSelect: "none", // For Firefox support
msUserSelect: "none", // For IE/Edge support
});
// Move preview tooltip events to nameEl instead of loraEl
nameEl.addEventListener('mouseenter', async (e) => {
e.stopPropagation();
const rect = nameEl.getBoundingClientRect();
await previewTooltip.show(name, rect.right, rect.top);
});
nameEl.addEventListener('mouseleave', (e) => {
e.stopPropagation();
previewTooltip.hide();
});
// Remove the preview tooltip events from loraEl
loraEl.onmouseenter = () => {
loraEl.style.backgroundColor = active ? "rgba(50, 60, 80, 0.8)" : "rgba(40, 45, 55, 0.6)";
};
loraEl.onmouseleave = () => {
loraEl.style.backgroundColor = active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)";
};
// Add context menu event
loraEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
createContextMenu(e.clientX, e.clientY, name, widget);
});
// Create strength control
const strengthControl = document.createElement("div");
Object.assign(strengthControl.style, {
display: "flex",
alignItems: "center",
gap: "8px",
});
// Left arrow
const leftArrow = createArrowButton("left", () => {
// Decrease strength
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].strength = (lorasData[loraIndex].strength - 0.05).toFixed(2);
const newValue = formatLoraValue(lorasData);
widget.value = newValue;
}
});
// Strength display
const strengthEl = document.createElement("input");
strengthEl.type = "text";
strengthEl.value = typeof strength === 'number' ? strength.toFixed(2) : Number(strength).toFixed(2);
Object.assign(strengthEl.style, {
minWidth: "50px",
width: "50px",
textAlign: "center",
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
fontSize: "13px",
background: "none",
border: "1px solid transparent",
padding: "2px 4px",
borderRadius: "3px",
outline: "none",
});
// 添加hover效果
strengthEl.addEventListener('mouseenter', () => {
strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)";
});
strengthEl.addEventListener('mouseleave', () => {
if (document.activeElement !== strengthEl) {
strengthEl.style.border = "1px solid transparent";
}
});
// 处理焦点
strengthEl.addEventListener('focus', () => {
strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
strengthEl.style.background = "rgba(0, 0, 0, 0.2)";
// 自动选中所有内容
strengthEl.select();
});
strengthEl.addEventListener('blur', () => {
strengthEl.style.border = "1px solid transparent";
strengthEl.style.background = "none";
});
// 处理输入变化
strengthEl.addEventListener('change', () => {
let newValue = parseFloat(strengthEl.value);
// 验证输入
if (isNaN(newValue)) {
newValue = 1.0;
}
// 更新数值
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].strength = newValue.toFixed(2);
// 更新值并触发回调
const newLorasValue = formatLoraValue(lorasData);
widget.value = newLorasValue;
}
});
// 处理按键事件
strengthEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
strengthEl.blur();
}
});
// Right arrow
const rightArrow = createArrowButton("right", () => {
// Increase strength
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2);
const newValue = formatLoraValue(lorasData);
widget.value = newValue;
}
});
strengthControl.appendChild(leftArrow);
strengthControl.appendChild(strengthEl);
strengthControl.appendChild(rightArrow);
// Assemble entry
const leftSection = document.createElement("div");
Object.assign(leftSection.style, {
display: "flex",
alignItems: "center",
flex: "1",
minWidth: "0", // Allow shrinking
});
leftSection.appendChild(toggle);
leftSection.appendChild(nameEl);
loraEl.appendChild(leftSection);
loraEl.appendChild(strengthControl);
container.appendChild(loraEl);
});
};
// Store the value in a variable to avoid recursion
let widgetValue = defaultValue;
// Create widget with initial properties
const widget = node.addDOMWidget(name, "loras", container, {
getValue: function() {
return widgetValue;
},
setValue: function(v) {
// Remove duplicates by keeping the last occurrence of each lora name
const uniqueValue = (v || []).reduce((acc, lora) => {
// Remove any existing lora with the same name
const filtered = acc.filter(l => l.name !== lora.name);
// Add the current lora
return [...filtered, lora];
}, []);
widgetValue = uniqueValue;
renderLoras(widgetValue, widget);
// Update container height after rendering
requestAnimationFrame(() => {
const minHeight = this.getMinHeight();
container.style.height = `${minHeight}px`;
// Force node to update size
node.setSize([node.size[0], node.computeSize()[1]]);
node.setDirtyCanvas(true, true);
});
},
getMinHeight: function() {
// Calculate height based on content
const lorasCount = parseLoraValue(widgetValue).length;
return Math.max(
100,
lorasCount > 0 ? 60 + lorasCount * 44 : 60
);
},
});
widget.value = defaultValue;
widget.callback = callback;
widget.serializeValue = () => {
// Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
return [...widgetValue,
{ name: "__dummy_item1__", strength: 0, active: false, _isDummy: true },
{ name: "__dummy_item2__", strength: 0, active: false, _isDummy: true }
];
}
widget.onRemove = () => {
container.remove();
previewTooltip.cleanup();
};
return { minWidth: 400, minHeight: 200, widget };
}
// Function to directly save the recipe without dialog
async function saveRecipeDirectly(widget) {
try {
// Get the workflow data from the ComfyUI app
const prompt = await app.graphToPrompt();
console.log('Prompt:', prompt);
// Show loading toast
if (app && app.extensionManager && app.extensionManager.toast) {
app.extensionManager.toast.add({
severity: 'info',
summary: 'Saving Recipe',
detail: 'Please wait...',
life: 2000
});
}
// Prepare the data - only send workflow JSON
const formData = new FormData();
formData.append('workflow_json', JSON.stringify(prompt.output));
// Send the request
const response = await fetch('/api/recipes/save-from-widget', {
method: 'POST',
body: formData
});
const result = await response.json();
// Show result toast
if (app && app.extensionManager && app.extensionManager.toast) {
if (result.success) {
app.extensionManager.toast.add({
severity: 'success',
summary: 'Recipe Saved',
detail: 'Recipe has been saved successfully',
life: 3000
});
} else {
app.extensionManager.toast.add({
severity: 'error',
summary: 'Error',
detail: result.error || 'Failed to save recipe',
life: 5000
});
}
}
} catch (error) {
console.error('Error saving recipe:', error);
// Show error toast
if (app && app.extensionManager && app.extensionManager.toast) {
app.extensionManager.toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to save recipe: ' + (error.message || 'Unknown error'),
life: 5000
});
}
}
}

View File

@@ -0,0 +1,193 @@
export function addTagsWidget(node, name, opts, callback) {
// Create container for tags
const container = document.createElement("div");
container.className = "comfy-tags-container";
Object.assign(container.style, {
display: "flex",
flexWrap: "wrap",
gap: "4px", // 从8px减小到4px
padding: "6px",
minHeight: "30px",
backgroundColor: "rgba(40, 44, 52, 0.6)", // Darker, more modern background
borderRadius: "6px", // Slightly larger radius
width: "100%",
});
// Initialize default value as array
const initialTagsData = opts?.defaultVal || [];
// Function to render tags from array data
const renderTags = (tagsData, widget) => {
// Clear existing tags
while (container.firstChild) {
container.removeChild(container.firstChild);
}
const normalizedTags = tagsData;
if (normalizedTags.length === 0) {
// Show message when no tags are present
const emptyMessage = document.createElement("div");
emptyMessage.textContent = "No trigger words detected";
Object.assign(emptyMessage.style, {
textAlign: "center",
padding: "20px 0",
color: "rgba(226, 232, 240, 0.8)",
fontStyle: "italic",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
});
container.appendChild(emptyMessage);
return;
}
normalizedTags.forEach((tagData, index) => {
const { text, active } = tagData;
const tagEl = document.createElement("div");
tagEl.className = "comfy-tag";
updateTagStyle(tagEl, active);
tagEl.textContent = text;
tagEl.title = text; // Set tooltip for full content
// Add click handler to toggle state
tagEl.addEventListener("click", (e) => {
e.stopPropagation();
// Toggle active state for this specific tag using its index
const updatedTags = [...widget.value];
updatedTags[index].active = !updatedTags[index].active;
updateTagStyle(tagEl, updatedTags[index].active);
widget.value = updatedTags;
});
container.appendChild(tagEl);
});
};
// Helper function to update tag style based on active state
function updateTagStyle(tagEl, active) {
const baseStyles = {
padding: "4px 12px", // 垂直内边距从6px减小到4px
borderRadius: "6px", // Matching container radius
maxWidth: "200px", // Increased max width
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "13px", // Slightly larger font
cursor: "pointer",
transition: "all 0.2s ease", // Smoother transition
border: "1px solid transparent",
display: "inline-block",
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
margin: "2px", // 从4px减小到2px
userSelect: "none", // Add this line to prevent text selection
WebkitUserSelect: "none", // For Safari support
MozUserSelect: "none", // For Firefox support
msUserSelect: "none", // For IE/Edge support
};
if (active) {
Object.assign(tagEl.style, {
...baseStyles,
backgroundColor: "rgba(66, 153, 225, 0.9)", // Modern blue
color: "white",
borderColor: "rgba(66, 153, 225, 0.9)",
});
} else {
Object.assign(tagEl.style, {
...baseStyles,
backgroundColor: "rgba(45, 55, 72, 0.7)", // Darker inactive state
color: "rgba(226, 232, 240, 0.8)", // Lighter text for contrast
borderColor: "rgba(226, 232, 240, 0.2)",
});
}
// Add hover effect
tagEl.onmouseenter = () => {
tagEl.style.transform = "translateY(-1px)";
tagEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)";
};
tagEl.onmouseleave = () => {
tagEl.style.transform = "translateY(0)";
tagEl.style.boxShadow = "0 1px 2px rgba(0,0,0,0.1)";
};
}
// Store the value as array
let widgetValue = initialTagsData;
// Create widget with initial properties
const widget = node.addDOMWidget(name, "tags", container, {
getValue: function() {
return widgetValue;
},
setValue: function(v) {
widgetValue = v;
renderTags(widgetValue, widget);
// Update container height after rendering
requestAnimationFrame(() => {
const minHeight = this.getMinHeight();
container.style.height = `${minHeight}px`;
// Force node to update size
node.setSize([node.size[0], node.computeSize()[1]]);
node.setDirtyCanvas(true, true);
});
},
getMinHeight: function() {
const minHeight = 150;
// If no tags or only showing the empty message, return a minimum height
if (widgetValue.length === 0) {
return minHeight; // Height for empty state with message
}
// Get all tag elements
const tagElements = container.querySelectorAll('.comfy-tag');
if (tagElements.length === 0) {
return minHeight; // Fallback if elements aren't rendered yet
}
// Calculate the actual height based on tag positions
let maxBottom = 0;
tagElements.forEach(tag => {
const rect = tag.getBoundingClientRect();
const tagBottom = rect.bottom - container.getBoundingClientRect().top;
maxBottom = Math.max(maxBottom, tagBottom);
});
// Add padding (top and bottom padding of container)
const computedStyle = window.getComputedStyle(container);
const paddingTop = parseInt(computedStyle.paddingTop, 10) || 0;
const paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0;
// Add extra buffer for potential wrapping issues and to ensure no clipping
const extraBuffer = 20;
// Round up to nearest 5px for clean sizing and ensure minimum height
return Math.max(minHeight, Math.ceil((maxBottom + paddingBottom + extraBuffer) / 5) * 5);
},
});
widget.value = initialTagsData;
widget.callback = callback;
widget.serializeValue = () => {
// Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
return [...widgetValue,
{ text: "__dummy_item__", active: false, _isDummy: true },
{ text: "__dummy_item__", active: false, _isDummy: true }
];
};
return { minWidth: 300, minHeight: 150, widget };
}

View File

@@ -1,9 +1,14 @@
import { app } from "../../scripts/app.js";
import { addLorasWidget } from "./loras_widget.js";
import { dynamicImportByVersion } from "./utils.js";
// Extract pattern into a constant for consistent use
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)>/g;
// Function to get the appropriate loras widget based on ComfyUI version
async function getLorasWidgetModule() {
return await dynamicImportByVersion("./loras_widget.js", "./legacy_loras_widget.js");
}
function mergeLoras(lorasText, lorasArr) {
const result = [];
let match;
@@ -44,7 +49,7 @@ app.registerExtension({
});
// Wait for node to be properly initialized
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
// Restore saved value if exists
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
@@ -67,6 +72,10 @@ app.registerExtension({
// Add flag to prevent callback loops
let isUpdating = false;
// Dynamically load the appropriate widget module
const lorasModule = await getLorasWidgetModule();
const { addLorasWidget } = lorasModule;
// Get the widget object directly from the returned object
const result = addLorasWidget(node, "loras", {

View File

@@ -5,19 +5,34 @@ export function addLorasWidget(node, name, opts, callback) {
// Create container for loras
const container = document.createElement("div");
container.className = "comfy-loras-container";
// Set initial height using CSS variables approach
const defaultHeight = 200;
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
Object.assign(container.style, {
display: "flex",
flexDirection: "column",
gap: "8px",
gap: "5px",
padding: "6px",
backgroundColor: "rgba(40, 44, 52, 0.6)",
borderRadius: "6px",
width: "100%",
boxSizing: "border-box",
overflow: "auto"
});
// Initialize default value
const defaultValue = opts?.defaultVal || [];
// Fixed sizes for component calculations
const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
const HEADER_HEIGHT = 40; // Height of the header section
const CONTAINER_PADDING = 12; // Top and bottom padding
const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present
// Parse LoRA entries from value
const parseLoraValue = (value) => {
if (!value) return [];
@@ -29,6 +44,23 @@ export function addLorasWidget(node, name, opts, callback) {
return loras;
};
// Function to update widget height consistently
const updateWidgetHeight = (height) => {
// Ensure minimum height
const finalHeight = Math.max(defaultHeight, height);
// Update CSS variables
container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`);
container.style.setProperty('--comfy-widget-height', `${finalHeight}px`);
// Force node to update size after a short delay to ensure DOM is updated
if (node) {
setTimeout(() => {
node.setDirtyCanvas(true, true);
}, 10);
}
};
// Function to create toggle element
const createToggle = (active, onChange) => {
const toggle = document.createElement("div");
@@ -107,7 +139,7 @@ export function addLorasWidget(node, name, opts, callback) {
return button;
};
// 添加预览弹窗组件
// Preview tooltip class
class PreviewTooltip {
constructor() {
this.element = document.createElement('div');
@@ -122,31 +154,31 @@ export function addLorasWidget(node, name, opts, callback) {
maxWidth: '300px',
});
document.body.appendChild(this.element);
this.hideTimeout = null; // 添加超时处理变量
this.hideTimeout = null;
// 添加全局点击事件来隐藏tooltip
// Add global click event to hide tooltip
document.addEventListener('click', () => this.hide());
// 添加滚动事件监听
// Add scroll event listener
document.addEventListener('scroll', () => this.hide(), true);
}
async show(loraName, x, y) {
try {
// 清除之前的隐藏定时器
// Clear previous hide timer
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
// 如果已经显示同一个lora的预览则不重复显示
// Don't redisplay the same lora preview
if (this.element.style.display === 'block' && this.currentLora === loraName) {
return;
}
this.currentLora = loraName;
// 获取预览URL
// Get preview URL
const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, {
method: 'GET'
});
@@ -160,7 +192,7 @@ export function addLorasWidget(node, name, opts, callback) {
throw new Error('No preview available');
}
// 清除现有内容
// Clear existing content
while (this.element.firstChild) {
this.element.removeChild(this.element.firstChild);
}
@@ -217,7 +249,7 @@ export function addLorasWidget(node, name, opts, callback) {
mediaContainer.appendChild(nameLabel);
this.element.appendChild(mediaContainer);
// 添加淡入效果
// Add fade-in effect
this.element.style.opacity = '0';
this.element.style.display = 'block';
this.position(x, y);
@@ -232,20 +264,20 @@ export function addLorasWidget(node, name, opts, callback) {
}
position(x, y) {
// 确保预览框不超出视窗边界
// Ensure preview box doesn't exceed viewport boundaries
const rect = this.element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x + 10; // 默认在鼠标右侧偏移10px
let top = y + 10; // 默认在鼠标下方偏移10px
let left = x + 10; // Default 10px offset to the right of mouse
let top = y + 10; // Default 10px offset below mouse
// 检查右边界
// Check right boundary
if (left + rect.width > viewportWidth) {
left = x - rect.width - 10;
}
// 检查下边界
// Check bottom boundary
if (top + rect.height > viewportHeight) {
top = y - rect.height - 10;
}
@@ -257,13 +289,13 @@ export function addLorasWidget(node, name, opts, callback) {
}
hide() {
// 使用淡出效果
// Use fade-out effect
if (this.element.style.display === 'block') {
this.element.style.opacity = '0';
this.hideTimeout = setTimeout(() => {
this.element.style.display = 'none';
this.currentLora = null;
// 停止视频播放
// Stop video playback
const video = this.element.querySelector('video');
if (video) {
video.pause();
@@ -277,14 +309,14 @@ export function addLorasWidget(node, name, opts, callback) {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
}
// 移除所有事件监听器
// Remove all event listeners
document.removeEventListener('click', () => this.hide());
document.removeEventListener('scroll', () => this.hide(), true);
this.element.remove();
}
}
// 创建预览tooltip实例
// Create preview tooltip instance
const previewTooltip = new PreviewTooltip();
// Function to create menu item
@@ -357,7 +389,7 @@ export function addLorasWidget(node, name, opts, callback) {
padding: '4px 0',
zIndex: 1000,
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
minWidth: '180px',
minWidth: '180px',
});
// View on Civitai option with globe icon
@@ -447,7 +479,7 @@ export function addLorasWidget(node, name, opts, callback) {
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
});
menu.appendChild(viewOnCivitaiOption); // Add the new menu option
menu.appendChild(viewOnCivitaiOption);
menu.appendChild(deleteOption);
menu.appendChild(separator);
menu.appendChild(saveOption);
@@ -483,12 +515,16 @@ export function addLorasWidget(node, name, opts, callback) {
padding: "20px 0",
color: "rgba(226, 232, 240, 0.8)",
fontStyle: "italic",
userSelect: "none", // Add this line to prevent text selection
WebkitUserSelect: "none", // For Safari support
MozUserSelect: "none", // For Firefox support
msUserSelect: "none", // For IE/Edge support
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
width: "100%"
});
container.appendChild(emptyMessage);
// Set fixed height for empty state
updateWidgetHeight(EMPTY_CONTAINER_HEIGHT);
return;
}
@@ -501,7 +537,7 @@ export function addLorasWidget(node, name, opts, callback) {
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
marginBottom: "8px"
marginBottom: "5px"
});
// Add toggle all control
@@ -522,10 +558,10 @@ export function addLorasWidget(node, name, opts, callback) {
color: "rgba(226, 232, 240, 0.8)",
fontSize: "13px",
marginLeft: "8px",
userSelect: "none", // Add this line to prevent text selection
WebkitUserSelect: "none", // For Safari support
MozUserSelect: "none", // For Firefox support
msUserSelect: "none", // For IE/Edge support
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
});
const toggleContainer = document.createElement("div");
@@ -543,10 +579,10 @@ export function addLorasWidget(node, name, opts, callback) {
color: "rgba(226, 232, 240, 0.8)",
fontSize: "13px",
marginRight: "8px",
userSelect: "none", // Add this line to prevent text selection
WebkitUserSelect: "none", // For Safari support
MozUserSelect: "none", // For Firefox support
msUserSelect: "none", // For IE/Edge support
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
});
header.appendChild(toggleContainer);
@@ -563,11 +599,11 @@ export function addLorasWidget(node, name, opts, callback) {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px",
padding: "6px",
borderRadius: "6px",
backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)",
transition: "all 0.2s ease",
marginBottom: "6px",
marginBottom: "4px",
});
// Create toggle for this lora
@@ -595,11 +631,11 @@ export function addLorasWidget(node, name, opts, callback) {
whiteSpace: "nowrap",
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
fontSize: "13px",
cursor: "pointer", // Add pointer cursor to indicate hoverable area
userSelect: "none", // Add this line to prevent text selection
WebkitUserSelect: "none", // For Safari support
MozUserSelect: "none", // For Firefox support
msUserSelect: "none", // For IE/Edge support
cursor: "pointer",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
});
// Move preview tooltip events to nameEl instead of loraEl
@@ -645,7 +681,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].strength = (lorasData[loraIndex].strength - 0.05).toFixed(2);
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2);
const newValue = formatLoraValue(lorasData);
widget.value = newValue;
@@ -669,7 +705,7 @@ export function addLorasWidget(node, name, opts, callback) {
outline: "none",
});
// 添加hover效果
// Add hover effect
strengthEl.addEventListener('mouseenter', () => {
strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)";
});
@@ -680,11 +716,11 @@ export function addLorasWidget(node, name, opts, callback) {
}
});
// 处理焦点
// Handle focus
strengthEl.addEventListener('focus', () => {
strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
strengthEl.style.background = "rgba(0, 0, 0, 0.2)";
// 自动选中所有内容
// Auto-select all content
strengthEl.select();
});
@@ -693,29 +729,29 @@ export function addLorasWidget(node, name, opts, callback) {
strengthEl.style.background = "none";
});
// 处理输入变化
// Handle input changes
strengthEl.addEventListener('change', () => {
let newValue = parseFloat(strengthEl.value);
// 验证输入
// Validate input
if (isNaN(newValue)) {
newValue = 1.0;
}
// 更新数值
// Update value
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].strength = newValue.toFixed(2);
// 更新值并触发回调
// Update value and trigger callback
const newLorasValue = formatLoraValue(lorasData);
widget.value = newLorasValue;
}
});
// 处理按键事件
// Handle key events
strengthEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
strengthEl.blur();
@@ -757,13 +793,17 @@ export function addLorasWidget(node, name, opts, callback) {
container.appendChild(loraEl);
});
// Calculate height based on number of loras and fixed sizes
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (lorasData.length * LORA_ENTRY_HEIGHT);
updateWidgetHeight(calculatedHeight);
};
// Store the value in a variable to avoid recursion
let widgetValue = defaultValue;
// Create widget with initial properties
const widget = node.addDOMWidget(name, "loras", container, {
// Create widget with new DOM Widget API
const widget = node.addDOMWidget(name, "custom", container, {
getValue: function() {
return widgetValue;
},
@@ -778,29 +818,28 @@ export function addLorasWidget(node, name, opts, callback) {
widgetValue = uniqueValue;
renderLoras(widgetValue, widget);
// Update container height after rendering
requestAnimationFrame(() => {
const minHeight = this.getMinHeight();
container.style.height = `${minHeight}px`;
// Force node to update size
node.setSize([node.size[0], node.computeSize()[1]]);
node.setDirtyCanvas(true, true);
});
},
getMinHeight: function() {
// Calculate height based on content
const lorasCount = parseLoraValue(widgetValue).length;
return Math.max(
100,
lorasCount > 0 ? 60 + lorasCount * 44 : 60
);
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
},
getMaxHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
},
getHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
},
hideOnZoom: true,
selectOn: ['click', 'focus'],
afterResize: function(node) {
// Re-render after node resize
if (this.value && this.value.length > 0) {
renderLoras(this.value, this);
}
}
});
widget.value = defaultValue;
widget.callback = callback;
widget.serializeValue = () => {
@@ -816,7 +855,7 @@ export function addLorasWidget(node, name, opts, callback) {
previewTooltip.cleanup();
};
return { minWidth: 400, minHeight: 200, widget };
return { minWidth: 400, minHeight: defaultHeight, widget };
}
// Function to directly save the recipe without dialog
@@ -824,7 +863,6 @@ async function saveRecipeDirectly(widget) {
try {
// Get the workflow data from the ComfyUI app
const prompt = await app.graphToPrompt();
console.log('Prompt:', prompt.output);
// Show loading toast
if (app && app.extensionManager && app.extensionManager.toast) {
@@ -879,4 +917,4 @@ async function saveRecipeDirectly(widget) {
});
}
}
}
}

View File

@@ -2,20 +2,36 @@ export function addTagsWidget(node, name, opts, callback) {
// Create container for tags
const container = document.createElement("div");
container.className = "comfy-tags-container";
// Set initial height
const defaultHeight = 150;
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
Object.assign(container.style, {
display: "flex",
flexWrap: "wrap",
gap: "4px", // 从8px减小到4px
gap: "4px",
padding: "6px",
minHeight: "30px",
backgroundColor: "rgba(40, 44, 52, 0.6)", // Darker, more modern background
borderRadius: "6px", // Slightly larger radius
backgroundColor: "rgba(40, 44, 52, 0.6)",
borderRadius: "6px",
width: "100%",
boxSizing: "border-box",
overflow: "auto",
alignItems: "flex-start" // Ensure tags align at the top of each row
});
// Initialize default value as array
const initialTagsData = opts?.defaultVal || [];
// Fixed sizes for tag elements to avoid zoom-related calculation issues
const TAG_HEIGHT = 26; // Adjusted height of a single tag including margins
const TAGS_PER_ROW = 3; // Approximate number of tags per row
const ROW_GAP = 2; // Reduced gap between rows
const CONTAINER_PADDING = 12; // Top and bottom padding
const EMPTY_CONTAINER_HEIGHT = 60; // Height when no tags are present
// Function to render tags from array data
const renderTags = (tagsData, widget) => {
// Clear existing tags
@@ -38,11 +54,28 @@ export function addTagsWidget(node, name, opts, callback) {
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
width: "100%"
});
container.appendChild(emptyMessage);
// Set fixed height for empty state
updateWidgetHeight(EMPTY_CONTAINER_HEIGHT);
return;
}
// Create a row container approach for better layout control
let rowContainer = document.createElement("div");
rowContainer.className = "comfy-tags-row";
Object.assign(rowContainer.style, {
display: "flex",
flexWrap: "wrap",
gap: "4px",
width: "100%",
marginBottom: "2px" // Small gap between rows
});
container.appendChild(rowContainer);
let tagCount = 0;
normalizedTags.forEach((tagData, index) => {
const { text, active } = tagData;
const tagEl = document.createElement("div");
@@ -65,44 +98,75 @@ export function addTagsWidget(node, name, opts, callback) {
widget.value = updatedTags;
});
container.appendChild(tagEl);
rowContainer.appendChild(tagEl);
tagCount++;
});
// Calculate height based on number of tags and fixed sizes
const tagsCount = normalizedTags.length;
const rows = Math.ceil(tagsCount / TAGS_PER_ROW);
const calculatedHeight = CONTAINER_PADDING + (rows * TAG_HEIGHT) + ((rows - 1) * ROW_GAP);
// Update widget height with calculated value
updateWidgetHeight(calculatedHeight);
};
// Function to update widget height consistently
const updateWidgetHeight = (height) => {
// Ensure minimum height
const finalHeight = Math.max(defaultHeight, height);
// Update CSS variables
container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`);
container.style.setProperty('--comfy-widget-height', `${finalHeight}px`);
// Force node to update size after a short delay to ensure DOM is updated
if (node) {
setTimeout(() => {
node.setDirtyCanvas(true, true);
}, 10);
}
};
// Helper function to update tag style based on active state
function updateTagStyle(tagEl, active) {
const baseStyles = {
padding: "4px 12px", // 垂直内边距从6px减小到4px
borderRadius: "6px", // Matching container radius
maxWidth: "200px", // Increased max width
padding: "4px 10px", // Slightly reduced horizontal padding
borderRadius: "6px",
maxWidth: "200px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "13px", // Slightly larger font
fontSize: "13px",
cursor: "pointer",
transition: "all 0.2s ease", // Smoother transition
transition: "all 0.2s ease",
border: "1px solid transparent",
display: "inline-block",
display: "inline-flex", // Changed to inline-flex for better text alignment
alignItems: "center", // Center text vertically
justifyContent: "center", // Center text horizontally
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
margin: "2px", // 从4px减小到2px
userSelect: "none", // Add this line to prevent text selection
WebkitUserSelect: "none", // For Safari support
MozUserSelect: "none", // For Firefox support
msUserSelect: "none", // For IE/Edge support
margin: "1px", // Reduced margin
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
height: "20px", // Slightly increased height to prevent text cutoff
minHeight: "20px", // Ensure consistent height
boxSizing: "border-box" // Ensure padding doesn't affect the overall size
};
if (active) {
Object.assign(tagEl.style, {
...baseStyles,
backgroundColor: "rgba(66, 153, 225, 0.9)", // Modern blue
backgroundColor: "rgba(66, 153, 225, 0.9)",
color: "white",
borderColor: "rgba(66, 153, 225, 0.9)",
});
} else {
Object.assign(tagEl.style, {
...baseStyles,
backgroundColor: "rgba(45, 55, 72, 0.7)", // Darker inactive state
color: "rgba(226, 232, 240, 0.8)", // Lighter text for contrast
backgroundColor: "rgba(45, 55, 72, 0.7)",
color: "rgba(226, 232, 240, 0.8)",
borderColor: "rgba(226, 232, 240, 0.2)",
});
}
@@ -122,72 +186,48 @@ export function addTagsWidget(node, name, opts, callback) {
// Store the value as array
let widgetValue = initialTagsData;
// Create widget with initial properties
const widget = node.addDOMWidget(name, "tags", container, {
// Create widget with new DOM Widget API
const widget = node.addDOMWidget(name, "custom", container, {
getValue: function() {
return widgetValue;
},
setValue: function(v) {
widgetValue = v;
renderTags(widgetValue, widget);
// Update container height after rendering
requestAnimationFrame(() => {
const minHeight = this.getMinHeight();
container.style.height = `${minHeight}px`;
// Force node to update size
node.setSize([node.size[0], node.computeSize()[1]]);
node.setDirtyCanvas(true, true);
});
},
getMinHeight: function() {
const minHeight = 150;
// If no tags or only showing the empty message, return a minimum height
if (widgetValue.length === 0) {
return minHeight; // Height for empty state with message
}
// Get all tag elements
const tagElements = container.querySelectorAll('.comfy-tag');
if (tagElements.length === 0) {
return minHeight; // Fallback if elements aren't rendered yet
}
// Calculate the actual height based on tag positions
let maxBottom = 0;
tagElements.forEach(tag => {
const rect = tag.getBoundingClientRect();
const tagBottom = rect.bottom - container.getBoundingClientRect().top;
maxBottom = Math.max(maxBottom, tagBottom);
});
// Add padding (top and bottom padding of container)
const computedStyle = window.getComputedStyle(container);
const paddingTop = parseInt(computedStyle.paddingTop, 10) || 0;
const paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0;
// Add extra buffer for potential wrapping issues and to ensure no clipping
const extraBuffer = 20;
// Round up to nearest 5px for clean sizing and ensure minimum height
return Math.max(minHeight, Math.ceil((maxBottom + paddingBottom + extraBuffer) / 5) * 5);
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
},
getMaxHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
},
getHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
},
hideOnZoom: true,
selectOn: ['click', 'focus'],
afterResize: function(node) {
// Re-render tags after node resize
if (this.value && this.value.length > 0) {
renderTags(this.value, this);
}
}
});
// Set initial value
widget.value = initialTagsData;
// Set callback
widget.callback = callback;
// Add serialization method to avoid ComfyUI serialization issues
widget.serializeValue = () => {
// Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
// Add dummy items to avoid the 2-element serialization issue
return [...widgetValue,
{ text: "__dummy_item__", active: false, _isDummy: true },
{ text: "__dummy_item__", active: false, _isDummy: true }
];
};
return { minWidth: 300, minHeight: 150, widget };
}
return { minWidth: 300, minHeight: defaultHeight, widget };
}

View File

@@ -1,8 +1,11 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { addTagsWidget } from "./tags_widget.js";
import { CONVERTED_TYPE, dynamicImportByVersion } from "./utils.js";
const CONVERTED_TYPE = 'converted-widget'
// Function to get the appropriate tags widget based on ComfyUI version
async function getTagsWidgetModule() {
return await dynamicImportByVersion("./tags_widget.js", "./legacy_tags_widget.js");
}
// TriggerWordToggle extension for ComfyUI
app.registerExtension({
@@ -26,7 +29,11 @@ app.registerExtension({
});
// Wait for node to be properly initialized
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
// Dynamically import the appropriate tags widget module
const tagsModule = await getTagsWidgetModule();
const { addTagsWidget } = tagsModule;
// Get the widget object directly from the returned object
const result = addTagsWidget(node, "toggle_trigger_words", {
defaultVal: []

View File

@@ -1,5 +1,32 @@
export const CONVERTED_TYPE = 'converted-widget';
export function getComfyUIFrontendVersion() {
return window['__COMFYUI_FRONTEND_VERSION__'] || "0.0.0";
}
// Dynamically import the appropriate widget based on app version
export async function dynamicImportByVersion(latestModulePath, legacyModulePath) {
// Parse app version and compare with 1.12.6 (version when tags widget API changed)
const currentVersion = getComfyUIFrontendVersion();
const versionParts = currentVersion.split('.').map(part => parseInt(part, 10));
const requiredVersion = [1, 12, 6];
// Compare version numbers
for (let i = 0; i < 3; i++) {
if (versionParts[i] > requiredVersion[i]) {
console.log(`Using latest widget: ${latestModulePath}`);
return import(latestModulePath);
} else if (versionParts[i] < requiredVersion[i]) {
console.log(`Using legacy widget: ${legacyModulePath}`);
return import(legacyModulePath);
}
}
// If we get here, versions are equal, use the latest module
console.log(`Using latest widget: ${latestModulePath}`);
return import(latestModulePath);
}
export function hideWidgetForGood(node, widget, suffix = "") {
widget.origType = widget.type;
widget.origComputeSize = widget.computeSize;