feat: Add support for video recipe previews by conditionally optimizing media during persistence and updating UI components to display videos.

This commit is contained in:
Will Miao
2025-12-21 20:00:44 +08:00
parent 63b087fc80
commit 30fd0470de
6 changed files with 283 additions and 126 deletions

View File

@@ -23,6 +23,7 @@ from ...services.recipes import (
RecipeValidationError, RecipeValidationError,
) )
from ...services.metadata_service import get_default_metadata_provider from ...services.metadata_service import get_default_metadata_provider
from ...utils.civitai_utils import rewrite_preview_url
Logger = logging.Logger Logger = logging.Logger
EnsureDependenciesCallable = Callable[[], Awaitable[None]] EnsureDependenciesCallable = Callable[[], Awaitable[None]]
@@ -455,6 +456,7 @@ class RecipeManagementHandler:
image_url = params.get("image_url") image_url = params.get("image_url")
name = params.get("name") name = params.get("name")
resources_raw = params.get("resources") resources_raw = params.get("resources")
if not image_url: if not image_url:
raise RecipeValidationError("Missing required field: image_url") raise RecipeValidationError("Missing required field: image_url")
if not name: if not name:
@@ -483,7 +485,7 @@ class RecipeManagementHandler:
metadata["base_model"] = base_model_from_metadata metadata["base_model"] = base_model_from_metadata
tags = self._parse_tags(params.get("tags")) tags = self._parse_tags(params.get("tags"))
image_bytes = await self._download_image_bytes(image_url) image_bytes, extension = await self._download_remote_media(image_url)
result = await self._persistence_service.save_recipe( result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
@@ -492,6 +494,7 @@ class RecipeManagementHandler:
name=name, name=name,
tags=tags, tags=tags,
metadata=metadata, metadata=metadata,
extension=extension,
) )
return web.json_response(result.payload, status=result.status) return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc: except RecipeValidationError as exc:
@@ -729,7 +732,7 @@ class RecipeManagementHandler:
"exclude": False, "exclude": False,
} }
async def _download_image_bytes(self, image_url: str) -> bytes: async def _download_remote_media(self, image_url: str) -> tuple[bytes, str]:
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
downloader = await self._downloader_factory() downloader = await self._downloader_factory()
temp_path = None temp_path = None
@@ -744,15 +747,31 @@ class RecipeManagementHandler:
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info: if not image_info:
raise RecipeDownloadError("Failed to fetch image information from Civitai") raise RecipeDownloadError("Failed to fetch image information from Civitai")
download_url = image_info.get("url")
if not download_url: media_url = image_info.get("url")
if not media_url:
raise RecipeDownloadError("No image URL found in Civitai response") raise RecipeDownloadError("No image URL found in Civitai response")
# Use optimized preview URLs if possible
media_type = image_info.get("type")
rewritten_url, _ = rewrite_preview_url(media_url, media_type=media_type)
if rewritten_url:
download_url = rewritten_url
else:
download_url = media_url
success, result = await downloader.download_file(download_url, temp_path, use_auth=False) success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
if not success: if not success:
raise RecipeDownloadError(f"Failed to download image: {result}") raise RecipeDownloadError(f"Failed to download image: {result}")
# Extract extension from URL
url_path = download_url.split('?')[0].split('#')[0]
extension = os.path.splitext(url_path)[1].lower()
if not extension:
extension = ".webp" # Default to webp if unknown
with open(temp_path, "rb") as file_obj: with open(temp_path, "rb") as file_obj:
return file_obj.read() return file_obj.read(), extension
except RecipeDownloadError: except RecipeDownloadError:
raise raise
except RecipeValidationError: except RecipeValidationError:
@@ -766,6 +785,7 @@ class RecipeManagementHandler:
except FileNotFoundError: except FileNotFoundError:
pass pass
def _safe_int(self, value: Any) -> int: def _safe_int(self, value: Any) -> int:
try: try:
return int(value) return int(value)

View File

@@ -46,6 +46,7 @@ class RecipePersistenceService:
name: str | None, name: str | None,
tags: Iterable[str], tags: Iterable[str],
metadata: Optional[dict[str, Any]], metadata: Optional[dict[str, Any]],
extension: str | None = None,
) -> PersistenceResult: ) -> PersistenceResult:
"""Persist a user uploaded recipe.""" """Persist a user uploaded recipe."""
@@ -64,13 +65,21 @@ class RecipePersistenceService:
os.makedirs(recipes_dir, exist_ok=True) os.makedirs(recipes_dir, exist_ok=True)
recipe_id = str(uuid.uuid4()) recipe_id = str(uuid.uuid4())
optimized_image, extension = self._exif_utils.optimize_image(
image_data=resolved_image_bytes, # Handle video formats by bypassing optimization and metadata embedding
target_width=self._card_preview_width, is_video = extension in [".mp4", ".webm"]
format="webp", if is_video:
quality=85, optimized_image = resolved_image_bytes
preserve_metadata=True, # extension is already set
) else:
optimized_image, extension = self._exif_utils.optimize_image(
image_data=resolved_image_bytes,
target_width=self._card_preview_width,
format="webp",
quality=85,
preserve_metadata=True,
)
image_filename = f"{recipe_id}{extension}" image_filename = f"{recipe_id}{extension}"
image_path = os.path.join(recipes_dir, image_filename) image_path = os.path.join(recipes_dir, image_filename)
normalized_image_path = os.path.normpath(image_path) normalized_image_path = os.path.normpath(image_path)
@@ -126,7 +135,8 @@ class RecipePersistenceService:
with open(json_path, "w", encoding="utf-8") as file_obj: with open(json_path, "w", encoding="utf-8") as file_obj:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False) json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data) if not is_video:
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id) matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
await recipe_scanner.add_recipe(recipe_data) await recipe_scanner.add_recipe(recipe_data)

View File

@@ -1,5 +1,6 @@
// Recipe Card Component // Recipe Card Component
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { configureModelCardVideo } from './shared/ModelCard.js';
import { modalManager } from '../managers/ModalManager.js'; import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js'; import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
@@ -10,11 +11,11 @@ class RecipeCard {
this.recipe = recipe; this.recipe = recipe;
this.clickHandler = clickHandler; this.clickHandler = clickHandler;
this.element = this.createCardElement(); this.element = this.createCardElement();
// Store reference to this instance on the DOM element for updates // Store reference to this instance on the DOM element for updates
this.element._recipeCardInstance = this; this.element._recipeCardInstance = this;
} }
createCardElement() { createCardElement() {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'model-card'; card.className = 'model-card';
@@ -23,24 +24,40 @@ class RecipeCard {
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0; card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
card.dataset.created = this.recipe.created_date; card.dataset.created = this.recipe.created_date;
card.dataset.id = this.recipe.id || ''; card.dataset.id = this.recipe.id || '';
// Get base model with fallback // Get base model with fallback
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown'; const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
const baseModelAbbreviation = getBaseModelAbbreviation(baseModelLabel); const baseModelAbbreviation = getBaseModelAbbreviation(baseModelLabel);
const baseModelDisplay = baseModelLabel === 'Unknown' ? 'Unknown' : baseModelAbbreviation; const baseModelDisplay = baseModelLabel === 'Unknown' ? 'Unknown' : baseModelAbbreviation;
// Ensure loras array exists // Ensure loras array exists
const loras = this.recipe.loras || []; const loras = this.recipe.loras || [];
const lorasCount = loras.length; const lorasCount = loras.length;
// Check if all LoRAs are available in the library // Check if all LoRAs are available in the library
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length; const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0; const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
// Ensure file_url exists, fallback to file_path if needed // Ensure file_url exists, fallback to file_path if needed
const imageUrl = this.recipe.file_url || const previewUrl = this.recipe.file_url ||
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` : (this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png'); '/loras_static/images/no-preview.png');
// Video preview logic
const autoplayOnHover = state.settings.autoplay_on_hover || false;
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const videoAttrs = [
'controls',
'muted',
'loop',
'playsinline',
'preload="none"',
`data-src="${previewUrl}"`
];
if (!autoplayOnHover) {
videoAttrs.push('data-autoplay="true"');
}
// Check if in duplicates mode // Check if in duplicates mode
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
@@ -49,7 +66,7 @@ class RecipeCard {
// NSFW blur logic - similar to LoraCard // NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0; const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
if (shouldBlur) { if (shouldBlur) {
card.classList.add('nsfw-content'); card.classList.add('nsfw-content');
} }
@@ -66,11 +83,14 @@ class RecipeCard {
card.innerHTML = ` card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}"> <div class="card-preview ${shouldBlur ? 'blurred' : ''}">
<img src="${imageUrl}" alt="${this.recipe.title}"> ${isVideo ?
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img src="${previewUrl}" alt="${this.recipe.title}">`
}
${!isDuplicatesMode ? ` ${!isDuplicatesMode ? `
<div class="card-header"> <div class="card-header">
${shouldBlur ? ${shouldBlur ?
`<button class="toggle-blur-btn" title="Toggle blur"> `<button class="toggle-blur-btn" title="Toggle blur">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</button>` : ''} </button>` : ''}
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span> <span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
@@ -102,30 +122,37 @@ class RecipeCard {
</div> </div>
</div> </div>
`; `;
this.attachEventListeners(card, isDuplicatesMode, shouldBlur); 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; return card;
} }
getLoraStatusTitle(totalCount, missingCount) { getLoraStatusTitle(totalCount, missingCount) {
if (totalCount === 0) return "No LoRAs in this recipe"; if (totalCount === 0) return "No LoRAs in this recipe";
if (missingCount === 0) return "All LoRAs available - Ready to use"; if (missingCount === 0) return "All LoRAs available - Ready to use";
return `${missingCount} of ${totalCount} LoRAs missing`; return `${missingCount} of ${totalCount} LoRAs missing`;
} }
attachEventListeners(card, isDuplicatesMode, shouldBlur) { attachEventListeners(card, isDuplicatesMode, shouldBlur) {
// Add blur toggle functionality if content should be blurred // Add blur toggle functionality if content should be blurred
if (shouldBlur) { if (shouldBlur) {
const toggleBtn = card.querySelector('.toggle-blur-btn'); const toggleBtn = card.querySelector('.toggle-blur-btn');
const showBtn = card.querySelector('.show-content-btn'); const showBtn = card.querySelector('.show-content-btn');
if (toggleBtn) { if (toggleBtn) {
toggleBtn.addEventListener('click', (e) => { toggleBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.toggleBlurContent(card); this.toggleBlurContent(card);
}); });
} }
if (showBtn) { if (showBtn) {
showBtn.addEventListener('click', (e) => { showBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -139,19 +166,19 @@ class RecipeCard {
card.addEventListener('click', () => { card.addEventListener('click', () => {
this.clickHandler(this.recipe); this.clickHandler(this.recipe);
}); });
// Share button click event - prevent propagation to card // Share button click event - prevent propagation to card
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.shareRecipe(); this.shareRecipe();
}); });
// Send button click event - prevent propagation to card // Send button click event - prevent propagation to card
card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => { card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.sendRecipeToWorkflow(e.shiftKey); this.sendRecipeToWorkflow(e.shiftKey);
}); });
// Delete button click event - prevent propagation to card // Delete button click event - prevent propagation to card
card.querySelector('.fa-trash')?.addEventListener('click', (e) => { card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -159,19 +186,19 @@ class RecipeCard {
}); });
} }
} }
toggleBlurContent(card) { toggleBlurContent(card) {
const preview = card.querySelector('.card-preview'); const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred'); const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn i'); const icon = card.querySelector('.toggle-blur-btn i');
// Update the icon based on blur state // Update the icon based on blur state
if (isBlurred) { if (isBlurred) {
icon.className = 'fas fa-eye'; icon.className = 'fas fa-eye';
} else { } else {
icon.className = 'fas fa-eye-slash'; icon.className = 'fas fa-eye-slash';
} }
// Toggle the overlay visibility // Toggle the overlay visibility
const overlay = card.querySelector('.nsfw-overlay'); const overlay = card.querySelector('.nsfw-overlay');
if (overlay) { if (overlay) {
@@ -182,13 +209,13 @@ class RecipeCard {
showBlurredContent(card) { showBlurredContent(card) {
const preview = card.querySelector('.card-preview'); const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred'); preview.classList.remove('blurred');
// Update the toggle button icon // Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn'); const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) { if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
} }
// Hide the overlay // Hide the overlay
const overlay = card.querySelector('.nsfw-overlay'); const overlay = card.querySelector('.nsfw-overlay');
if (overlay) { if (overlay) {
@@ -223,7 +250,7 @@ class RecipeCard {
showToast('toast.recipes.sendError', {}, 'error'); showToast('toast.recipes.sendError', {}, 'error');
} }
} }
showDeleteConfirmation() { showDeleteConfirmation() {
try { try {
// Get recipe ID // Get recipe ID
@@ -233,15 +260,21 @@ class RecipeCard {
showToast('toast.recipes.cannotDelete', {}, 'error'); showToast('toast.recipes.cannotDelete', {}, 'error');
return; return;
} }
// Create delete modal content // Create delete modal content
const previewUrl = this.recipe.file_url || '/loras_static/images/no-preview.png';
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const deleteModalContent = ` const deleteModalContent = `
<div class="modal-content delete-modal-content"> <div class="modal-content delete-modal-content">
<h2>Delete Recipe</h2> <h2>Delete Recipe</h2>
<p class="delete-message">Are you sure you want to delete this recipe?</p> <p class="delete-message">Are you sure you want to delete this recipe?</p>
<div class="delete-model-info"> <div class="delete-model-info">
<div class="delete-preview"> <div class="delete-preview">
<img src="${this.recipe.file_url || '/loras_static/images/no-preview.png'}" alt="${this.recipe.title}"> ${isVideo ?
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
`<img src="${previewUrl}" alt="${this.recipe.title}">`
}
</div> </div>
<div class="delete-info"> <div class="delete-info">
<h3>${this.recipe.title}</h3> <h3>${this.recipe.title}</h3>
@@ -255,7 +288,7 @@ class RecipeCard {
</div> </div>
</div> </div>
`; `;
// Show the modal with custom content and setup callbacks // Show the modal with custom content and setup callbacks
modalManager.showModal('deleteModal', deleteModalContent, () => { modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback // This is the onClose callback
@@ -264,20 +297,20 @@ class RecipeCard {
deleteBtn.textContent = 'Delete'; deleteBtn.textContent = 'Delete';
deleteBtn.disabled = false; deleteBtn.disabled = false;
}); });
// Set up the delete and cancel buttons with proper event handlers // Set up the delete and cancel buttons with proper event handlers
const deleteModal = document.getElementById('deleteModal'); const deleteModal = document.getElementById('deleteModal');
const cancelBtn = deleteModal.querySelector('.cancel-btn'); const cancelBtn = deleteModal.querySelector('.cancel-btn');
const deleteBtn = deleteModal.querySelector('.delete-btn'); const deleteBtn = deleteModal.querySelector('.delete-btn');
// Store recipe ID in the modal for the delete confirmation handler // Store recipe ID in the modal for the delete confirmation handler
deleteModal.dataset.recipeId = recipeId; deleteModal.dataset.recipeId = recipeId;
deleteModal.dataset.filePath = filePath; deleteModal.dataset.filePath = filePath;
// Update button event handlers // Update button event handlers
cancelBtn.onclick = () => modalManager.closeModal('deleteModal'); cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => this.confirmDeleteRecipe(); deleteBtn.onclick = () => this.confirmDeleteRecipe();
} catch (error) { } catch (error) {
console.error('Error showing delete confirmation:', error); console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error'); showToast('toast.recipes.deleteConfirmationError', {}, 'error');
@@ -287,19 +320,19 @@ class RecipeCard {
confirmDeleteRecipe() { confirmDeleteRecipe() {
const deleteModal = document.getElementById('deleteModal'); const deleteModal = document.getElementById('deleteModal');
const recipeId = deleteModal.dataset.recipeId; const recipeId = deleteModal.dataset.recipeId;
if (!recipeId) { if (!recipeId) {
showToast('toast.recipes.cannotDelete', {}, 'error'); showToast('toast.recipes.cannotDelete', {}, 'error');
modalManager.closeModal('deleteModal'); modalManager.closeModal('deleteModal');
return; return;
} }
// Show loading state // Show loading state
const deleteBtn = deleteModal.querySelector('.delete-btn'); const deleteBtn = deleteModal.querySelector('.delete-btn');
const originalText = deleteBtn.textContent; const originalText = deleteBtn.textContent;
deleteBtn.textContent = 'Deleting...'; deleteBtn.textContent = 'Deleting...';
deleteBtn.disabled = true; deleteBtn.disabled = true;
// Call API to delete the recipe // Call API to delete the recipe
fetch(`/api/lm/recipe/${recipeId}`, { fetch(`/api/lm/recipe/${recipeId}`, {
method: 'DELETE', method: 'DELETE',
@@ -307,27 +340,27 @@ class RecipeCard {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to delete recipe'); throw new Error('Failed to delete recipe');
} }
return response.json(); return response.json();
}) })
.then(data => { .then(data => {
showToast('toast.recipes.deletedSuccessfully', {}, 'success'); showToast('toast.recipes.deletedSuccessfully', {}, 'success');
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath); state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
modalManager.closeModal('deleteModal'); modalManager.closeModal('deleteModal');
}) })
.catch(error => { .catch(error => {
console.error('Error deleting recipe:', error); console.error('Error deleting recipe:', error);
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error'); showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
// Reset button state // Reset button state
deleteBtn.textContent = originalText; deleteBtn.textContent = originalText;
deleteBtn.disabled = false; deleteBtn.disabled = false;
}); });
} }
shareRecipe() { shareRecipe() {
@@ -338,10 +371,10 @@ class RecipeCard {
showToast('toast.recipes.cannotShare', {}, 'error'); showToast('toast.recipes.cannotShare', {}, 'error');
return; return;
} }
// Show loading toast // Show loading toast
showToast('toast.recipes.preparingForSharing', {}, 'info'); showToast('toast.recipes.preparingForSharing', {}, 'info');
// Call the API to process the image with metadata // Call the API to process the image with metadata
fetch(`/api/lm/recipe/${recipeId}/share`) fetch(`/api/lm/recipe/${recipeId}/share`)
.then(response => { .then(response => {
@@ -354,17 +387,17 @@ class RecipeCard {
if (!data.success) { if (!data.success) {
throw new Error(data.error || 'Unknown error'); throw new Error(data.error || 'Unknown error');
} }
// Create a temporary anchor element for download // Create a temporary anchor element for download
const downloadLink = document.createElement('a'); const downloadLink = document.createElement('a');
downloadLink.href = data.download_url; downloadLink.href = data.download_url;
downloadLink.download = data.filename; downloadLink.download = data.filename;
// Append to body, click and remove // Append to body, click and remove
document.body.appendChild(downloadLink); document.body.appendChild(downloadLink);
downloadLink.click(); downloadLink.click();
document.body.removeChild(downloadLink); document.body.removeChild(downloadLink);
showToast('toast.recipes.downloadStarted', {}, 'success'); showToast('toast.recipes.downloadStarted', {}, 'success');
}) })
.catch(error => { .catch(error => {

View File

@@ -14,11 +14,11 @@ import { eventManager } from '../../utils/EventManager.js';
// Helper function to get display name based on settings // Helper function to get display name based on settings
function getDisplayName(model) { function getDisplayName(model) {
const displayNameSetting = state.global.settings.model_name_display || 'model_name'; const displayNameSetting = state.global.settings.model_name_display || 'model_name';
if (displayNameSetting === 'file_name') { if (displayNameSetting === 'file_name') {
return model.file_name || model.model_name || 'Unknown Model'; return model.file_name || model.model_name || 'Unknown Model';
} }
return model.model_name || model.file_name || 'Unknown Model'; return model.model_name || model.file_name || 'Unknown Model';
} }
@@ -26,7 +26,7 @@ function getDisplayName(model) {
export function setupModelCardEventDelegation(modelType) { export function setupModelCardEventDelegation(modelType) {
// Remove any existing handler first // Remove any existing handler first
eventManager.removeHandler('click', 'modelCard-delegation'); eventManager.removeHandler('click', 'modelCard-delegation');
// Register model card event delegation with event manager // Register model card event delegation with event manager
eventManager.addHandler('click', 'modelCard-delegation', (event) => { eventManager.addHandler('click', 'modelCard-delegation', (event) => {
return handleModelCardEvent_internal(event, modelType); return handleModelCardEvent_internal(event, modelType);
@@ -42,26 +42,26 @@ function handleModelCardEvent_internal(event, modelType) {
// Find the closest card element // Find the closest card element
const card = event.target.closest('.model-card'); const card = event.target.closest('.model-card');
if (!card) return false; // Continue with other handlers if (!card) return false; // Continue with other handlers
// Handle specific elements within the card // Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) { if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation(); event.stopPropagation();
toggleBlurContent(card); toggleBlurContent(card);
return true; // Stop propagation return true; // Stop propagation
} }
if (event.target.closest('.show-content-btn')) { if (event.target.closest('.show-content-btn')) {
event.stopPropagation(); event.stopPropagation();
showBlurredContent(card); showBlurredContent(card);
return true; // Stop propagation return true; // Stop propagation
} }
if (event.target.closest('.fa-star')) { if (event.target.closest('.fa-star')) {
event.stopPropagation(); event.stopPropagation();
toggleFavorite(card); toggleFavorite(card);
return true; // Stop propagation return true; // Stop propagation
} }
if (event.target.closest('.fa-globe')) { if (event.target.closest('.fa-globe')) {
event.stopPropagation(); event.stopPropagation();
if (card.dataset.from_civitai === 'true') { if (card.dataset.from_civitai === 'true') {
@@ -69,37 +69,37 @@ function handleModelCardEvent_internal(event, modelType) {
} }
return true; // Stop propagation return true; // Stop propagation
} }
if (event.target.closest('.fa-paper-plane')) { if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation(); event.stopPropagation();
handleSendToWorkflow(card, event.shiftKey, modelType); handleSendToWorkflow(card, event.shiftKey, modelType);
return true; // Stop propagation return true; // Stop propagation
} }
if (event.target.closest('.fa-copy')) { if (event.target.closest('.fa-copy')) {
event.stopPropagation(); event.stopPropagation();
handleCopyAction(card, modelType); handleCopyAction(card, modelType);
return true; // Stop propagation return true; // Stop propagation
} }
if (event.target.closest('.fa-trash')) { if (event.target.closest('.fa-trash')) {
event.stopPropagation(); event.stopPropagation();
showDeleteModal(card.dataset.filepath); showDeleteModal(card.dataset.filepath);
return true; // Stop propagation return true; // Stop propagation
} }
if (event.target.closest('.fa-image')) { if (event.target.closest('.fa-image')) {
event.stopPropagation(); event.stopPropagation();
getModelApiClient().replaceModelPreview(card.dataset.filepath); getModelApiClient().replaceModelPreview(card.dataset.filepath);
return true; // Stop propagation return true; // Stop propagation
} }
if (event.target.closest('.fa-folder-open')) { if (event.target.closest('.fa-folder-open')) {
event.stopPropagation(); event.stopPropagation();
handleExampleImagesAccess(card, modelType); handleExampleImagesAccess(card, modelType);
return true; // Stop propagation return true; // Stop propagation
} }
// If no specific element was clicked, handle the card click (show modal or toggle selection) // If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType); handleCardClick(card, modelType);
return false; // Continue with other handlers (e.g., bulk selection) return false; // Continue with other handlers (e.g., bulk selection)
@@ -110,14 +110,14 @@ function toggleBlurContent(card) {
const preview = card.querySelector('.card-preview'); const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred'); const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn i'); const icon = card.querySelector('.toggle-blur-btn i');
// Update the icon based on blur state // Update the icon based on blur state
if (isBlurred) { if (isBlurred) {
icon.className = 'fas fa-eye'; icon.className = 'fas fa-eye';
} else { } else {
icon.className = 'fas fa-eye-slash'; icon.className = 'fas fa-eye-slash';
} }
// Toggle the overlay visibility // Toggle the overlay visibility
const overlay = card.querySelector('.nsfw-overlay'); const overlay = card.querySelector('.nsfw-overlay');
if (overlay) { if (overlay) {
@@ -128,13 +128,13 @@ function toggleBlurContent(card) {
function showBlurredContent(card) { function showBlurredContent(card) {
const preview = card.querySelector('.card-preview'); const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred'); preview.classList.remove('blurred');
// Update the toggle button icon // Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn'); const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) { if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
} }
// Hide the overlay // Hide the overlay
const overlay = card.querySelector('.nsfw-overlay'); const overlay = card.querySelector('.nsfw-overlay');
if (overlay) { if (overlay) {
@@ -146,10 +146,10 @@ async function toggleFavorite(card) {
const starIcon = card.querySelector('.fa-star'); const starIcon = card.querySelector('.fa-star');
const isFavorite = starIcon.classList.contains('fas'); const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite; const newFavoriteState = !isFavorite;
try { try {
await getModelApiClient().saveModelMetadata(card.dataset.filepath, { await getModelApiClient().saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState favorite: newFavoriteState
}); });
if (newFavoriteState) { if (newFavoriteState) {
@@ -239,11 +239,11 @@ function handleReplacePreview(filePath, modelType) {
async function handleExampleImagesAccess(card, modelType) { async function handleExampleImagesAccess(card, modelType) {
const modelHash = card.dataset.sha256; const modelHash = card.dataset.sha256;
try { try {
const response = await fetch(`/api/lm/has-example-images?model_hash=${modelHash}`); const response = await fetch(`/api/lm/has-example-images?model_hash=${modelHash}`);
const data = await response.json(); const data = await response.json();
if (data.has_images) { if (data.has_images) {
openExampleImagesFolder(modelHash); openExampleImagesFolder(modelHash);
} else { } else {
@@ -257,7 +257,7 @@ async function handleExampleImagesAccess(card, modelType) {
function handleCardClick(card, modelType) { function handleCardClick(card, modelType) {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
if (state.bulkMode) { if (state.bulkMode) {
// Toggle selection using the bulk manager // Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card); bulkManager.toggleCardSelection(card);
@@ -294,7 +294,7 @@ async function showModelModalFromCard(card, modelType) {
usage_tips: card.dataset.usage_tips, usage_tips: card.dataset.usage_tips,
}) })
}; };
await showModelModal(modelMeta, modelType); await showModelModal(modelMeta, modelType);
} }
@@ -310,9 +310,9 @@ function showExampleAccessModal(card, modelType) {
try { try {
const metaData = JSON.parse(card.dataset.meta || '{}'); const metaData = JSON.parse(card.dataset.meta || '{}');
hasRemoteExamples = metaData.images && hasRemoteExamples = metaData.images &&
Array.isArray(metaData.images) && Array.isArray(metaData.images) &&
metaData.images.length > 0 && metaData.images.length > 0 &&
metaData.images[0].url; metaData.images[0].url;
} catch (e) { } catch (e) {
console.error('Error parsing meta data:', e); console.error('Error parsing meta data:', e);
} }
@@ -329,10 +329,10 @@ function showExampleAccessModal(card, modelType) {
showToast('modelCard.exampleImages.missingHash', {}, 'error'); showToast('modelCard.exampleImages.missingHash', {}, 'error');
return; return;
} }
// Close the modal // Close the modal
modalManager.closeModal('exampleAccessModal'); modalManager.closeModal('exampleAccessModal');
try { try {
// Use the appropriate model API client to download examples // Use the appropriate model API client to download examples
const apiClient = getModelApiClient(modelType); const apiClient = getModelApiClient(modelType);
@@ -456,7 +456,7 @@ export function createModelCard(model, modelType) {
if (model.civitai) { if (model.civitai) {
card.dataset.meta = JSON.stringify(model.civitai || {}); card.dataset.meta = JSON.stringify(model.civitai || {});
} }
// Store tags if available // Store tags if available
if (model.tags && Array.isArray(model.tags)) { if (model.tags && Array.isArray(model.tags)) {
card.dataset.tags = JSON.stringify(model.tags); card.dataset.tags = JSON.stringify(model.tags);
@@ -469,7 +469,7 @@ export function createModelCard(model, modelType) {
// Store NSFW level if available // Store NSFW level if available
const nsfwLevel = model.preview_nsfw_level !== undefined ? model.preview_nsfw_level : 0; const nsfwLevel = model.preview_nsfw_level !== undefined ? model.preview_nsfw_level : 0;
card.dataset.nsfwLevel = nsfwLevel; card.dataset.nsfwLevel = nsfwLevel;
// Determine if the preview should be blurred based on NSFW level and user settings // Determine if the preview should be blurred based on NSFW level and user settings
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
if (shouldBlur) { if (shouldBlur) {
@@ -500,7 +500,7 @@ export function createModelCard(model, modelType) {
// Check if autoplayOnHover is enabled for video previews // Check if autoplayOnHover is enabled for video previews
const autoplayOnHover = state.global?.settings?.autoplay_on_hover || false; const autoplayOnHover = state.global?.settings?.autoplay_on_hover || false;
const isVideo = previewUrl.endsWith('.mp4'); const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const videoAttrs = [ const videoAttrs = [
'controls', 'controls',
'muted', 'muted',
@@ -521,10 +521,10 @@ export function createModelCard(model, modelType) {
} }
// Generate action icons based on model type with i18n support // Generate action icons based on model type with i18n support
const favoriteTitle = isFavorite ? const favoriteTitle = isFavorite ?
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') : translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites'); translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
const globeTitle = model.from_civitai ? const globeTitle = model.from_civitai ?
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') : translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai'); translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
let sendTitle; let sendTitle;
@@ -576,13 +576,13 @@ export function createModelCard(model, modelType) {
card.innerHTML = ` card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}"> <div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ? ${isVideo ?
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` : `<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">` `<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
} }
<div class="card-header"> <div class="card-header">
${shouldBlur ? ${shouldBlur ?
`<button class="toggle-blur-btn" title="${toggleBlurTitle}"> `<button class="toggle-blur-btn" title="${toggleBlurTitle}">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</button>` : ''} </button>` : ''}
<div class="card-header-info"> <div class="card-header-info">
@@ -620,7 +620,7 @@ export function createModelCard(model, modelType) {
</div> </div>
</div> </div>
`; `;
// Add video auto-play on hover functionality if needed // Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video'); const videoElement = card.querySelector('video');
if (videoElement) { if (videoElement) {
@@ -756,7 +756,7 @@ function cleanupHoverHandlers(videoElement) {
function requestSafePlay(videoElement) { function requestSafePlay(videoElement) {
const playPromise = videoElement.play(); const playPromise = videoElement.play();
if (playPromise && typeof playPromise.catch === 'function') { if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {}); playPromise.catch(() => { });
} }
} }
@@ -878,16 +878,16 @@ export function configureModelCardVideo(videoElement, autoplayOnHover) {
export function updateCardsForBulkMode(isBulkMode) { export function updateCardsForBulkMode(isBulkMode) {
// Update the state // Update the state
state.bulkMode = isBulkMode; state.bulkMode = isBulkMode;
document.body.classList.toggle('bulk-mode', isBulkMode); document.body.classList.toggle('bulk-mode', isBulkMode);
// Get all lora cards - this can now be from the DOM or through the virtual scroller // Get all lora cards - this can now be from the DOM or through the virtual scroller
const loraCards = document.querySelectorAll('.model-card'); const loraCards = document.querySelectorAll('.model-card');
loraCards.forEach(card => { loraCards.forEach(card => {
// Get all action containers for this card // Get all action containers for this card
const actions = card.querySelectorAll('.card-actions'); const actions = card.querySelectorAll('.card-actions');
// Handle display property based on mode // Handle display property based on mode
if (isBulkMode) { if (isBulkMode) {
// Hide actions when entering bulk mode // Hide actions when entering bulk mode
@@ -902,12 +902,12 @@ export function updateCardsForBulkMode(isBulkMode) {
}); });
} }
}); });
// If using virtual scroller, we need to rerender after toggling bulk mode // If using virtual scroller, we need to rerender after toggling bulk mode
if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') { if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
state.virtualScroller.scheduleRender(); state.virtualScroller.scheduleRender();
} }
// Apply selection state to cards if entering bulk mode // Apply selection state to cards if entering bulk mode
if (isBulkMode) { if (isBulkMode) {
bulkManager.applySelectionState(); bulkManager.applySelectionState();

View File

@@ -29,6 +29,7 @@ class RecipeRouteHarness:
persistence: "StubPersistenceService" persistence: "StubPersistenceService"
sharing: "StubSharingService" sharing: "StubSharingService"
downloader: "StubDownloader" downloader: "StubDownloader"
civitai: "StubCivitaiClient"
tmp_dir: Path tmp_dir: Path
@@ -122,7 +123,7 @@ class StubPersistenceService:
self.delete_result = SimpleNamespace(payload={"success": True}, status=200) self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
StubPersistenceService.instances.append(self) StubPersistenceService.instances.append(self)
async def save_recipe(self, *, recipe_scanner, image_bytes, image_base64, name, tags, metadata) -> SimpleNamespace: # noqa: D401 async def save_recipe(self, *, recipe_scanner, image_bytes, image_base64, name, tags, metadata, extension=None) -> SimpleNamespace: # noqa: D401
self.save_calls.append( self.save_calls.append(
{ {
"recipe_scanner": recipe_scanner, "recipe_scanner": recipe_scanner,
@@ -131,6 +132,7 @@ class StubPersistenceService:
"name": name, "name": name,
"tags": list(tags), "tags": list(tags),
"metadata": metadata, "metadata": metadata,
"extension": extension,
} }
) )
return self.save_result return self.save_result
@@ -189,6 +191,16 @@ class StubDownloader:
return True, destination return True, destination
class StubCivitaiClient:
"""Stub for Civitai API client."""
def __init__(self) -> None:
self.image_info: Dict[str, Any] = {}
async def get_image_info(self, image_id: str) -> Optional[Dict[str, Any]]:
return self.image_info.get(image_id)
@asynccontextmanager @asynccontextmanager
async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRouteHarness]: async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRouteHarness]:
"""Context manager that yields a fully wired recipe route harness.""" """Context manager that yields a fully wired recipe route harness."""
@@ -198,12 +210,13 @@ async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRou
StubSharingService.instances.clear() StubSharingService.instances.clear()
scanner = StubRecipeScanner(tmp_path) scanner = StubRecipeScanner(tmp_path)
civitai_client = StubCivitaiClient()
async def fake_get_recipe_scanner(): async def fake_get_recipe_scanner():
return scanner return scanner
async def fake_get_civitai_client(): async def fake_get_civitai_client():
return object() return civitai_client
downloader = StubDownloader() downloader = StubDownloader()
@@ -232,6 +245,7 @@ async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRou
persistence=StubPersistenceService.instances[-1], persistence=StubPersistenceService.instances[-1],
sharing=StubSharingService.instances[-1], sharing=StubSharingService.instances[-1],
downloader=downloader, downloader=downloader,
civitai=civitai_client,
tmp_dir=tmp_path, tmp_dir=tmp_path,
) )
@@ -400,6 +414,41 @@ async def test_import_remote_recipe_falls_back_to_request_base_model(monkeypatch
assert provider_calls == [77] assert provider_calls == [77]
async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
async def fake_get_default_metadata_provider():
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
monkeypatch.setattr(recipe_handlers, "get_default_metadata_provider", fake_get_default_metadata_provider)
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.civitai.image_info["12345"] = {
"id": 12345,
"url": "https://image.civitai.com/x/y/original=true/video.mp4",
"type": "video"
}
response = await harness.client.get(
"/api/lm/recipes/import-remote",
params={
"image_url": "https://civitai.com/images/12345",
"name": "Video Recipe",
"resources": json.dumps([]),
"base_model": "Flux",
},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
# Verify downloader was called with rewritten URL
assert "transcode=true" in harness.downloader.urls[0]
# Verify persistence was called with correct extension
call = harness.persistence.save_calls[-1]
assert call["extension"] == ".mp4"
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None: async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.raise_for_uploaded = RecipeValidationError("No image data provided") harness.analysis.raise_for_uploaded = RecipeValidationError("No image data provided")

View File

@@ -12,7 +12,12 @@ from py.services.recipes.persistence_service import RecipePersistenceService
class DummyExifUtils: class DummyExifUtils:
def __init__(self):
self.appended = None
self.optimized_calls = 0
def optimize_image(self, image_data, target_width, format, quality, preserve_metadata): def optimize_image(self, image_data, target_width, format, quality, preserve_metadata):
self.optimized_calls += 1
return image_data, ".webp" return image_data, ".webp"
def append_recipe_metadata(self, image_path, recipe_data): def append_recipe_metadata(self, image_path, recipe_data):
@@ -22,6 +27,46 @@ class DummyExifUtils:
return {} return {}
@pytest.mark.asyncio
async def test_save_recipe_video_bypasses_optimization(tmp_path):
exif_utils = DummyExifUtils()
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
async def find_recipes_by_fingerprint(self, fingerprint):
return []
async def add_recipe(self, recipe_data):
return None
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
metadata = {"base_model": "Flux", "loras": []}
video_bytes = b"mp4-content"
result = await service.save_recipe(
recipe_scanner=scanner,
image_bytes=video_bytes,
image_base64=None,
name="Video Recipe",
tags=[],
metadata=metadata,
extension=".mp4",
)
assert result.payload["image_path"].endswith(".mp4")
assert Path(result.payload["image_path"]).read_bytes() == video_bytes
assert exif_utils.optimized_calls == 0, "Optimization should be bypassed for video"
assert exif_utils.appended is None, "Metadata embedding should be bypassed for video"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monkeypatch): async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monkeypatch):
exif_utils = DummyExifUtils() exif_utils = DummyExifUtils()