mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
@@ -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())
|
||||
|
||||
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 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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user