mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 07:05:43 -03:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user