mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 15:15:44 -03:00
Add API endpoint and frontend integration for fetching example image files
This commit is contained in:
@@ -66,6 +66,9 @@ class MiscRoutes:
|
|||||||
# 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 example images folder contents
|
||||||
|
app.router.add_get('/api/example-image-files', MiscRoutes.get_example_image_files)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def clear_cache(request):
|
async def clear_cache(request):
|
||||||
"""Clear all cache files from the cache folder"""
|
"""Clear all cache files from the cache folder"""
|
||||||
@@ -462,7 +465,7 @@ class MiscRoutes:
|
|||||||
|
|
||||||
model_success = True
|
model_success = True
|
||||||
|
|
||||||
for i, image in enumerate(model_images, 1):
|
for i, image in enumerate(model_images):
|
||||||
image_url = image.get('url')
|
image_url = image.get('url')
|
||||||
if not image_url:
|
if not image_url:
|
||||||
continue
|
continue
|
||||||
@@ -479,6 +482,7 @@ class MiscRoutes:
|
|||||||
logger.debug(f"Skipping unsupported file type: {image_filename}")
|
logger.debug(f"Skipping unsupported file type: {image_filename}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Use 0-based indexing instead of 1-based
|
||||||
save_filename = f"image_{i}{image_ext}"
|
save_filename = f"image_{i}{image_ext}"
|
||||||
|
|
||||||
# If optimizing images and this is a Civitai image, use their pre-optimized WebP version
|
# If optimizing images and this is a Civitai image, use their pre-optimized WebP version
|
||||||
@@ -1118,7 +1122,7 @@ class MiscRoutes:
|
|||||||
|
|
||||||
# Handle multiple occurrences of {model}
|
# Handle multiple occurrences of {model}
|
||||||
model_count = pattern.count('{model}')
|
model_count = pattern.count('{model}')
|
||||||
if model_count > 1:
|
if (model_count > 1):
|
||||||
# Replace the first occurrence with a named capture group
|
# Replace the first occurrence with a named capture group
|
||||||
regex_safe = regex_safe.replace(r'\{model\}', r'(?P<model>.*?)', 1)
|
regex_safe = regex_safe.replace(r'\{model\}', r'(?P<model>.*?)', 1)
|
||||||
|
|
||||||
@@ -1455,3 +1459,141 @@ class MiscRoutes:
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_example_image_files(request):
|
||||||
|
"""
|
||||||
|
Get list of example image files for a specific model
|
||||||
|
|
||||||
|
Expects:
|
||||||
|
- model_hash in query parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- List of image files with their paths
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get the model hash from query parameters
|
||||||
|
model_hash = request.query.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'
|
||||||
|
}, 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',
|
||||||
|
'files': []
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# Get list of files in the folder
|
||||||
|
files = []
|
||||||
|
for file in os.listdir(model_folder):
|
||||||
|
file_path = os.path.join(model_folder, file)
|
||||||
|
if os.path.isfile(file_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']):
|
||||||
|
files.append({
|
||||||
|
'name': file,
|
||||||
|
'path': f'/example_images_static/{model_hash}/{file}',
|
||||||
|
'extension': file_ext,
|
||||||
|
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check if files are using 1-based indexing (looking for pattern like "image_1.jpg")
|
||||||
|
has_one_based = any(re.match(r'image_1\.\w+$', f['name']) for f in files)
|
||||||
|
has_zero_based = any(re.match(r'image_0\.\w+$', f['name']) for f in files)
|
||||||
|
|
||||||
|
# If there's 1-based indexing and no 0-based, rename files
|
||||||
|
if has_one_based and not has_zero_based:
|
||||||
|
logger.info(f"Converting 1-based to 0-based indexing in {model_folder}")
|
||||||
|
# Sort files to ensure we process them in the right order
|
||||||
|
files.sort(key=lambda x: x['name'])
|
||||||
|
|
||||||
|
# First, create a mapping of renames to avoid conflicts
|
||||||
|
renames = []
|
||||||
|
for file in files:
|
||||||
|
match = re.match(r'image_(\d+)\.(\w+)$', file['name'])
|
||||||
|
if match:
|
||||||
|
index = int(match.group(1))
|
||||||
|
ext = match.group(2)
|
||||||
|
if index > 0: # Only rename if index is positive
|
||||||
|
new_name = f"image_{index-1}.{ext}"
|
||||||
|
renames.append((file['name'], new_name))
|
||||||
|
|
||||||
|
# To avoid conflicts, use temporary filenames first
|
||||||
|
for old_name, new_name in renames:
|
||||||
|
old_path = os.path.join(model_folder, old_name)
|
||||||
|
temp_path = os.path.join(model_folder, f"temp_{old_name}")
|
||||||
|
try:
|
||||||
|
os.rename(old_path, temp_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to rename {old_path} to {temp_path}: {e}")
|
||||||
|
|
||||||
|
# Now rename from temporary names to final names
|
||||||
|
for old_name, new_name in renames:
|
||||||
|
temp_path = os.path.join(model_folder, f"temp_{old_name}")
|
||||||
|
new_path = os.path.join(model_folder, new_name)
|
||||||
|
try:
|
||||||
|
os.rename(temp_path, new_path)
|
||||||
|
logger.debug(f"Renamed {old_name} to {new_name}")
|
||||||
|
|
||||||
|
# Update the entry in our files list
|
||||||
|
for file in files:
|
||||||
|
if file['name'] == old_name:
|
||||||
|
file['name'] = new_name
|
||||||
|
file['path'] = f'/example_images_static/{model_hash}/{new_name}'
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to rename {temp_path} to {new_path}: {e}")
|
||||||
|
|
||||||
|
# Refresh the file list after renaming
|
||||||
|
files = []
|
||||||
|
for file in os.listdir(model_folder):
|
||||||
|
file_path = os.path.join(model_folder, file)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
file_ext = os.path.splitext(file)[1].lower()
|
||||||
|
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
|
||||||
|
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
|
||||||
|
files.append({
|
||||||
|
'name': file,
|
||||||
|
'path': f'/example_images_static/{model_hash}/{file}',
|
||||||
|
'extension': file_ext,
|
||||||
|
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort files by their index for consistent ordering
|
||||||
|
def extract_index(filename):
|
||||||
|
match = re.match(r'image_(\d+)\.\w+$', filename)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return float('inf') # Put non-matching files at the end
|
||||||
|
|
||||||
|
files.sort(key=lambda x: extract_index(x['name']))
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'files': files
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get example image files: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -110,7 +109,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 +147,56 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load example images asynchronously
|
||||||
|
* @param {Array} images - Array of image objects
|
||||||
|
* @param {string} modelHash - Model hash for fetching local files
|
||||||
|
*/
|
||||||
|
async function loadExampleImages(images, modelHash) {
|
||||||
|
try {
|
||||||
|
const showcaseTab = document.getElementById('showcase-tab');
|
||||||
|
if (!showcaseTab) return;
|
||||||
|
|
||||||
|
// First fetch local example files
|
||||||
|
let localFiles = [];
|
||||||
|
if (modelHash) {
|
||||||
|
try {
|
||||||
|
localFiles = await getExampleImageFiles(modelHash);
|
||||||
|
} 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';
|
||||||
@@ -136,7 +136,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 +184,56 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load example images asynchronously
|
||||||
|
* @param {Array} images - Array of image objects
|
||||||
|
* @param {string} modelHash - Model hash for fetching local files
|
||||||
|
*/
|
||||||
|
async function loadExampleImages(images, modelHash) {
|
||||||
|
try {
|
||||||
|
const showcaseTab = document.getElementById('showcase-tab');
|
||||||
|
if (!showcaseTab) return;
|
||||||
|
|
||||||
|
// First fetch local example files
|
||||||
|
let localFiles = [];
|
||||||
|
if (modelHash) {
|
||||||
|
try {
|
||||||
|
localFiles = await getExampleImageFiles(modelHash);
|
||||||
|
} 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
|
||||||
|
|||||||
@@ -937,3 +937,25 @@ export function scrollToTop(button) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user