mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0daf9d92ff | ||
|
|
37de26ce25 | ||
|
|
0eaef7e7a0 | ||
|
|
8063cee3cd | ||
|
|
cbb25b4ac0 | ||
|
|
c62206a157 | ||
|
|
09832141d0 | ||
|
|
bf8e121a10 | ||
|
|
68568073ec | ||
|
|
ec36524c35 | ||
|
|
67acd9fd2c | ||
|
|
f7be5c8d25 | ||
|
|
ceacac75e0 | ||
|
|
bae66f94e8 | ||
|
|
ddf132bd78 | ||
|
|
afb012029f | ||
|
|
651e14c8c3 | ||
|
|
e7c626eb5f | ||
|
|
a0b0d40a19 | ||
|
|
42e3ab9e27 | ||
|
|
6e5f333364 | ||
|
|
f33a9abe60 | ||
|
|
7f1bbdd615 | ||
|
|
d3bf8eaceb | ||
|
|
b9c9d602de | ||
|
|
b25fbd6e24 | ||
|
|
6052608a4e | ||
|
|
a073b82751 | ||
|
|
8250acdfb5 | ||
|
|
8e1f73a34e | ||
|
|
50704bc882 | ||
|
|
35d34e3513 | ||
|
|
ea834f3de6 | ||
|
|
11aedde72f | ||
|
|
488654abc8 | ||
|
|
da1be0dc65 | ||
|
|
d0c728a339 | ||
|
|
66c66c4d9b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ settings.json
|
||||
output/*
|
||||
py/run_test.py
|
||||
.vscode/
|
||||
cache/
|
||||
|
||||
83
README.md
83
README.md
@@ -9,18 +9,30 @@
|
||||
A comprehensive toolset that streamlines organizing, downloading, and applying LoRA models in ComfyUI. With powerful features like recipe management, checkpoint organization, and one-click workflow integration, working with models becomes faster, smoother, and significantly easier. Access the interface at: `http://localhost:8188/loras`
|
||||
|
||||

|
||||
|
||||
One-click Integration:
|
||||

|
||||
|
||||
## 📺 Tutorial: One-Click LoRA Integration
|
||||
Watch this quick tutorial to learn how to use the new one-click LoRA integration feature:
|
||||
|
||||
[](https://youtu.be/qS95OjX3e70)
|
||||
[](https://youtu.be/VKvTlCB78h4)
|
||||
[](https://youtu.be/hvKw31YpE-U)
|
||||
|
||||
---
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.8.16
|
||||
* **Dramatic Startup Speed Improvement** - Added cache serialization mechanism for significantly faster loading times, especially beneficial for large model collections
|
||||
* **Enhanced Refresh Options** - Extended functionality with "Full Rebuild (complete)" option alongside "Quick Refresh (incremental)" to fix potential memory cache issues without requiring application restart
|
||||
* **Customizable Display Density** - Replaced compact mode with adjustable display density settings for personalized layout customization
|
||||
* **Model Creator Information** - Added creator details to model information panels for better attribution
|
||||
* **Improved WebP Support** - Enhanced Save Image node with workflow embedding capability for WebP format images
|
||||
* **Direct Example Access** - Added "Open Example Images Folder" button to card interfaces for convenient browsing of downloaded model examples
|
||||
* **Enhanced Compatibility** - Full ComfyUI Desktop support for "Send lora or recipe to workflow" functionality
|
||||
* **Cache Management** - Added settings to clear existing cache files when needed
|
||||
* **Bug Fixes & Stability** - Various improvements for overall reliability and performance
|
||||
|
||||
### v0.8.15
|
||||
* **Enhanced One-Click Integration** - Replaced copy button with direct send button allowing LoRAs/recipes to be sent directly to your current ComfyUI workflow without needing to paste
|
||||
* **Flexible Workflow Integration** - Click to append LoRAs/recipes to existing loader nodes or Shift+click to replace content, with additional right-click menu options for "Send to Workflow (Append)" or "Send to Workflow (Replace)"
|
||||
@@ -56,71 +68,6 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
||||
* **Enhanced Metadata Collection** - Added support for SamplerCustomAdvanced node in the metadata collector module
|
||||
* **Improved UI Organization** - Optimized Lora Loader node height to display up to 5 LoRAs at once with scrolling capability for larger collections
|
||||
|
||||
### v0.8.9
|
||||
* **Favorites System** - New functionality to bookmark your favorite LoRAs and checkpoints for quick access and better organization
|
||||
* **Enhanced UI Controls** - Increased model card button sizes for improved usability and easier interaction
|
||||
* **Smoother Page Transitions** - Optimized interface switching between pages, eliminating flash issues particularly noticeable in dark theme
|
||||
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
|
||||
|
||||
### v0.8.8
|
||||
* **Real-time TriggerWord Updates** - Enhanced TriggerWord Toggle node to instantly update when connected Lora Loader or Lora Stacker nodes change, without requiring workflow execution
|
||||
* **Optimized Metadata Recovery** - Improved utilization of existing .civitai.info files for faster initialization and preservation of metadata from models deleted from CivitAI
|
||||
* **Migration Acceleration** - Further speed improvements for users transitioning from A1111/Forge environments
|
||||
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
|
||||
|
||||
### v0.8.7
|
||||
* **Enhanced Context Menu** - Added comprehensive context menu functionality to Recipes and Checkpoints pages for improved workflow
|
||||
* **Interactive LoRA Strength Control** - Implemented drag functionality in LoRA Loader for intuitive strength adjustment
|
||||
* **Metadata Collector Overhaul** - Rebuilt metadata collection system with optimized architecture for better performance
|
||||
* **Improved Save Image Node** - Enhanced metadata capture and image saving performance with the new metadata collector
|
||||
* **Streamlined Recipe Saving** - Optimized Save Recipe functionality to work independently without requiring Preview Image nodes
|
||||
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
|
||||
|
||||
### v0.8.6 Major Update
|
||||
* **Checkpoint Management** - Added comprehensive management for model checkpoints including scanning, searching, filtering, and deletion
|
||||
* **Enhanced Metadata Support** - New capabilities for retrieving and managing checkpoint metadata with improved operations
|
||||
* **Improved Initial Loading** - Optimized cache initialization with visual progress indicators for better user experience
|
||||
|
||||
### v0.8.5
|
||||
* **Enhanced LoRA & Recipe Connectivity** - Added Recipes tab in LoRA details to see all recipes using a specific LoRA
|
||||
* **Improved Navigation** - New shortcuts to jump between related LoRAs and Recipes with one-click navigation
|
||||
* **Video Preview Controls** - Added "Autoplay Videos on Hover" setting to optimize performance and reduce resource usage
|
||||
* **UI Experience Refinements** - Smoother transitions between related content pages
|
||||
|
||||
### 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 Forge’s CivitAI helper extension, making migration smoother.
|
||||
* **LoRA Filename Editing** - Added support for renaming LoRA files directly within LoRA Manager.
|
||||
* **Recipe Editing** - Users can now edit recipe names and tags.
|
||||
* **Retain Deleted LoRAs in Recipes** - Deleted LoRAs will remain listed in recipes, allowing future functionality to reconnect them once re-obtained.
|
||||
* **Download Missing LoRAs from Recipes** - Easily fetch missing LoRAs associated with a recipe.
|
||||
|
||||
### v0.8.1
|
||||
* **Base Model Correction** - Added support for modifying base model associations to fix incorrect metadata for non-CivitAI LoRAs
|
||||
* **LoRA Loader Flexibility** - Made CLIP input optional for model-only workflows like Hunyuan video generation
|
||||
* **Expanded Recipe Support** - Added compatibility with 3 additional recipe metadata formats
|
||||
* **Enhanced Showcase Images** - Generation parameters now displayed alongside LoRA preview images
|
||||
* **UI Improvements & Bug Fixes** - Various interface refinements and stability enhancements
|
||||
|
||||
### v0.8.0
|
||||
* **Introduced LoRA Recipes** - Create, import, save, and share your favorite LoRA combinations
|
||||
* **Recipe Management System** - Easily browse, search, and organize your LoRA recipes
|
||||
* **Workflow Integration** - Save recipes directly from your workflow with generation parameters preserved
|
||||
* **Simplified Workflow Application** - Quickly apply saved recipes to new projects
|
||||
* **Enhanced UI & UX** - Improved interface design and user experience
|
||||
* **Bug Fixes & Stability** - Resolved various issues and enhanced overall performance
|
||||
|
||||
[View Update History](./update_logs.md)
|
||||
|
||||
---
|
||||
@@ -185,7 +132,7 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
||||
|
||||
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
|
||||
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.8.10/lora_manager_portable.7z)
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.8.15/lora_manager_portable.7z)
|
||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder
|
||||
3. Edit `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
4. Run run.bat
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Constants used by the metadata collector"""
|
||||
|
||||
# Metadata collection constants
|
||||
|
||||
# Metadata categories
|
||||
MODELS = "models"
|
||||
PROMPTS = "prompts"
|
||||
@@ -9,6 +7,7 @@ SAMPLING = "sampling"
|
||||
LORAS = "loras"
|
||||
SIZE = "size"
|
||||
IMAGES = "images"
|
||||
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
|
||||
|
||||
# Complete list of categories to track
|
||||
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES]
|
||||
|
||||
@@ -4,33 +4,109 @@ import sys
|
||||
# Check if running in standalone mode
|
||||
standalone_mode = 'nodes' not in sys.modules
|
||||
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER
|
||||
|
||||
class MetadataProcessor:
|
||||
"""Process and format collected metadata"""
|
||||
|
||||
@staticmethod
|
||||
def find_primary_sampler(metadata):
|
||||
"""Find the primary KSampler node (with highest denoise value)"""
|
||||
def find_primary_sampler(metadata, downstream_id=None):
|
||||
"""
|
||||
Find the primary KSampler node that executed before the given downstream node
|
||||
|
||||
Parameters:
|
||||
- metadata: The workflow metadata
|
||||
- downstream_id: Optional ID of a downstream node to help identify the specific primary sampler
|
||||
"""
|
||||
# If we have a downstream_id and execution_order, use it to narrow down potential samplers
|
||||
if downstream_id and "execution_order" in metadata:
|
||||
execution_order = metadata["execution_order"]
|
||||
|
||||
# Find the index of the downstream node in the execution order
|
||||
if downstream_id in execution_order:
|
||||
downstream_index = execution_order.index(downstream_id)
|
||||
|
||||
# Extract all sampler nodes that executed before the downstream node
|
||||
candidate_samplers = {}
|
||||
for i in range(downstream_index):
|
||||
node_id = execution_order[i]
|
||||
# Use IS_SAMPLER flag to identify true sampler nodes
|
||||
if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
|
||||
candidate_samplers[node_id] = metadata[SAMPLING][node_id]
|
||||
|
||||
# If we found candidate samplers, apply primary sampler logic to these candidates only
|
||||
if candidate_samplers:
|
||||
# Collect potential primary samplers based on different criteria
|
||||
custom_advanced_samplers = []
|
||||
advanced_add_noise_samplers = []
|
||||
high_denoise_samplers = []
|
||||
max_denoise = -1
|
||||
high_denoise_id = None
|
||||
|
||||
# First, check for SamplerCustomAdvanced among candidates
|
||||
prompt = metadata.get("current_prompt")
|
||||
if prompt and prompt.original_prompt:
|
||||
for node_id in candidate_samplers:
|
||||
node_info = prompt.original_prompt.get(node_id, {})
|
||||
if node_info.get("class_type") == "SamplerCustomAdvanced":
|
||||
custom_advanced_samplers.append(node_id)
|
||||
|
||||
# Next, check for KSamplerAdvanced with add_noise="enable" among candidates
|
||||
for node_id, sampler_info in candidate_samplers.items():
|
||||
parameters = sampler_info.get("parameters", {})
|
||||
add_noise = parameters.get("add_noise")
|
||||
if add_noise == "enable":
|
||||
advanced_add_noise_samplers.append(node_id)
|
||||
|
||||
# Find the sampler with highest denoise value among candidates
|
||||
for node_id, sampler_info in candidate_samplers.items():
|
||||
parameters = sampler_info.get("parameters", {})
|
||||
denoise = parameters.get("denoise")
|
||||
if denoise is not None and denoise > max_denoise:
|
||||
max_denoise = denoise
|
||||
high_denoise_id = node_id
|
||||
|
||||
if high_denoise_id:
|
||||
high_denoise_samplers.append(high_denoise_id)
|
||||
|
||||
# Combine all potential primary samplers
|
||||
potential_samplers = custom_advanced_samplers + advanced_add_noise_samplers + high_denoise_samplers
|
||||
|
||||
# Find the most recent potential primary sampler (closest to downstream node)
|
||||
for i in range(downstream_index - 1, -1, -1):
|
||||
node_id = execution_order[i]
|
||||
if node_id in potential_samplers:
|
||||
return node_id, candidate_samplers[node_id]
|
||||
|
||||
# If no potential sampler found from our criteria, return the most recent sampler
|
||||
if candidate_samplers:
|
||||
for i in range(downstream_index - 1, -1, -1):
|
||||
node_id = execution_order[i]
|
||||
if node_id in candidate_samplers:
|
||||
return node_id, candidate_samplers[node_id]
|
||||
|
||||
# If no downstream_id provided or no suitable sampler found, fall back to original logic
|
||||
primary_sampler = None
|
||||
primary_sampler_id = None
|
||||
max_denoise = -1 # Track the highest denoise value
|
||||
max_denoise = -1
|
||||
|
||||
# First, check for SamplerCustomAdvanced
|
||||
prompt = metadata.get("current_prompt")
|
||||
if prompt and prompt.original_prompt:
|
||||
for node_id, node_info in prompt.original_prompt.items():
|
||||
if node_info.get("class_type") == "SamplerCustomAdvanced":
|
||||
# Found a SamplerCustomAdvanced node
|
||||
if node_id in metadata.get(SAMPLING, {}):
|
||||
# Check if the node is in SAMPLING and has IS_SAMPLER flag
|
||||
if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
|
||||
return node_id, metadata[SAMPLING][node_id]
|
||||
|
||||
# Next, check for KSamplerAdvanced with add_noise="enable"
|
||||
# Next, check for KSamplerAdvanced with add_noise="enable" using IS_SAMPLER flag
|
||||
for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
|
||||
# Skip if not marked as a sampler
|
||||
if not sampler_info.get(IS_SAMPLER, False):
|
||||
continue
|
||||
|
||||
parameters = sampler_info.get("parameters", {})
|
||||
add_noise = parameters.get("add_noise")
|
||||
|
||||
# If add_noise is "enable", this is likely the primary sampler for KSamplerAdvanced
|
||||
if add_noise == "enable":
|
||||
primary_sampler = sampler_info
|
||||
primary_sampler_id = node_id
|
||||
@@ -39,10 +115,12 @@ class MetadataProcessor:
|
||||
# If no specialized sampler found, find the sampler with highest denoise value
|
||||
if primary_sampler is None:
|
||||
for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
|
||||
# Skip if not marked as a sampler
|
||||
if not sampler_info.get(IS_SAMPLER, False):
|
||||
continue
|
||||
|
||||
parameters = sampler_info.get("parameters", {})
|
||||
denoise = parameters.get("denoise")
|
||||
|
||||
# If denoise exists and is higher than current max, use this sampler
|
||||
if denoise is not None and denoise > max_denoise:
|
||||
max_denoise = denoise
|
||||
primary_sampler = sampler_info
|
||||
@@ -74,13 +152,18 @@ class MetadataProcessor:
|
||||
current_node_id = node_id
|
||||
current_input = input_name
|
||||
|
||||
# If we're just tracing to origin (no target_class), keep track of the last valid node
|
||||
last_valid_node = None
|
||||
|
||||
while current_depth < max_depth:
|
||||
if current_node_id not in prompt.original_prompt:
|
||||
return None
|
||||
return last_valid_node if not target_class else None
|
||||
|
||||
node_inputs = prompt.original_prompt[current_node_id].get("inputs", {})
|
||||
if current_input not in node_inputs:
|
||||
return None
|
||||
# We've reached a node without the specified input - this is our origin node
|
||||
# if we're not looking for a specific target_class
|
||||
return current_node_id if not target_class else None
|
||||
|
||||
input_value = node_inputs[current_input]
|
||||
# Input connections are formatted as [node_id, output_index]
|
||||
@@ -91,9 +174,9 @@ class MetadataProcessor:
|
||||
if target_class and prompt.original_prompt[found_node_id].get("class_type") == target_class:
|
||||
return found_node_id
|
||||
|
||||
# If we're not looking for a specific class or haven't found it yet
|
||||
# If we're not looking for a specific class, update the last valid node
|
||||
if not target_class:
|
||||
return found_node_id
|
||||
last_valid_node = found_node_id
|
||||
|
||||
# Continue tracing through intermediate nodes
|
||||
current_node_id = found_node_id
|
||||
@@ -101,16 +184,17 @@ class MetadataProcessor:
|
||||
if "conditioning" in prompt.original_prompt[current_node_id].get("inputs", {}):
|
||||
current_input = "conditioning"
|
||||
else:
|
||||
# If there's no "conditioning" input, we can't trace further
|
||||
# If there's no "conditioning" input, return the current node
|
||||
# if we're not looking for a specific target_class
|
||||
return found_node_id if not target_class else None
|
||||
else:
|
||||
# We've reached a node with no further connections
|
||||
return None
|
||||
return last_valid_node if not target_class else None
|
||||
|
||||
current_depth += 1
|
||||
|
||||
# If we've reached max depth without finding target_class
|
||||
return None
|
||||
return last_valid_node if not target_class else None
|
||||
|
||||
@staticmethod
|
||||
def find_primary_checkpoint(metadata):
|
||||
@@ -126,8 +210,14 @@ class MetadataProcessor:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_generation_params(metadata):
|
||||
"""Extract generation parameters from metadata using node relationships"""
|
||||
def extract_generation_params(metadata, id=None):
|
||||
"""
|
||||
Extract generation parameters from metadata using node relationships
|
||||
|
||||
Parameters:
|
||||
- metadata: The workflow metadata
|
||||
- id: Optional ID of a downstream node to help identify the specific primary sampler
|
||||
"""
|
||||
params = {
|
||||
"prompt": "",
|
||||
"negative_prompt": "",
|
||||
@@ -147,13 +237,20 @@ class MetadataProcessor:
|
||||
prompt = metadata.get("current_prompt")
|
||||
|
||||
# Find the primary KSampler node
|
||||
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata)
|
||||
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
||||
|
||||
# Directly get checkpoint from metadata instead of tracing
|
||||
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata)
|
||||
if checkpoint:
|
||||
params["checkpoint"] = checkpoint
|
||||
|
||||
# Check if guidance parameter exists in any sampling node
|
||||
for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
|
||||
parameters = sampler_info.get("parameters", {})
|
||||
if "guidance" in parameters and parameters["guidance"] is not None:
|
||||
params["guidance"] = parameters["guidance"]
|
||||
break
|
||||
|
||||
if primary_sampler:
|
||||
# Extract sampling parameters
|
||||
sampling_params = primary_sampler.get("parameters", {})
|
||||
@@ -187,7 +284,7 @@ class MetadataProcessor:
|
||||
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
|
||||
params["sampler"] = sampler_params.get("sampler_name")
|
||||
|
||||
# 3. Trace guider input for CFGGuider, FluxGuidance and CLIPTextEncode
|
||||
# 3. Trace guider input for CFGGuider and CLIPTextEncode
|
||||
guider_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "guider", max_depth=5)
|
||||
if guider_node_id and guider_node_id in prompt.original_prompt:
|
||||
# Check if the guider node is a CFGGuider
|
||||
@@ -207,21 +304,14 @@ class MetadataProcessor:
|
||||
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||
else:
|
||||
# Look for FluxGuidance along the guider path
|
||||
flux_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "FluxGuidance", max_depth=5)
|
||||
if flux_node_id and flux_node_id in metadata.get(SAMPLING, {}):
|
||||
flux_params = metadata[SAMPLING][flux_node_id].get("parameters", {})
|
||||
params["guidance"] = flux_params.get("guidance")
|
||||
|
||||
# Find CLIPTextEncode for positive prompt (through conditioning)
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "CLIPTextEncode", max_depth=10)
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||
|
||||
else:
|
||||
# Original tracing for standard samplers
|
||||
# Trace positive prompt - look specifically for CLIPTextEncode
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "CLIPTextEncode", max_depth=10)
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", max_depth=10)
|
||||
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||
else:
|
||||
@@ -229,21 +319,9 @@ class MetadataProcessor:
|
||||
positive_flux_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "CLIPTextEncodeFlux", max_depth=10)
|
||||
if positive_flux_node_id and positive_flux_node_id in metadata.get(PROMPTS, {}):
|
||||
params["prompt"] = metadata[PROMPTS][positive_flux_node_id].get("text", "")
|
||||
|
||||
# Also extract guidance value if present in the sampling data
|
||||
if positive_flux_node_id in metadata.get(SAMPLING, {}):
|
||||
flux_params = metadata[SAMPLING][positive_flux_node_id].get("parameters", {})
|
||||
if "guidance" in flux_params:
|
||||
params["guidance"] = flux_params.get("guidance")
|
||||
|
||||
# Find any FluxGuidance nodes in the positive conditioning path
|
||||
flux_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "FluxGuidance", max_depth=5)
|
||||
if flux_node_id and flux_node_id in metadata.get(SAMPLING, {}):
|
||||
flux_params = metadata[SAMPLING][flux_node_id].get("parameters", {})
|
||||
params["guidance"] = flux_params.get("guidance")
|
||||
|
||||
# Trace negative prompt - look specifically for CLIPTextEncode
|
||||
negative_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "negative", "CLIPTextEncode", max_depth=10)
|
||||
negative_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "negative", max_depth=10)
|
||||
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||
|
||||
@@ -273,13 +351,19 @@ class MetadataProcessor:
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def to_dict(metadata):
|
||||
"""Convert extracted metadata to the ComfyUI output.json format"""
|
||||
def to_dict(metadata, id=None):
|
||||
"""
|
||||
Convert extracted metadata to the ComfyUI output.json format
|
||||
|
||||
Parameters:
|
||||
- metadata: The workflow metadata
|
||||
- id: Optional ID of a downstream node to help identify the specific primary sampler
|
||||
"""
|
||||
if standalone_mode:
|
||||
# Return empty dictionary in standalone mode
|
||||
return {}
|
||||
|
||||
params = MetadataProcessor.extract_generation_params(metadata)
|
||||
params = MetadataProcessor.extract_generation_params(metadata, id)
|
||||
|
||||
# Convert all values to strings to match output.json format
|
||||
for key in params:
|
||||
@@ -289,7 +373,7 @@ class MetadataProcessor:
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def to_json(metadata):
|
||||
def to_json(metadata, id=None):
|
||||
"""Convert metadata to JSON string"""
|
||||
params = MetadataProcessor.to_dict(metadata)
|
||||
params = MetadataProcessor.to_dict(metadata, id)
|
||||
return json.dumps(params, indent=4)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
||||
|
||||
|
||||
class NodeMetadataExtractor:
|
||||
@@ -61,7 +61,8 @@ class SamplerExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id] = {
|
||||
"parameters": sampling_params,
|
||||
"node_id": node_id
|
||||
"node_id": node_id,
|
||||
IS_SAMPLER: True # Add sampler flag
|
||||
}
|
||||
|
||||
# Extract latent image dimensions if available
|
||||
@@ -98,7 +99,8 @@ class KSamplerAdvancedExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id] = {
|
||||
"parameters": sampling_params,
|
||||
"node_id": node_id
|
||||
"node_id": node_id,
|
||||
IS_SAMPLER: True # Add sampler flag
|
||||
}
|
||||
|
||||
# Extract latent image dimensions if available
|
||||
@@ -269,7 +271,8 @@ class KSamplerSelectExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id] = {
|
||||
"parameters": sampling_params,
|
||||
"node_id": node_id
|
||||
"node_id": node_id,
|
||||
IS_SAMPLER: False # Mark as non-primary sampler
|
||||
}
|
||||
|
||||
class BasicSchedulerExtractor(NodeMetadataExtractor):
|
||||
@@ -285,7 +288,8 @@ class BasicSchedulerExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id] = {
|
||||
"parameters": sampling_params,
|
||||
"node_id": node_id
|
||||
"node_id": node_id,
|
||||
IS_SAMPLER: False # Mark as non-primary sampler
|
||||
}
|
||||
|
||||
class SamplerCustomAdvancedExtractor(NodeMetadataExtractor):
|
||||
@@ -303,7 +307,8 @@ class SamplerCustomAdvancedExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id] = {
|
||||
"parameters": sampling_params,
|
||||
"node_id": node_id
|
||||
"node_id": node_id,
|
||||
IS_SAMPLER: True # Add sampler flag
|
||||
}
|
||||
|
||||
# Extract latent image dimensions if available
|
||||
@@ -338,11 +343,20 @@ class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
|
||||
clip_l_text = inputs.get("clip_l", "")
|
||||
t5xxl_text = inputs.get("t5xxl", "")
|
||||
|
||||
# Create JSON string with T5 content first, then CLIP-L
|
||||
combined_text = json.dumps({
|
||||
"T5": t5xxl_text,
|
||||
"CLIP-L": clip_l_text
|
||||
})
|
||||
# If both are empty, use empty string
|
||||
if not clip_l_text and not t5xxl_text:
|
||||
combined_text = ""
|
||||
# If one is empty, use the non-empty one
|
||||
elif not clip_l_text:
|
||||
combined_text = t5xxl_text
|
||||
elif not t5xxl_text:
|
||||
combined_text = clip_l_text
|
||||
# If both have content, use JSON format
|
||||
else:
|
||||
combined_text = json.dumps({
|
||||
"T5": t5xxl_text,
|
||||
"CLIP-L": clip_l_text
|
||||
})
|
||||
|
||||
metadata[PROMPTS][node_id] = {
|
||||
"text": combined_text,
|
||||
@@ -391,11 +405,13 @@ NODE_EXTRACTORS = {
|
||||
# Loaders
|
||||
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
||||
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"LoraLoader": LoraLoaderExtractor,
|
||||
"LoraManagerLoader": LoraLoaderManagerExtractor,
|
||||
# Conditioning
|
||||
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
||||
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
||||
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
||||
# Latent
|
||||
"EmptyLatentImage": ImageSizeExtractor,
|
||||
# Flux
|
||||
|
||||
@@ -14,20 +14,23 @@ class DebugMetadata:
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
},
|
||||
"hidden": {
|
||||
"id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("metadata_json",)
|
||||
FUNCTION = "process_metadata"
|
||||
|
||||
def process_metadata(self, images):
|
||||
def process_metadata(self, images, id):
|
||||
try:
|
||||
# Get the current execution context's metadata
|
||||
from ..metadata_collector import get_metadata
|
||||
metadata = get_metadata()
|
||||
|
||||
# Use the MetadataProcessor to convert it to JSON string
|
||||
metadata_json = MetadataProcessor.to_json(metadata)
|
||||
metadata_json = MetadataProcessor.to_json(metadata, id)
|
||||
|
||||
return (metadata_json,)
|
||||
except Exception as e:
|
||||
|
||||
@@ -41,6 +41,7 @@ class SaveImage:
|
||||
"add_counter_to_filename": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"id": "UNIQUE_ID",
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
@@ -223,7 +224,7 @@ class SaveImage:
|
||||
if lora_hashes:
|
||||
lora_hash_parts = []
|
||||
for lora_name, hash_value in lora_hashes.items():
|
||||
lora_hash_parts.append(f"{lora_name}: {hash_value}")
|
||||
lora_hash_parts.append(f"{lora_name}: {hash_value[:10]}")
|
||||
|
||||
if lora_hash_parts:
|
||||
params.append(f"Lora hashes: \"{', '.join(lora_hash_parts)}\"")
|
||||
@@ -300,14 +301,14 @@ class SaveImage:
|
||||
|
||||
return filename
|
||||
|
||||
def save_images(self, images, filename_prefix, file_format, prompt=None, extra_pnginfo=None,
|
||||
def save_images(self, images, filename_prefix, file_format, id, prompt=None, extra_pnginfo=None,
|
||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
|
||||
"""Save images with metadata"""
|
||||
results = []
|
||||
|
||||
|
||||
# Get metadata using the metadata collector
|
||||
raw_metadata = get_metadata()
|
||||
metadata_dict = MetadataProcessor.to_dict(raw_metadata)
|
||||
metadata_dict = MetadataProcessor.to_dict(raw_metadata, id)
|
||||
|
||||
# Get or create metadata asynchronously
|
||||
metadata = asyncio.run(self.format_metadata(metadata_dict))
|
||||
@@ -378,14 +379,23 @@ class SaveImage:
|
||||
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}")
|
||||
try:
|
||||
# For WebP, use piexif for metadata
|
||||
exif_dict = {}
|
||||
|
||||
if metadata:
|
||||
exif_dict['Exif'] = {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}
|
||||
|
||||
# Add workflow if needed
|
||||
if embed_workflow and extra_pnginfo is not None:
|
||||
workflow_json = json.dumps(extra_pnginfo["workflow"])
|
||||
exif_dict['0th'] = {piexif.ImageIFD.ImageDescription: "Workflow:" + workflow_json}
|
||||
|
||||
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({
|
||||
@@ -399,7 +409,7 @@ class SaveImage:
|
||||
|
||||
return results
|
||||
|
||||
def process_image(self, images, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
|
||||
def process_image(self, images, id, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
|
||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
|
||||
"""Process and save image with metadata"""
|
||||
# Make sure the output directory exists
|
||||
@@ -416,6 +426,7 @@ class SaveImage:
|
||||
images,
|
||||
filename_prefix,
|
||||
file_format,
|
||||
id,
|
||||
prompt,
|
||||
extra_pnginfo,
|
||||
lossless_webp,
|
||||
|
||||
@@ -106,8 +106,11 @@ class ApiRoutes:
|
||||
|
||||
async def scan_loras(self, request: web.Request) -> web.Response:
|
||||
"""Force a rescan of LoRA files"""
|
||||
try:
|
||||
await self.scanner.get_cached_data(force_refresh=True)
|
||||
try:
|
||||
# Get full_rebuild parameter from query string, default to false
|
||||
full_rebuild = request.query.get('full_rebuild', 'false').lower() == 'true'
|
||||
|
||||
await self.scanner.get_cached_data(force_refresh=True, rebuild_cache=full_rebuild)
|
||||
return web.json_response({"status": "success", "message": "LoRA scan completed"})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scan_loras: {e}", exc_info=True)
|
||||
|
||||
@@ -420,7 +420,10 @@ class CheckpointsRoutes:
|
||||
async def scan_checkpoints(self, request):
|
||||
"""Force a rescan of checkpoint files"""
|
||||
try:
|
||||
await self.scanner.get_cached_data(force_refresh=True)
|
||||
# Get the full_rebuild parameter and convert to bool, default to False
|
||||
full_rebuild = request.query.get('full_rebuild', 'false').lower() == 'true'
|
||||
|
||||
await self.scanner.get_cached_data(force_refresh=True, rebuild_cache=full_rebuild)
|
||||
return web.json_response({"status": "success", "message": "Checkpoint scan completed"})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scan_checkpoints: {e}", exc_info=True)
|
||||
|
||||
@@ -4,13 +4,15 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import aiohttp
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from server import PromptServer # type: ignore
|
||||
from aiohttp import web
|
||||
from ..services.settings_manager import settings
|
||||
from ..utils.usage_stats import UsageStats
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.constants import EXAMPLE_IMAGE_WIDTH, SUPPORTED_MEDIA_EXTENSIONS
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
from ..services.civitai_client import CivitaiClient
|
||||
from ..utils.routes_common import ModelRouteUtils
|
||||
|
||||
@@ -39,6 +41,9 @@ class MiscRoutes:
|
||||
def setup_routes(app):
|
||||
"""Register miscellaneous routes"""
|
||||
app.router.add_post('/api/settings', MiscRoutes.update_settings)
|
||||
|
||||
# Add new route for clearing cache
|
||||
app.router.add_post('/api/clear-cache', MiscRoutes.clear_cache)
|
||||
|
||||
# Usage stats routes
|
||||
app.router.add_post('/api/update-usage-stats', MiscRoutes.update_usage_stats)
|
||||
@@ -52,7 +57,59 @@ class MiscRoutes:
|
||||
|
||||
# Lora code update endpoint
|
||||
app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
|
||||
|
||||
|
||||
# Add new route for opening example images folder
|
||||
app.router.add_post('/api/open-example-images-folder', MiscRoutes.open_example_images_folder)
|
||||
|
||||
@staticmethod
|
||||
async def clear_cache(request):
|
||||
"""Clear all cache files from the cache folder"""
|
||||
try:
|
||||
# Get the cache folder path (relative to project directory)
|
||||
project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
cache_folder = os.path.join(project_dir, 'cache')
|
||||
|
||||
# Check if cache folder exists
|
||||
if not os.path.exists(cache_folder):
|
||||
logger.info("Cache folder does not exist, nothing to clear")
|
||||
return web.json_response({'success': True, 'message': 'No cache folder found'})
|
||||
|
||||
# Get list of cache files before deleting for reporting
|
||||
cache_files = [f for f in os.listdir(cache_folder) if os.path.isfile(os.path.join(cache_folder, f))]
|
||||
deleted_files = []
|
||||
|
||||
# Delete each .msgpack file in the cache folder
|
||||
for filename in cache_files:
|
||||
if filename.endswith('.msgpack'):
|
||||
file_path = os.path.join(cache_folder, filename)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
deleted_files.append(filename)
|
||||
logger.info(f"Deleted cache file: {filename}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete {filename}: {e}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f"Failed to delete {filename}: {str(e)}"
|
||||
}, status=500)
|
||||
|
||||
# If we want to completely remove the cache folder too (optional,
|
||||
# but we'll keep the folder structure in place here)
|
||||
# shutil.rmtree(cache_folder)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'message': f"Successfully cleared {len(deleted_files)} cache files",
|
||||
'deleted_files': deleted_files
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing cache files: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@staticmethod
|
||||
async def update_settings(request):
|
||||
"""Update application settings"""
|
||||
@@ -354,6 +411,28 @@ class MiscRoutes:
|
||||
download_progress['last_error'] = error_msg
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _get_civitai_optimized_url(image_url):
|
||||
"""Convert a Civitai image URL to its optimized WebP version
|
||||
|
||||
Args:
|
||||
image_url: Original Civitai image URL
|
||||
|
||||
Returns:
|
||||
str: URL to optimized WebP version
|
||||
"""
|
||||
# Match the base part of Civitai URLs
|
||||
base_pattern = r'(https://image\.civitai\.com/[^/]+/[^/]+)'
|
||||
match = re.match(base_pattern, image_url)
|
||||
|
||||
if match:
|
||||
base_url = match.group(1)
|
||||
# Create the optimized WebP URL
|
||||
return f"{base_url}/optimized=true/image.webp"
|
||||
|
||||
# Return original URL if it doesn't match the expected format
|
||||
return image_url
|
||||
|
||||
@staticmethod
|
||||
async def _process_model_images(model_hash, model_name, model_images, model_dir, optimize, independent_session, delay):
|
||||
"""Process and download images for a single model
|
||||
@@ -393,6 +472,13 @@ class MiscRoutes:
|
||||
|
||||
save_filename = f"image_{i}{image_ext}"
|
||||
|
||||
# If optimizing images and this is a Civitai image, use their pre-optimized WebP version
|
||||
if is_image and optimize and 'civitai.com' in image_url:
|
||||
# Transform URL to use Civitai's optimized WebP version
|
||||
image_url = MiscRoutes._get_civitai_optimized_url(image_url)
|
||||
# Update filename to use .webp extension
|
||||
save_filename = f"image_{i}.webp"
|
||||
|
||||
# Check if already downloaded
|
||||
save_path = os.path.join(model_dir, save_filename)
|
||||
if os.path.exists(save_path):
|
||||
@@ -406,31 +492,10 @@ class MiscRoutes:
|
||||
# Direct download using the independent session
|
||||
async with independent_session.get(image_url, timeout=60) as response:
|
||||
if response.status == 200:
|
||||
if is_image and optimize:
|
||||
# For images, optimize if requested
|
||||
image_data = await response.read()
|
||||
optimized_data, ext = ExifUtils.optimize_image(
|
||||
image_data,
|
||||
target_width=EXAMPLE_IMAGE_WIDTH,
|
||||
format='webp',
|
||||
quality=85,
|
||||
preserve_metadata=False
|
||||
)
|
||||
|
||||
# Update save filename if format changed
|
||||
if ext == '.webp':
|
||||
save_filename = os.path.splitext(save_filename)[0] + '.webp'
|
||||
save_path = os.path.join(model_dir, save_filename)
|
||||
|
||||
# Save the optimized image
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(optimized_data)
|
||||
else:
|
||||
# For videos or unoptimized images, save directly
|
||||
with open(save_path, 'wb') as f:
|
||||
async for chunk in response.content.iter_chunked(8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
with open(save_path, 'wb') as f:
|
||||
async for chunk in response.content.iter_chunked(8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
elif response.status == 404:
|
||||
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
|
||||
logger.warning(error_msg)
|
||||
@@ -506,35 +571,11 @@ class MiscRoutes:
|
||||
logger.debug(f"File already exists in output: {save_path}")
|
||||
continue
|
||||
|
||||
# Handle image processing based on file type and optimize setting
|
||||
is_image = local_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
|
||||
|
||||
if is_image and optimize:
|
||||
# Optimize the image
|
||||
with open(local_image_path, 'rb') as img_file:
|
||||
image_data = img_file.read()
|
||||
|
||||
optimized_data, ext = ExifUtils.optimize_image(
|
||||
image_data,
|
||||
target_width=EXAMPLE_IMAGE_WIDTH,
|
||||
format='webp',
|
||||
quality=85,
|
||||
preserve_metadata=False
|
||||
)
|
||||
|
||||
# Update save filename if format changed
|
||||
if ext == '.webp':
|
||||
save_filename = os.path.splitext(save_filename)[0] + '.webp'
|
||||
save_path = os.path.join(model_dir, save_filename)
|
||||
|
||||
# Save the optimized image
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(optimized_data)
|
||||
else:
|
||||
# For videos or unoptimized images, copy directly
|
||||
with open(local_image_path, 'rb') as src_file:
|
||||
with open(save_path, 'wb') as dst_file:
|
||||
dst_file.write(src_file.read())
|
||||
# For local files, we just copy them without optimization
|
||||
# since we don't want to modify user files
|
||||
with open(local_image_path, 'rb') as src_file:
|
||||
with open(save_path, 'wb') as dst_file:
|
||||
dst_file.write(src_file.read())
|
||||
|
||||
return True
|
||||
return False
|
||||
@@ -777,7 +818,7 @@ class MiscRoutes:
|
||||
|
||||
Expects a JSON body with:
|
||||
{
|
||||
"node_ids": [123, 456], # List of node IDs to update
|
||||
"node_ids": [123, 456], # Optional - List of node IDs to update (for browser mode)
|
||||
"lora_code": "<lora:modelname:1.0>", # The Lora code to send
|
||||
"mode": "append" # or "replace" - whether to append or replace existing code
|
||||
}
|
||||
@@ -785,37 +826,59 @@ class MiscRoutes:
|
||||
try:
|
||||
# Parse the request body
|
||||
data = await request.json()
|
||||
node_ids = data.get('node_ids', [])
|
||||
node_ids = data.get('node_ids')
|
||||
lora_code = data.get('lora_code', '')
|
||||
mode = data.get('mode', 'append')
|
||||
|
||||
if not node_ids or not lora_code:
|
||||
if not lora_code:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Missing node_ids or lora_code parameter'
|
||||
'error': 'Missing lora_code parameter'
|
||||
}, status=400)
|
||||
|
||||
# Send the lora code update to each node
|
||||
results = []
|
||||
for node_id in node_ids:
|
||||
|
||||
# Desktop mode: no specific node_ids provided
|
||||
if node_ids is None:
|
||||
try:
|
||||
# Send the message to the frontend
|
||||
# Send broadcast message with id=-1 to all Lora Loader nodes
|
||||
PromptServer.instance.send_sync("lora_code_update", {
|
||||
"id": node_id,
|
||||
"id": -1,
|
||||
"lora_code": lora_code,
|
||||
"mode": mode
|
||||
})
|
||||
results.append({
|
||||
'node_id': node_id,
|
||||
'node_id': 'broadcast',
|
||||
'success': True
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending lora code to node {node_id}: {e}")
|
||||
logger.error(f"Error broadcasting lora code: {e}")
|
||||
results.append({
|
||||
'node_id': node_id,
|
||||
'node_id': 'broadcast',
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
else:
|
||||
# Browser mode: send to specific nodes
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
# Send the message to the frontend
|
||||
PromptServer.instance.send_sync("lora_code_update", {
|
||||
"id": node_id,
|
||||
"lora_code": lora_code,
|
||||
"mode": mode
|
||||
})
|
||||
results.append({
|
||||
'node_id': node_id,
|
||||
'success': True
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending lora code to node {node_id}: {e}")
|
||||
results.append({
|
||||
'node_id': node_id,
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
@@ -828,3 +891,63 @@ class MiscRoutes:
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@staticmethod
|
||||
async def open_example_images_folder(request):
|
||||
"""
|
||||
Open the example images folder for a specific model
|
||||
|
||||
Expects a JSON body with:
|
||||
{
|
||||
"model_hash": "sha256_hash" # SHA256 hash of the model
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Parse the request body
|
||||
data = await request.json()
|
||||
model_hash = data.get('model_hash')
|
||||
|
||||
if not model_hash:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Missing model_hash parameter'
|
||||
}, status=400)
|
||||
|
||||
# Get the example images path from settings
|
||||
example_images_path = settings.get('example_images_path')
|
||||
if not example_images_path:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'No example images path configured. Please set it in the settings panel first.'
|
||||
}, status=400)
|
||||
|
||||
# Construct the folder path for this model
|
||||
model_folder = os.path.join(example_images_path, model_hash)
|
||||
|
||||
# Check if the folder exists
|
||||
if not os.path.exists(model_folder):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'No example images found for this model. Download example images first.'
|
||||
}, status=404)
|
||||
|
||||
# Open the folder in the file explorer
|
||||
if os.name == 'nt': # Windows
|
||||
os.startfile(model_folder)
|
||||
elif os.name == 'posix': # macOS and Linux
|
||||
if sys.platform == 'darwin': # macOS
|
||||
subprocess.Popen(['open', model_folder])
|
||||
else: # Linux
|
||||
subprocess.Popen(['xdg-open', model_folder])
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'message': f'Opened example images folder for model {model_hash}'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open example images folder: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@@ -2,8 +2,7 @@ import logging
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Any
|
||||
from .civitai_client import CivitaiClient
|
||||
from typing import Dict
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -281,17 +280,11 @@ class DownloadManager:
|
||||
scanner = await self._get_lora_scanner()
|
||||
logger.info(f"Updating lora cache for {save_path}")
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
# Convert metadata to dictionary
|
||||
metadata_dict = metadata.to_dict()
|
||||
metadata_dict['folder'] = relative_path
|
||||
cache.raw_data.append(metadata_dict)
|
||||
await cache.resort()
|
||||
all_folders = set(cache.folders)
|
||||
all_folders.add(relative_path)
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
# Update the hash index with the new model entry
|
||||
scanner._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
|
||||
|
||||
# Add model to cache and save to disk in a single operation
|
||||
await scanner.add_model_to_cache(metadata_dict, relative_path)
|
||||
|
||||
# Report 100% completion
|
||||
if progress_callback:
|
||||
|
||||
@@ -5,8 +5,7 @@ import asyncio
|
||||
import time
|
||||
import shutil
|
||||
from typing import List, Dict, Optional, Type, Set
|
||||
|
||||
from ..utils.model_utils import determine_base_model
|
||||
import msgpack # Add MessagePack import for efficient serialization
|
||||
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..config import config
|
||||
@@ -19,6 +18,9 @@ from .websocket_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define cache version to handle future format changes
|
||||
CACHE_VERSION = 1
|
||||
|
||||
class ModelScanner:
|
||||
"""Base service for scanning and managing model files"""
|
||||
|
||||
@@ -41,6 +43,7 @@ class ModelScanner:
|
||||
self._tags_count = {} # Dictionary to store tag counts
|
||||
self._is_initializing = False # Flag to track initialization state
|
||||
self._excluded_models = [] # List to track excluded models
|
||||
self._dirs_last_modified = {} # Track directory modification times
|
||||
|
||||
# Register this service
|
||||
asyncio.create_task(self._register_service())
|
||||
@@ -50,6 +53,171 @@ class ModelScanner:
|
||||
service_name = f"{self.model_type}_scanner"
|
||||
await ServiceRegistry.register_service(service_name, self)
|
||||
|
||||
def _get_cache_file_path(self) -> Optional[str]:
|
||||
"""Get the path to the cache file"""
|
||||
# Get the directory where this module is located
|
||||
current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
|
||||
|
||||
# Create a cache directory within the project if it doesn't exist
|
||||
cache_dir = os.path.join(current_dir, "cache")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
# Create filename based on model type
|
||||
cache_filename = f"lm_{self.model_type}_cache.msgpack"
|
||||
return os.path.join(cache_dir, cache_filename)
|
||||
|
||||
def _prepare_for_msgpack(self, data):
|
||||
"""Preprocess data to accommodate MessagePack serialization limitations
|
||||
|
||||
Converts integers exceeding safe range to strings
|
||||
|
||||
Args:
|
||||
data: Any type of data structure
|
||||
|
||||
Returns:
|
||||
Preprocessed data structure with large integers converted to strings
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return {k: self._prepare_for_msgpack(v) for k, v in data.items()}
|
||||
elif isinstance(data, list):
|
||||
return [self._prepare_for_msgpack(item) for item in data]
|
||||
elif isinstance(data, int) and (data > 9007199254740991 or data < -9007199254740991):
|
||||
# Convert integers exceeding JavaScript's safe integer range (2^53-1) to strings
|
||||
return str(data)
|
||||
else:
|
||||
return data
|
||||
|
||||
async def _save_cache_to_disk(self) -> bool:
|
||||
"""Save cache data to disk using MessagePack"""
|
||||
if self._cache is None or not self._cache.raw_data:
|
||||
logger.debug(f"No {self.model_type} cache data to save")
|
||||
return False
|
||||
|
||||
cache_path = self._get_cache_file_path()
|
||||
if not cache_path:
|
||||
logger.warning(f"Cannot determine {self.model_type} cache file location")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create cache data structure
|
||||
cache_data = {
|
||||
"version": CACHE_VERSION,
|
||||
"timestamp": time.time(),
|
||||
"model_type": self.model_type,
|
||||
"raw_data": self._cache.raw_data,
|
||||
"hash_index": {
|
||||
"hash_to_path": self._hash_index._hash_to_path,
|
||||
"filename_to_hash": self._hash_index._filename_to_hash # Fix: changed from path_to_hash to filename_to_hash
|
||||
},
|
||||
"tags_count": self._tags_count,
|
||||
"dirs_last_modified": self._get_dirs_last_modified()
|
||||
}
|
||||
|
||||
# Preprocess data to handle large integers
|
||||
processed_cache_data = self._prepare_for_msgpack(cache_data)
|
||||
|
||||
# Write to temporary file first (atomic operation)
|
||||
temp_path = f"{cache_path}.tmp"
|
||||
with open(temp_path, 'wb') as f:
|
||||
msgpack.pack(processed_cache_data, f)
|
||||
|
||||
# Replace the old file with the new one
|
||||
if os.path.exists(cache_path):
|
||||
os.replace(temp_path, cache_path)
|
||||
else:
|
||||
os.rename(temp_path, cache_path)
|
||||
|
||||
logger.info(f"Saved {self.model_type} cache with {len(self._cache.raw_data)} models to {cache_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving {self.model_type} cache to disk: {e}")
|
||||
# Try to clean up temp file if it exists
|
||||
if 'temp_path' in locals() and os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _get_dirs_last_modified(self) -> Dict[str, float]:
|
||||
"""Get last modified time for all model directories"""
|
||||
dirs_info = {}
|
||||
for root in self.get_model_roots():
|
||||
if os.path.exists(root):
|
||||
dirs_info[root] = os.path.getmtime(root)
|
||||
# Also check immediate subdirectories for changes
|
||||
try:
|
||||
with os.scandir(root) as it:
|
||||
for entry in it:
|
||||
if entry.is_dir(follow_symlinks=True):
|
||||
dirs_info[entry.path] = entry.stat().st_mtime
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting directory info for {root}: {e}")
|
||||
return dirs_info
|
||||
|
||||
def _is_cache_valid(self, cache_data: Dict) -> bool:
|
||||
"""Validate if the loaded cache is still valid"""
|
||||
if not cache_data or cache_data.get("version") != CACHE_VERSION:
|
||||
return False
|
||||
|
||||
if cache_data.get("model_type") != self.model_type:
|
||||
return False
|
||||
|
||||
# Check if directories have changed
|
||||
stored_dirs = cache_data.get("dirs_last_modified", {})
|
||||
current_dirs = self._get_dirs_last_modified()
|
||||
|
||||
# If directory structure has changed, cache is invalid
|
||||
if set(stored_dirs.keys()) != set(current_dirs.keys()):
|
||||
return False
|
||||
|
||||
# Remove the modification time check to make cache validation less strict
|
||||
# This allows the cache to be valid even when files have changed
|
||||
# Users can explicitly refresh the cache when needed
|
||||
|
||||
return True
|
||||
|
||||
async def _load_cache_from_disk(self) -> bool:
|
||||
"""Load cache data from disk using MessagePack"""
|
||||
start_time = time.time()
|
||||
cache_path = self._get_cache_file_path()
|
||||
if not cache_path or not os.path.exists(cache_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(cache_path, 'rb') as f:
|
||||
cache_data = msgpack.unpack(f)
|
||||
|
||||
# Validate cache data
|
||||
if not self._is_cache_valid(cache_data):
|
||||
logger.info(f"{self.model_type.capitalize()} cache file found but invalid or outdated")
|
||||
return False
|
||||
|
||||
# Load data into memory
|
||||
self._cache = ModelCache(
|
||||
raw_data=cache_data["raw_data"],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[],
|
||||
folders=[]
|
||||
)
|
||||
|
||||
# Load hash index
|
||||
hash_index_data = cache_data.get("hash_index", {})
|
||||
self._hash_index._hash_to_path = hash_index_data.get("hash_to_path", {})
|
||||
self._hash_index._filename_to_hash = hash_index_data.get("filename_to_hash", {}) # Fix: changed from path_to_hash to filename_to_hash
|
||||
|
||||
# Load tags count
|
||||
self._tags_count = cache_data.get("tags_count", {})
|
||||
|
||||
# Resort the cache
|
||||
await self._cache.resort()
|
||||
|
||||
logger.info(f"Loaded {self.model_type} cache from disk with {len(self._cache.raw_data)} models in {time.time() - start_time:.2f} seconds")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading {self.model_type} cache from disk: {e}")
|
||||
return False
|
||||
|
||||
async def initialize_in_background(self) -> None:
|
||||
"""Initialize cache in background using thread pool"""
|
||||
try:
|
||||
@@ -68,7 +236,31 @@ class ModelScanner:
|
||||
# Determine the page type based on model type
|
||||
page_type = 'loras' if self.model_type == 'lora' else 'checkpoints'
|
||||
|
||||
# First, count all model files to track progress
|
||||
# First, try to load from cache
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'loading_cache',
|
||||
'progress': 0,
|
||||
'details': f"Loading {self.model_type} cache...",
|
||||
'scanner_type': self.model_type,
|
||||
'pageType': page_type
|
||||
})
|
||||
|
||||
cache_loaded = await self._load_cache_from_disk()
|
||||
|
||||
if cache_loaded:
|
||||
# Cache loaded successfully, broadcast complete message
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'finalizing',
|
||||
'progress': 100,
|
||||
'status': 'complete',
|
||||
'details': f"Loaded {len(self._cache.raw_data)} {self.model_type} files from cache.",
|
||||
'scanner_type': self.model_type,
|
||||
'pageType': page_type
|
||||
})
|
||||
self._is_initializing = False
|
||||
return
|
||||
|
||||
# If cache loading failed, proceed with full scan
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'scan_folders',
|
||||
'progress': 0,
|
||||
@@ -113,6 +305,9 @@ class ModelScanner:
|
||||
|
||||
logger.info(f"{self.model_type.capitalize()} cache initialized in {time.time() - start_time:.2f} seconds. Found {len(self._cache.raw_data)} models")
|
||||
|
||||
# Save the cache to disk after initialization
|
||||
await self._save_cache_to_disk()
|
||||
|
||||
# Send completion message
|
||||
await asyncio.sleep(0.5) # Small delay to ensure final progress message is sent
|
||||
await ws_manager.broadcast_init_progress({
|
||||
@@ -282,8 +477,13 @@ class ModelScanner:
|
||||
# Clean up the event loop
|
||||
loop.close()
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False) -> ModelCache:
|
||||
"""Get cached model data, refresh if needed"""
|
||||
async def get_cached_data(self, force_refresh: bool = False, rebuild_cache: bool = False) -> ModelCache:
|
||||
"""Get cached model data, refresh if needed
|
||||
|
||||
Args:
|
||||
force_refresh: Whether to refresh the cache
|
||||
rebuild_cache: Whether to completely rebuild the cache by reloading from disk first
|
||||
"""
|
||||
# If cache is not initialized, return an empty cache
|
||||
# Actual initialization should be done via initialize_in_background
|
||||
if self._cache is None and not force_refresh:
|
||||
@@ -295,10 +495,25 @@ class ModelScanner:
|
||||
)
|
||||
|
||||
# If force refresh is requested, initialize the cache directly
|
||||
if (force_refresh):
|
||||
if force_refresh:
|
||||
# If rebuild_cache is True, try to reload from disk before reconciliation
|
||||
if rebuild_cache:
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Attempting to rebuild cache from disk...")
|
||||
cache_loaded = await self._load_cache_from_disk()
|
||||
if cache_loaded:
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Successfully reloaded cache from disk")
|
||||
else:
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Could not reload cache from disk, proceeding with complete rebuild")
|
||||
# If loading from disk failed, do a complete rebuild and save to disk
|
||||
await self._initialize_cache()
|
||||
await self._save_cache_to_disk()
|
||||
return self._cache
|
||||
|
||||
if self._cache is None:
|
||||
# For initial creation, do a full initialization
|
||||
await self._initialize_cache()
|
||||
# Save the newly built cache
|
||||
await self._save_cache_to_disk()
|
||||
else:
|
||||
# For subsequent refreshes, use fast reconciliation
|
||||
await self._reconcile_cache()
|
||||
@@ -486,6 +701,9 @@ class ModelScanner:
|
||||
# Resort cache
|
||||
await self._cache.resort()
|
||||
|
||||
# Save updated cache to disk
|
||||
await self._save_cache_to_disk()
|
||||
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
|
||||
except Exception as e:
|
||||
logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True)
|
||||
@@ -539,113 +757,69 @@ class ModelScanner:
|
||||
# Common methods shared between scanners
|
||||
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
||||
"""Process a single model file and return its metadata"""
|
||||
needs_metadata_update = False
|
||||
original_save_metadata = save_metadata
|
||||
metadata = await load_metadata(file_path, self.model_class)
|
||||
|
||||
# Temporarily override save_metadata to prevent intermediate writes
|
||||
async def no_op_save(*args, **kwargs):
|
||||
nonlocal needs_metadata_update
|
||||
needs_metadata_update = True
|
||||
return None
|
||||
|
||||
# Use a context manager to temporarily replace save_metadata
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def prevent_metadata_writes():
|
||||
nonlocal needs_metadata_update
|
||||
# Replace the function temporarily
|
||||
import sys
|
||||
from .. import utils
|
||||
original = utils.file_utils.save_metadata
|
||||
utils.file_utils.save_metadata = no_op_save
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Restore the original function
|
||||
utils.file_utils.save_metadata = original
|
||||
|
||||
# Process with write prevention
|
||||
with prevent_metadata_writes():
|
||||
metadata = await load_metadata(file_path, self.model_class)
|
||||
|
||||
if metadata is None:
|
||||
if metadata is None:
|
||||
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
||||
if os.path.exists(civitai_info_path):
|
||||
try:
|
||||
with open(civitai_info_path, 'r', encoding='utf-8') as f:
|
||||
version_info = json.load(f)
|
||||
|
||||
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
|
||||
if file_info:
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
file_info['name'] = file_name
|
||||
|
||||
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
|
||||
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||
await save_metadata(file_path, metadata)
|
||||
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
||||
else:
|
||||
# Check if metadata exists but civitai field is empty - try to restore from civitai.info
|
||||
if metadata.civitai is None or metadata.civitai == {}:
|
||||
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
||||
if os.path.exists(civitai_info_path):
|
||||
try:
|
||||
with open(civitai_info_path, 'r', encoding='utf-8') as f:
|
||||
version_info = json.load(f)
|
||||
|
||||
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
|
||||
if file_info:
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
file_info['name'] = file_name
|
||||
logger.debug(f"Restoring missing civitai data from .civitai.info for {file_path}")
|
||||
metadata.civitai = version_info
|
||||
|
||||
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
|
||||
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||
needs_metadata_update = True
|
||||
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
||||
# Ensure tags are also updated if they're missing
|
||||
if (not metadata.tags or len(metadata.tags) == 0) and 'model' in version_info:
|
||||
if 'tags' in version_info['model']:
|
||||
metadata.tags = version_info['model']['tags']
|
||||
|
||||
# Also restore description if missing
|
||||
if (not metadata.modelDescription or metadata.modelDescription == "") and 'model' in version_info:
|
||||
if 'description' in version_info['model']:
|
||||
metadata.modelDescription = version_info['model']['description']
|
||||
|
||||
# Save the updated metadata
|
||||
await save_metadata(file_path, metadata)
|
||||
logger.debug(f"Updated metadata with civitai info for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
||||
else:
|
||||
# Check if metadata exists but civitai field is empty - try to restore from civitai.info
|
||||
if metadata.civitai is None or metadata.civitai == {}:
|
||||
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
||||
if os.path.exists(civitai_info_path):
|
||||
try:
|
||||
with open(civitai_info_path, 'r', encoding='utf-8') as f:
|
||||
version_info = json.load(f)
|
||||
|
||||
logger.debug(f"Restoring missing civitai data from .civitai.info for {file_path}")
|
||||
metadata.civitai = version_info
|
||||
needs_metadata_update = True
|
||||
|
||||
# Ensure tags are also updated if they're missing
|
||||
if (not metadata.tags or len(metadata.tags) == 0) and 'model' in version_info:
|
||||
if 'tags' in version_info['model']:
|
||||
metadata.tags = version_info['model']['tags']
|
||||
needs_metadata_update = True
|
||||
|
||||
# Also restore description if missing
|
||||
if (not metadata.modelDescription or metadata.modelDescription == "") and 'model' in version_info:
|
||||
if 'description' in version_info['model']:
|
||||
metadata.modelDescription = version_info['model']['description']
|
||||
needs_metadata_update = True
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
|
||||
|
||||
# Check if base_model is consistent with civitai baseModel
|
||||
if metadata.civitai and 'baseModel' in metadata.civitai:
|
||||
civitai_base_model = determine_base_model(metadata.civitai['baseModel'])
|
||||
if metadata.base_model != civitai_base_model:
|
||||
logger.debug(f"Updating base_model from {metadata.base_model} to {civitai_base_model} for {file_path}")
|
||||
metadata.base_model = civitai_base_model
|
||||
needs_metadata_update = True
|
||||
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
|
||||
|
||||
if metadata is None:
|
||||
metadata = await self._get_file_info(file_path)
|
||||
needs_metadata_update = True
|
||||
if metadata is None:
|
||||
metadata = await self._get_file_info(file_path)
|
||||
|
||||
# Continue processing
|
||||
model_data = metadata.to_dict() if metadata else None
|
||||
model_data = metadata.to_dict()
|
||||
|
||||
# Skip excluded models
|
||||
if model_data and model_data.get('exclude', False):
|
||||
if model_data.get('exclude', False):
|
||||
self._excluded_models.append(model_data['file_path'])
|
||||
return None
|
||||
|
||||
# Fetch missing metadata from Civitai if needed (with write prevention)
|
||||
with prevent_metadata_writes():
|
||||
await self._fetch_missing_metadata(file_path, model_data)
|
||||
|
||||
await self._fetch_missing_metadata(file_path, model_data)
|
||||
rel_path = os.path.relpath(file_path, root_path)
|
||||
folder = os.path.dirname(rel_path)
|
||||
model_data['folder'] = folder.replace(os.path.sep, '/')
|
||||
|
||||
# Only save metadata if needed
|
||||
if needs_metadata_update and metadata:
|
||||
await original_save_metadata(file_path, metadata)
|
||||
|
||||
return model_data
|
||||
|
||||
async def _fetch_missing_metadata(self, file_path: str, model_data: Dict) -> None:
|
||||
@@ -697,9 +871,9 @@ class ModelScanner:
|
||||
|
||||
model_data['civitai']['creator'] = model_metadata['creator']
|
||||
|
||||
# Create a metadata object and save it using save_metadata
|
||||
metadata_obj = self.model_class.from_dict(model_data)
|
||||
await save_metadata(file_path, metadata_obj)
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(model_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
||||
|
||||
@@ -744,6 +918,44 @@ class ModelScanner:
|
||||
models_list.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {file_path}: {e}")
|
||||
|
||||
async def add_model_to_cache(self, metadata_dict: Dict, folder: str = '') -> bool:
|
||||
"""Add a model to the cache and save to disk
|
||||
|
||||
Args:
|
||||
metadata_dict: The model metadata dictionary
|
||||
folder: The relative folder path for the model
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if self._cache is None:
|
||||
await self.get_cached_data()
|
||||
|
||||
# Update folder in metadata
|
||||
metadata_dict['folder'] = folder
|
||||
|
||||
# Add to cache
|
||||
self._cache.raw_data.append(metadata_dict)
|
||||
|
||||
# Resort cache data
|
||||
await self._cache.resort()
|
||||
|
||||
# Update folders list
|
||||
all_folders = set(self._cache.folders)
|
||||
all_folders.add(folder)
|
||||
self._cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
# Update the hash index
|
||||
self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
|
||||
|
||||
# Save to disk
|
||||
await self._save_cache_to_disk()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding model to cache: {e}")
|
||||
return False
|
||||
|
||||
async def move_model(self, source_path: str, target_path: str) -> bool:
|
||||
"""Move a model and its associated files to a new location"""
|
||||
@@ -789,25 +1001,40 @@ class ModelScanner:
|
||||
|
||||
shutil.move(real_source, real_target)
|
||||
|
||||
source_metadata = os.path.join(source_dir, f"{base_name}.metadata.json")
|
||||
# Move all associated files with the same base name
|
||||
source_metadata = None
|
||||
moved_metadata_path = None
|
||||
|
||||
# Find all files with the same base name in the source directory
|
||||
files_to_move = []
|
||||
try:
|
||||
for file in os.listdir(source_dir):
|
||||
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
|
||||
source_file_path = os.path.join(source_dir, file)
|
||||
# Store metadata file path for special handling
|
||||
if file == f"{base_name}.metadata.json":
|
||||
source_metadata = source_file_path
|
||||
moved_metadata_path = os.path.join(target_path, file)
|
||||
else:
|
||||
files_to_move.append((source_file_path, os.path.join(target_path, file)))
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing files in {source_dir}: {e}")
|
||||
|
||||
# Move all associated files
|
||||
metadata = None
|
||||
if os.path.exists(source_metadata):
|
||||
target_metadata = os.path.join(target_path, f"{base_name}.metadata.json")
|
||||
shutil.move(source_metadata, target_metadata)
|
||||
metadata = await self._update_metadata_paths(target_metadata, target_file)
|
||||
for source_file, target_file_path in files_to_move:
|
||||
try:
|
||||
shutil.move(source_file, target_file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving associated file {source_file}: {e}")
|
||||
|
||||
# Move civitai.info file if exists
|
||||
source_civitai = os.path.join(source_dir, f"{base_name}.civitai.info")
|
||||
if os.path.exists(source_civitai):
|
||||
target_civitai = os.path.join(target_path, f"{base_name}.civitai.info")
|
||||
shutil.move(source_civitai, target_civitai)
|
||||
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
source_preview = os.path.join(source_dir, f"{base_name}{ext}")
|
||||
if os.path.exists(source_preview):
|
||||
target_preview = os.path.join(target_path, f"{base_name}{ext}")
|
||||
shutil.move(source_preview, target_preview)
|
||||
break
|
||||
# Handle metadata file specially to update paths
|
||||
if source_metadata and os.path.exists(source_metadata):
|
||||
try:
|
||||
shutil.move(source_metadata, moved_metadata_path)
|
||||
metadata = await self._update_metadata_paths(moved_metadata_path, target_file)
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving metadata file: {e}")
|
||||
|
||||
await self.update_single_model_cache(source_path, target_file, metadata)
|
||||
|
||||
@@ -885,6 +1112,9 @@ class ModelScanner:
|
||||
|
||||
await cache.resort()
|
||||
|
||||
# Save the updated cache
|
||||
await self._save_cache_to_disk()
|
||||
|
||||
return True
|
||||
|
||||
def has_hash(self, sha256: str) -> bool:
|
||||
@@ -977,4 +1207,8 @@ class ModelScanner:
|
||||
if self._cache is None:
|
||||
return False
|
||||
|
||||
return await self._cache.update_preview_url(file_path, preview_url)
|
||||
updated = await self._cache.update_preview_url(file_path, preview_url)
|
||||
if updated:
|
||||
# Save updated cache to disk
|
||||
await self._save_cache_to_disk()
|
||||
return updated
|
||||
|
||||
@@ -245,7 +245,8 @@ async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = L
|
||||
# needs_update = True
|
||||
|
||||
if needs_update:
|
||||
save_metadata(file_path, model_class.from_dict(data))
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return model_class.from_dict(data)
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class ModelRouteUtils:
|
||||
model_id = civitai_metadata['modelId']
|
||||
if model_id:
|
||||
model_metadata, _ = await client.get_model_metadata(str(model_id))
|
||||
if model_metadata:
|
||||
if (model_metadata):
|
||||
local_metadata['modelDescription'] = model_metadata.get('description', '')
|
||||
local_metadata['tags'] = model_metadata.get('tags', [])
|
||||
local_metadata['civitai']['creator'] = model_metadata['creator']
|
||||
@@ -144,6 +144,11 @@ class ModelRouteUtils:
|
||||
"""
|
||||
client = CivitaiClient()
|
||||
try:
|
||||
# Validate input parameters
|
||||
if not isinstance(model_data, dict):
|
||||
logger.error(f"Invalid model_data type: {type(model_data)}")
|
||||
return False
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
|
||||
# Check if model metadata exists
|
||||
@@ -167,21 +172,25 @@ class ModelRouteUtils:
|
||||
client
|
||||
)
|
||||
|
||||
# Update cache object directly
|
||||
model_data.update({
|
||||
# Update cache object directly using safe .get() method
|
||||
update_dict = {
|
||||
'model_name': local_metadata.get('model_name'),
|
||||
'preview_url': local_metadata.get('preview_url'),
|
||||
'from_civitai': True,
|
||||
'civitai': civitai_metadata
|
||||
})
|
||||
}
|
||||
model_data.update(update_dict)
|
||||
|
||||
# Update cache using the provided function
|
||||
await update_cache_func(file_path, file_path, local_metadata)
|
||||
|
||||
return True
|
||||
|
||||
except KeyError as e:
|
||||
logger.error(f"Error fetching CivitAI data - Missing key: {e} in model_data={model_data}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CivitAI data: {e}")
|
||||
logger.error(f"Error fetching CivitAI data: {str(e)}", exc_info=True) # Include stack trace
|
||||
return False
|
||||
finally:
|
||||
await client.close()
|
||||
@@ -195,7 +204,7 @@ class ModelRouteUtils:
|
||||
fields = [
|
||||
"id", "modelId", "name", "createdAt", "updatedAt",
|
||||
"publishedAt", "trainedWords", "baseModel", "description",
|
||||
"model", "images"
|
||||
"model", "images", "creator"
|
||||
]
|
||||
return {k: data[k] for k in fields if k in data}
|
||||
|
||||
|
||||
@@ -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.15"
|
||||
version = "0.8.16"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
@@ -14,7 +14,8 @@ dependencies = [
|
||||
"olefile", # for getting rid of warning message
|
||||
"requests",
|
||||
"toml",
|
||||
"natsort"
|
||||
"natsort",
|
||||
"msgpack"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -10,4 +10,5 @@ requests
|
||||
toml
|
||||
numpy
|
||||
torch
|
||||
natsort
|
||||
natsort
|
||||
msgpack
|
||||
@@ -7,9 +7,11 @@
|
||||
margin-top: var(--space-2);
|
||||
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
||||
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
||||
width: 100%; /* Ensure it takes full width of container */
|
||||
max-width: 1400px; /* Base container width */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
box-sizing: border-box; /* Include padding in width calculation */
|
||||
}
|
||||
|
||||
.lora-card {
|
||||
@@ -84,23 +86,39 @@
|
||||
min-height: 0; /* Fix for potential flexbox sizing issue in Firefox */
|
||||
}
|
||||
|
||||
/* Smaller text for medium density */
|
||||
.medium-density .model-name {
|
||||
font-size: 0.95em;
|
||||
max-height: 2.6em;
|
||||
}
|
||||
|
||||
.medium-density .base-model-label {
|
||||
font-size: 0.85em;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.medium-density .card-actions i {
|
||||
font-size: 0.98em;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Smaller text for compact mode */
|
||||
.compact-mode .model-name {
|
||||
.compact-density .model-name {
|
||||
font-size: 0.9em;
|
||||
max-height: 2.4em;
|
||||
}
|
||||
|
||||
.compact-mode .base-model-label {
|
||||
.compact-density .base-model-label {
|
||||
font-size: 0.8em;
|
||||
max-width: 110px;
|
||||
}
|
||||
|
||||
.compact-mode .card-actions i {
|
||||
.compact-density .card-actions i {
|
||||
font-size: 0.95em;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.compact-mode .model-info {
|
||||
.compact-density .model-info {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -448,10 +466,12 @@
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
padding: 6px 0; /* Add top/bottom padding equivalent to card padding */
|
||||
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
|
||||
height: auto;
|
||||
width: 100%;
|
||||
max-width: 1400px; /* Keep the max-width from original grid */
|
||||
box-sizing: border-box; /* Include padding in width calculation */
|
||||
overflow-x: hidden; /* Prevent horizontal overflow */
|
||||
}
|
||||
|
||||
/* For larger screens, allow more space for the cards */
|
||||
|
||||
@@ -673,11 +673,6 @@
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Model name field styles - complete replacement */
|
||||
.model-name-field {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* New Model Name Header Styles */
|
||||
.model-name-header {
|
||||
display: flex;
|
||||
@@ -1323,4 +1318,61 @@
|
||||
|
||||
.recipes-error i {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Creator Information Styles */
|
||||
.creator-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: var(--space-1);
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .creator-info {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.creator-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.creator-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.creator-placeholder {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.creator-username {
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Optional: add hover effect for creator info */
|
||||
.creator-info:hover {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
@@ -151,7 +151,7 @@ body.modal-open {
|
||||
|
||||
.modal-content h2 {
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
@@ -682,4 +682,15 @@ input:checked + .toggle-slider:before {
|
||||
|
||||
[data-theme="dark"] .warning-text {
|
||||
color: var(--lora-warning, #f39c12);
|
||||
}
|
||||
|
||||
/* Add styles for density description list */
|
||||
.density-description {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.density-description li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
.page-content {
|
||||
height: calc(100vh - 48px); /* Full height minus header */
|
||||
margin-top: 48px; /* Push down below header */
|
||||
overflow-y: auto; /* Enable scrolling here */
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden; /* Prevent horizontal scrolling */
|
||||
overflow-y: auto; /* Enable vertical scrolling */
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -333,6 +333,66 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Dropdown Button Styling */
|
||||
.dropdown-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dropdown-main {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
width: 24px !important;
|
||||
min-width: unset !important;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
min-width: 230px;
|
||||
padding: 5px 0;
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.85em;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
|
||||
}
|
||||
|
||||
.dropdown-group.active .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
padding: 6px 15px;
|
||||
clear: both;
|
||||
font-weight: 400;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: oklch(var(--lora-accent) / 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item i {
|
||||
margin-right: 8px;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
@@ -378,4 +438,9 @@
|
||||
.back-to-top {
|
||||
bottom: 60px; /* Give some extra space from bottom on mobile */
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
left: auto;
|
||||
right: 0; /* Align to right on mobile */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,13 +499,18 @@ export async function refreshModels(options = {}) {
|
||||
const {
|
||||
modelType = 'lora',
|
||||
scanEndpoint = '/api/loras/scan',
|
||||
resetAndReloadFunction
|
||||
resetAndReloadFunction,
|
||||
fullRebuild = false // New parameter with default value false
|
||||
} = options;
|
||||
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Refreshing ${modelType}s...`);
|
||||
state.loadingManager.showSimpleLoading(`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${modelType}s...`);
|
||||
|
||||
const response = await fetch(scanEndpoint);
|
||||
// Add fullRebuild parameter to the request
|
||||
const url = new URL(scanEndpoint, window.location.origin);
|
||||
url.searchParams.append('full_rebuild', fullRebuild);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to refresh ${modelType}s: ${response.status} ${response.statusText}`);
|
||||
@@ -515,10 +520,10 @@ export async function refreshModels(options = {}) {
|
||||
await resetAndReloadFunction(true); // update folders
|
||||
}
|
||||
|
||||
showToast(`Refresh complete`, 'success');
|
||||
showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success');
|
||||
} catch (error) {
|
||||
console.error(`Refresh failed:`, error);
|
||||
showToast(`Failed to refresh ${modelType}s`, 'error');
|
||||
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${modelType}s`, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
state.loadingManager.restoreProgressBar();
|
||||
|
||||
@@ -76,11 +76,12 @@ export async function resetAndReload(updateFolders = false) {
|
||||
}
|
||||
|
||||
// Refresh checkpoints
|
||||
export async function refreshCheckpoints() {
|
||||
export async function refreshCheckpoints(fullRebuild = false) {
|
||||
return baseRefreshModels({
|
||||
modelType: 'checkpoint',
|
||||
scanEndpoint: '/api/checkpoints/scan',
|
||||
resetAndReloadFunction: resetAndReload
|
||||
resetAndReloadFunction: resetAndReload,
|
||||
fullRebuild: fullRebuild
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,22 +120,30 @@ export async function refreshSingleCheckpointMetadata(filePath) {
|
||||
* @returns {Promise} - Promise that resolves with the server response
|
||||
*/
|
||||
export async function saveModelMetadata(filePath, data) {
|
||||
const response = await fetch('/api/checkpoints/save-metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
...data
|
||||
})
|
||||
});
|
||||
try {
|
||||
// Show loading indicator
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
|
||||
const response = await fetch('/api/checkpoints/save-metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
...data
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save metadata');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save metadata');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} finally {
|
||||
// Always hide the loading indicator when done
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,4 +153,40 @@ export async function saveModelMetadata(filePath, data) {
|
||||
*/
|
||||
export function excludeCheckpoint(filePath) {
|
||||
return baseExcludeModel(filePath, 'checkpoint');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a checkpoint file
|
||||
* @param {string} filePath - Current file path
|
||||
* @param {string} newFileName - New file name (without path)
|
||||
* @returns {Promise<Object>} - Promise that resolves with the server response
|
||||
*/
|
||||
export async function renameCheckpointFile(filePath, newFileName) {
|
||||
try {
|
||||
// Show loading indicator
|
||||
state.loadingManager.showSimpleLoading('Renaming checkpoint file...');
|
||||
|
||||
const response = await fetch('/api/rename_checkpoint', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
new_file_name: newFileName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error renaming checkpoint file:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Hide loading indicator
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
@@ -21,22 +21,30 @@ import { state, getCurrentPageState } from '../state/index.js';
|
||||
* @returns {Promise} Promise of the save operation
|
||||
*/
|
||||
export async function saveModelMetadata(filePath, data) {
|
||||
const response = await fetch('/api/loras/save-metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
...data
|
||||
})
|
||||
});
|
||||
try {
|
||||
// Show loading indicator
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
|
||||
const response = await fetch('/api/loras/save-metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
...data
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save metadata');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save metadata');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} finally {
|
||||
// Always hide the loading indicator when done
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,11 +151,12 @@ export async function resetAndReload(updateFolders = false) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshLoras() {
|
||||
export async function refreshLoras(fullRebuild = false) {
|
||||
return baseRefreshModels({
|
||||
modelType: 'lora',
|
||||
scanEndpoint: '/api/loras/scan',
|
||||
resetAndReloadFunction: resetAndReload
|
||||
resetAndReloadFunction: resetAndReload,
|
||||
fullRebuild: fullRebuild
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,4 +181,40 @@ export async function fetchModelDescription(modelId, filePath) {
|
||||
console.error('Error fetching model description:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a LoRA file
|
||||
* @param {string} filePath - Current file path
|
||||
* @param {string} newFileName - New file name (without path)
|
||||
* @returns {Promise<Object>} - Promise that resolves with the server response
|
||||
*/
|
||||
export async function renameLoraFile(filePath, newFileName) {
|
||||
try {
|
||||
// Show loading indicator
|
||||
state.loadingManager.showSimpleLoading('Renaming LoRA file...');
|
||||
|
||||
const response = await fetch('/api/rename_lora', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
new_file_name: newFileName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error renaming LoRA file:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Hide loading indicator
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
|
||||
import { showToast, copyToClipboard, openExampleImagesFolder } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { showCheckpointModal } from './checkpointModal/index.js';
|
||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||
@@ -115,8 +115,8 @@ export function createCheckpointCard(checkpoint) {
|
||||
<span class="model-name">${checkpoint.model_name}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-image"
|
||||
title="Replace Preview Image">
|
||||
<i class="fas fa-folder-open"
|
||||
title="Open Example Images Folder">
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,6 +272,12 @@ export function createCheckpointCard(checkpoint) {
|
||||
replaceCheckpointPreview(checkpoint.file_path);
|
||||
});
|
||||
|
||||
// Open example images folder button click event
|
||||
card.querySelector('.fa-folder-open')?.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
openExampleImagesFolder(checkpoint.sha256);
|
||||
});
|
||||
|
||||
// Add autoplayOnHover handlers for video elements if needed
|
||||
const videoElement = card.querySelector('video');
|
||||
if (videoElement && autoplayOnHover) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { refreshSingleCheckpointMetadata, saveModelMetadata } from '../../api/checkpointApi.js';
|
||||
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
|
||||
import { refreshSingleCheckpointMetadata, saveModelMetadata, replaceCheckpointPreview } from '../../api/checkpointApi.js';
|
||||
import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||
import { showExcludeModal } from '../../utils/modalUtils.js';
|
||||
@@ -23,10 +23,12 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
this.currentCard.click();
|
||||
break;
|
||||
case 'preview':
|
||||
// Replace checkpoint preview
|
||||
if (this.currentCard.querySelector('.fa-image')) {
|
||||
this.currentCard.querySelector('.fa-image').click();
|
||||
}
|
||||
// Open example images folder instead of replacing preview
|
||||
openExampleImagesFolder(this.currentCard.dataset.sha256);
|
||||
break;
|
||||
case 'replace-preview':
|
||||
// Add new action for replacing preview images
|
||||
replaceCheckpointPreview(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'civitai':
|
||||
// Open civitai page
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { refreshSingleLoraMetadata, saveModelMetadata } from '../../api/loraApi.js';
|
||||
import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview } from '../../api/loraApi.js';
|
||||
import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||
import { showExcludeModal } from '../../utils/modalUtils.js';
|
||||
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||
|
||||
export class LoraContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -47,10 +47,16 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
this.sendLoraToWorkflow(true);
|
||||
break;
|
||||
case 'preview':
|
||||
this.currentCard.querySelector('.fa-image')?.click();
|
||||
// Open example images folder instead of showing preview image dialog
|
||||
openExampleImagesFolder(this.currentCard.dataset.sha256);
|
||||
break;
|
||||
case 'replace-preview':
|
||||
// Add a new action for replacing preview images
|
||||
replacePreview(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'delete':
|
||||
this.currentCard.querySelector('.fa-trash')?.click();
|
||||
// Call showDeleteModal directly instead of clicking the trash button
|
||||
showDeleteModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'move':
|
||||
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { showLoraModal } from './loraModal/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
@@ -57,9 +57,9 @@ function handleLoraCardEvent(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-trash')) {
|
||||
if (event.target.closest('.fa-copy')) {
|
||||
event.stopPropagation();
|
||||
showDeleteModal(card.dataset.filepath);
|
||||
copyLoraSyntax(card);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@ function handleLoraCardEvent(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-folder-open')) {
|
||||
event.stopPropagation();
|
||||
openExampleImagesFolder(card.dataset.sha256);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||
if (state.bulkMode) {
|
||||
// Toggle selection using the bulk manager
|
||||
@@ -182,6 +188,15 @@ async function sendLoraToComfyUI(card, replaceMode) {
|
||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||
}
|
||||
|
||||
// Add function to copy lora syntax
|
||||
function copyLoraSyntax(card) {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
|
||||
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
|
||||
}
|
||||
|
||||
export function createLoraCard(lora) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'lora-card';
|
||||
@@ -273,8 +288,8 @@ export function createLoraCard(lora) {
|
||||
<i class="fas fa-paper-plane"
|
||||
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
|
||||
</i>
|
||||
<i class="fas fa-trash"
|
||||
title="Delete Model">
|
||||
<i class="fas fa-copy"
|
||||
title="Copy LoRA Syntax">
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,14 +306,14 @@ export function createLoraCard(lora) {
|
||||
<span class="model-name">${lora.model_name}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-image"
|
||||
title="Replace Preview Image">
|
||||
<i class="fas fa-folder-open"
|
||||
title="Open Example Images Folder">
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
// Add a special class for virtual scroll positioning if needed
|
||||
if (state.virtualScroller) {
|
||||
card.classList.add('virtual-scroll-item');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { BASE_MODELS } from '../../utils/constants.js';
|
||||
import { updateCheckpointCard } from '../../utils/cardUpdater.js';
|
||||
import { saveModelMetadata } from '../../api/checkpointApi.js';
|
||||
import { saveModelMetadata, renameCheckpointFile } from '../../api/checkpointApi.js';
|
||||
|
||||
/**
|
||||
* Set up model name editing functionality
|
||||
@@ -17,6 +17,9 @@ export function setupModelNameEditing(filePath) {
|
||||
|
||||
if (!modelNameContent || !editBtn) return;
|
||||
|
||||
// Store the file path in a data attribute for later use
|
||||
modelNameContent.dataset.filePath = filePath;
|
||||
|
||||
// Show edit button on hover
|
||||
const modelNameHeader = document.querySelector('.model-name-header');
|
||||
modelNameHeader.addEventListener('mouseenter', () => {
|
||||
@@ -24,14 +27,17 @@ export function setupModelNameEditing(filePath) {
|
||||
});
|
||||
|
||||
modelNameHeader.addEventListener('mouseleave', () => {
|
||||
if (!modelNameContent.getAttribute('data-editing')) {
|
||||
if (!modelNameHeader.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button click
|
||||
editBtn.addEventListener('click', () => {
|
||||
modelNameContent.setAttribute('data-editing', 'true');
|
||||
modelNameHeader.classList.add('editing');
|
||||
modelNameContent.setAttribute('contenteditable', 'true');
|
||||
// Store original value for comparison later
|
||||
modelNameContent.dataset.originalValue = modelNameContent.textContent.trim();
|
||||
modelNameContent.focus();
|
||||
|
||||
// Place cursor at the end
|
||||
@@ -47,33 +53,25 @@ export function setupModelNameEditing(filePath) {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
// Handle focus out
|
||||
modelNameContent.addEventListener('blur', function() {
|
||||
this.removeAttribute('data-editing');
|
||||
editBtn.classList.remove('visible');
|
||||
|
||||
if (this.textContent.trim() === '') {
|
||||
// Restore original model name if empty
|
||||
// Use the passed filePath to find the card
|
||||
const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`);
|
||||
if (checkpointCard) {
|
||||
this.textContent = checkpointCard.dataset.model_name;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle enter key
|
||||
// Handle keyboard events in edit mode
|
||||
modelNameContent.addEventListener('keydown', function(e) {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Use the passed filePath
|
||||
saveModelName(filePath);
|
||||
this.blur();
|
||||
this.blur(); // Trigger save on Enter
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// Restore original value
|
||||
this.textContent = this.dataset.originalValue;
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Limit model name length
|
||||
modelNameContent.addEventListener('input', function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (this.textContent.length > 100) {
|
||||
this.textContent = this.textContent.substring(0, 100);
|
||||
// Place cursor at the end
|
||||
@@ -87,44 +85,59 @@ export function setupModelNameEditing(filePath) {
|
||||
showToast('Model name is limited to 100 characters', 'warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save model name
|
||||
* @param {string} filePath - File path
|
||||
*/
|
||||
async function saveModelName(filePath) {
|
||||
const modelNameElement = document.querySelector('.model-name-content');
|
||||
const newModelName = modelNameElement.textContent.trim();
|
||||
|
||||
// Validate model name
|
||||
if (!newModelName) {
|
||||
showToast('Model name cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
// Handle focus out - save changes
|
||||
modelNameContent.addEventListener('blur', async function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
const newModelName = this.textContent.trim();
|
||||
const originalValue = this.dataset.originalValue;
|
||||
|
||||
// Basic validation
|
||||
if (!newModelName) {
|
||||
// Restore original value if empty
|
||||
this.textContent = originalValue;
|
||||
showToast('Model name cannot be empty', 'error');
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newModelName === originalValue) {
|
||||
// No changes, just exit edit mode
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the file path from the dataset
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
await saveModelMetadata(filePath, { model_name: newModelName });
|
||||
|
||||
// Update the corresponding checkpoint card's dataset and display
|
||||
updateCheckpointCard(filePath, { model_name: newModelName });
|
||||
|
||||
// BUGFIX: Directly update the card's dataset.name attribute to ensure
|
||||
// it's correctly read when reopening the modal
|
||||
const checkpointCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (checkpointCard) {
|
||||
checkpointCard.dataset.name = newModelName;
|
||||
}
|
||||
|
||||
showToast('Model name updated successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error updating model name:', error);
|
||||
this.textContent = originalValue; // Restore original model name
|
||||
showToast('Failed to update model name', 'error');
|
||||
} finally {
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Check if model name is too long
|
||||
if (newModelName.length > 100) {
|
||||
showToast('Model name is too long (maximum 100 characters)', 'error');
|
||||
// Truncate the displayed text
|
||||
modelNameElement.textContent = newModelName.substring(0, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveModelMetadata(filePath, { model_name: newModelName });
|
||||
|
||||
// Update the card with the new model name
|
||||
updateCheckpointCard(filePath, { name: newModelName });
|
||||
|
||||
showToast('Model name updated successfully', 'success');
|
||||
|
||||
// No need to reload the entire page
|
||||
// setTimeout(() => {
|
||||
// window.location.reload();
|
||||
// }, 1500);
|
||||
} catch (error) {
|
||||
showToast('Failed to update model name', 'error');
|
||||
function exitEditMode() {
|
||||
modelNameContent.removeAttribute('contenteditable');
|
||||
modelNameHeader.classList.remove('editing');
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +151,9 @@ export function setupBaseModelEditing(filePath) {
|
||||
|
||||
if (!baseModelContent || !editBtn) return;
|
||||
|
||||
// Store the file path in a data attribute for later use
|
||||
baseModelContent.dataset.filePath = filePath;
|
||||
|
||||
// Show edit button on hover
|
||||
const baseModelDisplay = document.querySelector('.base-model-display');
|
||||
baseModelDisplay.addEventListener('mouseenter', () => {
|
||||
@@ -303,6 +319,9 @@ export function setupFileNameEditing(filePath) {
|
||||
|
||||
if (!fileNameContent || !editBtn) return;
|
||||
|
||||
// Store the original file path
|
||||
fileNameContent.dataset.filePath = filePath;
|
||||
|
||||
// Show edit button on hover
|
||||
const fileNameWrapper = document.querySelector('.file-name-wrapper');
|
||||
fileNameWrapper.addEventListener('mouseenter', () => {
|
||||
@@ -400,19 +419,8 @@ export function setupFileNameEditing(filePath) {
|
||||
|
||||
try {
|
||||
// Use the passed filePath (which includes the original filename)
|
||||
// Call API to rename the file
|
||||
const response = await fetch('/api/rename_checkpoint', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath, // Use the full original path
|
||||
new_file_name: newFileName
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
// Call API to rename the file using the new function from checkpointApi.js
|
||||
const result = await renameCheckpointFile(filePath, newFileName);
|
||||
|
||||
if (result.success) {
|
||||
showToast('File name updated successfully', 'success');
|
||||
|
||||
@@ -27,11 +27,25 @@ export function showCheckpointModal(checkpoint) {
|
||||
<button class="close" onclick="modalManager.closeModal('checkpointModal')">×</button>
|
||||
<header class="modal-header">
|
||||
<div class="model-name-header">
|
||||
<h2 class="model-name-content" contenteditable="true" spellcheck="false">${checkpoint.model_name || 'Checkpoint Details'}</h2>
|
||||
<h2 class="model-name-content">${checkpoint.model_name || 'Checkpoint Details'}</h2>
|
||||
<button class="edit-model-name-btn" title="Edit model name">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${checkpoint.civitai?.creator ? `
|
||||
<div class="creator-info">
|
||||
${checkpoint.civitai.creator.image ?
|
||||
`<div class="creator-avatar">
|
||||
<img src="${checkpoint.civitai.creator.image}" alt="${checkpoint.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
|
||||
</div>` :
|
||||
`<div class="creator-avatar creator-placeholder">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>`
|
||||
}
|
||||
<span class="creator-username">${checkpoint.civitai.creator.username}</span>
|
||||
</div>` : ''}
|
||||
|
||||
${renderCompactTags(checkpoint.tags || [])}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ export class CheckpointsControls extends PageControls {
|
||||
return await resetAndReload(updateFolders);
|
||||
},
|
||||
|
||||
refreshModels: async () => {
|
||||
return await refreshCheckpoints();
|
||||
refreshModels: async (fullRebuild = false) => {
|
||||
return await refreshCheckpoints(fullRebuild);
|
||||
},
|
||||
|
||||
// Add fetch from Civitai functionality for checkpoints
|
||||
|
||||
@@ -36,8 +36,8 @@ export class LorasControls extends PageControls {
|
||||
return await resetAndReload(updateFolders);
|
||||
},
|
||||
|
||||
refreshModels: async () => {
|
||||
return await refreshLoras();
|
||||
refreshModels: async (fullRebuild = false) => {
|
||||
return await refreshLoras(fullRebuild);
|
||||
},
|
||||
|
||||
// LoRA-specific API functions
|
||||
|
||||
@@ -83,9 +83,12 @@ export class PageControls {
|
||||
// Refresh button handler
|
||||
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => this.refreshModels());
|
||||
refreshBtn.addEventListener('click', () => this.refreshModels(false)); // Regular refresh (incremental)
|
||||
}
|
||||
|
||||
// Initialize dropdown functionality
|
||||
this.initDropdowns();
|
||||
|
||||
// Toggle folders button
|
||||
const toggleFoldersBtn = document.querySelector('.toggle-folders-btn');
|
||||
if (toggleFoldersBtn) {
|
||||
@@ -102,6 +105,61 @@ export class PageControls {
|
||||
this.initPageSpecificListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dropdown functionality
|
||||
*/
|
||||
initDropdowns() {
|
||||
// Handle dropdown toggles
|
||||
const dropdownToggles = document.querySelectorAll('.dropdown-toggle');
|
||||
dropdownToggles.forEach(toggle => {
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Prevent triggering parent button
|
||||
const dropdownGroup = toggle.closest('.dropdown-group');
|
||||
|
||||
// Close all other open dropdowns first
|
||||
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
||||
if (group !== dropdownGroup) {
|
||||
group.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle current dropdown
|
||||
dropdownGroup.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle quick refresh option
|
||||
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
||||
if (quickRefreshOption) {
|
||||
quickRefreshOption.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.refreshModels(false);
|
||||
// Close the dropdown
|
||||
document.querySelector('.dropdown-group.active')?.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle full rebuild option
|
||||
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
||||
if (fullRebuildOption) {
|
||||
fullRebuildOption.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.refreshModels(true);
|
||||
// Close the dropdown
|
||||
document.querySelector('.dropdown-group.active')?.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.dropdown-group')) {
|
||||
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
||||
group.classList.remove('active');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize page-specific event listeners
|
||||
*/
|
||||
@@ -327,18 +385,19 @@ export class PageControls {
|
||||
|
||||
/**
|
||||
* Refresh models list
|
||||
* @param {boolean} fullRebuild - Whether to perform a full rebuild
|
||||
*/
|
||||
async refreshModels() {
|
||||
async refreshModels(fullRebuild = false) {
|
||||
if (!this.api) {
|
||||
console.error('API methods not registered');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.refreshModels();
|
||||
await this.api.refreshModels(fullRebuild);
|
||||
} catch (error) {
|
||||
console.error(`Error refreshing ${this.pageType}:`, error);
|
||||
showToast(`Failed to refresh ${this.pageType}: ${error.message}`, 'error');
|
||||
console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, error);
|
||||
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.pageType}: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { BASE_MODELS } from '../../utils/constants.js';
|
||||
import { updateLoraCard } from '../../utils/cardUpdater.js';
|
||||
import { saveModelMetadata } from '../../api/loraApi.js';
|
||||
import { saveModelMetadata, renameLoraFile } from '../../api/loraApi.js';
|
||||
|
||||
/**
|
||||
* 设置模型名称编辑功能
|
||||
@@ -27,14 +27,17 @@ export function setupModelNameEditing(filePath) {
|
||||
});
|
||||
|
||||
modelNameHeader.addEventListener('mouseleave', () => {
|
||||
if (!modelNameContent.getAttribute('data-editing')) {
|
||||
if (!modelNameHeader.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button click
|
||||
editBtn.addEventListener('click', () => {
|
||||
modelNameContent.setAttribute('data-editing', 'true');
|
||||
modelNameHeader.classList.add('editing');
|
||||
modelNameContent.setAttribute('contenteditable', 'true');
|
||||
// Store original value for comparison later
|
||||
modelNameContent.dataset.originalValue = modelNameContent.textContent.trim();
|
||||
modelNameContent.focus();
|
||||
|
||||
// Place cursor at the end
|
||||
@@ -50,33 +53,25 @@ export function setupModelNameEditing(filePath) {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
// Handle focus out
|
||||
modelNameContent.addEventListener('blur', function() {
|
||||
this.removeAttribute('data-editing');
|
||||
editBtn.classList.remove('visible');
|
||||
|
||||
if (this.textContent.trim() === '') {
|
||||
// Restore original model name if empty
|
||||
const filePath = this.dataset.filePath;
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (loraCard) {
|
||||
this.textContent = loraCard.dataset.model_name;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle enter key
|
||||
// Handle keyboard events in edit mode
|
||||
modelNameContent.addEventListener('keydown', function(e) {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const filePath = this.dataset.filePath;
|
||||
saveModelName(filePath);
|
||||
this.blur();
|
||||
this.blur(); // Trigger save on Enter
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// Restore original value
|
||||
this.textContent = this.dataset.originalValue;
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Limit model name length
|
||||
modelNameContent.addEventListener('input', function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
// Limit model name length
|
||||
if (this.textContent.length > 100) {
|
||||
this.textContent = this.textContent.substring(0, 100);
|
||||
@@ -91,6 +86,60 @@ export function setupModelNameEditing(filePath) {
|
||||
showToast('Model name is limited to 100 characters', 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle focus out - save changes
|
||||
modelNameContent.addEventListener('blur', async function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
const newModelName = this.textContent.trim();
|
||||
const originalValue = this.dataset.originalValue;
|
||||
|
||||
// Basic validation
|
||||
if (!newModelName) {
|
||||
// Restore original value if empty
|
||||
this.textContent = originalValue;
|
||||
showToast('Model name cannot be empty', 'error');
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newModelName === originalValue) {
|
||||
// No changes, just exit edit mode
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the file path from the dataset
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
await saveModelMetadata(filePath, { model_name: newModelName });
|
||||
|
||||
// Update the corresponding lora card's dataset and display
|
||||
updateLoraCard(filePath, { model_name: newModelName });
|
||||
|
||||
// BUGFIX: Directly update the card's dataset.name attribute to ensure
|
||||
// it's correctly read when reopening the modal
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (loraCard) {
|
||||
loraCard.dataset.name = newModelName;
|
||||
}
|
||||
|
||||
showToast('Model name updated successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error updating model name:', error);
|
||||
this.textContent = originalValue; // Restore original model name
|
||||
showToast('Failed to update model name', 'error');
|
||||
} finally {
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
function exitEditMode() {
|
||||
modelNameContent.removeAttribute('contenteditable');
|
||||
modelNameHeader.classList.remove('editing');
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,19 +459,8 @@ export function setupFileNameEditing(filePath) {
|
||||
// Get the file path from the dataset
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
// Call API to rename the file
|
||||
const response = await fetch('/api/rename_lora', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
new_file_name: newFileName
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
// Call API to rename the file using the new function from loraApi.js
|
||||
const result = await renameLoraFile(filePath, newFileName);
|
||||
|
||||
if (result.success) {
|
||||
showToast('File name updated successfully', 'success');
|
||||
|
||||
@@ -24,6 +24,7 @@ import { updateLoraCard } from '../../utils/cardUpdater.js';
|
||||
* @param {Object} lora - LoRA模型数据
|
||||
*/
|
||||
export function showLoraModal(lora) {
|
||||
console.log('Lora data:', lora);
|
||||
const escapedWords = lora.civitai?.trainedWords?.length ?
|
||||
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||
|
||||
@@ -32,11 +33,25 @@ export function showLoraModal(lora) {
|
||||
<button class="close" onclick="modalManager.closeModal('loraModal')">×</button>
|
||||
<header class="modal-header">
|
||||
<div class="model-name-header">
|
||||
<h2 class="model-name-content" contenteditable="true" spellcheck="false">${lora.model_name}</h2>
|
||||
<h2 class="model-name-content">${lora.model_name}</h2>
|
||||
<button class="edit-model-name-btn" title="Edit model name">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${lora.civitai?.creator ? `
|
||||
<div class="creator-info">
|
||||
${lora.civitai.creator.image ?
|
||||
`<div class="creator-avatar">
|
||||
<img src="${lora.civitai.creator.image}" alt="${lora.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
|
||||
</div>` :
|
||||
`<div class="creator-avatar creator-placeholder">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>`
|
||||
}
|
||||
<span class="creator-username">${lora.civitai.creator.username}</span>
|
||||
</div>` : ''}
|
||||
|
||||
${renderCompactTags(lora.tags || [])}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ export class CheckpointDownloadManager {
|
||||
this.cleanupFolderBrowser();
|
||||
});
|
||||
this.resetSteps();
|
||||
|
||||
// Auto-focus on the URL input
|
||||
setTimeout(() => {
|
||||
const urlInput = document.getElementById('checkpointUrl');
|
||||
if (urlInput) {
|
||||
urlInput.focus();
|
||||
}
|
||||
}, 100); // Small delay to ensure the modal is fully displayed
|
||||
}
|
||||
|
||||
resetSteps() {
|
||||
@@ -178,6 +186,11 @@ export class CheckpointDownloadManager {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Auto-select the version if there's only one
|
||||
if (this.versions.length === 1 && !this.currentVersion) {
|
||||
this.selectVersion(this.versions[0].id.toString());
|
||||
}
|
||||
|
||||
// Update Next button state based on initial selection
|
||||
this.updateNextButtonState();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,14 @@ export class DownloadManager {
|
||||
this.cleanupFolderBrowser();
|
||||
});
|
||||
this.resetSteps();
|
||||
|
||||
// Auto-focus on the URL input
|
||||
setTimeout(() => {
|
||||
const urlInput = document.getElementById('loraUrl');
|
||||
if (urlInput) {
|
||||
urlInput.focus();
|
||||
}
|
||||
}, 100); // Small delay to ensure the modal is fully displayed
|
||||
}
|
||||
|
||||
resetSteps() {
|
||||
@@ -182,6 +190,11 @@ export class DownloadManager {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Auto-select the version if there's only one
|
||||
if (this.versions.length === 1 && !this.currentVersion) {
|
||||
this.selectVersion(this.versions[0].id.toString());
|
||||
}
|
||||
|
||||
// Update Next button state based on initial selection
|
||||
this.updateNextButtonState();
|
||||
}
|
||||
|
||||
@@ -170,6 +170,18 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Add clearCacheModal registration
|
||||
const clearCacheModal = document.getElementById('clearCacheModal');
|
||||
if (clearCacheModal) {
|
||||
this.registerModal('clearCacheModal', {
|
||||
element: clearCacheModal,
|
||||
onClose: () => {
|
||||
this.getModal('clearCacheModal').element.classList.remove('show');
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for modal toggles
|
||||
const supportToggle = document.getElementById('supportToggleBtn');
|
||||
if (supportToggle) {
|
||||
@@ -233,7 +245,7 @@ export class ModalManager {
|
||||
// Store current scroll position before showing modal
|
||||
this.scrollPosition = window.scrollY;
|
||||
|
||||
if (id === 'deleteModal' || id === 'excludeModal' || id === 'duplicateDeleteModal') {
|
||||
if (id === 'deleteModal' || id === 'excludeModal' || id === 'duplicateDeleteModal' || id === 'clearCacheModal') {
|
||||
modal.element.classList.add('show');
|
||||
} else {
|
||||
modal.element.style.display = 'block';
|
||||
|
||||
@@ -31,6 +31,16 @@ export class SettingsManager {
|
||||
if (state.global.settings.compactMode === undefined) {
|
||||
state.global.settings.compactMode = false;
|
||||
}
|
||||
|
||||
// Convert old boolean compactMode to new displayDensity string
|
||||
if (typeof state.global.settings.displayDensity === 'undefined') {
|
||||
if (state.global.settings.compactMode === true) {
|
||||
state.global.settings.displayDensity = 'compact';
|
||||
} else {
|
||||
state.global.settings.displayDensity = 'default';
|
||||
}
|
||||
// We can delete the old setting, but keeping it for backwards compatibility
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
@@ -82,10 +92,10 @@ export class SettingsManager {
|
||||
autoplayOnHoverCheckbox.checked = state.global.settings.autoplayOnHover || false;
|
||||
}
|
||||
|
||||
// Set compact mode setting
|
||||
const compactModeCheckbox = document.getElementById('compactMode');
|
||||
if (compactModeCheckbox) {
|
||||
compactModeCheckbox.checked = state.global.settings.compactMode || false;
|
||||
// Set display density setting
|
||||
const displayDensitySelect = document.getElementById('displayDensity');
|
||||
if (displayDensitySelect) {
|
||||
displayDensitySelect.value = state.global.settings.displayDensity || 'default';
|
||||
}
|
||||
|
||||
// Load default lora root
|
||||
@@ -219,6 +229,11 @@ export class SettingsManager {
|
||||
// Update frontend state
|
||||
if (settingKey === 'default_lora_root') {
|
||||
state.global.settings.default_loras_root = value;
|
||||
} else if (settingKey === 'display_density') {
|
||||
state.global.settings.displayDensity = value;
|
||||
|
||||
// Also update compactMode for backwards compatibility
|
||||
state.global.settings.compactMode = (value !== 'default');
|
||||
} else {
|
||||
// For any other settings that might be added in the future
|
||||
state.global.settings[settingKey] = value;
|
||||
@@ -229,22 +244,38 @@ export class SettingsManager {
|
||||
|
||||
try {
|
||||
// For backend settings, make API call
|
||||
const payload = {};
|
||||
payload[settingKey] = value;
|
||||
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (settingKey === 'default_lora_root') {
|
||||
const payload = {};
|
||||
payload[settingKey] = value;
|
||||
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save setting');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save setting');
|
||||
}
|
||||
|
||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||
}
|
||||
|
||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||
// Apply frontend settings immediately
|
||||
this.applyFrontendSettings();
|
||||
|
||||
// Recalculate layout when display density changes
|
||||
if (settingKey === 'display_density' && state.virtualScroller) {
|
||||
state.virtualScroller.calculateLayout();
|
||||
|
||||
let densityName = "Default";
|
||||
if (value === 'medium') densityName = "Medium";
|
||||
if (value === 'compact') densityName = "Compact";
|
||||
|
||||
showToast(`Display Density set to ${densityName}`, 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to save setting: ' + error.message, 'error');
|
||||
@@ -310,6 +341,37 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
confirmClearCache() {
|
||||
// Show confirmation modal
|
||||
modalManager.showModal('clearCacheModal');
|
||||
}
|
||||
|
||||
async executeClearCache() {
|
||||
try {
|
||||
// Call the API endpoint to clear cache files
|
||||
const response = await fetch('/api/clear-cache', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Cache files have been cleared successfully. Cache will rebuild on next action.', 'success');
|
||||
} else {
|
||||
showToast(`Failed to clear cache: ${result.error}`, 'error');
|
||||
}
|
||||
|
||||
// Close the confirmation modal
|
||||
modalManager.closeModal('clearCacheModal');
|
||||
} catch (error) {
|
||||
showToast(`Error clearing cache: ${error.message}`, 'error');
|
||||
modalManager.closeModal('clearCacheModal');
|
||||
}
|
||||
}
|
||||
|
||||
async reloadContent() {
|
||||
if (this.currentPage === 'loras') {
|
||||
// Reload the loras without updating folders
|
||||
@@ -419,8 +481,17 @@ export class SettingsManager {
|
||||
videoParent.replaceChild(videoClone, video);
|
||||
});
|
||||
|
||||
// For show_only_sfw, there's no immediate action needed as it affects content loading
|
||||
// The setting will take effect on next reload
|
||||
// Apply display density class to grid
|
||||
const grid = document.querySelector('.card-grid');
|
||||
if (grid) {
|
||||
const density = state.global.settings.displayDensity || 'default';
|
||||
|
||||
// Remove all density classes first
|
||||
grid.classList.remove('default-density', 'medium-density', 'compact-density');
|
||||
|
||||
// Add the appropriate density class
|
||||
grid.classList.add(`${density}-density`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export class VirtualScroller {
|
||||
this.itemAspectRatio = 896/1152; // Aspect ratio of cards
|
||||
this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px)
|
||||
|
||||
// Add container padding properties
|
||||
this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS
|
||||
this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS
|
||||
|
||||
// Add data windowing enable/disable flag
|
||||
this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false;
|
||||
|
||||
@@ -74,6 +78,10 @@ export class VirtualScroller {
|
||||
this.gridElement.style.position = 'relative';
|
||||
this.gridElement.style.minHeight = '0';
|
||||
|
||||
// Apply padding directly to ensure consistency
|
||||
this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`;
|
||||
this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`;
|
||||
|
||||
// Place the spacer inside the grid container
|
||||
this.gridElement.appendChild(this.spacerElement);
|
||||
}
|
||||
@@ -88,22 +96,40 @@ export class VirtualScroller {
|
||||
// Calculate available content width (excluding padding)
|
||||
const availableContentWidth = containerWidth - paddingLeft - paddingRight;
|
||||
|
||||
// Get compact mode setting
|
||||
const compactMode = state.global.settings?.compactMode || false;
|
||||
// Get display density setting
|
||||
const displayDensity = state.global.settings?.displayDensity || 'default';
|
||||
|
||||
// Set exact column counts and grid widths to match CSS container widths
|
||||
let maxColumns, maxGridWidth;
|
||||
|
||||
// Match exact column counts and CSS container width values
|
||||
// Match exact column counts and CSS container width values based on density
|
||||
if (window.innerWidth >= 3000) { // 4K
|
||||
maxColumns = compactMode ? 10 : 8;
|
||||
if (displayDensity === 'default') {
|
||||
maxColumns = 8;
|
||||
} else if (displayDensity === 'medium') {
|
||||
maxColumns = 9;
|
||||
} else { // compact
|
||||
maxColumns = 10;
|
||||
}
|
||||
maxGridWidth = 2400; // Match exact CSS container width for 4K
|
||||
} else if (window.innerWidth >= 2000) { // 2K/1440p
|
||||
maxColumns = compactMode ? 8 : 6;
|
||||
if (displayDensity === 'default') {
|
||||
maxColumns = 6;
|
||||
} else if (displayDensity === 'medium') {
|
||||
maxColumns = 7;
|
||||
} else { // compact
|
||||
maxColumns = 8;
|
||||
}
|
||||
maxGridWidth = 1800; // Match exact CSS container width for 2K
|
||||
} else {
|
||||
// 1080p
|
||||
maxColumns = compactMode ? 7 : 5;
|
||||
if (displayDensity === 'default') {
|
||||
maxColumns = 5;
|
||||
} else if (displayDensity === 'medium') {
|
||||
maxColumns = 6;
|
||||
} else { // compact
|
||||
maxColumns = 7;
|
||||
}
|
||||
maxGridWidth = 1400; // Match exact CSS container width for 1080p
|
||||
}
|
||||
|
||||
@@ -145,7 +171,7 @@ export class VirtualScroller {
|
||||
leftOffset: this.leftOffset,
|
||||
paddingLeft,
|
||||
paddingRight,
|
||||
compactMode,
|
||||
displayDensity,
|
||||
maxColumns,
|
||||
baseCardWidth,
|
||||
rowGap: this.rowGap
|
||||
@@ -154,12 +180,9 @@ export class VirtualScroller {
|
||||
// Update grid element max-width to match available width
|
||||
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
||||
|
||||
// Add or remove compact-mode class for style adjustments
|
||||
if (compactMode) {
|
||||
this.gridElement.classList.add('compact-mode');
|
||||
} else {
|
||||
this.gridElement.classList.remove('compact-mode');
|
||||
}
|
||||
// Add or remove density classes for style adjustments
|
||||
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
||||
this.gridElement.classList.add(`${displayDensity}-density`);
|
||||
|
||||
// Update spacer height
|
||||
this.updateSpacerHeight();
|
||||
@@ -321,8 +344,11 @@ export class VirtualScroller {
|
||||
// Add row gaps to the total height calculation
|
||||
const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap;
|
||||
|
||||
// Include container padding in the total height
|
||||
const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom;
|
||||
|
||||
// Update spacer height to represent all items
|
||||
this.spacerElement.style.height = `${totalHeight}px`;
|
||||
this.spacerElement.style.height = `${spacerHeight}px`;
|
||||
}
|
||||
|
||||
getVisibleRange() {
|
||||
@@ -450,7 +476,8 @@ export class VirtualScroller {
|
||||
const col = index % this.columnsCount;
|
||||
|
||||
// Calculate precise positions with row gap included
|
||||
const topPos = row * (this.itemHeight + this.rowGap);
|
||||
// Add the top padding to account for container padding
|
||||
const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap));
|
||||
|
||||
// Position correctly with leftOffset (no need to add padding as absolute
|
||||
// positioning is already relative to the padding edge of the container)
|
||||
|
||||
@@ -122,6 +122,8 @@ async function initializeVirtualScroll(pageType) {
|
||||
fetchItemsFn: fetchDataFn,
|
||||
pageSize: 100,
|
||||
rowGap: 20,
|
||||
containerPaddingTop: 4,
|
||||
containerPaddingBottom: 4,
|
||||
enableDataWindowing: false // Explicitly set to false to disable data windowing
|
||||
});
|
||||
|
||||
|
||||
@@ -362,29 +362,31 @@ export function getNSFWLevelName(level) {
|
||||
*/
|
||||
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
|
||||
try {
|
||||
let loraNodes = [];
|
||||
let isDesktopMode = false;
|
||||
|
||||
// Get the current workflow from localStorage
|
||||
const workflowData = localStorage.getItem('workflow');
|
||||
if (!workflowData) {
|
||||
showToast('No active workflow found', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the workflow JSON
|
||||
const workflow = JSON.parse(workflowData);
|
||||
|
||||
// Find all Lora Loader (LoraManager) nodes
|
||||
const loraNodes = [];
|
||||
if (workflow.nodes && Array.isArray(workflow.nodes)) {
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.type === "Lora Loader (LoraManager)") {
|
||||
loraNodes.push(node.id);
|
||||
if (workflowData) {
|
||||
// Web browser mode - extract node IDs from workflow
|
||||
const workflow = JSON.parse(workflowData);
|
||||
|
||||
// Find all Lora Loader (LoraManager) nodes
|
||||
if (workflow.nodes && Array.isArray(workflow.nodes)) {
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.type === "Lora Loader (LoraManager)") {
|
||||
loraNodes.push(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loraNodes.length === 0) {
|
||||
showToast('No Lora Loader nodes found in the workflow', 'warning');
|
||||
return false;
|
||||
|
||||
if (loraNodes.length === 0) {
|
||||
showToast('No Lora Loader nodes found in the workflow', 'warning');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// ComfyUI Desktop mode - don't specify node IDs and let backend handle it
|
||||
isDesktopMode = true;
|
||||
}
|
||||
|
||||
// Call the backend API to update the lora code
|
||||
@@ -394,7 +396,7 @@ export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntax
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
node_ids: loraNodes,
|
||||
node_ids: isDesktopMode ? undefined : loraNodes,
|
||||
lora_code: loraSyntax,
|
||||
mode: replaceMode ? 'replace' : 'append'
|
||||
})
|
||||
@@ -419,4 +421,36 @@ export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntax
|
||||
showToast(`Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the example images folder for a specific model
|
||||
* @param {string} modelHash - The SHA256 hash of the model
|
||||
*/
|
||||
export async function openExampleImagesFolder(modelHash) {
|
||||
try {
|
||||
const response = await fetch('/api/open-example-images-folder', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model_hash: modelHash
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Opening example images folder', 'success');
|
||||
return true;
|
||||
} else {
|
||||
showToast(result.error || 'Failed to open example images folder', 'error');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open example images folder:', error);
|
||||
showToast('Failed to open example images folder', 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@
|
||||
<div class="context-menu-item" data-action="civitai"><i class="fas fa-external-link-alt"></i> View on CivitAI</div>
|
||||
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> Refresh Civitai Data</div>
|
||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
|
||||
<div class="context-menu-item" data-action="preview"><i class="fas fa-image"></i> Replace Preview</div>
|
||||
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> Open Examples Folder</div>
|
||||
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>
|
||||
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> Set Content Rating</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> Move to Folder</div>
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
<i class="fas fa-exchange-alt"></i> Send to Workflow (Replace)
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="preview">
|
||||
<i class="fas fa-folder-open"></i> Open Examples Folder
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="replace-preview">
|
||||
<i class="fas fa-image"></i> Replace Preview
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="set-nsfw">
|
||||
|
||||
@@ -15,8 +15,19 @@
|
||||
<option value="date">Date</option>
|
||||
</select>
|
||||
</div>
|
||||
<div title="Refresh model list" class="control-group">
|
||||
<button data-action="refresh"><i class="fas fa-sync"></i> Refresh</button>
|
||||
<div title="Refresh model list" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> Refresh</button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="quick-refresh">
|
||||
<i class="fas fa-bolt"></i> Quick Refresh (incremental)
|
||||
</div>
|
||||
<div class="dropdown-item" data-action="full-rebuild">
|
||||
<i class="fas fa-tools"></i> Full Rebuild (complete)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Clear Confirmation Modal -->
|
||||
<div id="clearCacheModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2>Clear Cache Files</h2>
|
||||
<p class="delete-message">Are you sure you want to clear all cache files?</p>
|
||||
<div class="delete-model-info">
|
||||
<p>This will remove all cached model data. The system will need to rebuild the cache on next startup, which may take some time depending on your model collection size.</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="modalManager.closeModal('clearCacheModal')">Cancel</button>
|
||||
<button class="delete-btn" onclick="settingsManager.executeClearCache()">Clear Cache</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settingsModal" class="modal">
|
||||
<div class="modal-content settings-modal">
|
||||
@@ -161,18 +176,47 @@
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="compactMode">Compact Mode</label>
|
||||
<label for="displayDensity">Display Density</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="compactMode"
|
||||
onchange="settingsManager.saveToggleSetting('compactMode', 'compact_mode')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-control select-control">
|
||||
<select id="displayDensity" onchange="settingsManager.saveSelectSetting('displayDensity', 'display_density')">
|
||||
<option value="default">Default</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
Display more cards per row (7 on 1080p, 8 on 2K, 10 on 4K). <span class="warning-text">Warning: May cause performance issues (lag and lower FPS) on systems with limited resources.</span>
|
||||
Choose how many cards to display per row:
|
||||
<ul class="density-description">
|
||||
<li><strong>Default:</strong> 5 (1080p), 6 (2K), 8 (4K)</li>
|
||||
<li><strong>Medium:</strong> 6 (1080p), 7 (2K), 9 (4K)</li>
|
||||
<li><strong>Compact:</strong> 7 (1080p), 8 (2K), 10 (4K)</li>
|
||||
</ul>
|
||||
<span class="warning-text">Warning: Higher densities may cause performance issues on systems with limited resources.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Cache Management Section -->
|
||||
<div class="settings-section">
|
||||
<h3>Cache Management</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>Clear Cache Files</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button id="clearCacheBtn" class="secondary-btn" onclick="settingsManager.confirmClearCache()">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>Clear Cache</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
Removes cached model data. System will rebuild cache on next startup or when triggered manually.
|
||||
<span class="warning-text">May cause temporary performance impact during rebuild.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,71 @@
|
||||
|
||||
---
|
||||
|
||||
### v0.8.9
|
||||
* **Favorites System** - New functionality to bookmark your favorite LoRAs and checkpoints for quick access and better organization
|
||||
* **Enhanced UI Controls** - Increased model card button sizes for improved usability and easier interaction
|
||||
* **Smoother Page Transitions** - Optimized interface switching between pages, eliminating flash issues particularly noticeable in dark theme
|
||||
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
|
||||
|
||||
### v0.8.8
|
||||
* **Real-time TriggerWord Updates** - Enhanced TriggerWord Toggle node to instantly update when connected Lora Loader or Lora Stacker nodes change, without requiring workflow execution
|
||||
* **Optimized Metadata Recovery** - Improved utilization of existing .civitai.info files for faster initialization and preservation of metadata from models deleted from CivitAI
|
||||
* **Migration Acceleration** - Further speed improvements for users transitioning from A1111/Forge environments
|
||||
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
|
||||
|
||||
### v0.8.7
|
||||
* **Enhanced Context Menu** - Added comprehensive context menu functionality to Recipes and Checkpoints pages for improved workflow
|
||||
* **Interactive LoRA Strength Control** - Implemented drag functionality in LoRA Loader for intuitive strength adjustment
|
||||
* **Metadata Collector Overhaul** - Rebuilt metadata collection system with optimized architecture for better performance
|
||||
* **Improved Save Image Node** - Enhanced metadata capture and image saving performance with the new metadata collector
|
||||
* **Streamlined Recipe Saving** - Optimized Save Recipe functionality to work independently without requiring Preview Image nodes
|
||||
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
|
||||
|
||||
### v0.8.6 Major Update
|
||||
* **Checkpoint Management** - Added comprehensive management for model checkpoints including scanning, searching, filtering, and deletion
|
||||
* **Enhanced Metadata Support** - New capabilities for retrieving and managing checkpoint metadata with improved operations
|
||||
* **Improved Initial Loading** - Optimized cache initialization with visual progress indicators for better user experience
|
||||
|
||||
### v0.8.5
|
||||
* **Enhanced LoRA & Recipe Connectivity** - Added Recipes tab in LoRA details to see all recipes using a specific LoRA
|
||||
* **Improved Navigation** - New shortcuts to jump between related LoRAs and Recipes with one-click navigation
|
||||
* **Video Preview Controls** - Added "Autoplay Videos on Hover" setting to optimize performance and reduce resource usage
|
||||
* **UI Experience Refinements** - Smoother transitions between related content pages
|
||||
|
||||
### 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 Forge’s CivitAI helper extension, making migration smoother.
|
||||
* **LoRA Filename Editing** - Added support for renaming LoRA files directly within LoRA Manager.
|
||||
* **Recipe Editing** - Users can now edit recipe names and tags.
|
||||
* **Retain Deleted LoRAs in Recipes** - Deleted LoRAs will remain listed in recipes, allowing future functionality to reconnect them once re-obtained.
|
||||
* **Download Missing LoRAs from Recipes** - Easily fetch missing LoRAs associated with a recipe.
|
||||
|
||||
### v0.8.1
|
||||
* **Base Model Correction** - Added support for modifying base model associations to fix incorrect metadata for non-CivitAI LoRAs
|
||||
* **LoRA Loader Flexibility** - Made CLIP input optional for model-only workflows like Hunyuan video generation
|
||||
* **Expanded Recipe Support** - Added compatibility with 3 additional recipe metadata formats
|
||||
* **Enhanced Showcase Images** - Generation parameters now displayed alongside LoRA preview images
|
||||
* **UI Improvements & Bug Fixes** - Various interface refinements and stability enhancements
|
||||
|
||||
### v0.8.0
|
||||
* **Introduced LoRA Recipes** - Create, import, save, and share your favorite LoRA combinations
|
||||
* **Recipe Management System** - Easily browse, search, and organize your LoRA recipes
|
||||
* **Workflow Integration** - Save recipes directly from your workflow with generation parameters preserved
|
||||
* **Simplified Workflow Application** - Quickly apply saved recipes to new projects
|
||||
* **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
|
||||
|
||||
@@ -49,12 +49,42 @@ app.registerExtension({
|
||||
|
||||
// Handle lora code updates from Python
|
||||
handleLoraCodeUpdate(id, loraCode, mode) {
|
||||
// Handle broadcast mode (for Desktop/non-browser support)
|
||||
if (id === -1) {
|
||||
// Find all Lora Loader nodes in the current graph
|
||||
const loraLoaderNodes = [];
|
||||
for (const nodeId in app.graph._nodes_by_id) {
|
||||
const node = app.graph._nodes_by_id[nodeId];
|
||||
if (node.comfyClass === "Lora Loader (LoraManager)") {
|
||||
loraLoaderNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Update each Lora Loader node found
|
||||
if (loraLoaderNodes.length > 0) {
|
||||
loraLoaderNodes.forEach(node => {
|
||||
this.updateNodeLoraCode(node, loraCode, mode);
|
||||
});
|
||||
console.log(`Updated ${loraLoaderNodes.length} Lora Loader nodes in broadcast mode`);
|
||||
} else {
|
||||
console.warn("No Lora Loader nodes found in the workflow for broadcast update");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard mode - update a specific node
|
||||
const node = app.graph.getNodeById(+id);
|
||||
if (!node || node.comfyClass !== "Lora Loader (LoraManager)") {
|
||||
console.warn("Node not found or not a LoraLoader:", id);
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateNodeLoraCode(node, loraCode, mode);
|
||||
},
|
||||
|
||||
// Helper method to update a single node's lora code
|
||||
updateNodeLoraCode(node, loraCode, mode) {
|
||||
// Update the input widget with new lora code
|
||||
const inputWidget = node.widgets[0];
|
||||
if (!inputWidget) return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { api } from "../../scripts/api.js";
|
||||
|
||||
// Register the extension
|
||||
app.registerExtension({
|
||||
name: "ComfyUI-Lora-Manager.UsageStats",
|
||||
name: "Lora-Manager.UsageStats",
|
||||
|
||||
init() {
|
||||
// Listen for successful executions
|
||||
|
||||
Reference in New Issue
Block a user