/**
* ShowcaseView.js
* Shared showcase component for displaying examples in model modals (Lora/Checkpoint)
*/
import { showToast } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js';
import { modalManager } from '../../../managers/ModalManager.js';
import { translate } from '../../../utils/i18nHelpers.js';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import {
initLazyLoading,
initNsfwBlurHandlers,
initMetadataPanelHandlers,
initMediaControlHandlers,
positionAllMediaControls
} from './MediaUtils.js';
import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer } from '../MediaViewer.js';
export const showcaseListenerMetrics = {
wheelListeners: 0,
mutationObservers: 0,
backToTopHandlers: 0,
};
export function resetShowcaseListenerMetrics() {
showcaseListenerMetrics.wheelListeners = 0;
showcaseListenerMetrics.mutationObservers = 0;
showcaseListenerMetrics.backToTopHandlers = 0;
}
/**
* Load example images asynchronously
* @param {Array} images - Array of image objects (both regular and custom)
* @param {string} modelHash - Model hash for fetching local files
*/
export async function loadExampleImages(images, modelHash) {
try {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
// First fetch local example files
let localFiles = [];
try {
const endpoint = '/api/lm/example-image-files';
const params = `model_hash=${modelHash}`;
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) {
// Always bind scroll-indicator click events (even when collapsed)
bindScrollIndicatorEvents(carousel);
// Only initialize full showcase content when expanded
if (!carousel.classList.contains('collapsed')) {
initShowcaseContent(carousel);
}
}
// Initialize the example import functionality
initExampleImport(modelHash, showcaseTab);
} catch (error) {
console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
showcaseTab.innerHTML = `
Error loading example images
`;
}
}
}
/**
* Render showcase content
* @param {Array} images - Array of images/videos to show
* @param {Array} exampleFiles - Local example files
* @param {boolean} startExpanded - Whether to start in expanded state
* @returns {string} HTML content
*/
export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) {
if (!images?.length) {
// Show empty state with import interface
return renderImportInterface(true);
}
// Filter images based on SFW setting
const showOnlySFW = state.settings.show_only_sfw;
let filteredImages = images;
let hiddenCount = 0;
if (showOnlySFW) {
filteredImages = images.filter(img => {
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const isSfw = nsfwLevel < NSFW_LEVELS.R;
if (!isSfw) hiddenCount++;
return isSfw;
});
}
// Show message if no images are available after filtering
if (filteredImages.length === 0) {
return `
All example images are filtered due to NSFW content settings
Your settings are currently set to show only safe-for-work content
You can change this in Settings
`;
}
// Show hidden content notification if applicable
const hiddenNotification = hiddenCount > 0 ?
`
${hiddenCount} ${hiddenCount === 1 ? 'image' : 'images'} hidden due to SFW-only setting
` : '';
return `
Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples
`;
}
/**
* Render a single media item (image or video)
* @param {Object} img - Image/video metadata
* @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files
* @returns {string} HTML for the media item
*/
function renderMediaItem(img, index, exampleFiles) {
// Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles);
// Get original remote URL
const originalRemoteUrl = img.url || '';
// Determine media type for optimization
const isVideo = localFile ? localFile.is_video :
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
const mediaType = isVideo ? 'video' : 'image';
// Optimize CivitAI URLs for showcase display (full quality)
const remoteUrl = getShowcaseUrl(originalRemoteUrl, mediaType);
const localUrl = localFile ? localFile.path : '';
// 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)
);
// Extract CivitAI image ID from CDN URL for import status check
const cdnImageId = (img.url || '').match(/\/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)/)?.[1] || '';
// Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
// 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
);
// Determine if this is a custom image (has id property)
const isCustomImage = Boolean(typeof img.id === 'string' && img.id);
const hasGenMeta = img.hasMeta || (img.meta && (img.meta.prompt || img.meta.seed || img.meta.resources));
// Create the media control buttons HTML
const mediaControlsHtml = `
${hasGenMeta ? `
` : ''}
`;
// Generate the appropriate wrapper based on media type
if (isVideo) {
return generateVideoWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml
);
}
return generateImageWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml
);
}
/**
* Find the matching local file for an image
* @param {Object} img - Image metadata
* @param {number} index - Image index
* @param {Array} exampleFiles - Array of local files
* @returns {Object|null} Matching local file or null
*/
function findLocalFile(img, index, exampleFiles) {
if (!exampleFiles || exampleFiles.length === 0) return null;
let localFile = null;
if (typeof img.id === 'string' && img.id) {
// This is a custom image, find by custom_
const customPrefix = `custom_${img.id}`;
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
} else {
// This is a regular image from civitai, find by index
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
}
return localFile;
}
/**
* Render the import interface for example images
* @param {boolean} isEmpty - Whether there are no existing examples
* @returns {string} HTML content for import interface
*/
function renderImportInterface(isEmpty) {
// Check if example images path is configured
const exampleImagesPath = state.global.settings.example_images_path;
const isPathConfigured = exampleImagesPath && exampleImagesPath.trim() !== '';
// If path is not configured, show setup guidance
if (!isPathConfigured) {
const title = translate('uiHelpers.exampleImages.setupRequired', {}, 'Example Images Storage');
const description = translate('uiHelpers.exampleImages.setupDescription', {}, 'To add custom example images, you need to set a download location first.');
const usage = translate('uiHelpers.exampleImages.setupUsage', {}, 'This path is used for both downloaded and custom example images.');
const openSettings = translate('uiHelpers.exampleImages.openSettings', {}, 'Open Settings');
return `
${title}
${description}
${usage}
`;
}
return `
${isEmpty ? 'No example images available' : 'Add more examples'}