Files
Will Miao 14e90006de fix(previews): detect video previews from the versioned preview URL
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
2026-10-31 16:46:04 +08:00

593 lines
25 KiB
JavaScript

// Recipe Card Component
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateRecipeMetadata } from '../api/recipeApi.js';
import { configureModelCardVideo } from './shared/ModelCard.js';
import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { isVideoPreviewUrl } from '../utils/mediaTypes.js';
class RecipeCard {
constructor(recipe, clickHandler) {
this.recipe = recipe;
this.clickHandler = clickHandler;
this.element = this.createCardElement();
// Store reference to this instance on the DOM element for updates
this.element._recipeCardInstance = this;
}
createCardElement() {
const card = document.createElement('div');
card.className = 'model-card';
card.draggable = true;
card.dataset.filepath = this.recipe.file_path;
card.dataset.title = this.recipe.title;
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
card.dataset.created = this.recipe.created_date;
card.dataset.id = this.recipe.id || '';
card.dataset.folder = this.recipe.folder || '';
card.dataset.favorite = this.recipe.favorite ? 'true' : 'false';
// Get base model with fallback
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
const baseModelAbbreviation = getBaseModelAbbreviation(baseModelLabel);
const baseModelDisplay = baseModelLabel === 'Unknown' ? 'Unknown' : baseModelAbbreviation;
// Ensure loras array exists
const loras = this.recipe.loras || [];
const lorasCount = loras.length;
// Count LoRAs by availability: in library, missing (still downloadable
// from the source), or unobtainable (deleted from the source, or an
// unresolvable hash) which is silently skipped when the recipe is used.
const availableLorasCount = loras.filter(lora => lora.inLibrary).length;
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted && !lora.hashInvalid).length;
const unavailableLorasCount = lorasCount - availableLorasCount - missingLorasCount;
// Compact status pill: state icon + available/total fraction.
// Icon switches by state so status never relies on color alone.
// - missing (red): something can still be downloaded, most actionable
// - partial (amber): usable but degraded, unobtainable LoRAs are skipped
// - unavailable (gray, ban): no usable LoRA at all
let loraCountStateClass = '';
let loraCountIcon = 'fa-layer-group';
if (lorasCount > 0) {
if (availableLorasCount === lorasCount) {
loraCountStateClass = 'ready';
loraCountIcon = 'fa-check';
} else if (missingLorasCount > 0) {
loraCountStateClass = 'missing';
loraCountIcon = 'fa-exclamation-triangle';
} else if (availableLorasCount > 0) {
loraCountStateClass = 'partial';
loraCountIcon = 'fa-circle-minus';
} else {
loraCountStateClass = 'unavailable';
loraCountIcon = 'fa-ban';
}
}
const loraCountLabel = lorasCount > 0 ? `${availableLorasCount}/${lorasCount}` : `${lorasCount}`;
// Ensure file_url exists, fallback to API URL if needed
let previewUrl = this.recipe.file_url;
if (!previewUrl) {
if (this.recipe.file_path) {
const encodedPath = encodeURIComponent(this.recipe.file_path.replace(/\\/g, '/'));
previewUrl = `/api/lm/previews?path=${encodedPath}`;
} else {
previewUrl = '/loras_static/images/no-preview.png';
}
}
const isDuplicatesMode = getCurrentPageState().duplicatesMode;
const autoplayOnHover = state?.global?.settings?.autoplay_on_hover === true;
const isFavorite = this.recipe.favorite === true;
// Video preview logic
const isVideo = isVideoPreviewUrl(previewUrl);
const videoAttrs = [
'controls',
'muted',
'loop',
'playsinline',
'preload="none"',
`data-src="${previewUrl}"`
];
if (!autoplayOnHover) {
videoAttrs.push('data-autoplay="true"');
}
// NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
if (shouldBlur) {
card.classList.add('nsfw-content');
}
// Determine NSFW warning text based on level (shared with model cards)
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');
}
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img src="${previewUrl}" alt="${this.recipe.title}">`
}
${!isDuplicatesMode ? `
<div class="card-header">
${shouldBlur ?
`<button class="toggle-blur-btn" title="${translate('modelCard.actions.toggleBlur', {}, 'Toggle blur')}">
<i class="fas fa-eye"></i>
</button>` : ''}
<div class="card-header-info">
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
${this.recipe.has_workflow === true ? `
<span class="workflow-badge" title="${translate('recipes.workflow.hasWorkflow', {}, 'Contains a ComfyUI workflow')}">
<i class="fas fa-diagram-project"></i>
</span>` : ''}
</div>
<div class="card-actions">
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}" title="${isFavorite ? translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') : translate('modelCard.actions.addToFavorites', {}, 'Add to favorites')}"></i>
<i class="fas fa-share-alt" title="${translate('recipes.actions.shareRecipe', {}, 'Share Recipe')}"></i>
<i class="fas fa-paper-plane" title="${translate('recipes.actions.sendRecipeModes', {}, 'Send Recipe to Workflow (Click: Append, Shift+Click: Replace)')}"></i>
<i class="fas fa-trash" title="${translate('loras.contextMenu.deleteRecipe', {}, 'Delete Recipe')}"></i>
</div>
</div>
` : ''}
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">${translate('modelCard.actions.show', {}, 'Show')}</button>
</div>
</div>
` : ''}
<div class="card-footer">
<div class="model-info">
<span class="model-name">${this.recipe.title}</span>
</div>
${!isDuplicatesMode ? `
<div class="lora-count ${loraCountStateClass}" title="${this.getLoraStatusTitle(lorasCount, availableLorasCount, missingLorasCount, unavailableLorasCount)}">
<i class="fas ${loraCountIcon}" aria-hidden="true"></i> ${loraCountLabel}
</div>
` : ''}
</div>
</div>
`;
this.attachEventListeners(card, isDuplicatesMode, shouldBlur);
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement) {
configureModelCardVideo(videoElement, autoplayOnHover);
}
return card;
}
getLoraStatusTitle(totalCount, availableCount, missingCount, unavailableCount) {
if (totalCount === 0) {
return translate('recipes.loraStatus.none', {}, 'No LoRAs in this recipe');
}
if (availableCount === totalCount) {
return translate('recipes.loraStatus.allAvailable', {}, 'All LoRAs available - Ready to use');
}
if (missingCount > 0 && unavailableCount > 0) {
return translate(
'recipes.loraStatus.missingAndUnavailable',
{ missing: missingCount, unavailable: unavailableCount, total: totalCount },
`${missingCount} of ${totalCount} LoRAs missing, ${unavailableCount} unavailable (deleted from source or unresolvable hash)`
);
}
if (missingCount > 0) {
return translate(
'recipes.loraStatus.missing',
{ missing: missingCount, total: totalCount },
`${missingCount} of ${totalCount} LoRAs missing`
);
}
if (availableCount > 0) {
return translate(
'recipes.loraStatus.partial',
{ unavailable: unavailableCount, total: totalCount },
`${unavailableCount} of ${totalCount} LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used`
);
}
return translate(
'recipes.loraStatus.noneUsable',
{ unavailable: unavailableCount, total: totalCount },
`No usable LoRAs - ${unavailableCount} of ${totalCount} deleted from source or unresolvable hash`
);
}
async toggleFavorite(card) {
// Find the latest star icon in case the card was re-rendered
const getStarIcon = (c) => c.querySelector('.fa-star');
let starIcon = getStarIcon(card);
const isFavorite = this.recipe.favorite || false;
const newFavoriteState = !isFavorite;
// Update early to provide instant feedback and avoid race conditions with re-renders
this.recipe.favorite = newFavoriteState;
card.dataset.favorite = newFavoriteState ? 'true' : 'false';
// Function to update icon state
const updateIconUI = (icon, state) => {
if (!icon) return;
if (state) {
icon.classList.remove('far');
icon.classList.add('fas', 'favorite-active');
icon.title = 'Remove from Favorites';
} else {
icon.classList.remove('fas', 'favorite-active');
icon.classList.add('far');
icon.title = 'Add to Favorites';
}
};
// Update current icon immediately
updateIconUI(starIcon, newFavoriteState);
try {
await updateRecipeMetadata(this.recipe.file_path, {
favorite: newFavoriteState
});
// Status already updated, just show toast
if (newFavoriteState) {
showToast('modelCard.favorites.added', {}, 'success');
} else {
showToast('modelCard.favorites.removed', {}, 'success');
}
// Re-find star icon after API call as VirtualScroller might have replaced the element
// During updateRecipeMetadata, VirtualScroller.updateSingleItem might have re-rendered the card
// We need to find the NEW element in the DOM to ensure we don't have a stale reference
// Though typically VirtualScroller handles the re-render with the NEW this.recipe.favorite
// we will check the DOM just to be sure if this instance's internal card is still what's in DOM
} catch (error) {
console.error('Failed to update favorite status:', error);
// Revert local state on error
this.recipe.favorite = isFavorite;
// Re-find star icon in case of re-render during fault
const filePathForXpath = this.recipe.file_path.replace(/"/g, '&quot;');
const currentCard = card.ownerDocument.evaluate(
`.//*[@data-filepath="${filePathForXpath}"]`,
card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
).singleNodeValue || card;
updateIconUI(getStarIcon(currentCard), isFavorite);
showToast('modelCard.favorites.updateFailed', {}, 'error');
}
}
attachEventListeners(card, isDuplicatesMode, shouldBlur) {
// Add blur toggle functionality if content should be blurred
if (shouldBlur) {
const toggleBtn = card.querySelector('.toggle-blur-btn');
const showBtn = card.querySelector('.show-content-btn');
if (toggleBtn) {
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleBlurContent(card);
});
}
if (showBtn) {
showBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showBlurredContent(card);
});
}
}
// Recipe card click event - only attach if not in duplicates mode
if (!isDuplicatesMode) {
card.addEventListener('click', (e) => {
if (state.bulkMode) {
if (e.shiftKey) {
e.preventDefault();
}
bulkManager.toggleCardSelection(card, e.shiftKey);
return;
}
this.clickHandler(this.recipe);
});
// Favorite button click event - prevent propagation to card
card.querySelector('.fa-star')?.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleFavorite(card);
});
// Share button click event - prevent propagation to card
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
e.stopPropagation();
this.shareRecipe();
});
// Send button click event - prevent propagation to card
card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => {
e.stopPropagation();
this.sendRecipeToWorkflow(e.shiftKey);
});
// Delete button click event - prevent propagation to card
card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
e.stopPropagation();
this.showDeleteConfirmation();
});
}
}
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';
}
}
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';
}
}
sendRecipeToWorkflow(replaceMode = false) {
try {
// Get recipe ID
const recipeId = this.recipe.id;
if (!recipeId) {
showToast('toast.recipes.cannotSend', {}, 'error');
return;
}
fetch(`/api/lm/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
return sendLoraToWorkflow(data.syntax, replaceMode, 'recipe');
} else {
throw new Error(data.error || 'No syntax returned');
}
})
.catch(err => {
console.error('Failed to send recipe to workflow: ', err);
showToast('toast.recipes.sendFailed', {}, 'error');
});
} catch (error) {
console.error('Error sending recipe to workflow:', error);
showToast('toast.recipes.sendError', {}, 'error');
}
}
showDeleteConfirmation() {
showRecipeDeleteConfirmation(this.recipe);
}
confirmDeleteRecipe() {
confirmRecipeDelete(this.recipe);
}
shareRecipe() {
try {
// Get recipe ID
const recipeId = this.recipe.id;
if (!recipeId) {
showToast('toast.recipes.cannotShare', {}, 'error');
return;
}
// Show loading toast
showToast('toast.recipes.preparingForSharing', {}, 'info');
// Call the API to process the image with metadata
fetch(`/api/lm/recipe/${recipeId}/share`)
.then(response => {
if (!response.ok) {
throw new Error('Failed to prepare recipe for sharing');
}
return response.json();
})
.then(data => {
if (!data.success) {
throw new Error(data.error || 'Unknown error');
}
// Create a temporary anchor element for download
const downloadLink = document.createElement('a');
downloadLink.href = data.download_url;
downloadLink.download = data.filename;
// Append to body, click and remove
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
showToast('toast.recipes.downloadStarted', {}, 'success');
})
.catch(error => {
console.error('Error sharing recipe:', error);
showToast('toast.recipes.shareError', { message: error.message }, 'error');
});
} catch (error) {
console.error('Error sharing recipe:', error);
showToast('toast.recipes.sharePreparationError', {}, 'error');
}
}
}
/**
* Show the delete confirmation modal for a recipe. Shared by RecipeCard and
* RecipeModal so the flow stays identical regardless of where it starts.
* @param {Object} recipe - The recipe to delete
*/
export function showRecipeDeleteConfirmation(recipe) {
try {
// Get recipe ID
const recipeId = recipe.id;
const filePath = recipe.file_path;
if (!recipeId) {
showToast('toast.recipes.cannotDelete', {}, 'error');
return;
}
// Create delete modal content
const previewUrl = recipe.file_url || '/loras_static/images/no-preview.png';
const isVideo = isVideoPreviewUrl(previewUrl);
const deleteModalContent = `
<div class="modal-content delete-modal-content">
<h2>${translate('loras.contextMenu.deleteRecipe', {}, 'Delete Recipe')}</h2>
<p class="delete-message">${translate('recipes.deleteModal.message', {}, 'Are you sure you want to delete this recipe?')}</p>
<div class="delete-model-info">
<div class="delete-preview">
${isVideo ?
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
`<img src="${previewUrl}" alt="${recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
}
</div>
<div class="delete-info">
<h3>${recipe.title}</h3>
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
</div>
</div>
<p class="delete-note">${translate('recipes.deleteModal.note', {}, 'Deleting this recipe will not affect the LoRA files used in it.')}</p>
<div class="modal-actions">
<button class="cancel-btn" onclick="closeDeleteModal()">${translate('common.actions.cancel', {}, 'Cancel')}</button>
<button class="delete-btn" onclick="confirmDelete()">${translate('common.actions.delete', {}, 'Delete')}</button>
</div>
</div>
`;
// Show the modal with custom content and setup callbacks
modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback
const deleteModal = document.getElementById('deleteModal');
const deleteBtn = deleteModal.querySelector('.delete-btn');
deleteBtn.textContent = translate('common.actions.delete', {}, 'Delete');
deleteBtn.disabled = false;
});
// Set up the delete and cancel buttons with proper event handlers
const deleteModal = document.getElementById('deleteModal');
const cancelBtn = deleteModal.querySelector('.cancel-btn');
const deleteBtn = deleteModal.querySelector('.delete-btn');
// Store recipe ID in the modal for the delete confirmation handler
deleteModal.dataset.recipeId = recipeId;
deleteModal.dataset.filePath = filePath;
// Update button event handlers
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => confirmRecipeDelete(recipe);
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
}
}
/**
* Execute the recipe deletion after the user confirms in the delete modal.
* @param {Object} recipe - The recipe being deleted (used for toast messaging)
*/
function confirmRecipeDelete(recipe) {
const deleteModal = document.getElementById('deleteModal');
const recipeId = deleteModal.dataset.recipeId;
if (!recipeId) {
showToast('toast.recipes.cannotDelete', {}, 'error');
modalManager.closeModal('deleteModal');
return;
}
// Show loading state
const deleteBtn = deleteModal.querySelector('.delete-btn');
const originalText = deleteBtn.textContent;
deleteBtn.textContent = translate('recipes.deleteModal.deleting', {}, 'Deleting...');
deleteBtn.disabled = true;
// Call API to delete the recipe
fetch(`/api/lm/recipe/${recipeId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete recipe');
}
return response.json();
})
.then(data => {
if (data.batch_id) {
// Staged delete: offer undo instead of the plain success toast
const batchId = data.batch_id;
showActionToast('toast.undo.deleted', { name: recipe.title }, 'success', {
actionText: translate('toast.undo.action'),
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
});
} else {
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
}
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
modalManager.closeModal('deleteModal');
})
.catch(error => {
console.error('Error deleting recipe:', error);
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
// Reset button state
deleteBtn.textContent = originalText;
deleteBtn.disabled = false;
});
}
export { RecipeCard };