Preview URLs now carry a cache-busting `&v=<mtime_ns>` suffix, so the media type could no longer be read from the end of the string. Model cards, duplicates, recipe cards and modals, the showcase thumbnail and the ComfyUI preview tooltip all fell into the image branch and rendered `<img>` for video previews, which browsers cannot decode. Move the query-aware video detection that already lived in the versions tab into a shared mediaTypes helper and use it everywhere a server-provided preview URL is inspected. The web UI and the ComfyUI widget bundle each keep a copy because they are served from different mounts. Video previews that turn out to be webm now also get the right `<source type>`. Add regression coverage for versioned mp4/webm/image URLs. Fixes #1168
1134 lines
45 KiB
JavaScript
1134 lines
45 KiB
JavaScript
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||
import { getModelSourceInfo, getModelSourceGroupKey, getModelSourceViewTitle, openModelSource } from '../../utils/modelSourceHelpers.js';
|
||
import { state, getCurrentPageState } from '../../state/index.js';
|
||
import { showModelModal } from './ModelModal.js';
|
||
import { hasCivitaiSource } from './utils.js';
|
||
import { isVideoPreviewUrl } from '../../utils/mediaTypes.js';
|
||
import { bulkManager } from '../../managers/BulkManager.js';
|
||
import { modalManager } from '../../managers/ModalManager.js';
|
||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
|
||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||
import { copyWorkflowToClipboard, sendWorkflowToComfy } from '../../utils/workflowActions.js';
|
||
import { translate } from '../../utils/i18nHelpers.js';
|
||
import { eventManager } from '../../utils/EventManager.js';
|
||
|
||
// Helper function to get display name based on settings
|
||
function getDisplayName(model) {
|
||
const displayNameSetting = state.global.settings.model_name_display || 'model_name';
|
||
|
||
if (displayNameSetting === 'file_name') {
|
||
return model.file_name || model.model_name || 'Unknown Model';
|
||
}
|
||
|
||
return model.model_name || model.file_name || 'Unknown Model';
|
||
}
|
||
|
||
// Add global event delegation handlers using event manager
|
||
export function setupModelCardEventDelegation(modelType) {
|
||
// Remove any existing handler first
|
||
eventManager.removeHandler('click', 'modelCard-delegation');
|
||
|
||
// Register model card event delegation with event manager
|
||
eventManager.addHandler('click', 'modelCard-delegation', (event) => {
|
||
return handleModelCardEvent_internal(event, modelType);
|
||
}, {
|
||
priority: 60, // Medium priority for model card interactions
|
||
targetSelector: '#modelGrid',
|
||
skipWhenModalOpen: false // Allow model card interactions even when modals are open (for some actions)
|
||
});
|
||
}
|
||
|
||
// Event delegation handler for all model card events
|
||
function handleModelCardEvent_internal(event, modelType) {
|
||
// Find the closest card element
|
||
const card = event.target.closest('.model-card');
|
||
if (!card) return false; // Continue with other handlers
|
||
|
||
// Handle specific elements within the card
|
||
if (event.target.closest('.toggle-blur-btn')) {
|
||
event.stopPropagation();
|
||
toggleBlurContent(card);
|
||
return true; // Stop propagation
|
||
}
|
||
|
||
if (event.target.closest('.show-content-btn')) {
|
||
event.stopPropagation();
|
||
showBlurredContent(card);
|
||
return true; // Stop propagation
|
||
}
|
||
|
||
if (event.target.closest('.fa-star')) {
|
||
event.stopPropagation();
|
||
toggleFavorite(card);
|
||
return true; // Stop propagation
|
||
}
|
||
|
||
if (event.target.closest('.fa-globe')) {
|
||
event.stopPropagation();
|
||
// CivitAI wins when the model actually has CivitAI data; otherwise fall
|
||
// back to the linked external source. Relying on `from_civitai` here
|
||
// made the two sources mutually exclusive whenever one of them was
|
||
// (re)linked (#1094).
|
||
if (card.dataset.has_civitai === 'true') {
|
||
openCivitai(card.dataset.filepath);
|
||
} else if (card.dataset.source_platform === 'huggingface' && card.dataset.hf_url) {
|
||
openHuggingFace(card.dataset.hf_url);
|
||
} else if (card.dataset.source_url) {
|
||
openModelSource(card.dataset.source_url);
|
||
}
|
||
return true; // Stop propagation
|
||
}
|
||
|
||
if (event.target.closest('.fa-paper-plane')) {
|
||
event.stopPropagation();
|
||
handleSendToWorkflow(card, event.shiftKey, modelType);
|
||
return true; // Stop propagation
|
||
}
|
||
|
||
if (event.target.closest('.fa-copy')) {
|
||
event.stopPropagation();
|
||
handleCopyAction(card, modelType);
|
||
return true; // Stop propagation
|
||
}
|
||
|
||
if (event.target.closest('.fa-trash')) {
|
||
event.stopPropagation();
|
||
showDeleteModal(card.dataset.filepath);
|
||
return true; // Stop propagation
|
||
}
|
||
|
||
if (event.target.closest('.fa-image')) {
|
||
event.stopPropagation();
|
||
getModelApiClient().replaceModelPreview(card.dataset.filepath);
|
||
return true; // Stop propagation
|
||
}
|
||
|
||
if (event.target.closest('.fa-folder-open')) {
|
||
event.stopPropagation();
|
||
handleExampleImagesAccess(card, modelType);
|
||
return true; // Stop propagation
|
||
}
|
||
|
||
if (event.target.closest('.version-count-link')) {
|
||
event.stopPropagation();
|
||
handleViewLocalVersionsFromCard(card, modelType);
|
||
return true;
|
||
}
|
||
|
||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||
if (state.bulkMode && event.shiftKey) {
|
||
event.preventDefault(); // keep shift+click from extending a text selection
|
||
}
|
||
handleCardClick(card, modelType, event.shiftKey);
|
||
return false; // Continue with other handlers (e.g., bulk selection)
|
||
}
|
||
|
||
// Helper functions for event handling
|
||
function toggleBlurContent(card) {
|
||
const preview = card.querySelector('.card-preview');
|
||
const isBlurred = preview.classList.toggle('blurred');
|
||
const icon = card.querySelector('.toggle-blur-btn i');
|
||
|
||
// Update the icon based on blur state
|
||
if (isBlurred) {
|
||
icon.className = 'fas fa-eye';
|
||
} else {
|
||
icon.className = 'fas fa-eye-slash';
|
||
}
|
||
|
||
// Toggle the overlay visibility
|
||
const overlay = card.querySelector('.nsfw-overlay');
|
||
if (overlay) {
|
||
overlay.style.display = isBlurred ? 'flex' : 'none';
|
||
}
|
||
}
|
||
|
||
function showBlurredContent(card) {
|
||
const preview = card.querySelector('.card-preview');
|
||
preview.classList.remove('blurred');
|
||
|
||
// Update the toggle button icon
|
||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
||
if (toggleBtn) {
|
||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||
}
|
||
|
||
// Hide the overlay
|
||
const overlay = card.querySelector('.nsfw-overlay');
|
||
if (overlay) {
|
||
overlay.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function toggleFavorite(card) {
|
||
const starIcon = card.querySelector('.fa-star');
|
||
const isFavorite = starIcon.classList.contains('fas');
|
||
const newFavoriteState = !isFavorite;
|
||
|
||
try {
|
||
await getModelApiClient().saveModelMetadata(card.dataset.filepath, {
|
||
favorite: newFavoriteState
|
||
});
|
||
|
||
if (newFavoriteState) {
|
||
showToast('modelCard.favorites.added', {}, 'success');
|
||
} else {
|
||
showToast('modelCard.favorites.removed', {}, 'success');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update favorite status:', error);
|
||
showToast('modelCard.favorites.updateFailed', {}, 'error');
|
||
}
|
||
}
|
||
|
||
function handleSendToWorkflow(card, replaceMode, modelType) {
|
||
if (modelType === MODEL_TYPES.LORA) {
|
||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||
const folder = card.dataset.folder || '';
|
||
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
|
||
const loraSyntax = buildLoraSyntax(loraName, usageTips);
|
||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||
const modelPath = card.dataset.filepath;
|
||
if (!modelPath) {
|
||
const message = translate('modelCard.sendToWorkflow.missingPath', {}, 'Unable to determine model path for this card');
|
||
showToast(message, {}, 'error');
|
||
return;
|
||
}
|
||
|
||
const subtype = (card.dataset.sub_type || 'checkpoint').toLowerCase();
|
||
const isDiffusionModel = subtype === 'diffusion_model';
|
||
const widgetName = isDiffusionModel ? 'unet_name' : 'ckpt_name';
|
||
const actionTypeText = translate(
|
||
isDiffusionModel ? 'uiHelpers.nodeSelector.diffusionModel' : 'uiHelpers.nodeSelector.checkpoint',
|
||
{},
|
||
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
||
);
|
||
const successMessage = translate(
|
||
'uiHelpers.workflow.modelUpdated',
|
||
{},
|
||
'Model updated in workflow'
|
||
);
|
||
const failureMessage = translate(
|
||
'uiHelpers.workflow.modelFailed',
|
||
{},
|
||
'Failed to update model node'
|
||
);
|
||
const missingNodesMessage = translate(
|
||
'uiHelpers.workflow.noMatchingNodes',
|
||
{},
|
||
'No compatible nodes available in the current workflow'
|
||
);
|
||
const missingTargetMessage = translate(
|
||
'uiHelpers.workflow.noTargetNodeSelected',
|
||
{},
|
||
'No target node selected'
|
||
);
|
||
|
||
sendModelPathToWorkflow(modelPath, {
|
||
widgetName,
|
||
collectionType: MODEL_TYPES.CHECKPOINT,
|
||
actionTypeText,
|
||
successMessage,
|
||
failureMessage,
|
||
missingNodesMessage,
|
||
missingTargetMessage,
|
||
});
|
||
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||
const folder = card.dataset.folder || '';
|
||
const name = card.dataset.file_name || '';
|
||
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
|
||
sendEmbeddingToWorkflow(embeddingCode);
|
||
} else if (modelType === MODEL_TYPES.WORKFLOW) {
|
||
// Workflow send loads the whole graph document; no append/replace modes.
|
||
sendWorkflowToComfy(card.dataset.filepath);
|
||
} else {
|
||
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
|
||
}
|
||
}
|
||
|
||
function handleCopyAction(card, modelType) {
|
||
if (modelType === MODEL_TYPES.LORA) {
|
||
copyLoraSyntax(card);
|
||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||
// Checkpoint copy functionality - copy checkpoint name
|
||
const checkpointName = card.dataset.file_name;
|
||
const message = translate('modelCard.actions.checkpointNameCopied', {}, 'Checkpoint name copied');
|
||
copyToClipboard(checkpointName, message);
|
||
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||
const folder = card.dataset.folder || '';
|
||
const name = card.dataset.file_name || '';
|
||
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
|
||
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
|
||
copyToClipboard(embeddingCode, message);
|
||
} else if (modelType === MODEL_TYPES.WORKFLOW) {
|
||
copyWorkflowToClipboard(card.dataset.filepath);
|
||
} else {
|
||
// Other model types (VAE, upscalers, ...) - copy the file name
|
||
const fileName = card.dataset.file_name;
|
||
const message = translate('modelCard.actions.modelNameCopied', {}, 'Model name copied');
|
||
copyToClipboard(fileName, message);
|
||
}
|
||
}
|
||
|
||
function handleReplacePreview(filePath, modelType) {
|
||
apiClient.replaceModelPreview(filePath);
|
||
}
|
||
|
||
async function handleExampleImagesAccess(card, modelType) {
|
||
const modelHash = card.dataset.sha256;
|
||
|
||
try {
|
||
const response = await fetch(`/api/lm/has-example-images?model_hash=${modelHash}`);
|
||
const data = await response.json();
|
||
|
||
if (data.has_images) {
|
||
openExampleImagesFolder(modelHash);
|
||
} else {
|
||
showExampleAccessModal(card, modelType);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error checking for example images:', error);
|
||
showToast('modelCard.exampleImages.checkError', {}, 'error');
|
||
}
|
||
}
|
||
|
||
function handleViewLocalVersionsFromCard(card, modelType) {
|
||
const modelId = card.dataset.modelId;
|
||
const modelName = card.dataset.name;
|
||
if (!modelId) return;
|
||
// Respect version_grouping: only filter by base model when the strategy says so
|
||
const strategy = state.global?.settings?.version_grouping;
|
||
const shouldFilterByBase = strategy === 'same_base';
|
||
const baseModel = shouldFilterByBase && card.dataset.base_model !== 'Unknown'
|
||
? card.dataset.base_model
|
||
: undefined;
|
||
// Use the no-reload VLM flow via PageControls
|
||
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
|
||
window.pageControls.triggerVlmView(modelId, modelName, baseModel, modelType);
|
||
}
|
||
}
|
||
|
||
function handleCardClick(card, modelType, extendSelection = false) {
|
||
const pageState = getCurrentPageState();
|
||
|
||
if (state.bulkMode) {
|
||
// Toggle selection using the bulk manager
|
||
bulkManager.toggleCardSelection(card, extendSelection);
|
||
} else if (pageState && pageState.duplicatesMode) {
|
||
// In duplicates mode, don't open modal when clicking cards
|
||
return;
|
||
} else {
|
||
// Normal behavior - show modal
|
||
showModelModalFromCard(card, modelType);
|
||
}
|
||
}
|
||
|
||
// Preview URL is not in the dataset; read it from the card's rendered media
|
||
function getCardPreviewUrl(card) {
|
||
const cardMedia = card.querySelector('.card-preview img, .card-preview video');
|
||
if (!cardMedia) return '';
|
||
return cardMedia.tagName === 'VIDEO'
|
||
? (cardMedia.dataset.src || '')
|
||
: (cardMedia.src || '');
|
||
}
|
||
|
||
async function showModelModalFromCard(card, modelType) {
|
||
// Create model metadata object
|
||
const modelMeta = {
|
||
sha256: card.dataset.sha256,
|
||
autov3: card.dataset.autov3 || '',
|
||
preview_url: getCardPreviewUrl(card),
|
||
file_path: card.dataset.filepath,
|
||
model_name: card.dataset.name,
|
||
file_name: card.dataset.file_name,
|
||
folder: card.dataset.folder,
|
||
modified: card.dataset.modified,
|
||
file_size: parseInt(card.dataset.file_size || '0'),
|
||
from_civitai: card.dataset.from_civitai === 'true',
|
||
source_platform: card.dataset.source_platform || '',
|
||
source_url: card.dataset.source_url || '',
|
||
hf_url: card.dataset.hf_url || '',
|
||
base_model: card.dataset.base_model,
|
||
notes: card.dataset.notes || '',
|
||
favorite: card.dataset.favorite === 'true',
|
||
// Parse civitai metadata from the card's dataset
|
||
civitai: JSON.parse(card.dataset.meta || '{}'),
|
||
tags: JSON.parse(card.dataset.tags || '[]'),
|
||
update_available: card.dataset.update_available === 'true',
|
||
modelDescription: card.dataset.modelDescription || '',
|
||
// LoRA specific fields
|
||
...(modelType === MODEL_TYPES.LORA && {
|
||
usage_tips: card.dataset.usage_tips,
|
||
})
|
||
};
|
||
|
||
await showModelModal(modelMeta, modelType);
|
||
}
|
||
|
||
// Function to show the example access modal (generalized for lora and checkpoint)
|
||
function showExampleAccessModal(card, modelType) {
|
||
const modal = document.getElementById('exampleAccessModal');
|
||
if (!modal) return;
|
||
|
||
// Get download button and determine if download should be enabled
|
||
const downloadBtn = modal.querySelector('#downloadExamplesBtn');
|
||
let hasRemoteExamples = false;
|
||
|
||
try {
|
||
const metaData = JSON.parse(card.dataset.meta || '{}');
|
||
hasRemoteExamples = metaData.images &&
|
||
Array.isArray(metaData.images) &&
|
||
metaData.images.length > 0 &&
|
||
metaData.images[0].url;
|
||
} catch (e) {
|
||
console.error('Error parsing meta data:', e);
|
||
}
|
||
|
||
// Enable or disable download button
|
||
if (downloadBtn) {
|
||
if (hasRemoteExamples) {
|
||
downloadBtn.classList.remove('disabled');
|
||
downloadBtn.removeAttribute('title');
|
||
downloadBtn.onclick = async () => {
|
||
// Get the model hash
|
||
const modelHash = card.dataset.sha256;
|
||
if (!modelHash) {
|
||
showToast('modelCard.exampleImages.missingHash', {}, 'error');
|
||
return;
|
||
}
|
||
|
||
// Close the modal
|
||
modalManager.closeModal('exampleAccessModal');
|
||
|
||
try {
|
||
// Use the appropriate model API client to download examples
|
||
const apiClient = getModelApiClient(modelType);
|
||
await apiClient.downloadExampleImages([modelHash]);
|
||
|
||
// Open the example images folder if successful
|
||
openExampleImagesFolder(modelHash);
|
||
} catch (error) {
|
||
console.error('Error downloading example images:', error);
|
||
// Error already shown by the API client
|
||
}
|
||
};
|
||
} else {
|
||
downloadBtn.classList.add('disabled');
|
||
const noRemoteImagesTitle = translate('modelCard.exampleImages.noRemoteImagesAvailable', {}, 'No remote example images available for this model on Civitai');
|
||
downloadBtn.setAttribute('title', noRemoteImagesTitle);
|
||
downloadBtn.onclick = null;
|
||
}
|
||
}
|
||
|
||
// Set up import button
|
||
const importBtn = modal.querySelector('#importExamplesBtn');
|
||
if (importBtn) {
|
||
importBtn.onclick = async () => {
|
||
modalManager.closeModal('exampleAccessModal');
|
||
|
||
// Get the model data from card dataset (works for both lora and checkpoint)
|
||
const modelMeta = {
|
||
sha256: card.dataset.sha256,
|
||
autov3: card.dataset.autov3 || '',
|
||
preview_url: getCardPreviewUrl(card),
|
||
file_path: card.dataset.filepath,
|
||
model_name: card.dataset.name,
|
||
file_name: card.dataset.file_name,
|
||
folder: card.dataset.folder,
|
||
modified: card.dataset.modified,
|
||
file_size: card.dataset.file_size,
|
||
from_civitai: card.dataset.from_civitai === 'true',
|
||
source_platform: card.dataset.source_platform || '',
|
||
source_url: card.dataset.source_url || '',
|
||
hf_url: card.dataset.hf_url || '',
|
||
base_model: card.dataset.base_model,
|
||
notes: card.dataset.notes,
|
||
favorite: card.dataset.favorite === 'true',
|
||
civitai: JSON.parse(card.dataset.meta || '{}'),
|
||
tags: JSON.parse(card.dataset.tags || '[]'),
|
||
modelDescription: card.dataset.modelDescription || ''
|
||
};
|
||
|
||
// Add usage_tips if present (for lora)
|
||
if (card.dataset.usage_tips) {
|
||
modelMeta.usage_tips = card.dataset.usage_tips;
|
||
}
|
||
|
||
// Show the model modal
|
||
await showModelModal(modelMeta, modelType);
|
||
|
||
// Reveal the import entry once the modal content has rendered
|
||
setTimeout(() => {
|
||
// Gallery mode: the import button is always visible — expand the zone
|
||
const importBtn = document.querySelector('#modelModal .gallery-import-btn');
|
||
if (importBtn) {
|
||
importBtn.click();
|
||
return;
|
||
}
|
||
// Empty state: the import area is the whole tab content — scroll to it
|
||
const importArea = document.querySelector('#modelModal .example-import-area');
|
||
if (importArea) {
|
||
importArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
}, 500);
|
||
};
|
||
}
|
||
|
||
// Show the modal
|
||
modalManager.showModal('exampleAccessModal');
|
||
}
|
||
|
||
export function createModelCard(model, modelType) {
|
||
const card = document.createElement('div');
|
||
card.className = 'model-card'; // Reuse the same class for styling
|
||
// Always draggable (move-to-folder in the sidebar). Accidental micro-drags
|
||
// from click jitter are rendered harmless by the preview-drop handlers
|
||
// below, which ignore internal card drags via MODEL_CARD_DRAG_MIME_TYPE.
|
||
card.draggable = true;
|
||
card.dataset.sha256 = model.sha256;
|
||
card.dataset.autov3 = model.autov3 || '';
|
||
card.dataset.filepath = model.file_path;
|
||
card.dataset.name = model.model_name;
|
||
card.dataset.file_name = model.file_name;
|
||
card.dataset.folder = model.folder || '';
|
||
card.dataset.modified = model.modified;
|
||
card.dataset.file_size = model.file_size;
|
||
card.dataset.from_civitai = model.from_civitai;
|
||
// Independent of `from_civitai`: a model can have both CivitAI data and an
|
||
// HF link, and the card globe must keep pointing at CivitAI when it does.
|
||
card.dataset.has_civitai = hasCivitaiSource(model.civitai) ? 'true' : 'false';
|
||
card.dataset.usage_count = String(model.usage_count);
|
||
card.dataset.notes = model.notes || '';
|
||
card.dataset.base_model = model.base_model || 'Unknown';
|
||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
||
const modelSourceInfo = getModelSourceInfo(model);
|
||
card.dataset.source_url = modelSourceInfo?.url || '';
|
||
card.dataset.source_platform = modelSourceInfo?.platform || '';
|
||
// Legacy alias: only Hugging Face models expose `hf_url`.
|
||
card.dataset.hf_url = modelSourceInfo?.platform === 'huggingface' ? modelSourceInfo.url : '';
|
||
const hasUpdateAvailable = Boolean(model.update_available);
|
||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||
// Store version_count for group-by-model display
|
||
if (model.version_count !== undefined) {
|
||
card.dataset.version_count = model.version_count;
|
||
}
|
||
|
||
// To only show usage_count when sorting by usage.
|
||
const pageState = getCurrentPageState();
|
||
const isUsageSort = pageState?.sortBy?.startsWith('usage');
|
||
const hasUsageCount = isUsageSort && typeof model.usage_count === 'number';
|
||
|
||
const civitaiData = model.civitai || {};
|
||
const modelId = civitaiData?.modelId ?? civitaiData?.model_id;
|
||
if (modelId !== undefined && modelId !== null && modelId !== '') {
|
||
card.dataset.modelId = modelId;
|
||
} else {
|
||
// For externally-sourced models, derive a group key from the source
|
||
// identity for version grouping (ms:<model_id>, ta:<id>).
|
||
const sourceGroupKey = getModelSourceGroupKey(model);
|
||
if (sourceGroupKey) {
|
||
card.dataset.modelId = sourceGroupKey;
|
||
}
|
||
}
|
||
|
||
// LoRA specific data
|
||
if (modelType === MODEL_TYPES.LORA) {
|
||
card.dataset.usage_tips = model.usage_tips;
|
||
}
|
||
|
||
// Set sub_type for all model types (lora/locon/dora, checkpoint/diffusion_model, embedding)
|
||
if (model.sub_type) {
|
||
card.dataset.sub_type = model.sub_type;
|
||
}
|
||
|
||
// Store metadata if available
|
||
if (model.civitai) {
|
||
card.dataset.meta = JSON.stringify(model.civitai || {});
|
||
}
|
||
|
||
// Store tags if available
|
||
if (model.tags && Array.isArray(model.tags)) {
|
||
card.dataset.tags = JSON.stringify(model.tags);
|
||
}
|
||
|
||
if (model.modelDescription) {
|
||
card.dataset.modelDescription = model.modelDescription;
|
||
}
|
||
|
||
// Store NSFW level if available
|
||
const nsfwLevel = model.preview_nsfw_level !== undefined ? model.preview_nsfw_level : 0;
|
||
card.dataset.nsfwLevel = nsfwLevel;
|
||
|
||
// Determine if the preview should be blurred based on NSFW level and user settings
|
||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||
if (shouldBlur) {
|
||
card.classList.add('nsfw-content');
|
||
}
|
||
|
||
if (model.skip_metadata_refresh) {
|
||
card.classList.add('skip-refresh');
|
||
}
|
||
if (model.exclude) {
|
||
card.classList.add('excluded-model');
|
||
}
|
||
|
||
// state.selectedModels resolves to the active page's set (selectedLoras
|
||
// included) - do not narrow this back to selectedLoras/LORA-only.
|
||
if (state.bulkMode && state.selectedModels.has(model.file_path)) {
|
||
card.classList.add('selected');
|
||
}
|
||
|
||
// Get the appropriate preview versions map
|
||
const previewVersionsKey = modelType;
|
||
const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
|
||
const version = previewVersions.get(model.file_path);
|
||
const previewUrl = model.preview_url || '/loras_static/images/no-preview.png';
|
||
const versionedPreviewUrl = version ? `${previewUrl}${previewUrl.includes('?') ? '&' : '?'}t=${version}` : previewUrl;
|
||
|
||
// Determine NSFW warning text based on level with i18n support
|
||
let nsfwText = translate('modelCard.nsfw.matureContent', {}, 'Mature Content');
|
||
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||
nsfwText = translate('modelCard.nsfw.xxxRated', {}, 'XXX-rated Content');
|
||
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||
nsfwText = translate('modelCard.nsfw.xRated', {}, 'X-rated Content');
|
||
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||
nsfwText = translate('modelCard.nsfw.rRated', {}, 'R-rated Content');
|
||
}
|
||
|
||
// Check if autoplayOnHover is enabled for video previews
|
||
const autoplayOnHover = state.global?.settings?.autoplay_on_hover || false;
|
||
// Preview URLs are versioned (`&v=<mtime>`), so the extension has to come
|
||
// from the decoded `path` parameter rather than the end of the URL.
|
||
const isVideo = isVideoPreviewUrl(previewUrl);
|
||
const videoAttrs = [
|
||
'controls',
|
||
'muted',
|
||
'loop',
|
||
'playsinline',
|
||
'preload="none"',
|
||
`data-src="${versionedPreviewUrl}"`
|
||
];
|
||
|
||
if (!autoplayOnHover) {
|
||
videoAttrs.push('data-autoplay="true"');
|
||
}
|
||
|
||
// Get favorite status from model data
|
||
const isFavorite = model.favorite === true;
|
||
if (hasUpdateAvailable) {
|
||
card.classList.add('has-update');
|
||
}
|
||
|
||
// Generate action icons based on model type with i18n support
|
||
const favoriteTitle = isFavorite ?
|
||
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
|
||
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
||
const hasCivitai = hasCivitaiSource(model.civitai);
|
||
const globeTitle = hasCivitai ?
|
||
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||
modelSourceInfo ?
|
||
getModelSourceViewTitle(modelSourceInfo) :
|
||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||
const globeEnabled = hasCivitai || !!modelSourceInfo;
|
||
let sendTitle;
|
||
let copyTitle;
|
||
if (modelType === MODEL_TYPES.LORA) {
|
||
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
|
||
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
|
||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||
// Checkpoint send sets the widget value directly; no append/replace modes.
|
||
sendTitle = translate('modelCard.actions.sendCheckpointToWorkflow', {}, 'Send to ComfyUI');
|
||
copyTitle = translate('modelCard.actions.copyCheckpointName', {}, 'Copy checkpoint name');
|
||
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||
// Embedding send always appends to the prompt; no replace mode.
|
||
sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI');
|
||
copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name');
|
||
} else if (modelType === MODEL_TYPES.WORKFLOW) {
|
||
sendTitle = translate('modelCard.actions.openInComfyUI', {}, 'Open in ComfyUI');
|
||
copyTitle = translate('modelCard.actions.copyWorkflowToClipboard', {}, 'Copy workflow to clipboard');
|
||
} else {
|
||
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI');
|
||
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy value');
|
||
}
|
||
|
||
const updateBadgeLabel = translate('modelCard.badges.update', {}, 'Update');
|
||
const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available');
|
||
// Standalone has no running ComfyUI graph to push a workflow into.
|
||
const hideSendIcon = modelType === MODEL_TYPES.WORKFLOW && state.global.settings.standalone_mode;
|
||
const sendIcon = hideSendIcon ? '' : `
|
||
<i class="fas fa-paper-plane"
|
||
title="${sendTitle}">
|
||
</i>`;
|
||
const actionIcons = `
|
||
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
|
||
title="${favoriteTitle}">
|
||
</i>
|
||
<i class="fas fa-globe"
|
||
title="${globeTitle}"
|
||
${!globeEnabled ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||
</i>${sendIcon}
|
||
<i class="fas fa-copy"
|
||
title="${copyTitle}">
|
||
</i>`;
|
||
|
||
// Generate UI text with i18n support
|
||
const toggleBlurTitle = translate('modelCard.actions.toggleBlur', {}, 'Toggle blur');
|
||
const showButtonText = translate('modelCard.actions.show', {}, 'Show');
|
||
const footerActionSetting = state.global.settings.model_card_footer_action || 'example_images';
|
||
const footerActionTitle = footerActionSetting === 'replace_preview'
|
||
? translate('modelCard.actions.replacePreview', {}, 'Replace Preview')
|
||
: translate('modelCard.actions.openExampleImages', {}, 'Open Example Images Folder');
|
||
const footerActionIcon = footerActionSetting === 'replace_preview'
|
||
? 'fas fa-image'
|
||
: 'fas fa-folder-open';
|
||
|
||
const baseModelLabel = model.base_model || 'Unknown';
|
||
const baseModelAbbreviation = getBaseModelAbbreviation(baseModelLabel);
|
||
|
||
// Sub-type display (e.g., LoRA, LyCO, DoRA, CKPT, DM, EMB)
|
||
const subType = model.sub_type || '';
|
||
const subTypeAbbreviation = getSubTypeAbbreviation(subType);
|
||
const fullSubTypeName = MODEL_SUBTYPE_DISPLAY_NAMES[subType?.toLowerCase()] || subType || '';
|
||
|
||
card.innerHTML = `
|
||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||
${isVideo ?
|
||
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
||
`<img draggable="false" src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||
}
|
||
<div class="card-header">
|
||
${shouldBlur ?
|
||
`<button class="toggle-blur-btn" title="${toggleBlurTitle}">
|
||
<i class="fas fa-eye"></i>
|
||
</button>` : ''}
|
||
<div class="card-header-info">
|
||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}"
|
||
title="${fullSubTypeName ? fullSubTypeName + ' | ' : ''}${baseModelLabel}">
|
||
${subTypeAbbreviation ? `<span class="model-sub-type">${subTypeAbbreviation}</span>` : ''}
|
||
${subTypeAbbreviation ? `<span class="model-separator"></span>` : ''}
|
||
<span class="model-base-type">${baseModelAbbreviation}</span>
|
||
</span>
|
||
${hasUpdateAvailable ? `
|
||
<span class="model-update-badge" title="${updateBadgeTooltip}">
|
||
<i class="fas fa-arrow-up"></i>
|
||
</span>
|
||
` : ''}
|
||
${model.skip_metadata_refresh ? `
|
||
<span class="model-skip-refresh-badge" title="${translate('modelCard.badges.skipRefresh', {}, 'Metadata refresh skipped')}">
|
||
<i class="fas fa-ban"></i>
|
||
</span>
|
||
` : ''}
|
||
${model.exclude ? `
|
||
<span class="model-excluded-badge" title="${translate('globalContextMenu.manageExcludedModels.label', {}, 'Excluded Models')}">
|
||
<i class="fas fa-eye-slash"></i>
|
||
</span>
|
||
` : ''}
|
||
</div>
|
||
<div class="card-actions">
|
||
${actionIcons}
|
||
</div>
|
||
</div>
|
||
${shouldBlur ? `
|
||
<div class="nsfw-overlay">
|
||
<div class="nsfw-warning">
|
||
<p>${nsfwText}</p>
|
||
<button class="show-content-btn">${showButtonText}</button>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
<div class="card-footer">
|
||
<div class="model-info">
|
||
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '"')}">${getDisplayName(model)}</span>
|
||
<div class="version-row">
|
||
${(() => {
|
||
const autoTags = model.auto_tags || [];
|
||
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
|
||
const hasVersionName = model.civitai?.name;
|
||
const quantization = typeof model.quantization === 'string' && model.quantization
|
||
? model.quantization : null;
|
||
// When group_by_model is active and model has multiple versions,
|
||
// show clickable version count instead of version name (and hide badges)
|
||
const isGroupByModel = state.global.settings.group_by_model;
|
||
const versionCount = model.version_count;
|
||
const showVersionCount = isGroupByModel && versionCount > 1;
|
||
if (!hlTags.length && !hasVersionName && !showVersionCount && !quantization) return '';
|
||
const density = state.global.settings.display_density || 'default';
|
||
const shortLabels = density === 'medium' || density === 'compact';
|
||
// Don't show HIGH/LOW badges when showing version count (confusing in grouped mode)
|
||
const badges = !showVersionCount ? hlTags.map(t => {
|
||
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
|
||
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
|
||
const titleAttr = shortLabels ? ` title="${t}"` : '';
|
||
return `<span class="${cls}"${titleAttr}>${label}</span>`;
|
||
}).join('') : '';
|
||
// Suppress the quant badge in grouped mode too (a group mixes quants)
|
||
const quantBadge = !showVersionCount && quantization
|
||
? `<span class="quant-badge">${quantization}</span>` : '';
|
||
let versionHtml = '';
|
||
if (showVersionCount) {
|
||
const countLabel = translate('modelCard.footer.versionCount', { count: versionCount }, `${versionCount} versions`);
|
||
versionHtml = `<span class="version-count-link" title="${translate('modelCard.footer.viewAllVersions', {}, 'View all local versions')}">${countLabel}</span>`;
|
||
} else if (hasVersionName) {
|
||
versionHtml = `<span class="version-name civitai-version">${model.civitai.name}</span>`;
|
||
}
|
||
return `<span class="badge-version-unit">${badges}${versionHtml}${quantBadge}</span>`;
|
||
})()}
|
||
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="card-actions">
|
||
<i class="${footerActionIcon}"
|
||
title="${footerActionTitle}">
|
||
</i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Add video auto-play on hover functionality if needed
|
||
const videoElement = card.querySelector('video');
|
||
if (videoElement) {
|
||
configureModelCardVideo(videoElement, autoplayOnHover);
|
||
}
|
||
|
||
// Dropping an image/video onto the card replaces the model preview via the
|
||
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
|
||
// Internal card drags (move-to-folder) are tagged with a custom MIME type by
|
||
// SidebarManager and must be ignored here entirely: no highlight, no upload.
|
||
const isInternalCardDrag = (event) =>
|
||
Boolean(event.dataTransfer?.types?.includes(MODEL_CARD_DRAG_MIME_TYPE));
|
||
|
||
const preventDragDefaults = (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
};
|
||
|
||
['dragenter', 'dragover'].forEach((eventName) => {
|
||
card.addEventListener(eventName, (event) => {
|
||
if (isInternalCardDrag(event)) return;
|
||
preventDragDefaults(event);
|
||
card.classList.add('drag-over');
|
||
});
|
||
});
|
||
|
||
card.addEventListener('dragleave', (event) => {
|
||
if (isInternalCardDrag(event)) return;
|
||
preventDragDefaults(event);
|
||
card.classList.remove('drag-over');
|
||
});
|
||
|
||
card.addEventListener('drop', (event) => {
|
||
if (isInternalCardDrag(event)) return;
|
||
preventDragDefaults(event);
|
||
card.classList.remove('drag-over');
|
||
|
||
const files = event.dataTransfer?.files;
|
||
if (!files || files.length === 0) return;
|
||
|
||
const file = files[0];
|
||
// Keep in sync with the accept list of the preview file picker (image/* + video/mp4).
|
||
if (!file.type.startsWith('image/') && file.type !== 'video/mp4') {
|
||
showToast('toast.api.previewDropInvalid', { name: file.name || '' }, 'error');
|
||
return;
|
||
}
|
||
|
||
const filePath = card.dataset.filepath;
|
||
if (!filePath) return;
|
||
|
||
// uploadPreview handles loading state, card refresh and error toasts internally.
|
||
getModelApiClient().uploadPreview(filePath, file);
|
||
});
|
||
|
||
return card;
|
||
}
|
||
|
||
const VIDEO_LAZY_ROOT_MARGIN = '200px 0px';
|
||
const VIDEO_LOAD_INTERVAL_MS = 120;
|
||
const VIDEO_LOAD_MAX_CONCURRENCY = 2;
|
||
let videoLazyObserver = null;
|
||
|
||
const videoLoadQueue = [];
|
||
const queuedVideoElements = new Set();
|
||
let activeVideoLoads = 0;
|
||
let queueTimer = null;
|
||
|
||
const scheduleFrame = typeof requestAnimationFrame === 'function'
|
||
? requestAnimationFrame
|
||
: (callback) => setTimeout(callback, 16);
|
||
|
||
function scheduleVideoQueueProcessing(delay = 0) {
|
||
if (queueTimer !== null) {
|
||
return;
|
||
}
|
||
|
||
queueTimer = setTimeout(() => {
|
||
queueTimer = null;
|
||
processVideoLoadQueue();
|
||
}, delay);
|
||
}
|
||
|
||
function dequeueVideoElement(videoElement) {
|
||
if (!queuedVideoElements.has(videoElement)) {
|
||
return;
|
||
}
|
||
|
||
queuedVideoElements.delete(videoElement);
|
||
const index = videoLoadQueue.indexOf(videoElement);
|
||
if (index !== -1) {
|
||
videoLoadQueue.splice(index, 1);
|
||
}
|
||
}
|
||
|
||
function processVideoLoadQueue() {
|
||
if (videoLoadQueue.length === 0) {
|
||
return;
|
||
}
|
||
|
||
while (activeVideoLoads < VIDEO_LOAD_MAX_CONCURRENCY && videoLoadQueue.length > 0) {
|
||
const videoElement = videoLoadQueue.shift();
|
||
queuedVideoElements.delete(videoElement);
|
||
|
||
if (!videoElement || !videoElement.isConnected || videoElement.dataset.loaded === 'true') {
|
||
continue;
|
||
}
|
||
|
||
activeVideoLoads++;
|
||
videoElement.dataset.loading = 'true';
|
||
|
||
scheduleFrame(() => {
|
||
try {
|
||
loadVideoSource(videoElement);
|
||
} finally {
|
||
delete videoElement.dataset.loading;
|
||
activeVideoLoads--;
|
||
|
||
if (videoLoadQueue.length > 0) {
|
||
scheduleVideoQueueProcessing(VIDEO_LOAD_INTERVAL_MS);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
if (videoLoadQueue.length > 0 && queueTimer === null) {
|
||
scheduleVideoQueueProcessing(VIDEO_LOAD_INTERVAL_MS);
|
||
}
|
||
}
|
||
|
||
function enqueueVideoElement(videoElement) {
|
||
if (!videoElement || videoElement.dataset.loaded === 'true' || videoElement.dataset.loading === 'true') {
|
||
return;
|
||
}
|
||
|
||
if (!videoElement.isConnected) {
|
||
return;
|
||
}
|
||
|
||
if (queuedVideoElements.has(videoElement)) {
|
||
return;
|
||
}
|
||
|
||
queuedVideoElements.add(videoElement);
|
||
videoLoadQueue.push(videoElement);
|
||
scheduleVideoQueueProcessing();
|
||
}
|
||
|
||
function ensureVideoLazyObserver() {
|
||
if (videoLazyObserver) {
|
||
return videoLazyObserver;
|
||
}
|
||
|
||
videoLazyObserver = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
enqueueVideoElement(entry.target);
|
||
}
|
||
});
|
||
}, {
|
||
root: null,
|
||
rootMargin: VIDEO_LAZY_ROOT_MARGIN,
|
||
threshold: 0.01
|
||
});
|
||
|
||
return videoLazyObserver;
|
||
}
|
||
|
||
function cleanupHoverHandlers(videoElement) {
|
||
const handlers = videoElement._hoverHandlers;
|
||
if (!handlers) return;
|
||
|
||
const { cardPreview, mouseEnter, mouseLeave } = handlers;
|
||
if (cardPreview) {
|
||
cardPreview.removeEventListener('mouseenter', mouseEnter);
|
||
cardPreview.removeEventListener('mouseleave', mouseLeave);
|
||
}
|
||
|
||
delete videoElement._hoverHandlers;
|
||
}
|
||
|
||
function requestSafePlay(videoElement) {
|
||
const playPromise = videoElement.play();
|
||
if (playPromise && typeof playPromise.catch === 'function') {
|
||
playPromise.catch(() => { });
|
||
}
|
||
}
|
||
|
||
function loadVideoSource(videoElement) {
|
||
if (!videoElement) {
|
||
return false;
|
||
}
|
||
|
||
if (videoLazyObserver) {
|
||
try {
|
||
videoLazyObserver.unobserve(videoElement);
|
||
} catch (error) {
|
||
// Ignore observer errors (e.g., element already unobserved)
|
||
}
|
||
}
|
||
|
||
if (videoElement.dataset.loaded === 'true' || !videoElement.isConnected) {
|
||
return false;
|
||
}
|
||
|
||
const sourceElement = videoElement.querySelector('source');
|
||
const dataSrc = videoElement.dataset.src || sourceElement?.dataset?.src;
|
||
|
||
if (!dataSrc) {
|
||
return false;
|
||
}
|
||
|
||
// Ensure src attributes are reset before applying
|
||
videoElement.removeAttribute('src');
|
||
if (sourceElement) {
|
||
sourceElement.src = dataSrc;
|
||
} else {
|
||
videoElement.src = dataSrc;
|
||
}
|
||
|
||
videoElement.load();
|
||
videoElement.dataset.loaded = 'true';
|
||
|
||
if (videoElement.dataset.autoplay === 'true') {
|
||
videoElement.setAttribute('autoplay', '');
|
||
requestSafePlay(videoElement);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
export function configureModelCardVideo(videoElement, autoplayOnHover) {
|
||
if (!videoElement) return;
|
||
|
||
dequeueVideoElement(videoElement);
|
||
cleanupHoverHandlers(videoElement);
|
||
|
||
const sourceElement = videoElement.querySelector('source');
|
||
const existingSrc = videoElement.dataset.src || sourceElement?.dataset?.src || videoElement.currentSrc;
|
||
|
||
if (existingSrc && !videoElement.dataset.src) {
|
||
videoElement.dataset.src = existingSrc;
|
||
}
|
||
|
||
if (sourceElement && !sourceElement.dataset.src) {
|
||
sourceElement.dataset.src = videoElement.dataset.src || sourceElement.src;
|
||
}
|
||
|
||
videoElement.removeAttribute('autoplay');
|
||
videoElement.removeAttribute('src');
|
||
videoElement.setAttribute('preload', 'none');
|
||
videoElement.setAttribute('muted', '');
|
||
videoElement.setAttribute('loop', '');
|
||
videoElement.setAttribute('playsinline', '');
|
||
videoElement.setAttribute('controls', '');
|
||
videoElement.dataset.loaded = 'false';
|
||
delete videoElement.dataset.loading;
|
||
|
||
if (sourceElement) {
|
||
sourceElement.removeAttribute('src');
|
||
if (videoElement.dataset.src) {
|
||
sourceElement.dataset.src = videoElement.dataset.src;
|
||
}
|
||
}
|
||
|
||
if (!autoplayOnHover) {
|
||
videoElement.dataset.autoplay = 'true';
|
||
} else {
|
||
delete videoElement.dataset.autoplay;
|
||
}
|
||
|
||
const observer = ensureVideoLazyObserver();
|
||
observer.observe(videoElement);
|
||
|
||
// Pause the video until it is either hovered or autoplay kicks in
|
||
try {
|
||
videoElement.pause();
|
||
} catch (err) {
|
||
// Ignore pause errors (e.g., if not loaded yet)
|
||
}
|
||
|
||
if (autoplayOnHover) {
|
||
const cardPreview = videoElement.closest('.card-preview');
|
||
if (cardPreview) {
|
||
const mouseEnter = () => {
|
||
dequeueVideoElement(videoElement);
|
||
loadVideoSource(videoElement);
|
||
requestSafePlay(videoElement);
|
||
};
|
||
const mouseLeave = () => {
|
||
videoElement.pause();
|
||
videoElement.currentTime = 0;
|
||
};
|
||
|
||
cardPreview.addEventListener('mouseenter', mouseEnter);
|
||
cardPreview.addEventListener('mouseleave', mouseLeave);
|
||
|
||
videoElement._hoverHandlers = { cardPreview, mouseEnter, mouseLeave };
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add a method to update card appearance based on bulk mode (LoRA only)
|
||
export function updateCardsForBulkMode(isBulkMode) {
|
||
// Update the state
|
||
state.bulkMode = isBulkMode;
|
||
|
||
document.body.classList.toggle('bulk-mode', isBulkMode);
|
||
|
||
// Get all lora cards - this can now be from the DOM or through the virtual scroller
|
||
const loraCards = document.querySelectorAll('.model-card');
|
||
|
||
loraCards.forEach(card => {
|
||
// Get all action containers for this card
|
||
const actions = card.querySelectorAll('.card-actions');
|
||
|
||
// Handle display property based on mode
|
||
if (isBulkMode) {
|
||
// Hide actions when entering bulk mode
|
||
actions.forEach(actionGroup => {
|
||
actionGroup.style.display = 'none';
|
||
});
|
||
} else {
|
||
// Ensure actions are visible when exiting bulk mode
|
||
actions.forEach(actionGroup => {
|
||
// We need to reset to default display style which is flex
|
||
actionGroup.style.display = 'flex';
|
||
});
|
||
}
|
||
});
|
||
|
||
// If using virtual scroller, we need to rerender after toggling bulk mode
|
||
if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
|
||
state.virtualScroller.scheduleRender();
|
||
}
|
||
|
||
// Apply selection state to cards if entering bulk mode
|
||
if (isBulkMode) {
|
||
bulkManager.applySelectionState();
|
||
}
|
||
}
|