Merge pull request #213 from willmiao/migrate-images

Migrate images
This commit is contained in:
pixelpaws
2025-06-08 20:11:37 +08:00
committed by GitHub
13 changed files with 2046 additions and 763 deletions

View File

@@ -7,6 +7,7 @@ from .routes.recipe_routes import RecipeRoutes
from .routes.checkpoints_routes import CheckpointsRoutes
from .routes.update_routes import UpdateRoutes
from .routes.misc_routes import MiscRoutes
from .routes.example_images_routes import ExampleImagesRoutes
from .services.service_registry import ServiceRegistry
from .services.settings_manager import settings
import logging
@@ -112,6 +113,7 @@ class LoraManager:
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)
MiscRoutes.setup_routes(app) # Register miscellaneous routes
ExampleImagesRoutes.setup_routes(app) # Register example images routes
# Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services())

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,13 @@
import logging
import os
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.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..services.civitai_client import CivitaiClient
from ..utils.routes_common import ModelRouteUtils
from ..utils.lora_metadata import extract_trained_words
from ..config import config
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
import re
logger = logging.getLogger(__name__)
@@ -50,20 +42,14 @@ class MiscRoutes:
app.router.add_post('/api/update-usage-stats', MiscRoutes.update_usage_stats)
app.router.add_get('/api/get-usage-stats', MiscRoutes.get_usage_stats)
# Example images download routes
app.router.add_post('/api/download-example-images', MiscRoutes.download_example_images)
app.router.add_get('/api/example-images-status', MiscRoutes.get_example_images_status)
app.router.add_post('/api/pause-example-images', MiscRoutes.pause_example_images)
app.router.add_post('/api/resume-example-images', MiscRoutes.resume_example_images)
# 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)
# Add new route for getting trained words
app.router.add_get('/api/trained-words', MiscRoutes.get_trained_words)
# Add new route for getting model example files
app.router.add_get('/api/model-example-files', MiscRoutes.get_model_example_files)
@staticmethod
async def clear_cache(request):
@@ -202,637 +188,6 @@ class MiscRoutes:
'error': str(e)
}, status=500)
@staticmethod
async def download_example_images(request):
"""
Download example images for models from Civitai
Expects a JSON body with:
{
"output_dir": "path/to/output", # Base directory to save example images
"optimize": true, # Whether to optimize images (default: true)
"model_types": ["lora", "checkpoint"], # Model types to process (default: both)
"delay": 1.0 # Delay between downloads to avoid rate limiting (default: 1.0)
}
"""
global download_task, is_downloading, download_progress
if is_downloading:
# Create a copy for JSON serialization
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({
'success': False,
'error': 'Download already in progress',
'status': response_progress
}, status=400)
try:
# Parse the request body
data = await request.json()
output_dir = data.get('output_dir')
optimize = data.get('optimize', True)
model_types = data.get('model_types', ['lora', 'checkpoint'])
delay = float(data.get('delay', 0.1)) # Default to 0.1 seconds
if not output_dir:
return web.json_response({
'success': False,
'error': 'Missing output_dir parameter'
}, status=400)
# Create the output directory
os.makedirs(output_dir, exist_ok=True)
# Initialize progress tracking
download_progress['total'] = 0
download_progress['completed'] = 0
download_progress['current_model'] = ''
download_progress['status'] = 'running'
download_progress['errors'] = []
download_progress['last_error'] = None
download_progress['start_time'] = time.time()
download_progress['end_time'] = None
# Get the processed models list from a file if it exists
progress_file = os.path.join(output_dir, '.download_progress.json')
if os.path.exists(progress_file):
try:
with open(progress_file, 'r', encoding='utf-8') as f:
saved_progress = json.load(f)
download_progress['processed_models'] = set(saved_progress.get('processed_models', []))
logger.info(f"Loaded previous progress, {len(download_progress['processed_models'])} models already processed")
except Exception as e:
logger.error(f"Failed to load progress file: {e}")
download_progress['processed_models'] = set()
else:
download_progress['processed_models'] = set()
# Start the download task
is_downloading = True
download_task = asyncio.create_task(
MiscRoutes._download_all_example_images(
output_dir,
optimize,
model_types,
delay
)
)
# Create a copy for JSON serialization
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({
'success': True,
'message': 'Download started',
'status': response_progress
})
except Exception as e:
logger.error(f"Failed to start example images download: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def get_example_images_status(request):
"""Get the current status of example images download"""
global download_progress
# Create a copy of the progress dict with the set converted to a list for JSON serialization
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({
'success': True,
'is_downloading': is_downloading,
'status': response_progress
})
@staticmethod
async def pause_example_images(request):
"""Pause the example images download"""
global download_progress
if not is_downloading:
return web.json_response({
'success': False,
'error': 'No download in progress'
}, status=400)
download_progress['status'] = 'paused'
return web.json_response({
'success': True,
'message': 'Download paused'
})
@staticmethod
async def resume_example_images(request):
"""Resume the example images download"""
global download_progress
if not is_downloading:
return web.json_response({
'success': False,
'error': 'No download in progress'
}, status=400)
if download_progress['status'] == 'paused':
download_progress['status'] = 'running'
return web.json_response({
'success': True,
'message': 'Download resumed'
})
else:
return web.json_response({
'success': False,
'error': f"Download is in '{download_progress['status']}' state, cannot resume"
}, status=400)
@staticmethod
async def _refresh_model_metadata(model_hash, model_name, scanner_type, scanner):
"""Refresh model metadata from CivitAI
Args:
model_hash: SHA256 hash of the model
model_name: Name of the model (for logging)
scanner_type: Type of scanner ('lora' or 'checkpoint')
scanner: Scanner instance for this model type
Returns:
bool: True if metadata was successfully refreshed, False otherwise
"""
global download_progress
try:
# Find the model in the scanner cache
cache = await scanner.get_cached_data()
model_data = None
for item in cache.raw_data:
if item.get('sha256') == model_hash:
model_data = item
break
if not model_data:
logger.warning(f"Model {model_name} with hash {model_hash} not found in cache")
return False
file_path = model_data.get('file_path')
if not file_path:
logger.warning(f"Model {model_name} has no file path")
return False
# Track that we're refreshing this model
download_progress['refreshed_models'].add(model_hash)
# Use ModelRouteUtils to refresh the metadata
async def update_cache_func(old_path, new_path, metadata):
return await scanner.update_single_model_cache(old_path, new_path, metadata)
success = await ModelRouteUtils.fetch_and_update_model(
model_hash,
file_path,
model_data,
update_cache_func
)
if success:
logger.info(f"Successfully refreshed metadata for {model_name}")
return True
else:
logger.warning(f"Failed to refresh metadata for {model_name}")
return False
except Exception as e:
error_msg = f"Error refreshing metadata for {model_name}: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
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
Args:
model_hash: SHA256 hash of the model
model_name: Name of the model
model_images: List of image objects from CivitAI
model_dir: Directory to save images to
optimize: Whether to optimize images
independent_session: aiohttp session for downloads
delay: Delay between downloads
Returns:
bool: True if all images were processed successfully, False otherwise
"""
global download_progress
model_success = True
for i, image in enumerate(model_images, 1):
image_url = image.get('url')
if not image_url:
continue
# Get image filename from URL
image_filename = os.path.basename(image_url.split('?')[0])
image_ext = os.path.splitext(image_filename)[1].lower()
# Handle both images and videos
is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
if not (is_image or is_video):
logger.debug(f"Skipping unsupported file type: {image_filename}")
continue
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):
logger.debug(f"File already exists: {save_path}")
continue
# Download the file
try:
logger.debug(f"Downloading {save_filename} for {model_name}")
# Direct download using the independent session
async with independent_session.get(image_url, timeout=60) as response:
if response.status == 200:
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)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed due to 404
# Return early to trigger metadata refresh attempt
return False, True # (success, is_stale_metadata)
else:
error_msg = f"Failed to download file: {image_url}, status code: {response.status}"
logger.warning(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed
# Add a delay between downloads for remote files only
await asyncio.sleep(delay)
except Exception as e:
error_msg = f"Error downloading file {image_url}: {str(e)}"
logger.error(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed
return model_success, False # (success, is_stale_metadata)
@staticmethod
async def _process_local_example_images(model_file_path, model_file_name, model_name, model_dir, optimize):
"""Process local example images for a model
Args:
model_file_path: Path to the model file
model_file_name: Filename of the model
model_name: Name of the model
model_dir: Directory to save processed images to
optimize: Whether to optimize images
Returns:
bool: True if local images were processed successfully, False otherwise
"""
global download_progress
try:
model_dir_path = os.path.dirname(model_file_path)
local_images = []
# Look for files with pattern: filename.example.*.ext
if model_file_name:
example_prefix = f"{model_file_name}.example."
if os.path.exists(model_dir_path):
for file in os.listdir(model_dir_path):
file_lower = file.lower()
if file_lower.startswith(example_prefix.lower()):
file_ext = os.path.splitext(file_lower)[1]
is_supported = (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'])
if is_supported:
local_images.append(os.path.join(model_dir_path, file))
# Process local images if found
if local_images:
logger.info(f"Found {len(local_images)} local example images for {model_name}")
for local_image_path in local_images:
# Extract the index from the filename
file_name = os.path.basename(local_image_path)
example_prefix = f"{model_file_name}.example."
try:
# Extract the part after '.example.' and before file extension
index_part = file_name[len(example_prefix):].split('.')[0]
# Try to parse it as an integer
index = int(index_part)
local_ext = os.path.splitext(local_image_path)[1].lower()
save_filename = f"image_{index}{local_ext}"
except (ValueError, IndexError):
# If we can't parse the index, fall back to a sequential number
logger.warning(f"Could not extract index from {file_name}, using sequential numbering")
local_ext = os.path.splitext(local_image_path)[1].lower()
save_filename = f"image_{len(local_images)}{local_ext}"
save_path = os.path.join(model_dir, save_filename)
# Skip if already exists in output directory
if os.path.exists(save_path):
logger.debug(f"File already exists in output: {save_path}")
continue
# Copy the file
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
except Exception as e:
error_msg = f"Error processing local examples for {model_name}: {str(e)}"
logger.error(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
return False
@staticmethod
async def _download_all_example_images(output_dir, optimize, model_types, delay):
"""Download example images for all models
Args:
output_dir: Base directory to save example images
optimize: Whether to optimize images
model_types: List of model types to process
delay: Delay between downloads to avoid rate limiting
"""
global is_downloading, download_progress
# Create an independent session for downloading example images
# This avoids interference with the CivitAI client's session
connector = aiohttp.TCPConnector(
ssl=True,
limit=3,
force_close=False,
enable_cleanup_closed=True
)
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=60)
# Create a dedicated session just for this download task
independent_session = aiohttp.ClientSession(
connector=connector,
trust_env=True,
timeout=timeout
)
try:
# Get the scanners
scanners = []
if 'lora' in model_types:
lora_scanner = await ServiceRegistry.get_lora_scanner()
scanners.append(('lora', lora_scanner))
if 'checkpoint' in model_types:
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
scanners.append(('checkpoint', checkpoint_scanner))
# Get all models from all scanners
all_models = []
for scanner_type, scanner in scanners:
cache = await scanner.get_cached_data()
if cache and cache.raw_data:
for model in cache.raw_data:
# Only process models with images and a valid sha256
if model.get('civitai') and model.get('civitai', {}).get('images') and model.get('sha256'):
all_models.append((scanner_type, model, scanner))
# Update total count
download_progress['total'] = len(all_models)
logger.info(f"Found {download_progress['total']} models with example images")
# Process each model
for scanner_type, model, scanner in all_models:
# Check if download is paused
while download_progress['status'] == 'paused':
await asyncio.sleep(1)
# Check if download should continue
if download_progress['status'] != 'running':
logger.info(f"Download stopped: {download_progress['status']}")
break
model_hash = model.get('sha256', '').lower()
model_name = model.get('model_name', 'Unknown')
model_file_path = model.get('file_path', '')
model_file_name = model.get('file_name', '')
try:
# Update current model info
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
# Skip if already processed
if model_hash in download_progress['processed_models']:
logger.debug(f"Skipping already processed model: {model_name}")
download_progress['completed'] += 1
continue
# Create model directory
model_dir = os.path.join(output_dir, model_hash)
os.makedirs(model_dir, exist_ok=True)
# Process images for this model
images = model.get('civitai', {}).get('images', [])
if not images:
logger.debug(f"No images found for model: {model_name}")
download_progress['processed_models'].add(model_hash)
download_progress['completed'] += 1
continue
# First check if we have local example images for this model
local_images_processed = False
if model_file_path:
local_images_processed = await MiscRoutes._process_local_example_images(
model_file_path,
model_file_name,
model_name,
model_dir,
optimize
)
if local_images_processed:
# Mark as successfully processed if all local images were processed
download_progress['processed_models'].add(model_hash)
logger.info(f"Successfully processed local examples for {model_name}")
# If we didn't process local images, download from remote
if not local_images_processed:
# Try to download images
model_success, is_stale_metadata = await MiscRoutes._process_model_images(
model_hash,
model_name,
images,
model_dir,
optimize,
independent_session,
delay
)
# If metadata is stale (404 error), try to refresh it and download again
if is_stale_metadata and model_hash not in download_progress['refreshed_models']:
logger.info(f"Metadata seems stale for {model_name}, attempting to refresh...")
# Refresh metadata from CivitAI
refresh_success = await MiscRoutes._refresh_model_metadata(
model_hash,
model_name,
scanner_type,
scanner
)
if refresh_success:
# Get updated model data
updated_cache = await scanner.get_cached_data()
updated_model = None
for item in updated_cache.raw_data:
if item.get('sha256') == model_hash:
updated_model = item
break
if updated_model and updated_model.get('civitai', {}).get('images'):
# Try downloading with updated metadata
logger.info(f"Retrying download with refreshed metadata for {model_name}")
updated_images = updated_model.get('civitai', {}).get('images', [])
# Retry download with new images
model_success, _ = await MiscRoutes._process_model_images(
model_hash,
model_name,
updated_images,
model_dir,
optimize,
independent_session,
delay
)
# Only mark model as processed if all images downloaded successfully
if model_success:
download_progress['processed_models'].add(model_hash)
else:
logger.warning(f"Model {model_name} had download errors, will not mark as completed")
# Save progress to file periodically
if download_progress['completed'] % 10 == 0 or download_progress['completed'] == download_progress['total'] - 1:
progress_file = os.path.join(output_dir, '.download_progress.json')
with open(progress_file, 'w', encoding='utf-8') as f:
json.dump({
'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']),
'completed': download_progress['completed'],
'total': download_progress['total'],
'last_update': time.time()
}, f, indent=2)
except Exception as e:
error_msg = f"Error processing model {model.get('model_name')}: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
# Update progress
download_progress['completed'] += 1
# Mark as completed
download_progress['status'] = 'completed'
download_progress['end_time'] = time.time()
logger.info(f"Example images download completed: {download_progress['completed']}/{download_progress['total']} models processed")
except Exception as e:
error_msg = f"Error during example images download: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
download_progress['status'] = 'error'
download_progress['end_time'] = time.time()
finally:
# Close the independent session
try:
await independent_session.close()
except Exception as e:
logger.error(f"Error closing download session: {e}")
# Save final progress to file
try:
progress_file = os.path.join(output_dir, '.download_progress.json')
with open(progress_file, 'w', encoding='utf-8') as f:
json.dump({
'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']),
'completed': download_progress['completed'],
'total': download_progress['total'],
'last_update': time.time(),
'status': download_progress['status']
}, f, indent=2)
except Exception as e:
logger.error(f"Failed to save progress file: {e}")
# Set download status to not downloading
is_downloading = False
@staticmethod
async def update_lora_code(request):
"""
@@ -914,66 +269,6 @@ class MiscRoutes:
'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)
@staticmethod
async def get_trained_words(request):
"""
@@ -1021,3 +316,90 @@ class MiscRoutes:
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def get_model_example_files(request):
"""
Get list of example image files for a specific model based on file path
Expects:
- file_path in query parameters
Returns:
- List of image files with their paths as static URLs
"""
try:
# Get the model file path from query parameters
file_path = request.query.get('file_path')
if not file_path:
return web.json_response({
'success': False,
'error': 'Missing file_path parameter'
}, status=400)
# Extract directory and base filename
model_dir = os.path.dirname(file_path)
model_filename = os.path.basename(file_path)
model_name = os.path.splitext(model_filename)[0]
# Check if the directory exists
if not os.path.exists(model_dir):
return web.json_response({
'success': False,
'error': 'Model directory not found',
'files': []
}, status=404)
# Look for files matching the pattern modelname.example.<index>.<ext>
files = []
pattern = f"{model_name}.example."
for file in os.listdir(model_dir):
file_lower = file.lower()
if file_lower.startswith(pattern.lower()):
file_full_path = os.path.join(model_dir, file)
if os.path.isfile(file_full_path):
# Check if the file is a supported media file
file_ext = os.path.splitext(file)[1].lower()
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
# Extract the index from the filename
try:
# Extract the part after '.example.' and before file extension
index_part = file[len(pattern):].split('.')[0]
# Try to parse it as an integer
index = int(index_part)
except (ValueError, IndexError):
# If we can't parse the index, use infinity to sort at the end
index = float('inf')
# Convert file path to static URL
static_url = config.get_preview_static_url(file_full_path)
files.append({
'name': file,
'path': static_url,
'extension': file_ext,
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'],
'index': index
})
# Sort files by their index for consistent ordering
files.sort(key=lambda x: x['index'])
# Remove the index field as it's only used for sorting
for file in files:
file.pop('index', None)
return web.json_response({
'success': True,
'files': files
})
except Exception as e:
logger.error(f"Failed to get model example files: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -295,6 +295,7 @@ class StandaloneLoraManager(LoraManager):
from py.routes.checkpoints_routes import CheckpointsRoutes
from py.routes.update_routes import UpdateRoutes
from py.routes.misc_routes import MiscRoutes
from py.routes.example_images_routes import ExampleImagesRoutes
lora_routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes()
@@ -306,6 +307,7 @@ class StandaloneLoraManager(LoraManager):
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)
MiscRoutes.setup_routes(app)
ExampleImagesRoutes.setup_routes(app)
# Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services())

View File

@@ -306,6 +306,18 @@ body.modal-open {
width: 100%; /* Full width */
}
/* Migrate control styling */
.migrate-control {
display: flex;
align-items: center;
gap: 8px;
}
.migrate-control input {
flex: 1;
min-width: 0;
}
/* 统一各个 section 的样式 */
.support-section,
.changelog-section,
@@ -363,6 +375,12 @@ body.modal-open {
background: rgba(255, 255, 255, 0.05);
}
/* Add disabled style for setting items */
.setting-item[data-requires-centralized="true"].disabled {
opacity: 0.6;
pointer-events: none;
}
/* Control row with label and input together */
.setting-row {
display: flex;

View File

@@ -3,12 +3,6 @@
* Handles showcase content (images, videos) display for checkpoint modal
*/
import {
showToast,
copyToClipboard,
getLocalExampleImageUrl,
initLazyLoading,
initNsfwBlurHandlers,
initMetadataPanelHandlers,
toggleShowcase,
setupShowcaseScroll,
scrollToTop
@@ -20,9 +14,10 @@ import { NSFW_LEVELS } from '../../utils/constants.js';
* Render showcase content
* @param {Array} images - Array of images/videos to show
* @param {string} modelHash - Model hash for identifying local files
* @param {Array} exampleFiles - Local example files already fetched
* @returns {string} HTML content
*/
export function renderShowcaseContent(images, modelHash) {
export function renderShowcaseContent(images, exampleFiles = []) {
if (!images?.length) return '<div class="no-examples">No example images available</div>';
// Filter images based on SFW setting
@@ -65,9 +60,85 @@ export function renderShowcaseContent(images, modelHash) {
${hiddenNotification}
<div class="carousel-container">
${filteredImages.map((img, index) => {
// Get URLs for the example image
const urls = getLocalExampleImageUrl(img, index, modelHash);
return generateMediaWrapper(img, urls);
// Find matching file in our list of actual files
let localFile = null;
if (exampleFiles.length > 0) {
// Try to find the corresponding file by index first
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
// If not found by index, just use the same position in the array if available
if (!localFile && index < exampleFiles.length) {
localFile = exampleFiles[index];
}
}
const remoteUrl = img.url || '';
const localUrl = localFile ? localFile.path : '';
const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// Calculate appropriate aspect ratio
const aspectRatio = (img.height / img.width) * 100;
const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
const heightPercent = Math.max(
minHeightPercent,
Math.min(maxHeightPercent, aspectRatio)
);
// Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Extract metadata from the image
const meta = img.meta || {};
const prompt = meta.prompt || '';
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
const size = meta.Size || `${img.width}x${img.height}`;
const seed = meta.seed || '';
const model = meta.Model || '';
const steps = meta.steps || '';
const sampler = meta.sampler || '';
const cfgScale = meta.cfgScale || '';
const clipSkip = meta.clipSkip || '';
// Check if we have any meaningful generation parameters
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
const hasPrompts = prompt || negativePrompt;
// Create metadata panel content
const metadataPanel = generateMetadataPanel(
hasParams, hasPrompts,
prompt, negativePrompt,
size, seed, model, steps, sampler, cfgScale, clipSkip
);
// Check if this is a video or image
if (isVideo) {
return generateVideoWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}
return generateImageWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}).join('')}
</div>
</div>
@@ -205,7 +276,7 @@ function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, si
/**
* Generate video wrapper HTML
*/
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) {
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
@@ -215,10 +286,10 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
` : ''}
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer"
data-local-src="${urls.primary || ''}"
data-remote-src="${media.url}"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
<source data-local-src="${urls.primary || ''}" data-remote-src="${media.url}" type="video/mp4">
<source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
Your browser does not support video playback
</video>
${shouldBlur ? `
@@ -237,7 +308,7 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
/**
* Generate image wrapper HTML
*/
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) {
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
@@ -245,9 +316,8 @@ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metada
<i class="fas fa-eye"></i>
</button>
` : ''}
<img data-local-src="${urls.primary || ''}"
data-local-fallback-src="${urls.fallback || ''}"
data-remote-src="${media.url}"
<img data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"

View File

@@ -3,8 +3,7 @@
*
* Modularized checkpoint modal component that handles checkpoint model details display
*/
import { showToast } from '../../utils/uiHelpers.js';
import { state } from '../../state/index.js';
import { showToast, getExampleImageFiles, initLazyLoading, initNsfwBlurHandlers, initMetadataPanelHandlers } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
@@ -16,6 +15,7 @@ import {
import { saveModelMetadata } from '../../api/checkpointApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { updateCheckpointCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
/**
* Display the checkpoint modal with the given checkpoint data
@@ -110,7 +110,9 @@ export function showCheckpointModal(checkpoint) {
<div class="tab-content">
<div id="showcase-tab" class="tab-pane active">
${renderShowcaseContent(checkpoint.civitai?.images || [], checkpoint.sha256)}
<div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
</div>
</div>
<div id="description-tab" class="tab-pane">
@@ -146,6 +148,69 @@ export function showCheckpointModal(checkpoint) {
if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) {
loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path);
}
// Load example images asynchronously
loadExampleImages(checkpoint.civitai?.images, checkpoint.sha256, checkpoint.file_path);
}
/**
* Load example images asynchronously
* @param {Array} images - Array of image objects
* @param {string} modelHash - Model hash for fetching local files
* @param {string} filePath - File path for fetching local files
*/
async function loadExampleImages(images, modelHash, filePath) {
try {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
// First fetch local example files
let localFiles = [];
try {
// Choose endpoint based on centralized examples setting
const useCentralized = state.global.settings.useCentralizedExamples !== false;
const endpoint = useCentralized ? '/api/example-image-files' : '/api/model-example-files';
// Use different params based on endpoint
const params = useCentralized ?
`model_hash=${modelHash}` :
`file_path=${encodeURIComponent(filePath)}`;
const response = await fetch(`${endpoint}?${params}`);
const result = await response.json();
if (result.success) {
localFiles = result.files;
}
} catch (error) {
console.error("Failed to get example files:", error);
}
// Then render with both remote images and local files
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
// Re-initialize the showcase event listeners
const carousel = showcaseTab.querySelector('.carousel');
if (carousel) {
// Only initialize if we actually have examples and they're expanded
if (!carousel.classList.contains('collapsed')) {
initLazyLoading(carousel);
initNsfwBlurHandlers(carousel);
initMetadataPanelHandlers(carousel);
}
}
} catch (error) {
console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
showcaseTab.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-circle"></i>
Error loading example images
</div>
`;
}
}
}
/**

View File

@@ -3,12 +3,6 @@
* 处理LoRA模型展示内容图片、视频的功能模块
*/
import {
showToast,
copyToClipboard,
getLocalExampleImageUrl,
initLazyLoading,
initNsfwBlurHandlers,
initMetadataPanelHandlers,
toggleShowcase,
setupShowcaseScroll,
scrollToTop
@@ -17,12 +11,12 @@ import { state } from '../../state/index.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
/**
* 渲染展示内容
* 获取展示内容并进行渲染
* @param {Array} images - 要展示的图片/视频数组
* @param {string} modelHash - Model hash for identifying local files
* @returns {string} HTML内容
* @param {Array} exampleFiles - Local example files already fetched
* @returns {Promise<string>} HTML内容
*/
export function renderShowcaseContent(images, modelHash) {
export function renderShowcaseContent(images, exampleFiles = []) {
if (!images?.length) return '<div class="no-examples">No example images available</div>';
// Filter images based on SFW setting
@@ -65,8 +59,25 @@ export function renderShowcaseContent(images, modelHash) {
${hiddenNotification}
<div class="carousel-container">
${filteredImages.map((img, index) => {
// Get URLs for the example image
const urls = getLocalExampleImageUrl(img, index, modelHash);
// Find matching file in our list of actual files
let localFile = null;
if (exampleFiles.length > 0) {
// Try to find the corresponding file by index first
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
// If not found by index, just use the same position in the array if available
if (!localFile && index < exampleFiles.length) {
localFile = exampleFiles[index];
}
}
const remoteUrl = img.url || '';
const localUrl = localFile ? localFile.path : '';
const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// 计算适当的展示高度
const aspectRatio = (img.height / img.width) * 100;
@@ -113,10 +124,16 @@ export function renderShowcaseContent(images, modelHash) {
size, seed, model, steps, sampler, cfgScale, clipSkip
);
if (img.type === 'video') {
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, urls);
if (isVideo) {
return generateVideoWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, urls);
return generateImageWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}).join('')}
</div>
</div>
@@ -193,7 +210,7 @@ function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, si
/**
* 生成视频包装HTML
*/
function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) {
function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
@@ -203,10 +220,10 @@ function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
` : ''}
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer"
data-local-src="${urls.primary || ''}"
data-remote-src="${img.url}"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
<source data-local-src="${urls.primary || ''}" data-remote-src="${img.url}" type="video/mp4">
<source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
Your browser does not support video playback
</video>
${shouldBlur ? `
@@ -225,7 +242,7 @@ function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
/**
* 生成图片包装HTML
*/
function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) {
function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
@@ -233,9 +250,8 @@ function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
<i class="fas fa-eye"></i>
</button>
` : ''}
<img data-local-src="${urls.primary || ''}"
data-local-fallback-src="${urls.fallback || ''}"
data-remote-src="${img.url}"
<img data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"

View File

@@ -3,7 +3,7 @@
*
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { showToast, copyToClipboard, getExampleImageFiles } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
@@ -18,6 +18,7 @@ import {
import { saveModelMetadata } from '../../api/loraApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { updateLoraCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
/**
* 显示LoRA模型弹窗
@@ -136,7 +137,9 @@ export function showLoraModal(lora) {
<div class="tab-content">
<div id="showcase-tab" class="tab-pane active">
${renderShowcaseContent(lora.civitai?.images, lora.sha256)}
<div class="example-images-loading">
<i class="fas fa-spinner fa-spin"></i> Loading example images...
</div>
</div>
<div id="description-tab" class="tab-pane">
@@ -182,6 +185,70 @@ export function showLoraModal(lora) {
// Load recipes for this Lora
loadRecipesForLora(lora.model_name, lora.sha256);
// Load example images asynchronously
loadExampleImages(lora.civitai?.images, lora.sha256, lora.file_path);
}
/**
* Load example images asynchronously
* @param {Array} images - Array of image objects
* @param {string} modelHash - Model hash for fetching local files
* @param {string} filePath - File path for fetching local files
*/
async function loadExampleImages(images, modelHash, filePath) {
try {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
// First fetch local example files
let localFiles = [];
try {
// Choose endpoint based on centralized examples setting
const useCentralized = state.global.settings.useCentralizedExamples !== false;
const endpoint = useCentralized ? '/api/example-image-files' : '/api/model-example-files';
// Use different params based on endpoint
const params = useCentralized ?
`model_hash=${modelHash}` :
`file_path=${encodeURIComponent(filePath)}`;
const response = await fetch(`${endpoint}?${params}`);
const result = await response.json();
if (result.success) {
localFiles = result.files;
}
} catch (error) {
console.error("Failed to get example files:", error);
}
// Then render with both remote images and local files
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
// Re-initialize the showcase event listeners
const carousel = showcaseTab.querySelector('.carousel');
if (carousel) {
// Only initialize if we actually have examples and they're expanded
if (!carousel.classList.contains('collapsed')) {
initLazyLoading(carousel);
initNsfwBlurHandlers(carousel);
initMetadataPanelHandlers(carousel);
}
}
} catch (error) {
console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
showcaseTab.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-circle"></i>
Error loading example images
</div>
`;
}
}
}
// Copy file name function

View File

@@ -11,6 +11,8 @@ class ExampleImagesManager {
this.progressPanel = null;
this.isProgressPanelCollapsed = false;
this.pauseButton = null; // Store reference to the pause button
this.isMigrating = false; // Track migration state separately from downloading
this.hasShownCompletionToast = false; // Flag to track if completion toast has been shown
// Initialize download path field and check download status
this.initializePathOptions();
@@ -46,6 +48,12 @@ class ExampleImagesManager {
if (collapseBtn) {
collapseBtn.onclick = () => this.toggleProgressPanel();
}
// Initialize migration button handler
const migrateBtn = document.getElementById('exampleImagesMigrateBtn');
if (migrateBtn) {
migrateBtn.onclick = () => this.handleMigrateButton();
}
}
// Initialize event listeners for buttons
@@ -141,6 +149,95 @@ class ExampleImagesManager {
}
}
// Method to handle migrate button click
async handleMigrateButton() {
if (this.isDownloading || this.isMigrating) {
if (this.isPaused) {
// If paused, resume
this.resumeDownload();
} else {
showToast('Migration or download already in progress', 'info');
}
return;
}
// Start migration
this.startMigrate();
}
async startMigrate() {
try {
const outputDir = document.getElementById('exampleImagesPath').value || '';
if (!outputDir) {
showToast('Please enter a download location first', 'warning');
return;
}
// Update path in backend settings before starting migration
try {
const pathUpdateResponse = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
example_images_path: outputDir
})
});
if (!pathUpdateResponse.ok) {
throw new Error(`HTTP error! Status: ${pathUpdateResponse.status}`);
}
} catch (error) {
console.error('Failed to update example images path:', error);
}
const pattern = document.getElementById('exampleImagesMigratePattern').value || '{model}.example.{index}.{ext}';
const optimize = document.getElementById('optimizeExampleImages').checked;
const response = await fetch('/api/migrate-example-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
output_dir: outputDir,
pattern: pattern,
optimize: optimize,
model_types: ['lora', 'checkpoint']
})
});
const data = await response.json();
if (data.success) {
this.isDownloading = true;
this.isMigrating = true;
this.isPaused = false;
this.hasShownCompletionToast = false; // Reset toast flag when starting new migration
this.startTime = new Date();
this.updateUI(data.status);
this.showProgressPanel();
this.startProgressUpdates();
// Update button text
const btnTextElement = document.getElementById('exampleDownloadBtnText');
if (btnTextElement) {
btnTextElement.textContent = "Resume";
}
showToast('Example images migration started', 'success');
// Close settings modal
modalManager.closeModal('settingsModal');
} else {
showToast(data.error || 'Failed to start migration', 'error');
}
} catch (error) {
console.error('Failed to start migration:', error);
showToast('Failed to start migration', 'error');
}
}
async checkDownloadStatus() {
try {
const response = await fetch('/api/example-images-status');
@@ -224,6 +321,7 @@ class ExampleImagesManager {
if (data.success) {
this.isDownloading = true;
this.isPaused = false;
this.hasShownCompletionToast = false; // Reset toast flag when starting new download
this.startTime = new Date();
this.updateUI(data.status);
this.showProgressPanel();
@@ -334,6 +432,7 @@ class ExampleImagesManager {
if (data.success) {
this.isDownloading = data.is_downloading;
this.isPaused = data.status.status === 'paused';
this.isMigrating = data.is_migrating || false;
// Update download button text
this.updateDownloadButtonText();
@@ -345,12 +444,19 @@ class ExampleImagesManager {
clearInterval(this.progressUpdateInterval);
this.progressUpdateInterval = null;
if (data.status.status === 'completed') {
showToast('Example images download completed', 'success');
if (data.status.status === 'completed' && !this.hasShownCompletionToast) {
const actionType = this.isMigrating ? 'migration' : 'download';
showToast(`Example images ${actionType} completed`, 'success');
// Mark as shown to prevent duplicate toasts
this.hasShownCompletionToast = true;
// Reset migration flag
this.isMigrating = false;
// Hide the panel after a delay
setTimeout(() => this.hideProgressPanel(), 5000);
} else if (data.status.status === 'error') {
showToast('Example images download failed', 'error');
const actionType = this.isMigrating ? 'migration' : 'download';
showToast(`Example images ${actionType} failed`, 'error');
this.isMigrating = false;
}
}
}
@@ -441,6 +547,19 @@ class ExampleImagesManager {
this.updateMiniProgress(progressPercent);
}
}
// Update title text
const titleElement = document.querySelector('.progress-panel-title');
if (titleElement) {
const titleIcon = titleElement.querySelector('i');
if (titleIcon) {
titleIcon.className = this.isMigrating ? 'fas fa-file-import' : 'fas fa-images';
}
titleElement.innerHTML =
`<i class="${this.isMigrating ? 'fas fa-file-import' : 'fas fa-images'}"></i> ` +
`${this.isMigrating ? 'Example Images Migration' : 'Example Images Download'}`;
}
}
// Update the mini progress circle in the pause button
@@ -536,8 +655,10 @@ class ExampleImagesManager {
}
getStatusText(status) {
const prefix = this.isMigrating ? 'Migrating' : 'Downloading';
switch (status) {
case 'running': return 'Downloading';
case 'running': return this.isMigrating ? 'Migrating' : 'Downloading';
case 'paused': return 'Paused';
case 'completed': return 'Completed';
case 'error': return 'Error';

View File

@@ -37,6 +37,11 @@ export class SettingsManager {
state.global.settings.optimizeExampleImages = true;
}
// Set default for useCentralizedExamples if undefined
if (state.global.settings.useCentralizedExamples === undefined) {
state.global.settings.useCentralizedExamples = true;
}
// Convert old boolean compactMode to new displayDensity string
if (typeof state.global.settings.displayDensity === 'undefined') {
if (state.global.settings.compactMode === true) {
@@ -109,6 +114,14 @@ export class SettingsManager {
optimizeExampleImagesCheckbox.checked = state.global.settings.optimizeExampleImages || false;
}
// Set centralized examples setting
const useCentralizedExamplesCheckbox = document.getElementById('useCentralizedExamples');
if (useCentralizedExamplesCheckbox) {
useCentralizedExamplesCheckbox.checked = state.global.settings.useCentralizedExamples !== false;
// Update dependent controls
this.updateExamplesControlsState();
}
// Load default lora root
await this.loadLoraRoots();
@@ -183,6 +196,10 @@ export class SettingsManager {
state.global.settings.optimizeExampleImages = value;
} else if (settingKey === 'compact_mode') {
state.global.settings.compactMode = value;
} else if (settingKey === 'use_centralized_examples') {
state.global.settings.useCentralizedExamples = value;
// Update dependent controls state
this.updateExamplesControlsState();
} else {
// For any other settings that might be added in the future
state.global.settings[settingKey] = value;
@@ -193,7 +210,7 @@ export class SettingsManager {
try {
// For backend settings, make API call
if (['show_only_sfw', 'blur_mature_content', 'autoplay_on_hover', 'optimize_example_images'].includes(settingKey)) {
if (['show_only_sfw', 'blur_mature_content', 'autoplay_on_hover', 'optimize_example_images', 'use_centralized_examples'].includes(settingKey)) {
const payload = {};
payload[settingKey] = value;
@@ -506,6 +523,42 @@ export class SettingsManager {
// Add the appropriate density class
grid.classList.add(`${density}-density`);
}
// Apply centralized examples toggle state
this.updateExamplesControlsState();
}
// Add new method to update example control states
updateExamplesControlsState() {
const useCentralized = state.global.settings.useCentralizedExamples !== false;
// Find all controls that require centralized mode
const exampleSections = document.querySelectorAll('[data-requires-centralized="true"]');
exampleSections.forEach(section => {
// Enable/disable all inputs and buttons in the section
const controls = section.querySelectorAll('input, button, select');
controls.forEach(control => {
control.disabled = !useCentralized;
// Add/remove disabled class for styling
if (control.classList.contains('primary-btn') || control.classList.contains('secondary-btn')) {
if (!useCentralized) {
control.classList.add('disabled');
} else {
control.classList.remove('disabled');
}
}
});
// Visually show the section as disabled
if (!useCentralized) {
section.style.opacity = '0.6';
section.style.pointerEvents = 'none';
} else {
section.style.opacity = '';
section.style.pointerEvents = '';
}
});
}
}

View File

@@ -936,4 +936,26 @@ export function scrollToTop(button) {
behavior: 'smooth'
});
}
}
/**
* Get example image files for a specific model from the backend
* @param {string} modelHash - The model's hash
* @returns {Promise<Array>} Array of file objects with path and metadata
*/
export async function getExampleImageFiles(modelHash) {
try {
const response = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
const result = await response.json();
if (result.success) {
return result.files;
} else {
console.error('Failed to get example image files:', result.error);
return [];
}
} catch (error) {
console.error('Error fetching example image files:', error);
return [];
}
}

View File

@@ -256,6 +256,26 @@
<h3>Example Images</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="useCentralizedExamples">Use Centralized Example Storage</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="useCentralizedExamples" checked
onchange="settingsManager.saveToggleSetting('useCentralizedExamples', 'use_centralized_examples')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="input-help">
When enabled (recommended), example images are stored in a central folder for better organization and performance.
When disabled, only example images stored alongside models (e.g., model-name.example.0.jpg) will be shown, but download
and management features will be unavailable.
</div>
</div>
<div class="setting-item" data-requires-centralized="true">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesPath">Download Location</label>
@@ -271,8 +291,29 @@
Enter the folder path where example images from Civitai will be saved
</div>
</div>
<!-- New migrate section -->
<div class="setting-item" data-requires-centralized="true">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesMigratePattern">Migrate Existing Example Images</label>
</div>
<div class="setting-control migrate-control">
<input type="text" id="exampleImagesMigratePattern"
placeholder="{model}.example.{index}.{ext}"
value="{model}.example.{index}.{ext}" />
<button id="exampleImagesMigrateBtn" class="secondary-btn">
<i class="fas fa-file-import"></i> <span>Migrate</span>
</button>
</div>
</div>
<div class="input-help">
Pattern to find existing example images. Use {model} for model filename, {index} for numbering, and {ext} for file extension.<br>
Example patterns: "{model}.example.{index}.{ext}", "{model}_{index}.{ext}", "{model}/{model}.example.{index}.{ext}"
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-requires-centralized="true">
<div class="setting-row">
<div class="setting-info">
<label for="optimizeExampleImages">Optimize Downloaded Images</label>