Files
ComfyUI-Lora-Manager/static/js/api/baseModelApi.js
Will Miao 4793f096af update
2025-05-09 15:42:56 +08:00

825 lines
29 KiB
JavaScript

// filepath: d:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\static\js\api\baseModelApi.js
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
/**
* Shared functionality for handling models (loras and checkpoints)
*/
// Virtual scrolling configuration
const VIRTUAL_SCROLL_CONFIG = {
MAX_DOM_CARDS: 300, // Maximum DOM elements to keep
BUFFER_SIZE: 20, // Extra items to render above/below viewport
CLEANUP_INTERVAL: 5000, // How often to check for cards to clean up (ms)
}
// Track rendered items and all loaded items
const virtualScrollState = {
visibleItems: new Map(), // Track rendered items by filepath
allItems: [], // All data items loaded so far
observer: null, // IntersectionObserver for visibility tracking
cleanupTimer: null, // Timer for periodic cleanup
initialized: false // Whether virtual scrolling is initialized
}
// Initialize virtual scrolling
function initVirtualScroll(modelType) {
if (virtualScrollState.initialized) return;
const gridId = modelType === 'checkpoint' ? 'checkpointGrid' : 'loraGrid';
const gridElement = document.getElementById(gridId);
if (!gridElement) return;
// Create intersection observer to track visible cards
virtualScrollState.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const cardElement = entry.target;
const filepath = cardElement.dataset.filepath;
if (entry.isIntersecting) {
// Load media for cards entering viewport
lazyLoadCardMedia(cardElement);
} else {
// Card is no longer visible
if (entry.boundingClientRect.top < -1000 || entry.boundingClientRect.top > window.innerHeight + 1000) {
// If card is far outside viewport, consider removing it
virtualScrollState.visibleItems.delete(filepath);
cleanupCardResources(cardElement);
cardElement.remove();
}
}
});
}, {
rootMargin: '500px', // Start loading when within 500px of viewport
threshold: 0
});
// Set up periodic cleanup for DOM elements
virtualScrollState.cleanupTimer = setInterval(() => {
checkCardThreshold(modelType);
}, VIRTUAL_SCROLL_CONFIG.CLEANUP_INTERVAL);
// Set up scroll event listener for loading more content
window.addEventListener('scroll', throttle(() => {
const scrollPosition = window.scrollY + window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// If we're close to the bottom and not already loading, load more
if (scrollPosition > documentHeight - 1000) {
const pageState = getCurrentPageState();
if (!pageState.isLoading && pageState.hasMore) {
// This will trigger loading more items using the existing pagination
const loadMoreFunction = modelType === 'checkpoint' ?
window.loadMoreCheckpoints : window.loadMoreLoras;
if (typeof loadMoreFunction === 'function') {
loadMoreFunction(false, false);
}
}
}
}, 200));
virtualScrollState.initialized = true;
}
// Clean up resources for a card
function cleanupCardResources(cardElement) {
try {
// Stop videos and free resources
const video = cardElement.querySelector('video');
if (video) {
video.pause();
video.src = '';
video.load();
}
// Remove from observer
if (virtualScrollState.observer) {
virtualScrollState.observer.unobserve(cardElement);
}
} catch (e) {
console.error('Error cleaning up card resources:', e);
}
}
// Lazy load media content in a card
function lazyLoadCardMedia(cardElement) {
// Lazy load images
const img = cardElement.querySelector('img[data-src]');
if (img) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
}
// Lazy load videos
const video = cardElement.querySelector('video[data-src]');
if (video) {
video.src = video.dataset.src;
video.removeAttribute('data-src');
// Check if we should autoplay this video
const autoplayOnHover = state?.global?.settings?.autoplayOnHover || false;
if (!autoplayOnHover) {
// If not in hover-only mode, autoplay videos when they enter viewport
video.muted = true; // Muted videos can autoplay without user interaction
video.play().catch(err => {
console.log("Could not autoplay video, likely due to browser policy:", err);
});
}
}
}
// Check if we need to clean up any cards
function checkCardThreshold(modelType) {
const gridId = modelType === 'checkpoint' ? 'checkpointGrid' : 'loraGrid';
const cards = document.querySelectorAll(`#${gridId} .lora-card`);
if (cards.length > VIRTUAL_SCROLL_CONFIG.MAX_DOM_CARDS) {
// We have more cards than our threshold, remove those far from viewport
const cardsToRemove = cards.length - VIRTUAL_SCROLL_CONFIG.MAX_DOM_CARDS;
console.log(`Cleaning up ${cardsToRemove} cards to maintain performance`);
let removedCount = 0;
cards.forEach(card => {
if (removedCount >= cardsToRemove) return;
const rect = card.getBoundingClientRect();
// Remove cards that are far outside viewport
if (rect.bottom < -1000 || rect.top > window.innerHeight + 1000) {
const filepath = card.dataset.filepath;
virtualScrollState.visibleItems.delete(filepath);
cleanupCardResources(card);
card.remove();
removedCount++;
}
});
}
}
// Utility function to throttle function calls
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
// Generic function to load more models with pagination
export async function loadMoreModels(options = {}) {
const {
resetPage = false,
updateFolders = false,
modelType = 'lora', // 'lora' or 'checkpoint'
createCardFunction,
endpoint = '/api/loras'
} = options;
const pageState = getCurrentPageState();
if (pageState.isLoading || (!pageState.hasMore && !resetPage)) return;
pageState.isLoading = true;
document.body.classList.add('loading');
try {
// Initialize virtual scrolling if not already done
initVirtualScroll(modelType);
// Reset pagination and state if requested
if (resetPage) {
pageState.currentPage = 1;
// Clear the grid and virtual scroll state
const gridId = modelType === 'checkpoint' ? 'checkpointGrid' : 'loraGrid';
const grid = document.getElementById(gridId);
if (grid) grid.innerHTML = '';
virtualScrollState.visibleItems.clear();
virtualScrollState.allItems = [];
}
const params = new URLSearchParams({
page: pageState.currentPage,
page_size: pageState.pageSize || 20,
sort_by: pageState.sortBy
});
if (pageState.activeFolder !== null) {
params.append('folder', pageState.activeFolder);
}
// Add favorites filter parameter if enabled
if (pageState.showFavoritesOnly) {
params.append('favorites_only', 'true');
}
// Add active letter filter if set
if (pageState.activeLetterFilter) {
params.append('first_letter', pageState.activeLetterFilter);
}
// Add search parameters if there's a search term
if (pageState.filters?.search) {
params.append('search', pageState.filters.search);
params.append('fuzzy', 'true');
// Add search option parameters if available
if (pageState.searchOptions) {
params.append('search_filename', pageState.searchOptions.filename.toString());
params.append('search_modelname', pageState.searchOptions.modelname.toString());
if (pageState.searchOptions.tags !== undefined) {
params.append('search_tags', pageState.searchOptions.tags.toString());
}
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
}
}
// Add filter parameters if active
if (pageState.filters) {
// Handle tags filters
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
// Checkpoints API expects individual 'tag' parameters, Loras API expects comma-separated 'tags'
if (modelType === 'checkpoint') {
pageState.filters.tags.forEach(tag => {
params.append('tag', tag);
});
} else {
params.append('tags', pageState.filters.tags.join(','));
}
}
// Handle base model filters
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
if (modelType === 'checkpoint') {
pageState.filters.baseModel.forEach(model => {
params.append('base_model', model);
});
} else {
params.append('base_models', pageState.filters.baseModel.join(','));
}
}
}
// Add model-specific parameters
if (modelType === 'lora') {
// Check for recipe-based filtering parameters from session storage
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
// Add hash filter parameter if present
if (filterLoraHash) {
params.append('lora_hash', filterLoraHash);
}
// Add multiple hashes filter if present
else if (filterLoraHashes) {
try {
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
params.append('lora_hashes', filterLoraHashes.join(','));
}
} catch (error) {
console.error('Error parsing lora hashes from session storage:', error);
}
}
}
const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`);
}
const data = await response.json();
const gridId = modelType === 'checkpoint' ? 'checkpointGrid' : 'loraGrid';
const grid = document.getElementById(gridId);
if (data.items.length === 0 && pageState.currentPage === 1) {
grid.innerHTML = `<div class="no-results">No ${modelType}s found in this folder</div>`;
pageState.hasMore = false;
} else if (data.items.length > 0) {
pageState.hasMore = pageState.currentPage < data.total_pages;
// Add new items to our collection of all items
virtualScrollState.allItems = [...virtualScrollState.allItems, ...data.items];
// Create and append cards with optimized rendering
data.items.forEach(model => {
// Skip if we already have this card rendered
if (virtualScrollState.visibleItems.has(model.file_path)) return;
// Create the card with lazy loading for media
const card = createOptimizedCard(model, createCardFunction);
grid.appendChild(card);
// Track this card and observe it
virtualScrollState.visibleItems.set(model.file_path, card);
if (virtualScrollState.observer) {
virtualScrollState.observer.observe(card);
}
});
// Increment the page number AFTER successful loading
pageState.currentPage++;
} else {
pageState.hasMore = false;
}
if (updateFolders && data.folders) {
updateFolderTags(data.folders);
}
} catch (error) {
console.error(`Error loading ${modelType}s:`, error);
showToast(`Failed to load ${modelType}s: ${error.message}`, 'error');
} finally {
pageState.isLoading = false;
document.body.classList.remove('loading');
}
}
// Create a card with optimizations for lazy loading media
function createOptimizedCard(model, createCardFunction) {
// Create the card using the original function
const card = createCardFunction(model);
// Optimize image/video loading
const img = card.querySelector('img');
if (img) {
// Replace src with data-src to defer loading
img.dataset.src = img.src;
img.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; // Tiny transparent placeholder
}
const video = card.querySelector('video');
if (video) {
const source = video.querySelector('source');
if (source) {
// Store the video source for lazy loading
video.dataset.src = source.src;
source.removeAttribute('src');
} else if (video.src) {
// Handle direct src attribute
video.dataset.src = video.src;
video.removeAttribute('src');
}
// Save autoplay state but prevent autoplay until visible
if (video.hasAttribute('autoplay')) {
video.dataset.autoplay = 'true';
video.removeAttribute('autoplay');
}
}
return card;
}
// Clean up virtual scroll when page changes
export function cleanupVirtualScroll() {
if (virtualScrollState.observer) {
virtualScrollState.observer.disconnect();
}
if (virtualScrollState.cleanupTimer) {
clearInterval(virtualScrollState.cleanupTimer);
}
virtualScrollState.visibleItems.clear();
virtualScrollState.allItems = [];
virtualScrollState.initialized = false;
}
// Update folder tags in the UI
export function updateFolderTags(folders) {
const folderTagsContainer = document.querySelector('.folder-tags');
if (!folderTagsContainer) return;
// Keep track of currently selected folder
const pageState = getCurrentPageState();
const currentFolder = pageState.activeFolder;
// Create HTML for folder tags
const tagsHTML = folders.map(folder => {
const isActive = folder === currentFolder;
return `<div class="tag ${isActive ? 'active' : ''}" data-folder="${folder}">${folder}</div>`;
}).join('');
// Update the container
folderTagsContainer.innerHTML = tagsHTML;
// Reattach click handlers and ensure the active tag is visible
const tags = folderTagsContainer.querySelectorAll('.tag');
tags.forEach(tag => {
if (typeof toggleFolder === 'function') {
tag.addEventListener('click', toggleFolder);
}
if (tag.dataset.folder === currentFolder) {
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
}
// Generic function to replace a model preview
export function replaceModelPreview(filePath, modelType = 'lora') {
// Open file picker
const input = document.createElement('input');
input.type = 'file';
input.accept ='image/*,video/mp4';
input.onchange = async function() {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
await uploadPreview(filePath, file, modelType);
};
input.click();
}
// Delete a model (generic)
export async function deleteModel(filePath, modelType = 'lora') {
try {
const endpoint = modelType === 'checkpoint'
? '/api/checkpoints/delete'
: '/api/delete_model';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
if (!response.ok) {
throw new Error(`Failed to delete ${modelType}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
// Remove the card from UI
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
card.remove();
}
showToast(`${modelType} deleted successfully`, 'success');
return true;
} else {
throw new Error(data.error || `Failed to delete ${modelType}`);
}
} catch (error) {
console.error(`Error deleting ${modelType}:`, error);
showToast(`Failed to delete ${modelType}: ${error.message}`, 'error');
return false;
}
}
// Reset and reload models
export async function resetAndReload(options = {}) {
const {
updateFolders = false,
modelType = 'lora',
loadMoreFunction
} = options;
const pageState = getCurrentPageState();
// Reset pagination and load more models
if (typeof loadMoreFunction === 'function') {
await loadMoreFunction(true, updateFolders);
}
}
// Generic function to refresh models
export async function refreshModels(options = {}) {
const {
modelType = 'lora',
scanEndpoint = '/api/loras/scan',
resetAndReloadFunction
} = options;
try {
state.loadingManager.showSimpleLoading(`Refreshing ${modelType}s...`);
const response = await fetch(scanEndpoint);
if (!response.ok) {
throw new Error(`Failed to refresh ${modelType}s: ${response.status} ${response.statusText}`);
}
if (typeof resetAndReloadFunction === 'function') {
await resetAndReloadFunction();
}
showToast(`Refresh complete`, 'success');
} catch (error) {
console.error(`Refresh failed:`, error);
showToast(`Failed to refresh ${modelType}s`, 'error');
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
}
// Generic fetch from Civitai
export async function fetchCivitaiMetadata(options = {}) {
const {
modelType = 'lora',
fetchEndpoint = '/api/fetch-all-civitai',
resetAndReloadFunction
} = options;
let ws = null;
await state.loadingManager.showWithProgress(async (loading) => {
try {
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch(data.status) {
case 'started':
loading.setStatus('Starting metadata fetch...');
break;
case 'processing':
const percent = ((data.processed / data.total) * 100).toFixed(1);
loading.setProgress(percent);
loading.setStatus(
`Processing (${data.processed}/${data.total}) ${data.current_name}`
);
break;
case 'completed':
loading.setProgress(100);
loading.setStatus(
`Completed: Updated ${data.success} of ${data.processed} ${modelType}s`
);
resolve();
break;
case 'error':
reject(new Error(data.error));
break;
}
};
ws.onerror = (error) => {
reject(new Error('WebSocket error: ' + error.message));
};
});
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
const requestBody = modelType === 'checkpoint'
? JSON.stringify({ model_type: 'checkpoint' })
: JSON.stringify({});
const response = await fetch(fetchEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: requestBody
});
if (!response.ok) {
throw new Error('Failed to fetch metadata');
}
await operationComplete;
if (typeof resetAndReloadFunction === 'function') {
await resetAndReloadFunction();
}
} catch (error) {
console.error('Error fetching metadata:', error);
showToast('Failed to fetch metadata: ' + error.message, 'error');
} finally {
if (ws) {
ws.close();
}
}
}, {
initialMessage: 'Connecting...',
completionMessage: 'Metadata update complete'
});
}
// Generic function to refresh single model metadata
export async function refreshSingleModelMetadata(filePath, modelType = 'lora') {
try {
state.loadingManager.showSimpleLoading('Refreshing metadata...');
const endpoint = modelType === 'checkpoint'
? '/api/checkpoints/fetch-civitai'
: '/api/fetch-civitai';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ file_path: filePath })
});
if (!response.ok) {
throw new Error('Failed to refresh metadata');
}
const data = await response.json();
if (data.success) {
showToast('Metadata refreshed successfully', 'success');
return true;
} else {
throw new Error(data.error || 'Failed to refresh metadata');
}
} catch (error) {
console.error('Error refreshing metadata:', error);
showToast(error.message, 'error');
return false;
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
}
// Generic function to exclude a model
export async function excludeModel(filePath, modelType = 'lora') {
try {
const endpoint = modelType === 'checkpoint'
? '/api/checkpoints/exclude'
: '/api/loras/exclude';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
if (!response.ok) {
throw new Error(`Failed to exclude ${modelType}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
// Remove the card from UI
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
card.remove();
}
showToast(`${modelType} excluded successfully`, 'success');
return true;
} else {
throw new Error(data.error || `Failed to exclude ${modelType}`);
}
} catch (error) {
console.error(`Error excluding ${modelType}:`, error);
showToast(`Failed to exclude ${modelType}: ${error.message}`, 'error');
return false;
}
}
// Private methods
// Upload a preview image
async function uploadPreview(filePath, file, modelType = 'lora') {
const loadingOverlay = document.getElementById('loading-overlay');
const loadingStatus = document.querySelector('.loading-status');
try {
if (loadingOverlay) loadingOverlay.style.display = 'flex';
if (loadingStatus) loadingStatus.textContent = 'Uploading preview...';
const formData = new FormData();
// Use appropriate parameter names and endpoint based on model type
// Prepare common form data
formData.append('preview_file', file);
formData.append('model_path', filePath);
// Set endpoint based on model type
const endpoint = modelType === 'checkpoint'
? '/api/checkpoints/replace-preview'
: '/api/replace_preview';
const response = await fetch(endpoint, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
// Update the card preview in UI
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
const previewContainer = card.querySelector('.card-preview');
const oldPreview = previewContainer.querySelector('img, video');
// Get the current page's previewVersions Map based on model type
const pageType = modelType === 'checkpoint' ? 'checkpoints' : 'loras';
const previewVersions = state.pages[pageType].previewVersions;
// Update the version timestamp
const timestamp = Date.now();
if (previewVersions) {
previewVersions.set(filePath, timestamp);
// Save the updated Map to localStorage
const storageKey = modelType === 'checkpoint' ? 'checkpoint_preview_versions' : 'lora_preview_versions';
saveMapToStorage(storageKey, previewVersions);
}
const previewUrl = data.preview_url ?
`${data.preview_url}?t=${timestamp}` :
`/api/model/preview_image?path=${encodeURIComponent(filePath)}&t=${timestamp}`;
// Create appropriate element based on file type
if (file.type.startsWith('video/')) {
const video = document.createElement('video');
video.controls = true;
video.autoplay = true;
video.muted = true;
video.loop = true;
video.src = previewUrl;
oldPreview.replaceWith(video);
} else {
const img = document.createElement('img');
img.src = previewUrl;
oldPreview.replaceWith(img);
}
showToast('Preview updated successfully', 'success');
}
} catch (error) {
console.error('Error uploading preview:', error);
showToast('Failed to upload preview image', 'error');
} finally {
if (loadingOverlay) loadingOverlay.style.display = 'none';
}
}
// Private function to perform the delete operation
async function performDelete(filePath, modelType = 'lora') {
try {
showToast(`Deleting ${modelType}...`, 'info');
const response = await fetch('/api/model/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_path: filePath,
model_type: modelType
})
});
if (!response.ok) {
throw new Error(`Failed to delete ${modelType}: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
// Remove the card from UI
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
card.remove();
}
showToast(`${modelType} deleted successfully`, 'success');
} else {
throw new Error(data.error || `Failed to delete ${modelType}`);
}
} catch (error) {
console.error(`Error deleting ${modelType}:`, error);
showToast(`Failed to delete ${modelType}: ${error.message}`, 'error');
}
}