Implement saving model example images locally. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/88

This commit is contained in:
Will Miao
2025-04-29 16:18:25 +08:00
parent 4789711910
commit cb876cf77e
20 changed files with 1581 additions and 138 deletions

View File

@@ -496,6 +496,57 @@ input:checked + .toggle-slider:before {
filter: blur(8px);
}
/* Example Images Settings Styles */
.download-buttons {
justify-content: flex-start;
gap: var(--space-2);
}
.download-buttons .primary-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.95em;
}
.download-buttons .primary-btn:hover {
background-color: oklch(var(--lora-accent) / 0.9);
}
.path-browser-control {
display: flex;
gap: 8px;
}
.path-browser-control input[type="text"] {
flex: 1;
}
.browse-btn {
white-space: nowrap;
padding: 6px 12px;
background-color: var(--button-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
.browse-btn:hover {
background-color: var(--button-hover-bg);
}
.primary-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Add styles for delete preview image */
.delete-preview {
max-width: 150px;

View File

@@ -0,0 +1,167 @@
/* Progress Panel Styles */
.progress-panel {
position: fixed;
bottom: 20px;
right: 20px;
width: 350px;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: calc(var(--z-modal) - 1);
transition: transform 0.3s ease, opacity 0.3s ease;
opacity: 0;
transform: translateY(20px);
}
.progress-panel.visible {
opacity: 1;
transform: translateY(0);
}
.progress-panel.collapsed .progress-panel-content {
display: none;
}
.progress-panel.collapsed .progress-panel-header {
border-bottom: none;
}
.progress-panel-header {
padding: var(--space-2);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--lora-border);
}
.progress-panel-title {
font-weight: 500;
color: var(--text-color);
display: flex;
align-items: center;
gap: 8px;
}
.progress-panel-actions {
display: flex;
gap: 6px;
}
.icon-button {
background: none;
border: none;
color: var(--text-color);
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
transition: all 0.2s;
}
.icon-button:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .icon-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.progress-panel-content {
padding: var(--space-2);
}
.download-progress-info {
margin-bottom: var(--space-2);
}
.progress-status {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 0.9em;
color: var(--text-color);
}
.progress-container {
width: 100%;
background-color: var(--lora-border);
border-radius: 4px;
overflow: hidden;
height: var(--space-1);
}
.progress-bar {
width: 0%;
height: 100%;
background-color: var(--lora-accent);
transition: width 0.5s ease;
}
.current-model-info {
background: var(--bg-color);
border-radius: var(--border-radius-xs);
padding: 8px;
margin-bottom: var(--space-2);
font-size: 0.95em;
}
.current-label {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 4px;
}
.current-model-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-color);
}
.download-stats {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-2);
}
.stat-item {
font-size: 0.9em;
color: var(--text-color);
}
.stat-label {
opacity: 0.7;
margin-right: 4px;
}
.download-errors {
background: oklch(var(--lora-warning) / 0.1);
border: 1px solid var(--lora-warning);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
max-height: 100px;
overflow-y: auto;
font-size: 0.85em;
}
.error-header {
color: var(--lora-warning);
font-weight: 500;
margin-bottom: 4px;
}
.error-list {
color: var(--text-color);
opacity: 0.85;
}
.hidden {
display: none !important;
}

View File

@@ -20,6 +20,7 @@
@import 'components/shared.css';
@import 'components/filter-indicator.css';
@import 'components/initialization.css';
@import 'components/progress-panel.css';
.initialization-notice {
display: flex;

View File

@@ -6,12 +6,40 @@ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { state } from '../../state/index.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
/**
* Get the local URL for an example image if available
* @param {Object} img - Image object
* @param {number} index - Image index
* @param {string} modelHash - Model hash
* @returns {string|null} - Local URL or null if not available
*/
function getLocalExampleImageUrl(img, index, modelHash) {
if (!modelHash) return null;
// Get remote extension
const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase();
// If it's a video (mp4), use that extension
if (remoteExt === 'mp4') {
return `/example_images_static/${modelHash}/image_${index + 1}.mp4`;
}
// For images, check if optimization is enabled (defaults to true)
const optimizeImages = state.settings.optimizeExampleImages !== false;
// Use .webp for images if optimization enabled, otherwise use original extension
const extension = optimizeImages ? 'webp' : remoteExt;
return `/example_images_static/${modelHash}/image_${index + 1}.${extension}`;
}
/**
* Render showcase content
* @param {Array} images - Array of images/videos to show
* @param {string} modelHash - Model hash for identifying local files
* @returns {string} HTML content
*/
export function renderShowcaseContent(images) {
export function renderShowcaseContent(images, modelHash) {
if (!images?.length) return '<div class="no-examples">No example images available</div>';
// Filter images based on SFW setting
@@ -53,7 +81,11 @@ export function renderShowcaseContent(images) {
<div class="carousel collapsed">
${hiddenNotification}
<div class="carousel-container">
${filteredImages.map(img => generateMediaWrapper(img)).join('')}
${filteredImages.map((img, index) => {
// Try to get local URL for the example image
const localUrl = getLocalExampleImageUrl(img, index, modelHash);
return generateMediaWrapper(img, localUrl);
}).join('')}
</div>
</div>
`;
@@ -64,7 +96,7 @@ export function renderShowcaseContent(images) {
* @param {Object} media - Media object with image or video data
* @returns {string} HTML content
*/
function generateMediaWrapper(media) {
function generateMediaWrapper(media, localUrl = null) {
// Calculate appropriate aspect ratio:
// 1. Keep original aspect ratio
// 2. Limit maximum height to 60% of viewport height
@@ -117,10 +149,10 @@ function generateMediaWrapper(media) {
// Check if this is a video or image
if (media.type === 'video') {
return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel);
return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl);
}
return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel);
return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl);
}
/**
@@ -193,7 +225,7 @@ function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, si
/**
* Generate video wrapper HTML
*/
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel) {
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
@@ -202,9 +234,11 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
</button>
` : ''}
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer" data-src="${media.url}"
referrerpolicy="no-referrer"
data-local-src="${localUrl || ''}"
data-remote-src="${media.url}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
<source data-src="${media.url}" type="video/mp4">
<source data-local-src="${localUrl || ''}" data-remote-src="${media.url}" type="video/mp4">
Your browser does not support video playback
</video>
${shouldBlur ? `
@@ -223,7 +257,7 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
/**
* Generate image wrapper HTML
*/
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel) {
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
@@ -231,7 +265,8 @@ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metada
<i class="fas fa-eye"></i>
</button>
` : ''}
<img data-src="${media.url}"
<img data-local-src="${localUrl || ''}"
data-remote-src="${media.url}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
@@ -382,15 +417,73 @@ function initLazyLoading(container) {
const lazyElements = container.querySelectorAll('.lazy');
const lazyLoad = (element) => {
const localSrc = element.dataset.localSrc;
const remoteSrc = element.dataset.remoteSrc;
// Check if element is an image or video
if (element.tagName.toLowerCase() === 'video') {
element.src = element.dataset.src;
element.querySelector('source').src = element.dataset.src;
element.load();
// Try local first, then remote
tryLocalOrFallbackToRemote(element, localSrc, remoteSrc);
} else {
element.src = element.dataset.src;
// For images, we'll use an Image object to test if local file exists
tryLocalImageOrFallbackToRemote(element, localSrc, remoteSrc);
}
element.classList.remove('lazy');
};
// Try to load local image first, fall back to remote if local fails
const tryLocalImageOrFallbackToRemote = (imgElement, localSrc, remoteSrc) => {
// Only try local if we have a local path
if (localSrc) {
const testImg = new Image();
testImg.onload = () => {
// Local image loaded successfully
imgElement.src = localSrc;
};
testImg.onerror = () => {
// Local image failed, use remote
imgElement.src = remoteSrc;
};
// Start loading test image
testImg.src = localSrc;
} else {
// No local path, use remote directly
imgElement.src = remoteSrc;
}
};
// Try to load local video first, fall back to remote if local fails
const tryLocalOrFallbackToRemote = (videoElement, localSrc, remoteSrc) => {
// Only try local if we have a local path
if (localSrc) {
// Try to fetch local file headers to see if it exists
fetch(localSrc, { method: 'HEAD' })
.then(response => {
if (response.ok) {
// Local video exists, use it
videoElement.src = localSrc;
videoElement.querySelector('source').src = localSrc;
} else {
// Local video doesn't exist, use remote
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
}
videoElement.load();
})
.catch(() => {
// Error fetching, use remote
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
videoElement.load();
});
} else {
// No local path, use remote directly
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
videoElement.load();
}
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
@@ -485,4 +578,4 @@ export function scrollToTop(button) {
behavior: 'smooth'
});
}
}
}

View File

@@ -96,7 +96,7 @@ export function showCheckpointModal(checkpoint) {
<div class="tab-content">
<div id="showcase-tab" class="tab-pane active">
${renderShowcaseContent(checkpoint.civitai?.images || [])}
${renderShowcaseContent(checkpoint.civitai?.images || [], checkpoint.sha256)}
</div>
<div id="description-tab" class="tab-pane">

View File

@@ -6,12 +6,40 @@ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { state } from '../../state/index.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
/**
* Get the local URL for an example image if available
* @param {Object} img - Image object
* @param {number} index - Image index
* @param {string} modelHash - Model hash
* @returns {string|null} - Local URL or null if not available
*/
function getLocalExampleImageUrl(img, index, modelHash) {
if (!modelHash) return null;
// Get remote extension
const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase();
// If it's a video (mp4), use that extension
if (remoteExt === 'mp4') {
return `/example_images_static/${modelHash}/image_${index + 1}.mp4`;
}
// For images, check if optimization is enabled (defaults to true)
const optimizeImages = state.settings.optimizeExampleImages !== false;
// Use .webp for images if optimization enabled, otherwise use original extension
const extension = optimizeImages ? 'webp' : remoteExt;
return `/example_images_static/${modelHash}/image_${index + 1}.${extension}`;
}
/**
* 渲染展示内容
* @param {Array} images - 要展示的图片/视频数组
* @param {string} modelHash - Model hash for identifying local files
* @returns {string} HTML内容
*/
export function renderShowcaseContent(images) {
export function renderShowcaseContent(images, modelHash) {
if (!images?.length) return '<div class="no-examples">No example images available</div>';
// Filter images based on SFW setting
@@ -53,7 +81,15 @@ export function renderShowcaseContent(images) {
<div class="carousel collapsed">
${hiddenNotification}
<div class="carousel-container">
${filteredImages.map(img => {
${filteredImages.map((img, index) => {
// Try to get local URL for the example image
const localUrl = getLocalExampleImageUrl(img, index, modelHash);
// Create data attributes for both remote and local URLs
const remoteUrl = img.url;
const dataRemoteSrc = remoteUrl;
const dataLocalSrc = localUrl;
// 计算适当的展示高度:
// 1. 保持原始宽高比
// 2. 限制最大高度为视窗高度的60%
@@ -111,9 +147,9 @@ export function renderShowcaseContent(images) {
`;
if (img.type === 'video') {
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
}
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
}
// Create a data attribute with the prompt for copying instead of trying to handle it in the onclick
@@ -174,9 +210,9 @@ export function renderShowcaseContent(images) {
`;
if (img.type === 'video') {
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
}
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
}).join('')}
</div>
</div>
@@ -186,7 +222,7 @@ export function renderShowcaseContent(images) {
/**
* 生成视频包装HTML
*/
function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel) {
function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
@@ -195,9 +231,11 @@ function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
</button>
` : ''}
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer" data-src="${img.url}"
referrerpolicy="no-referrer"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
<source data-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 ? `
@@ -216,7 +254,7 @@ function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
/**
* 生成图片包装HTML
*/
function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel) {
function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
@@ -224,7 +262,8 @@ function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
<i class="fas fa-eye"></i>
</button>
` : ''}
<img data-src="${img.url}"
<img data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
@@ -392,15 +431,73 @@ function initLazyLoading(container) {
const lazyElements = container.querySelectorAll('.lazy');
const lazyLoad = (element) => {
const localSrc = element.dataset.localSrc;
const remoteSrc = element.dataset.remoteSrc;
// Check if element is an image or video
if (element.tagName.toLowerCase() === 'video') {
element.src = element.dataset.src;
element.querySelector('source').src = element.dataset.src;
element.load();
// Try local first, then remote
tryLocalOrFallbackToRemote(element, localSrc, remoteSrc);
} else {
element.src = element.dataset.src;
// For images, we'll use an Image object to test if local file exists
tryLocalImageOrFallbackToRemote(element, localSrc, remoteSrc);
}
element.classList.remove('lazy');
};
// Try to load local image first, fall back to remote if local fails
const tryLocalImageOrFallbackToRemote = (imgElement, localSrc, remoteSrc) => {
// Only try local if we have a local path
if (localSrc) {
const testImg = new Image();
testImg.onload = () => {
// Local image loaded successfully
imgElement.src = localSrc;
};
testImg.onerror = () => {
// Local image failed, use remote
imgElement.src = remoteSrc;
};
// Start loading test image
testImg.src = localSrc;
} else {
// No local path, use remote directly
imgElement.src = remoteSrc;
}
};
// Try to load local video first, fall back to remote if local fails
const tryLocalOrFallbackToRemote = (videoElement, localSrc, remoteSrc) => {
// Only try local if we have a local path
if (localSrc) {
// Try to fetch local file headers to see if it exists
fetch(localSrc, { method: 'HEAD' })
.then(response => {
if (response.ok) {
// Local video exists, use it
videoElement.src = localSrc;
videoElement.querySelector('source').src = localSrc;
} else {
// Local video doesn't exist, use remote
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
}
videoElement.load();
})
.catch(() => {
// Error fetching, use remote
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
videoElement.load();
});
} else {
// No local path, use remote directly
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
videoElement.load();
}
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
@@ -497,4 +594,4 @@ export function scrollToTop(button) {
behavior: 'smooth'
});
}
}
}

View File

@@ -122,7 +122,7 @@ export function showLoraModal(lora) {
<div class="tab-content">
<div id="showcase-tab" class="tab-pane active">
${renderShowcaseContent(lora.civitai?.images)}
${renderShowcaseContent(lora.civitai?.images, lora.sha256)}
</div>
<div id="description-tab" class="tab-pane">

View File

@@ -5,6 +5,7 @@ import { modalManager } from './managers/ModalManager.js';
import { updateService } from './managers/UpdateService.js';
import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js';
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js';
@@ -27,6 +28,7 @@ export class AppCore {
updateService.initialize();
window.modalManager = modalManager;
window.settingsManager = settingsManager;
window.exampleImagesManager = exampleImagesManager;
// Initialize UI components
window.headerManager = new HeaderManager();

View File

@@ -0,0 +1,449 @@
import { showToast } from '../utils/uiHelpers.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
// ExampleImagesManager.js
class ExampleImagesManager {
constructor() {
this.isDownloading = false;
this.isPaused = false;
this.progressUpdateInterval = null;
this.startTime = null;
this.progressPanel = document.getElementById('exampleImagesProgress');
// Wait for DOM before initializing event listeners
document.addEventListener('DOMContentLoaded', () => this.initEventListeners());
// Initialize download path field
this.initializePathOptions();
// Check download status on page load
this.checkDownloadStatus();
}
// Initialize event listeners for buttons
initEventListeners() {
const startBtn = document.getElementById('startExampleDownloadBtn');
if (startBtn) {
startBtn.onclick = () => this.startDownload();
}
const resumeBtn = document.getElementById('resumeExampleDownloadBtn');
if (resumeBtn) {
resumeBtn.onclick = () => this.resumeDownload();
}
}
async initializePathOptions() {
try {
// Get custom path input element
const pathInput = document.getElementById('exampleImagesPath');
// Set path from storage if available
const savedPath = getStorageItem('example_images_path', '');
if (savedPath) {
pathInput.value = savedPath;
// Enable download button if path is set
this.updateDownloadButtonState(true);
} else {
// Disable download button if no path is set
this.updateDownloadButtonState(false);
}
// Add event listener to the browse button
const browseButton = document.getElementById('browseExampleImagesPath');
if (browseButton) {
browseButton.addEventListener('click', () => this.browseFolderDialog());
}
// Add event listener to validate path input
pathInput.addEventListener('input', async () => {
const hasPath = pathInput.value.trim() !== '';
this.updateDownloadButtonState(hasPath);
// Save path to storage when changed
if (hasPath) {
setStorageItem('example_images_path', pathInput.value);
// Update path in backend settings
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
example_images_path: pathInput.value
})
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (!data.success) {
console.error('Failed to update example images path in backend:', data.error);
} else {
showToast('Example images path updated successfully', 'success');
}
} catch (error) {
console.error('Failed to update example images path:', error);
}
}
});
} catch (error) {
console.error('Failed to initialize path options:', error);
}
}
// Method to update download button state
updateDownloadButtonState(enabled) {
const startBtn = document.getElementById('startExampleDownloadBtn');
if (startBtn) {
if (enabled) {
startBtn.classList.remove('disabled');
startBtn.disabled = false;
} else {
startBtn.classList.add('disabled');
startBtn.disabled = true;
}
}
}
// Method to open folder browser dialog
async browseFolderDialog() {
try {
const response = await fetch('/api/browse-folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
initial_dir: getStorageItem('example_images_path', '')
})
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success && data.folder) {
const pathInput = document.getElementById('exampleImagesPath');
pathInput.value = data.folder;
setStorageItem('example_images_path', data.folder);
this.updateDownloadButtonState(true);
showToast('Example images path updated successfully', 'success');
}
} catch (error) {
console.error('Failed to browse folder:', error);
showToast('Failed to browse folder. Please ensure the server supports this feature.', 'error');
}
}
async checkDownloadStatus() {
try {
const response = await fetch('/api/example-images-status');
const data = await response.json();
if (data.success) {
this.isDownloading = data.is_downloading;
this.isPaused = data.status.status === 'paused';
if (this.isDownloading) {
this.updateUI(data.status);
this.showProgressPanel();
// Start the progress update interval if downloading
if (!this.progressUpdateInterval) {
this.startProgressUpdates();
}
}
}
} catch (error) {
console.error('Failed to check download status:', error);
}
}
async startDownload() {
if (this.isDownloading) {
showToast('Download already in progress', 'warning');
return;
}
try {
const outputDir = document.getElementById('exampleImagesPath').value || '';
if (!outputDir) {
showToast('Please select a download location first', 'warning');
return;
}
const optimize = document.getElementById('optimizeExampleImages').checked;
const response = await fetch('/api/download-example-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
output_dir: outputDir,
optimize: optimize,
model_types: ['lora', 'checkpoint']
})
});
const data = await response.json();
if (data.success) {
this.isDownloading = true;
this.isPaused = false;
this.startTime = new Date();
this.updateUI(data.status);
this.showProgressPanel();
this.startProgressUpdates();
showToast('Example images download started', 'success');
// Hide the start button, show resume button
document.getElementById('startExampleDownloadBtn').style.display = 'none';
// Close settings modal
modalManager.closeModal('settingsModal');
} else {
showToast(data.error || 'Failed to start download', 'error');
}
} catch (error) {
console.error('Failed to start download:', error);
showToast('Failed to start download', 'error');
}
}
async pauseDownload() {
if (!this.isDownloading || this.isPaused) {
return;
}
try {
const response = await fetch('/api/pause-example-images', {
method: 'POST'
});
const data = await response.json();
if (data.success) {
this.isPaused = true;
document.getElementById('downloadStatusText').textContent = 'Paused';
document.getElementById('pauseExampleDownloadBtn').innerHTML = '<i class="fas fa-play"></i>';
document.getElementById('pauseExampleDownloadBtn').onclick = () => this.resumeDownload();
showToast('Download paused', 'info');
// Show resume button in settings too
document.getElementById('resumeExampleDownloadBtn').style.display = 'block';
} else {
showToast(data.error || 'Failed to pause download', 'error');
}
} catch (error) {
console.error('Failed to pause download:', error);
showToast('Failed to pause download', 'error');
}
}
async resumeDownload() {
if (!this.isDownloading || !this.isPaused) {
return;
}
try {
const response = await fetch('/api/resume-example-images', {
method: 'POST'
});
const data = await response.json();
if (data.success) {
this.isPaused = false;
document.getElementById('downloadStatusText').textContent = 'Downloading';
document.getElementById('pauseExampleDownloadBtn').innerHTML = '<i class="fas fa-pause"></i>';
document.getElementById('pauseExampleDownloadBtn').onclick = () => this.pauseDownload();
showToast('Download resumed', 'success');
// Hide resume button in settings
document.getElementById('resumeExampleDownloadBtn').style.display = 'none';
} else {
showToast(data.error || 'Failed to resume download', 'error');
}
} catch (error) {
console.error('Failed to resume download:', error);
showToast('Failed to resume download', 'error');
}
}
startProgressUpdates() {
// Clear any existing interval
if (this.progressUpdateInterval) {
clearInterval(this.progressUpdateInterval);
}
// Set new interval to update progress every 2 seconds
this.progressUpdateInterval = setInterval(async () => {
await this.updateProgress();
}, 2000);
}
async updateProgress() {
try {
const response = await fetch('/api/example-images-status');
const data = await response.json();
if (data.success) {
this.isDownloading = data.is_downloading;
if (this.isDownloading) {
this.updateUI(data.status);
} else {
// Download completed or failed
clearInterval(this.progressUpdateInterval);
this.progressUpdateInterval = null;
if (data.status.status === 'completed') {
showToast('Example images download completed', 'success');
// Hide the panel after a delay
setTimeout(() => this.hideProgressPanel(), 5000);
} else if (data.status.status === 'error') {
showToast('Example images download failed', 'error');
}
// Reset UI
document.getElementById('startExampleDownloadBtn').style.display = 'block';
document.getElementById('resumeExampleDownloadBtn').style.display = 'none';
}
}
} catch (error) {
console.error('Failed to update progress:', error);
}
}
updateUI(status) {
// Update status text
const statusText = document.getElementById('downloadStatusText');
statusText.textContent = this.getStatusText(status.status);
// Update progress counts and bar
const progressCounts = document.getElementById('downloadProgressCounts');
progressCounts.textContent = `${status.completed}/${status.total}`;
const progressBar = document.getElementById('downloadProgressBar');
const progressPercent = status.total > 0 ? (status.completed / status.total) * 100 : 0;
progressBar.style.width = `${progressPercent}%`;
// Update current model
const currentModel = document.getElementById('currentModelName');
currentModel.textContent = status.current_model || '-';
// Update time stats
this.updateTimeStats(status);
// Update errors
this.updateErrors(status);
// Update pause/resume button
if (status.status === 'paused') {
document.getElementById('pauseExampleDownloadBtn').innerHTML = '<i class="fas fa-play"></i>';
document.getElementById('pauseExampleDownloadBtn').onclick = () => this.resumeDownload();
document.getElementById('resumeExampleDownloadBtn').style.display = 'block';
} else {
document.getElementById('pauseExampleDownloadBtn').innerHTML = '<i class="fas fa-pause"></i>';
document.getElementById('pauseExampleDownloadBtn').onclick = () => this.pauseDownload();
document.getElementById('resumeExampleDownloadBtn').style.display = 'none';
}
}
updateTimeStats(status) {
const elapsedTime = document.getElementById('elapsedTime');
const remainingTime = document.getElementById('remainingTime');
// Calculate elapsed time
let elapsed;
if (status.start_time) {
const now = new Date();
const startTime = new Date(status.start_time * 1000);
elapsed = Math.floor((now - startTime) / 1000);
} else {
elapsed = 0;
}
elapsedTime.textContent = this.formatTime(elapsed);
// Calculate remaining time
if (status.total > 0 && status.completed > 0 && status.status === 'running') {
const rate = status.completed / elapsed; // models per second
const remaining = Math.floor((status.total - status.completed) / rate);
remainingTime.textContent = this.formatTime(remaining);
} else {
remainingTime.textContent = '--:--:--';
}
}
updateErrors(status) {
const errorContainer = document.getElementById('downloadErrorContainer');
const errorList = document.getElementById('downloadErrors');
if (status.errors && status.errors.length > 0) {
// Show only the last 3 errors
const recentErrors = status.errors.slice(-3);
errorList.innerHTML = recentErrors.map(error =>
`<div class="error-item">${error}</div>`
).join('');
errorContainer.classList.remove('hidden');
} else {
errorContainer.classList.add('hidden');
}
}
formatTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return [
hours.toString().padStart(2, '0'),
minutes.toString().padStart(2, '0'),
secs.toString().padStart(2, '0')
].join(':');
}
getStatusText(status) {
switch (status) {
case 'running': return 'Downloading';
case 'paused': return 'Paused';
case 'completed': return 'Completed';
case 'error': return 'Error';
default: return 'Initializing';
}
}
showProgressPanel() {
this.progressPanel.classList.add('visible');
}
hideProgressPanel() {
this.progressPanel.classList.remove('visible');
}
toggleProgressPanel() {
this.progressPanel.classList.toggle('collapsed');
// Update icon
const icon = document.querySelector('#collapseProgressBtn i');
if (this.progressPanel.classList.contains('collapsed')) {
icon.className = 'fas fa-chevron-up';
} else {
icon.className = 'fas fa-chevron-down';
}
}
}
// Create singleton instance
export const exampleImagesManager = new ExampleImagesManager();

View File

@@ -147,6 +147,8 @@ export class SettingsManager {
state.global.settings.show_only_sfw = value;
} else if (settingKey === 'autoplay_on_hover') {
state.global.settings.autoplayOnHover = value;
} else if (settingKey === 'optimize_example_images') {
state.global.settings.optimizeExampleImages = value;
} else {
// For any other settings that might be added in the future
state.global.settings[settingKey] = value;