diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 911de839..35469eaf 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -437,6 +437,7 @@ class RecipeManagementHandler: name=payload["name"], tags=payload["tags"], metadata=payload["metadata"], + extension=payload.get("extension"), ) return web.json_response(result.payload, status=result.status) except RecipeValidationError as exc: @@ -625,6 +626,7 @@ class RecipeManagementHandler: name: Optional[str] = None tags: list[str] = [] metadata: Optional[Dict[str, Any]] = None + extension: Optional[str] = None while True: field = await reader.next() @@ -655,6 +657,8 @@ class RecipeManagementHandler: metadata = json.loads(metadata_text) except Exception: metadata = {} + elif field.name == "extension": + extension = await field.text() return { "image_bytes": image_bytes, @@ -662,6 +666,7 @@ class RecipeManagementHandler: "name": name, "tags": tags, "metadata": metadata, + "extension": extension, } def _parse_tags(self, tag_text: Optional[str]) -> list[str]: diff --git a/py/services/recipes/analysis_service.py b/py/services/recipes/analysis_service.py index b7c76afd..cf709743 100644 --- a/py/services/recipes/analysis_service.py +++ b/py/services/recipes/analysis_service.py @@ -13,6 +13,7 @@ import numpy as np from PIL import Image from ...utils.utils import calculate_recipe_fingerprint +from ...utils.civitai_utils import rewrite_preview_url from .errors import ( RecipeDownloadError, RecipeNotFoundError, @@ -94,18 +95,39 @@ class RecipeAnalysisService: if civitai_client is None: raise RecipeServiceError("Civitai client unavailable") - temp_path = self._create_temp_path() + temp_path = None metadata: Optional[dict[str, Any]] = None + is_video = False + extension = ".jpg" # Default + try: civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url) if civitai_match: 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") + image_url = image_info.get("url") if not image_url: raise RecipeDownloadError("No image URL found in Civitai response") + + is_video = image_info.get("type") == "video" + + # Use optimized preview URLs if possible + rewritten_url, _ = rewrite_preview_url(image_url, media_type=image_info.get("type")) + if rewritten_url: + image_url = rewritten_url + + if is_video: + # Extract extension from URL + url_path = image_url.split('?')[0].split('#')[0] + extension = os.path.splitext(url_path)[1].lower() or ".mp4" + else: + extension = ".jpg" + + temp_path = self._create_temp_path(suffix=extension) await self._download_image(image_url, temp_path) + metadata = image_info.get("meta") if "meta" in image_info else None if ( isinstance(metadata, dict) @@ -114,22 +136,31 @@ class RecipeAnalysisService: ): metadata = metadata["meta"] else: + # Basic extension detection for non-Civitai URLs + url_path = url.split('?')[0].split('#')[0] + extension = os.path.splitext(url_path)[1].lower() + if extension in [".mp4", ".webm"]: + is_video = True + else: + extension = ".jpg" + + temp_path = self._create_temp_path(suffix=extension) await self._download_image(url, temp_path) - if metadata is None: + if metadata is None and not is_video: metadata = self._exif_utils.extract_image_metadata(temp_path) - if not metadata: - return self._metadata_not_found_response(temp_path) - return await self._parse_metadata( - metadata, + metadata or {}, recipe_scanner=recipe_scanner, image_path=temp_path, include_image_base64=True, + is_video=is_video, + extension=extension, ) finally: - self._safe_cleanup(temp_path) + if temp_path: + self._safe_cleanup(temp_path) async def analyze_local_image( self, @@ -198,12 +229,16 @@ class RecipeAnalysisService: recipe_scanner, image_path: Optional[str], include_image_base64: bool, + is_video: bool = False, + extension: str = ".jpg", ) -> AnalysisResult: parser = self._recipe_parser_factory.create_parser(metadata) if parser is None: payload = {"error": "No parser found for this image", "loras": []} if include_image_base64 and image_path: payload["image_base64"] = self._encode_file(image_path) + payload["is_video"] = is_video + payload["extension"] = extension return AnalysisResult(payload) result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner) @@ -211,6 +246,9 @@ class RecipeAnalysisService: if include_image_base64 and image_path: result["image_base64"] = self._encode_file(image_path) + result["is_video"] = is_video + result["extension"] = extension + if "error" in result and not result.get("loras"): return AnalysisResult(result) @@ -241,8 +279,8 @@ class RecipeAnalysisService: temp_file.write(data) return temp_file.name - def _create_temp_path(self) -> str: - with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as temp_file: + def _create_temp_path(self, suffix: str = ".jpg") -> str: + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file: return temp_file.name def _safe_cleanup(self, path: Optional[str]) -> None: diff --git a/static/css/components/import-modal.css b/static/css/components/import-modal.css index 82c34672..89be93ad 100644 --- a/static/css/components/import-modal.css +++ b/static/css/components/import-modal.css @@ -1,7 +1,8 @@ /* Import Modal Styles */ .import-step { margin: var(--space-2) 0; - transition: none !important; /* Disable any transitions that might affect display */ + transition: none !important; + /* Disable any transitions that might affect display */ } /* Import Mode Toggle */ @@ -107,7 +108,8 @@ justify-content: center; } -.recipe-image img { +.recipe-image img, +.recipe-preview-video { max-width: 100%; max-height: 100%; object-fit: contain; @@ -379,7 +381,7 @@ .recipe-details-layout { grid-template-columns: 1fr; } - + .recipe-image-container { height: 150px; } @@ -512,14 +514,17 @@ /* Prevent layout shift with scrollbar */ .modal-content { - overflow-y: scroll; /* Always show scrollbar */ - scrollbar-gutter: stable; /* Reserve space for scrollbar */ + overflow-y: scroll; + /* Always show scrollbar */ + scrollbar-gutter: stable; + /* Reserve space for scrollbar */ } /* For browsers that don't support scrollbar-gutter */ @supports not (scrollbar-gutter: stable) { .modal-content { - padding-right: calc(var(--space-2) + var(--scrollbar-width)); /* Add extra padding for scrollbar */ + padding-right: calc(var(--space-2) + var(--scrollbar-width)); + /* Add extra padding for scrollbar */ } } @@ -586,7 +591,8 @@ /* Remove the old warning-message styles that were causing layout issues */ .warning-message { - display: none; /* Hide the old style */ + display: none; + /* Hide the old style */ } /* Update deleted badge to be more prominent */ @@ -613,7 +619,8 @@ color: var(--lora-error); font-size: 0.9em; margin-top: 8px; - min-height: 20px; /* Ensure there's always space for the error message */ + min-height: 20px; + /* Ensure there's always space for the error message */ font-weight: 500; } @@ -662,8 +669,15 @@ } @keyframes fadeIn { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .duplicate-warning { @@ -779,6 +793,7 @@ text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; } @@ -793,9 +808,9 @@ opacity: 0.8; } -.duplicate-recipe-date, +.duplicate-recipe-date, .duplicate-recipe-lora-count { display: flex; align-items: center; gap: 4px; -} +} \ No newline at end of file diff --git a/static/js/managers/import/DownloadManager.js b/static/js/managers/import/DownloadManager.js index dc4b24b7..4e4d1b41 100644 --- a/static/js/managers/import/DownloadManager.js +++ b/static/js/managers/import/DownloadManager.js @@ -12,21 +12,21 @@ export class DownloadManager { async saveRecipe() { // Check if we're in download-only mode (for existing recipe) const isDownloadOnly = !!this.importManager.recipeId; - + if (!isDownloadOnly && !this.importManager.recipeName) { showToast('toast.recipes.enterRecipeName', {}, 'error'); return; } - + try { // Show progress indicator this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...')); - + // Only send the complete recipe to save if not in download-only mode if (!isDownloadOnly) { // Create FormData object for saving recipe const formData = new FormData(); - + // Add image data - depends on import mode if (this.importManager.recipeImage) { // Direct upload @@ -45,10 +45,10 @@ export class DownloadManager { } else { throw new Error('No image data available'); } - + formData.append('name', this.importManager.recipeName); formData.append('tags', JSON.stringify(this.importManager.recipeTags)); - + // Prepare complete metadata including generation parameters const completeMetadata = { base_model: this.importManager.recipeData.base_model || "", @@ -65,7 +65,11 @@ export class DownloadManager { if (checkpointMetadata && typeof checkpointMetadata === 'object') { completeMetadata.checkpoint = checkpointMetadata; } - + + if (this.importManager.recipeData && this.importManager.recipeData.extension) { + formData.append('extension', this.importManager.recipeData.extension); + } + // Add source_path to metadata to track where the recipe was imported from if (this.importManager.importMode === 'url') { const urlInput = document.getElementById('imageUrlInput'); @@ -73,15 +77,15 @@ export class DownloadManager { completeMetadata.source_path = urlInput.value; } } - + formData.append('metadata', JSON.stringify(completeMetadata)); - + // Send save request const response = await fetch('/api/lm/recipes/save', { method: 'POST', body: formData }); - + const result = await response.json(); if (!result.success) { @@ -102,19 +106,19 @@ export class DownloadManager { // Show success message if (isDownloadOnly) { - if (failedDownloads === 0) { + if (failedDownloads === 0) { showToast('toast.loras.downloadSuccessful', {}, 'success'); } } else { showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success'); } - + // Close modal modalManager.closeModal('importModal'); - + // Refresh the recipe window.recipeManager.loadRecipes(); - + } catch (error) { console.error('Error:', error); showToast('toast.recipes.processingError', { message: error.message }, 'error'); @@ -129,49 +133,49 @@ export class DownloadManager { if (!loraRoot) { throw new Error(translate('recipes.controls.import.errors.selectLoraRoot', {}, 'Please select a LoRA root directory')); } - + // Build target path let targetPath = ''; if (this.importManager.selectedFolder) { targetPath = this.importManager.selectedFolder; } - + // Generate a unique ID for this batch download const batchDownloadId = Date.now().toString(); - + // Set up WebSocket for progress updates const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`); - + // Show enhanced loading with progress details for multiple items const updateProgress = this.importManager.loadingManager.showDownloadProgress( this.importManager.downloadableLoRAs.length ); - + let completedDownloads = 0; let failedDownloads = 0; let accessFailures = 0; let currentLoraProgress = 0; - + // Set up progress tracking for current download ws.onmessage = (event) => { const data = JSON.parse(event.data); - + // Handle download ID confirmation if (data.type === 'download_id') { console.log(`Connected to batch download progress with ID: ${data.download_id}`); return; } - + // Process progress updates for our current active download if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) { // Update current LoRA progress currentLoraProgress = data.progress; - + // Get current LoRA name const currentLora = this.importManager.downloadableLoRAs[completedDownloads + failedDownloads]; const loraName = currentLora ? currentLora.name : ''; - + // Update progress display const metrics = { bytesDownloaded: data.bytes_downloaded, @@ -180,7 +184,7 @@ export class DownloadManager { }; updateProgress(currentLoraProgress, completedDownloads, loraName, metrics); - + // Add more detailed status messages based on progress if (currentLoraProgress < 3) { this.importManager.loadingManager.setStatus( @@ -203,17 +207,17 @@ export class DownloadManager { }; const useDefaultPaths = getStorageItem('use_default_path_loras', false); - + for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) { const lora = this.importManager.downloadableLoRAs[i]; - + // Reset current LoRA progress for new download currentLoraProgress = 0; - + // Initial status update for new LoRA - this.importManager.loadingManager.setStatus(translate('recipes.controls.import.startingDownload', { current: i+1, total: this.importManager.downloadableLoRAs.length }, `Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`)); + this.importManager.loadingManager.setStatus(translate('recipes.controls.import.startingDownload', { current: i + 1, total: this.importManager.downloadableLoRAs.length }, `Starting download for LoRA ${i + 1}/${this.importManager.downloadableLoRAs.length}`)); updateProgress(0, completedDownloads, lora.name); - + try { // Download the LoRA with download ID const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel( @@ -224,7 +228,7 @@ export class DownloadManager { useDefaultPaths, batchDownloadId ); - + if (!response.success) { console.error(`Failed to download LoRA ${lora.name}: ${response.error}`); @@ -248,28 +252,28 @@ export class DownloadManager { // Continue with next download } } - + // Close WebSocket ws.close(); - + // Show appropriate completion message based on results if (failedDownloads === 0) { showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success'); } else { if (accessFailures > 0) { - showToast('toast.loras.downloadPartialWithAccess', { - completed: completedDownloads, + showToast('toast.loras.downloadPartialWithAccess', { + completed: completedDownloads, total: this.importManager.downloadableLoRAs.length, accessFailures: accessFailures }, 'error'); } else { - showToast('toast.loras.downloadPartialSuccess', { - completed: completedDownloads, - total: this.importManager.downloadableLoRAs.length + showToast('toast.loras.downloadPartialSuccess', { + completed: completedDownloads, + total: this.importManager.downloadableLoRAs.length }, 'error'); } } - + return failedDownloads; } } diff --git a/static/js/managers/import/RecipeDataManager.js b/static/js/managers/import/RecipeDataManager.js index 8f351859..da11f25c 100644 --- a/static/js/managers/import/RecipeDataManager.js +++ b/static/js/managers/import/RecipeDataManager.js @@ -8,10 +8,10 @@ export class RecipeDataManager { showRecipeDetailsStep() { this.importManager.stepManager.showStep('detailsStep'); - + // Set default recipe name from prompt or image filename const recipeName = document.getElementById('recipeName'); - + // Check if we have recipe metadata from a shared recipe if (this.importManager.recipeData && this.importManager.recipeData.from_recipe_metadata) { // Use title from recipe metadata @@ -19,24 +19,24 @@ export class RecipeDataManager { recipeName.value = this.importManager.recipeData.title; this.importManager.recipeName = this.importManager.recipeData.title; } - + // Use tags from recipe metadata if (this.importManager.recipeData.tags && Array.isArray(this.importManager.recipeData.tags)) { this.importManager.recipeTags = [...this.importManager.recipeData.tags]; this.updateTagsDisplay(); } - } else if (this.importManager.recipeData && - this.importManager.recipeData.gen_params && - this.importManager.recipeData.gen_params.prompt) { + } else if (this.importManager.recipeData && + this.importManager.recipeData.gen_params && + this.importManager.recipeData.gen_params.prompt) { // Use the first 10 words from the prompt as the default recipe name const promptWords = this.importManager.recipeData.gen_params.prompt.split(' '); const truncatedPrompt = promptWords.slice(0, 10).join(' '); recipeName.value = truncatedPrompt; this.importManager.recipeName = truncatedPrompt; - + // Set up click handler to select all text for easy editing if (!recipeName.hasSelectAllHandler) { - recipeName.addEventListener('click', function() { + recipeName.addEventListener('click', function () { this.select(); }); recipeName.hasSelectAllHandler = true; @@ -47,15 +47,15 @@ export class RecipeDataManager { recipeName.value = fileName; this.importManager.recipeName = fileName; } - + // Always set up click handler for easy editing if not already set if (!recipeName.hasSelectAllHandler) { - recipeName.addEventListener('click', function() { + recipeName.addEventListener('click', function () { this.select(); }); recipeName.hasSelectAllHandler = true; } - + // Display the uploaded image in the preview const imagePreview = document.getElementById('recipeImagePreview'); if (imagePreview) { @@ -67,13 +67,24 @@ export class RecipeDataManager { }; reader.readAsDataURL(this.importManager.recipeImage); } else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) { - // For URL mode - use the base64 image data returned from the backend - imagePreview.innerHTML = `${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}`; + // For URL mode - use the base64 data returned from the backend + if (this.importManager.recipeData.is_video) { + const mimeType = this.importManager.recipeData.extension === '.webm' ? 'video/webm' : 'video/mp4'; + imagePreview.innerHTML = ``; + } else { + imagePreview.innerHTML = `${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}`; + } } else if (this.importManager.importMode === 'url') { // Fallback for URL mode if no base64 data const urlInput = document.getElementById('imageUrlInput'); if (urlInput && urlInput.value) { - imagePreview.innerHTML = `${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}`; + const url = urlInput.value.toLowerCase(); + if (url.endsWith('.mp4') || url.endsWith('.webm')) { + const mimeType = url.endsWith('.webm') ? 'video/webm' : 'video/mp4'; + imagePreview.innerHTML = ``; + } else { + imagePreview.innerHTML = `${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}`; + } } } } @@ -85,7 +96,7 @@ export class RecipeDataManager { if (loraCountInfo) { loraCountInfo.textContent = translate('recipes.controls.import.loraCountInfo', { existing: existingLoras, total: totalLoras }, `(${existingLoras}/${totalLoras} in library)`); } - + // Display LoRAs list const lorasList = document.getElementById('lorasList'); if (lorasList) { @@ -94,7 +105,7 @@ export class RecipeDataManager { const isDeleted = lora.isDeleted; const isEarlyAccess = lora.isEarlyAccess; const localPath = lora.localPath || ''; - + // Create status badge based on LoRA status let statusBadge; if (isDeleted) { @@ -102,7 +113,7 @@ export class RecipeDataManager { ${translate('recipes.controls.import.deletedFromCivitai', {}, 'Deleted from Civitai')} `; } else { - statusBadge = existsLocally ? + statusBadge = existsLocally ? `
${translate('recipes.controls.import.inLibrary', {}, 'In Library')}
${localPath}
@@ -126,7 +137,7 @@ export class RecipeDataManager { console.warn('Failed to format early access date', e); } } - + earlyAccessBadge = `
${translate('recipes.controls.import.earlyAccess', {}, 'Early Access')}
${earlyAccessInfo} ${translate('recipes.controls.import.verifyEarlyAccess', {}, 'Verify that you have purchased early access before downloading.')}
@@ -134,7 +145,7 @@ export class RecipeDataManager { } // Format size if available - const sizeDisplay = lora.size ? + const sizeDisplay = lora.size ? `
${this.importManager.formatFileSize(lora.size)}
` : ''; return ` @@ -161,9 +172,9 @@ export class RecipeDataManager { `; }).join(''); } - + // Check for early access loras and show warning if any exist - const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora => + const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora => lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted); if (earlyAccessLoras.length > 0) { // Show a warning about early access loras @@ -179,7 +190,7 @@ export class RecipeDataManager {
`; - + // Show the warning message const buttonsContainer = document.querySelector('#detailsStep .modal-actions'); if (buttonsContainer) { @@ -188,7 +199,7 @@ export class RecipeDataManager { if (existingWarning) { existingWarning.remove(); } - + // Add new warning const warningContainer = document.createElement('div'); warningContainer.id = 'earlyAccessWarning'; @@ -196,27 +207,27 @@ export class RecipeDataManager { buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); } } - + // Check for duplicate recipes and display warning if found this.checkAndDisplayDuplicates(); - + // Update Next button state based on missing LoRAs and duplicates this.updateNextButtonState(); } - + checkAndDisplayDuplicates() { // Check if we have duplicate recipes - if (this.importManager.recipeData && - this.importManager.recipeData.matching_recipes && + if (this.importManager.recipeData && + this.importManager.recipeData.matching_recipes && this.importManager.recipeData.matching_recipes.length > 0) { - + // Store duplicates in the importManager for later use this.importManager.duplicateRecipes = this.importManager.recipeData.matching_recipes; - + // Create duplicate warning container - const duplicateContainer = document.getElementById('duplicateRecipesContainer') || + const duplicateContainer = document.getElementById('duplicateRecipesContainer') || this.createDuplicateContainer(); - + // Format date helper function const formatDate = (timestamp) => { try { @@ -226,7 +237,7 @@ export class RecipeDataManager { return 'Unknown date'; } }; - + // Generate the HTML for duplicate recipes warning duplicateContainer.innerHTML = `
@@ -262,10 +273,10 @@ export class RecipeDataManager { `).join('')}
`; - + // Show the duplicate container duplicateContainer.style.display = 'block'; - + // Add click event for the toggle button const toggleButton = document.getElementById('toggleDuplicatesList'); if (toggleButton) { @@ -290,49 +301,49 @@ export class RecipeDataManager { if (duplicateContainer) { duplicateContainer.style.display = 'none'; } - + // Reset duplicate tracking this.importManager.duplicateRecipes = []; } } - + createDuplicateContainer() { // Find where to insert the duplicate container const lorasListContainer = document.querySelector('.input-group:has(#lorasList)'); - + if (!lorasListContainer) return null; - + // Create container const duplicateContainer = document.createElement('div'); duplicateContainer.id = 'duplicateRecipesContainer'; duplicateContainer.className = 'duplicate-recipes-container'; - + // Insert before the LoRA list lorasListContainer.parentNode.insertBefore(duplicateContainer, lorasListContainer); - + return duplicateContainer; } - + updateNextButtonState() { const nextButton = document.querySelector('#detailsStep .primary-btn'); const actionsContainer = document.querySelector('#detailsStep .modal-actions'); if (!nextButton || !actionsContainer) return; - + // Always clean up previous warnings and buttons first const existingWarning = document.getElementById('deletedLorasWarning'); if (existingWarning) { existingWarning.remove(); } - + // Remove any existing "import anyway" button const importAnywayBtn = document.getElementById('importAnywayBtn'); if (importAnywayBtn) { importAnywayBtn.remove(); } - + // Count deleted LoRAs const deletedLoras = this.importManager.recipeData.loras.filter(lora => lora.isDeleted).length; - + // If we have deleted LoRAs, show a warning if (deletedLoras > 0) { // Create a new warning container above the buttons @@ -340,7 +351,7 @@ export class RecipeDataManager { const warningContainer = document.createElement('div'); warningContainer.id = 'deletedLorasWarning'; warningContainer.className = 'deleted-loras-warning'; - + // Create warning message warningContainer.innerHTML = `
@@ -349,19 +360,19 @@ export class RecipeDataManager {
These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.
`; - + // Insert before the buttons container buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); } - + // Check for duplicates but don't change button actions const missingNotDeleted = this.importManager.recipeData.loras.filter( lora => !lora.existsLocally && !lora.isDeleted ).length; - + // Standard button behavior regardless of duplicates nextButton.classList.remove('warning-btn'); - + if (missingNotDeleted > 0) { nextButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs'); } else { @@ -372,30 +383,30 @@ export class RecipeDataManager { addTag() { const tagInput = document.getElementById('tagInput'); const tag = tagInput.value.trim(); - + if (!tag) return; - + if (!this.importManager.recipeTags.includes(tag)) { this.importManager.recipeTags.push(tag); this.updateTagsDisplay(); } - + tagInput.value = ''; } - + removeTag(tag) { this.importManager.recipeTags = this.importManager.recipeTags.filter(t => t !== tag); this.updateTagsDisplay(); } - + updateTagsDisplay() { const tagsContainer = document.getElementById('tagsContainer'); - + if (this.importManager.recipeTags.length === 0) { tagsContainer.innerHTML = `
${translate('recipes.controls.import.noTagsAdded', {}, 'No tags added')}
`; return; } - + tagsContainer.innerHTML = this.importManager.recipeTags.map(tag => `
${tag} @@ -410,7 +421,7 @@ export class RecipeDataManager { showToast('toast.recipes.enterRecipeName', {}, 'error'); return; } - + // Automatically mark all deleted LoRAs as excluded if (this.importManager.recipeData && this.importManager.recipeData.loras) { this.importManager.recipeData.loras.forEach(lora => { @@ -419,11 +430,11 @@ export class RecipeDataManager { } }); } - + // Update missing LoRAs list to exclude deleted LoRAs - this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora => + this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora => !lora.existsLocally && !lora.isDeleted); - + // If we have downloadable missing LoRAs, go to location step if (this.importManager.missingLoras.length > 0) { // Store only downloadable LoRAs for the download step diff --git a/tests/services/test_recipe_services.py b/tests/services/test_recipe_services.py index de72fb32..8e33e1e0 100644 --- a/tests/services/test_recipe_services.py +++ b/tests/services/test_recipe_services.py @@ -95,7 +95,7 @@ async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monke temp_path = tmp_path / "temp.jpg" - def create_temp_path(): + def create_temp_path(suffix=".jpg"): temp_path.write_bytes(b"") return str(temp_path) @@ -401,3 +401,55 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path): assert stored["loras"] == [] assert stored["title"] == "recipe" assert scanner.added and scanner.added[0]["loras"] == [] + + +@pytest.mark.asyncio +async def test_analyze_remote_video(tmp_path): + exif_utils = DummyExifUtils() + + class DummyFactory: + def create_parser(self, metadata): + async def parse_metadata(m, recipe_scanner): + return {"loras": []} + return SimpleNamespace(parse_metadata=parse_metadata) + + async def downloader_factory(): + class Downloader: + async def download_file(self, url, path, use_auth=False): + Path(path).write_bytes(b"video-content") + return True, "success" + + return Downloader() + + service = RecipeAnalysisService( + exif_utils=exif_utils, + recipe_parser_factory=DummyFactory(), + downloader_factory=downloader_factory, + metadata_collector=None, + metadata_processor_cls=None, + metadata_registry_cls=None, + standalone_mode=False, + logger=logging.getLogger("test"), + ) + + class DummyClient: + async def get_image_info(self, image_id): + return { + "url": "https://civitai.com/video.mp4", + "type": "video", + "meta": {"prompt": "video prompt"}, + } + + class DummyScanner: + async def find_recipes_by_fingerprint(self, fingerprint): + return [] + + result = await service.analyze_remote_image( + url="https://civitai.com/images/123", + recipe_scanner=DummyScanner(), + civitai_client=DummyClient(), + ) + + assert result.payload["is_video"] is True + assert result.payload["extension"] == ".mp4" + assert result.payload["image_base64"] is not None