mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 22:52:12 -03:00
@@ -7,6 +7,7 @@ from .routes.recipe_routes import RecipeRoutes
|
|||||||
from .routes.checkpoints_routes import CheckpointsRoutes
|
from .routes.checkpoints_routes import CheckpointsRoutes
|
||||||
from .routes.update_routes import UpdateRoutes
|
from .routes.update_routes import UpdateRoutes
|
||||||
from .routes.misc_routes import MiscRoutes
|
from .routes.misc_routes import MiscRoutes
|
||||||
|
from .routes.example_images_routes import ExampleImagesRoutes
|
||||||
from .services.service_registry import ServiceRegistry
|
from .services.service_registry import ServiceRegistry
|
||||||
from .services.settings_manager import settings
|
from .services.settings_manager import settings
|
||||||
import logging
|
import logging
|
||||||
@@ -112,6 +113,7 @@ class LoraManager:
|
|||||||
RecipeRoutes.setup_routes(app)
|
RecipeRoutes.setup_routes(app)
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
MiscRoutes.setup_routes(app) # Register miscellaneous routes
|
MiscRoutes.setup_routes(app) # Register miscellaneous routes
|
||||||
|
ExampleImagesRoutes.setup_routes(app) # Register example images routes
|
||||||
|
|
||||||
# Schedule service initialization
|
# Schedule service initialization
|
||||||
app.on_startup.append(lambda app: cls._initialize_services())
|
app.on_startup.append(lambda app: cls._initialize_services())
|
||||||
|
|||||||
1424
py/routes/example_images_routes.py
Normal file
1424
py/routes/example_images_routes.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import aiohttp
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from server import PromptServer # type: ignore
|
from server import PromptServer # type: ignore
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from ..services.settings_manager import settings
|
from ..services.settings_manager import settings
|
||||||
from ..utils.usage_stats import UsageStats
|
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 ..utils.lora_metadata import extract_trained_words
|
||||||
|
from ..config import config
|
||||||
|
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||||
|
import re
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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_post('/api/update-usage-stats', MiscRoutes.update_usage_stats)
|
||||||
app.router.add_get('/api/get-usage-stats', MiscRoutes.get_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
|
# Lora code update endpoint
|
||||||
app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
|
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
|
# Add new route for getting trained words
|
||||||
app.router.add_get('/api/trained-words', MiscRoutes.get_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
|
@staticmethod
|
||||||
async def clear_cache(request):
|
async def clear_cache(request):
|
||||||
@@ -202,637 +188,6 @@ class MiscRoutes:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, 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
|
@staticmethod
|
||||||
async def update_lora_code(request):
|
async def update_lora_code(request):
|
||||||
"""
|
"""
|
||||||
@@ -914,66 +269,6 @@ class MiscRoutes:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, 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
|
@staticmethod
|
||||||
async def get_trained_words(request):
|
async def get_trained_words(request):
|
||||||
"""
|
"""
|
||||||
@@ -1021,3 +316,90 @@ class MiscRoutes:
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, 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)
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
from py.routes.checkpoints_routes import CheckpointsRoutes
|
from py.routes.checkpoints_routes import CheckpointsRoutes
|
||||||
from py.routes.update_routes import UpdateRoutes
|
from py.routes.update_routes import UpdateRoutes
|
||||||
from py.routes.misc_routes import MiscRoutes
|
from py.routes.misc_routes import MiscRoutes
|
||||||
|
from py.routes.example_images_routes import ExampleImagesRoutes
|
||||||
|
|
||||||
lora_routes = LoraRoutes()
|
lora_routes = LoraRoutes()
|
||||||
checkpoints_routes = CheckpointsRoutes()
|
checkpoints_routes = CheckpointsRoutes()
|
||||||
@@ -306,6 +307,7 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
RecipeRoutes.setup_routes(app)
|
RecipeRoutes.setup_routes(app)
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
MiscRoutes.setup_routes(app)
|
MiscRoutes.setup_routes(app)
|
||||||
|
ExampleImagesRoutes.setup_routes(app)
|
||||||
|
|
||||||
# Schedule service initialization
|
# Schedule service initialization
|
||||||
app.on_startup.append(lambda app: cls._initialize_services())
|
app.on_startup.append(lambda app: cls._initialize_services())
|
||||||
|
|||||||
@@ -306,6 +306,18 @@ body.modal-open {
|
|||||||
width: 100%; /* Full width */
|
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 的样式 */
|
/* 统一各个 section 的样式 */
|
||||||
.support-section,
|
.support-section,
|
||||||
.changelog-section,
|
.changelog-section,
|
||||||
@@ -363,6 +375,12 @@ body.modal-open {
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
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 */
|
/* Control row with label and input together */
|
||||||
.setting-row {
|
.setting-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,12 +3,6 @@
|
|||||||
* Handles showcase content (images, videos) display for checkpoint modal
|
* Handles showcase content (images, videos) display for checkpoint modal
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
showToast,
|
|
||||||
copyToClipboard,
|
|
||||||
getLocalExampleImageUrl,
|
|
||||||
initLazyLoading,
|
|
||||||
initNsfwBlurHandlers,
|
|
||||||
initMetadataPanelHandlers,
|
|
||||||
toggleShowcase,
|
toggleShowcase,
|
||||||
setupShowcaseScroll,
|
setupShowcaseScroll,
|
||||||
scrollToTop
|
scrollToTop
|
||||||
@@ -20,9 +14,10 @@ import { NSFW_LEVELS } from '../../utils/constants.js';
|
|||||||
* Render showcase content
|
* Render showcase content
|
||||||
* @param {Array} images - Array of images/videos to show
|
* @param {Array} images - Array of images/videos to show
|
||||||
* @param {string} modelHash - Model hash for identifying local files
|
* @param {string} modelHash - Model hash for identifying local files
|
||||||
|
* @param {Array} exampleFiles - Local example files already fetched
|
||||||
* @returns {string} HTML content
|
* @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>';
|
if (!images?.length) return '<div class="no-examples">No example images available</div>';
|
||||||
|
|
||||||
// Filter images based on SFW setting
|
// Filter images based on SFW setting
|
||||||
@@ -65,9 +60,85 @@ export function renderShowcaseContent(images, modelHash) {
|
|||||||
${hiddenNotification}
|
${hiddenNotification}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
${filteredImages.map((img, index) => {
|
${filteredImages.map((img, index) => {
|
||||||
// Get URLs for the example image
|
// Find matching file in our list of actual files
|
||||||
const urls = getLocalExampleImageUrl(img, index, modelHash);
|
let localFile = null;
|
||||||
return generateMediaWrapper(img, urls);
|
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('')}
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +276,7 @@ function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, si
|
|||||||
/**
|
/**
|
||||||
* Generate video wrapper HTML
|
* Generate video wrapper HTML
|
||||||
*/
|
*/
|
||||||
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) {
|
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
|
||||||
return `
|
return `
|
||||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
@@ -215,10 +286,10 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
|
|||||||
` : ''}
|
` : ''}
|
||||||
<video controls autoplay muted loop crossorigin="anonymous"
|
<video controls autoplay muted loop crossorigin="anonymous"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
data-local-src="${urls.primary || ''}"
|
data-local-src="${localUrl || ''}"
|
||||||
data-remote-src="${media.url}"
|
data-remote-src="${remoteUrl}"
|
||||||
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
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
|
Your browser does not support video playback
|
||||||
</video>
|
</video>
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
@@ -237,7 +308,7 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
|
|||||||
/**
|
/**
|
||||||
* Generate image wrapper HTML
|
* Generate image wrapper HTML
|
||||||
*/
|
*/
|
||||||
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) {
|
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
|
||||||
return `
|
return `
|
||||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
@@ -245,9 +316,8 @@ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metada
|
|||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
<img data-local-src="${urls.primary || ''}"
|
<img data-local-src="${localUrl || ''}"
|
||||||
data-local-fallback-src="${urls.fallback || ''}"
|
data-remote-src="${remoteUrl}"
|
||||||
data-remote-src="${media.url}"
|
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Modularized checkpoint modal component that handles checkpoint model details display
|
* Modularized checkpoint modal component that handles checkpoint model details display
|
||||||
*/
|
*/
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast, getExampleImageFiles, initLazyLoading, initNsfwBlurHandlers, initMetadataPanelHandlers } from '../../utils/uiHelpers.js';
|
||||||
import { state } from '../../state/index.js';
|
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
|
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
|
||||||
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
|
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
import { saveModelMetadata } from '../../api/checkpointApi.js';
|
import { saveModelMetadata } from '../../api/checkpointApi.js';
|
||||||
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||||
import { updateCheckpointCard } from '../../utils/cardUpdater.js';
|
import { updateCheckpointCard } from '../../utils/cardUpdater.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the checkpoint modal with the given checkpoint data
|
* Display the checkpoint modal with the given checkpoint data
|
||||||
@@ -110,7 +110,9 @@ export function showCheckpointModal(checkpoint) {
|
|||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div id="showcase-tab" class="tab-pane active">
|
<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>
|
||||||
|
|
||||||
<div id="description-tab" class="tab-pane">
|
<div id="description-tab" class="tab-pane">
|
||||||
@@ -146,6 +148,69 @@ export function showCheckpointModal(checkpoint) {
|
|||||||
if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) {
|
if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) {
|
||||||
loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path);
|
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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,12 +3,6 @@
|
|||||||
* 处理LoRA模型展示内容(图片、视频)的功能模块
|
* 处理LoRA模型展示内容(图片、视频)的功能模块
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
showToast,
|
|
||||||
copyToClipboard,
|
|
||||||
getLocalExampleImageUrl,
|
|
||||||
initLazyLoading,
|
|
||||||
initNsfwBlurHandlers,
|
|
||||||
initMetadataPanelHandlers,
|
|
||||||
toggleShowcase,
|
toggleShowcase,
|
||||||
setupShowcaseScroll,
|
setupShowcaseScroll,
|
||||||
scrollToTop
|
scrollToTop
|
||||||
@@ -17,12 +11,12 @@ import { state } from '../../state/index.js';
|
|||||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染展示内容
|
* 获取展示内容并进行渲染
|
||||||
* @param {Array} images - 要展示的图片/视频数组
|
* @param {Array} images - 要展示的图片/视频数组
|
||||||
* @param {string} modelHash - Model hash for identifying local files
|
* @param {Array} exampleFiles - Local example files already fetched
|
||||||
* @returns {string} HTML内容
|
* @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>';
|
if (!images?.length) return '<div class="no-examples">No example images available</div>';
|
||||||
|
|
||||||
// Filter images based on SFW setting
|
// Filter images based on SFW setting
|
||||||
@@ -65,8 +59,25 @@ export function renderShowcaseContent(images, modelHash) {
|
|||||||
${hiddenNotification}
|
${hiddenNotification}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
${filteredImages.map((img, index) => {
|
${filteredImages.map((img, index) => {
|
||||||
// Get URLs for the example image
|
// Find matching file in our list of actual files
|
||||||
const urls = getLocalExampleImageUrl(img, index, modelHash);
|
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;
|
const aspectRatio = (img.height / img.width) * 100;
|
||||||
@@ -113,10 +124,16 @@ export function renderShowcaseContent(images, modelHash) {
|
|||||||
size, seed, model, steps, sampler, cfgScale, clipSkip
|
size, seed, model, steps, sampler, cfgScale, clipSkip
|
||||||
);
|
);
|
||||||
|
|
||||||
if (img.type === 'video') {
|
if (isVideo) {
|
||||||
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, urls);
|
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('')}
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +210,7 @@ function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, si
|
|||||||
/**
|
/**
|
||||||
* 生成视频包装HTML
|
* 生成视频包装HTML
|
||||||
*/
|
*/
|
||||||
function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) {
|
function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
|
||||||
return `
|
return `
|
||||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
@@ -203,10 +220,10 @@ function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
|
|||||||
` : ''}
|
` : ''}
|
||||||
<video controls autoplay muted loop crossorigin="anonymous"
|
<video controls autoplay muted loop crossorigin="anonymous"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
data-local-src="${urls.primary || ''}"
|
data-local-src="${localUrl || ''}"
|
||||||
data-remote-src="${img.url}"
|
data-remote-src="${remoteUrl}"
|
||||||
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
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
|
Your browser does not support video playback
|
||||||
</video>
|
</video>
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
@@ -225,7 +242,7 @@ function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
|
|||||||
/**
|
/**
|
||||||
* 生成图片包装HTML
|
* 生成图片包装HTML
|
||||||
*/
|
*/
|
||||||
function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) {
|
function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
|
||||||
return `
|
return `
|
||||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
@@ -233,9 +250,8 @@ function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
|
|||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
<img data-local-src="${urls.primary || ''}"
|
<img data-local-src="${localUrl || ''}"
|
||||||
data-local-fallback-src="${urls.fallback || ''}"
|
data-remote-src="${remoteUrl}"
|
||||||
data-remote-src="${img.url}"
|
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
|
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
|
||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, getExampleImageFiles } from '../../utils/uiHelpers.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
|
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
|
||||||
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
|
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { saveModelMetadata } from '../../api/loraApi.js';
|
import { saveModelMetadata } from '../../api/loraApi.js';
|
||||||
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||||
import { updateLoraCard } from '../../utils/cardUpdater.js';
|
import { updateLoraCard } from '../../utils/cardUpdater.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示LoRA模型弹窗
|
* 显示LoRA模型弹窗
|
||||||
@@ -136,7 +137,9 @@ export function showLoraModal(lora) {
|
|||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div id="showcase-tab" class="tab-pane active">
|
<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>
|
||||||
|
|
||||||
<div id="description-tab" class="tab-pane">
|
<div id="description-tab" class="tab-pane">
|
||||||
@@ -182,6 +185,70 @@ export function showLoraModal(lora) {
|
|||||||
|
|
||||||
// Load recipes for this Lora
|
// Load recipes for this Lora
|
||||||
loadRecipesForLora(lora.model_name, lora.sha256);
|
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
|
// Copy file name function
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class ExampleImagesManager {
|
|||||||
this.progressPanel = null;
|
this.progressPanel = null;
|
||||||
this.isProgressPanelCollapsed = false;
|
this.isProgressPanelCollapsed = false;
|
||||||
this.pauseButton = null; // Store reference to the pause button
|
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
|
// Initialize download path field and check download status
|
||||||
this.initializePathOptions();
|
this.initializePathOptions();
|
||||||
@@ -46,6 +48,12 @@ class ExampleImagesManager {
|
|||||||
if (collapseBtn) {
|
if (collapseBtn) {
|
||||||
collapseBtn.onclick = () => this.toggleProgressPanel();
|
collapseBtn.onclick = () => this.toggleProgressPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize migration button handler
|
||||||
|
const migrateBtn = document.getElementById('exampleImagesMigrateBtn');
|
||||||
|
if (migrateBtn) {
|
||||||
|
migrateBtn.onclick = () => this.handleMigrateButton();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize event listeners for buttons
|
// 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() {
|
async checkDownloadStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/example-images-status');
|
const response = await fetch('/api/example-images-status');
|
||||||
@@ -224,6 +321,7 @@ class ExampleImagesManager {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.isDownloading = true;
|
this.isDownloading = true;
|
||||||
this.isPaused = false;
|
this.isPaused = false;
|
||||||
|
this.hasShownCompletionToast = false; // Reset toast flag when starting new download
|
||||||
this.startTime = new Date();
|
this.startTime = new Date();
|
||||||
this.updateUI(data.status);
|
this.updateUI(data.status);
|
||||||
this.showProgressPanel();
|
this.showProgressPanel();
|
||||||
@@ -334,6 +432,7 @@ class ExampleImagesManager {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.isDownloading = data.is_downloading;
|
this.isDownloading = data.is_downloading;
|
||||||
this.isPaused = data.status.status === 'paused';
|
this.isPaused = data.status.status === 'paused';
|
||||||
|
this.isMigrating = data.is_migrating || false;
|
||||||
|
|
||||||
// Update download button text
|
// Update download button text
|
||||||
this.updateDownloadButtonText();
|
this.updateDownloadButtonText();
|
||||||
@@ -345,12 +444,19 @@ class ExampleImagesManager {
|
|||||||
clearInterval(this.progressUpdateInterval);
|
clearInterval(this.progressUpdateInterval);
|
||||||
this.progressUpdateInterval = null;
|
this.progressUpdateInterval = null;
|
||||||
|
|
||||||
if (data.status.status === 'completed') {
|
if (data.status.status === 'completed' && !this.hasShownCompletionToast) {
|
||||||
showToast('Example images download completed', 'success');
|
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
|
// Hide the panel after a delay
|
||||||
setTimeout(() => this.hideProgressPanel(), 5000);
|
setTimeout(() => this.hideProgressPanel(), 5000);
|
||||||
} else if (data.status.status === 'error') {
|
} 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);
|
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
|
// Update the mini progress circle in the pause button
|
||||||
@@ -536,8 +655,10 @@ class ExampleImagesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getStatusText(status) {
|
getStatusText(status) {
|
||||||
|
const prefix = this.isMigrating ? 'Migrating' : 'Downloading';
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running': return 'Downloading';
|
case 'running': return this.isMigrating ? 'Migrating' : 'Downloading';
|
||||||
case 'paused': return 'Paused';
|
case 'paused': return 'Paused';
|
||||||
case 'completed': return 'Completed';
|
case 'completed': return 'Completed';
|
||||||
case 'error': return 'Error';
|
case 'error': return 'Error';
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export class SettingsManager {
|
|||||||
state.global.settings.optimizeExampleImages = true;
|
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
|
// Convert old boolean compactMode to new displayDensity string
|
||||||
if (typeof state.global.settings.displayDensity === 'undefined') {
|
if (typeof state.global.settings.displayDensity === 'undefined') {
|
||||||
if (state.global.settings.compactMode === true) {
|
if (state.global.settings.compactMode === true) {
|
||||||
@@ -109,6 +114,14 @@ export class SettingsManager {
|
|||||||
optimizeExampleImagesCheckbox.checked = state.global.settings.optimizeExampleImages || false;
|
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
|
// Load default lora root
|
||||||
await this.loadLoraRoots();
|
await this.loadLoraRoots();
|
||||||
|
|
||||||
@@ -183,6 +196,10 @@ export class SettingsManager {
|
|||||||
state.global.settings.optimizeExampleImages = value;
|
state.global.settings.optimizeExampleImages = value;
|
||||||
} else if (settingKey === 'compact_mode') {
|
} else if (settingKey === 'compact_mode') {
|
||||||
state.global.settings.compactMode = value;
|
state.global.settings.compactMode = value;
|
||||||
|
} else if (settingKey === 'use_centralized_examples') {
|
||||||
|
state.global.settings.useCentralizedExamples = value;
|
||||||
|
// Update dependent controls state
|
||||||
|
this.updateExamplesControlsState();
|
||||||
} else {
|
} else {
|
||||||
// For any other settings that might be added in the future
|
// For any other settings that might be added in the future
|
||||||
state.global.settings[settingKey] = value;
|
state.global.settings[settingKey] = value;
|
||||||
@@ -193,7 +210,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// For backend settings, make API call
|
// 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 = {};
|
const payload = {};
|
||||||
payload[settingKey] = value;
|
payload[settingKey] = value;
|
||||||
|
|
||||||
@@ -506,6 +523,42 @@ export class SettingsManager {
|
|||||||
// Add the appropriate density class
|
// Add the appropriate density class
|
||||||
grid.classList.add(`${density}-density`);
|
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 = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -936,4 +936,26 @@ export function scrollToTop(button) {
|
|||||||
behavior: 'smooth'
|
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 [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -256,6 +256,26 @@
|
|||||||
<h3>Example Images</h3>
|
<h3>Example Images</h3>
|
||||||
|
|
||||||
<div class="setting-item">
|
<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-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label for="exampleImagesPath">Download Location</label>
|
<label for="exampleImagesPath">Download Location</label>
|
||||||
@@ -271,8 +291,29 @@
|
|||||||
Enter the folder path where example images from Civitai will be saved
|
Enter the folder path where example images from Civitai will be saved
|
||||||
</div>
|
</div>
|
||||||
</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-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label for="optimizeExampleImages">Optimize Downloaded Images</label>
|
<label for="optimizeExampleImages">Optimize Downloaded Images</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user