From 30fd0470deeca6c9a5aaabafdbdd105030763f52 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 21 Dec 2025 20:00:44 +0800 Subject: [PATCH] feat: Add support for video recipe previews by conditionally optimizing media during persistence and updating UI components to display videos. --- py/routes/handlers/recipe_handlers.py | 30 +++- py/services/recipes/persistence_service.py | 26 ++-- static/js/components/RecipeCard.js | 159 +++++++++++++-------- static/js/components/shared/ModelCard.js | 96 ++++++------- tests/routes/test_recipe_routes.py | 53 ++++++- tests/services/test_recipe_services.py | 45 ++++++ 6 files changed, 283 insertions(+), 126 deletions(-) diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index cee3ad0c..911de839 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -23,6 +23,7 @@ from ...services.recipes import ( RecipeValidationError, ) from ...services.metadata_service import get_default_metadata_provider +from ...utils.civitai_utils import rewrite_preview_url Logger = logging.Logger EnsureDependenciesCallable = Callable[[], Awaitable[None]] @@ -455,6 +456,7 @@ class RecipeManagementHandler: image_url = params.get("image_url") name = params.get("name") resources_raw = params.get("resources") + if not image_url: raise RecipeValidationError("Missing required field: image_url") if not name: @@ -483,7 +485,7 @@ class RecipeManagementHandler: metadata["base_model"] = base_model_from_metadata 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( recipe_scanner=recipe_scanner, @@ -492,6 +494,7 @@ class RecipeManagementHandler: name=name, tags=tags, metadata=metadata, + extension=extension, ) return web.json_response(result.payload, status=result.status) except RecipeValidationError as exc: @@ -729,7 +732,7 @@ class RecipeManagementHandler: "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() downloader = await self._downloader_factory() temp_path = None @@ -744,15 +747,31 @@ class RecipeManagementHandler: image_info = await civitai_client.get_image_info(civitai_match.group(1)) if not image_info: 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") + + # 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) if not success: 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: - return file_obj.read() + return file_obj.read(), extension except RecipeDownloadError: raise except RecipeValidationError: @@ -766,6 +785,7 @@ class RecipeManagementHandler: except FileNotFoundError: pass + def _safe_int(self, value: Any) -> int: try: return int(value) diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index 2640035e..535f0853 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -46,6 +46,7 @@ class RecipePersistenceService: name: str | None, tags: Iterable[str], metadata: Optional[dict[str, Any]], + extension: str | None = None, ) -> PersistenceResult: """Persist a user uploaded recipe.""" @@ -64,13 +65,21 @@ class RecipePersistenceService: os.makedirs(recipes_dir, exist_ok=True) recipe_id = str(uuid.uuid4()) - 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, - ) + + # Handle video formats by bypassing optimization and metadata embedding + is_video = extension in [".mp4", ".webm"] + if is_video: + optimized_image = resolved_image_bytes + # 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_path = os.path.join(recipes_dir, image_filename) 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: 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) await recipe_scanner.add_recipe(recipe_data) diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index dec61fa0..2c42ddf8 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -1,5 +1,6 @@ // Recipe Card Component import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js'; +import { configureModelCardVideo } from './shared/ModelCard.js'; import { modalManager } from '../managers/ModalManager.js'; import { getCurrentPageState } from '../state/index.js'; import { state } from '../state/index.js'; @@ -10,11 +11,11 @@ class RecipeCard { this.recipe = recipe; this.clickHandler = clickHandler; this.element = this.createCardElement(); - + // Store reference to this instance on the DOM element for updates this.element._recipeCardInstance = this; } - + createCardElement() { const card = document.createElement('div'); card.className = 'model-card'; @@ -23,24 +24,40 @@ class RecipeCard { card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0; card.dataset.created = this.recipe.created_date; card.dataset.id = this.recipe.id || ''; - + // Get base model with fallback const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown'; const baseModelAbbreviation = getBaseModelAbbreviation(baseModelLabel); const baseModelDisplay = baseModelLabel === 'Unknown' ? 'Unknown' : baseModelAbbreviation; - + // Ensure loras array exists const loras = this.recipe.loras || []; const lorasCount = loras.length; - + // Check if all LoRAs are available in the library const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length; const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0; - + // Ensure file_url exists, fallback to file_path if needed - const imageUrl = this.recipe.file_url || - (this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` : - '/loras_static/images/no-preview.png'); + const previewUrl = this.recipe.file_url || + (this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` : + '/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 const pageState = getCurrentPageState(); @@ -49,7 +66,7 @@ class RecipeCard { // NSFW blur logic - similar to LoraCard 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; - + if (shouldBlur) { card.classList.add('nsfw-content'); } @@ -66,11 +83,14 @@ class RecipeCard { card.innerHTML = `
- ${this.recipe.title} + ${isVideo ? + `` : + `${this.recipe.title}` + } ${!isDuplicatesMode ? `
- ${shouldBlur ? - `` : ''} ${baseModelDisplay} @@ -102,30 +122,37 @@ class RecipeCard {
`; - + this.attachEventListeners(card, isDuplicatesMode, shouldBlur); + + // Add video auto-play on hover functionality if needed + const videoElement = card.querySelector('video'); + if (videoElement) { + configureModelCardVideo(videoElement, autoplayOnHover); + } + return card; } - + getLoraStatusTitle(totalCount, missingCount) { if (totalCount === 0) return "No LoRAs in this recipe"; if (missingCount === 0) return "All LoRAs available - Ready to use"; return `${missingCount} of ${totalCount} LoRAs missing`; } - + attachEventListeners(card, isDuplicatesMode, shouldBlur) { // Add blur toggle functionality if content should be blurred if (shouldBlur) { const toggleBtn = card.querySelector('.toggle-blur-btn'); const showBtn = card.querySelector('.show-content-btn'); - + if (toggleBtn) { toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleBlurContent(card); }); } - + if (showBtn) { showBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -139,19 +166,19 @@ class RecipeCard { card.addEventListener('click', () => { this.clickHandler(this.recipe); }); - + // Share button click event - prevent propagation to card card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { e.stopPropagation(); this.shareRecipe(); }); - + // Send button click event - prevent propagation to card card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => { e.stopPropagation(); this.sendRecipeToWorkflow(e.shiftKey); }); - + // Delete button click event - prevent propagation to card card.querySelector('.fa-trash')?.addEventListener('click', (e) => { e.stopPropagation(); @@ -159,19 +186,19 @@ class RecipeCard { }); } } - + toggleBlurContent(card) { const preview = card.querySelector('.card-preview'); const isBlurred = preview.classList.toggle('blurred'); const icon = card.querySelector('.toggle-blur-btn i'); - + // Update the icon based on blur state if (isBlurred) { icon.className = 'fas fa-eye'; } else { icon.className = 'fas fa-eye-slash'; } - + // Toggle the overlay visibility const overlay = card.querySelector('.nsfw-overlay'); if (overlay) { @@ -182,13 +209,13 @@ class RecipeCard { showBlurredContent(card) { const preview = card.querySelector('.card-preview'); preview.classList.remove('blurred'); - + // Update the toggle button icon const toggleBtn = card.querySelector('.toggle-blur-btn'); if (toggleBtn) { toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; } - + // Hide the overlay const overlay = card.querySelector('.nsfw-overlay'); if (overlay) { @@ -223,7 +250,7 @@ class RecipeCard { showToast('toast.recipes.sendError', {}, 'error'); } } - + showDeleteConfirmation() { try { // Get recipe ID @@ -233,15 +260,21 @@ class RecipeCard { showToast('toast.recipes.cannotDelete', {}, 'error'); return; } - + // 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 = `