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 = `
Delete Recipe
Are you sure you want to delete this recipe?
-

+ ${isVideo ?
+ `
` :
+ `

`
+ }
${this.recipe.title}
@@ -255,7 +288,7 @@ class RecipeCard {
`;
-
+
// Show the modal with custom content and setup callbacks
modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback
@@ -264,20 +297,20 @@ class RecipeCard {
deleteBtn.textContent = 'Delete';
deleteBtn.disabled = false;
});
-
+
// Set up the delete and cancel buttons with proper event handlers
const deleteModal = document.getElementById('deleteModal');
const cancelBtn = deleteModal.querySelector('.cancel-btn');
const deleteBtn = deleteModal.querySelector('.delete-btn');
-
+
// Store recipe ID in the modal for the delete confirmation handler
deleteModal.dataset.recipeId = recipeId;
deleteModal.dataset.filePath = filePath;
-
+
// Update button event handlers
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => this.confirmDeleteRecipe();
-
+
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
@@ -287,19 +320,19 @@ class RecipeCard {
confirmDeleteRecipe() {
const deleteModal = document.getElementById('deleteModal');
const recipeId = deleteModal.dataset.recipeId;
-
+
if (!recipeId) {
showToast('toast.recipes.cannotDelete', {}, 'error');
modalManager.closeModal('deleteModal');
return;
}
-
+
// Show loading state
const deleteBtn = deleteModal.querySelector('.delete-btn');
const originalText = deleteBtn.textContent;
deleteBtn.textContent = 'Deleting...';
deleteBtn.disabled = true;
-
+
// Call API to delete the recipe
fetch(`/api/lm/recipe/${recipeId}`, {
method: 'DELETE',
@@ -307,27 +340,27 @@ class RecipeCard {
'Content-Type': 'application/json'
}
})
- .then(response => {
- if (!response.ok) {
- throw new Error('Failed to delete recipe');
- }
- return response.json();
- })
- .then(data => {
- showToast('toast.recipes.deletedSuccessfully', {}, 'success');
-
- state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
-
- modalManager.closeModal('deleteModal');
- })
- .catch(error => {
- console.error('Error deleting recipe:', error);
- showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
-
- // Reset button state
- deleteBtn.textContent = originalText;
- deleteBtn.disabled = false;
- });
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Failed to delete recipe');
+ }
+ return response.json();
+ })
+ .then(data => {
+ showToast('toast.recipes.deletedSuccessfully', {}, 'success');
+
+ state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
+
+ modalManager.closeModal('deleteModal');
+ })
+ .catch(error => {
+ console.error('Error deleting recipe:', error);
+ showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
+
+ // Reset button state
+ deleteBtn.textContent = originalText;
+ deleteBtn.disabled = false;
+ });
}
shareRecipe() {
@@ -338,10 +371,10 @@ class RecipeCard {
showToast('toast.recipes.cannotShare', {}, 'error');
return;
}
-
+
// Show loading toast
showToast('toast.recipes.preparingForSharing', {}, 'info');
-
+
// Call the API to process the image with metadata
fetch(`/api/lm/recipe/${recipeId}/share`)
.then(response => {
@@ -354,17 +387,17 @@ class RecipeCard {
if (!data.success) {
throw new Error(data.error || 'Unknown error');
}
-
+
// Create a temporary anchor element for download
const downloadLink = document.createElement('a');
downloadLink.href = data.download_url;
downloadLink.download = data.filename;
-
+
// Append to body, click and remove
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
-
+
showToast('toast.recipes.downloadStarted', {}, 'success');
})
.catch(error => {
diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js
index d2c9c96a..40842dfd 100644
--- a/static/js/components/shared/ModelCard.js
+++ b/static/js/components/shared/ModelCard.js
@@ -14,11 +14,11 @@ import { eventManager } from '../../utils/EventManager.js';
// Helper function to get display name based on settings
function getDisplayName(model) {
const displayNameSetting = state.global.settings.model_name_display || 'model_name';
-
+
if (displayNameSetting === 'file_name') {
return model.file_name || model.model_name || 'Unknown Model';
}
-
+
return model.model_name || model.file_name || 'Unknown Model';
}
@@ -26,7 +26,7 @@ function getDisplayName(model) {
export function setupModelCardEventDelegation(modelType) {
// Remove any existing handler first
eventManager.removeHandler('click', 'modelCard-delegation');
-
+
// Register model card event delegation with event manager
eventManager.addHandler('click', 'modelCard-delegation', (event) => {
return handleModelCardEvent_internal(event, modelType);
@@ -42,26 +42,26 @@ function handleModelCardEvent_internal(event, modelType) {
// Find the closest card element
const card = event.target.closest('.model-card');
if (!card) return false; // Continue with other handlers
-
+
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return true; // Stop propagation
}
-
+
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return true; // Stop propagation
}
-
+
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return true; // Stop propagation
}
-
+
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
@@ -69,37 +69,37 @@ function handleModelCardEvent_internal(event, modelType) {
}
return true; // Stop propagation
}
-
+
if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation();
handleSendToWorkflow(card, event.shiftKey, modelType);
return true; // Stop propagation
}
-
+
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
handleCopyAction(card, modelType);
return true; // Stop propagation
}
-
+
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath);
return true; // Stop propagation
}
-
+
if (event.target.closest('.fa-image')) {
event.stopPropagation();
getModelApiClient().replaceModelPreview(card.dataset.filepath);
return true; // Stop propagation
}
-
+
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
handleExampleImagesAccess(card, modelType);
return true; // Stop propagation
}
-
+
// If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType);
return false; // Continue with other handlers (e.g., bulk selection)
@@ -110,14 +110,14 @@ function 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) {
@@ -128,13 +128,13 @@ function toggleBlurContent(card) {
function 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) {
@@ -146,10 +146,10 @@ async function toggleFavorite(card) {
const starIcon = card.querySelector('.fa-star');
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
-
+
try {
- await getModelApiClient().saveModelMetadata(card.dataset.filepath, {
- favorite: newFavoriteState
+ await getModelApiClient().saveModelMetadata(card.dataset.filepath, {
+ favorite: newFavoriteState
});
if (newFavoriteState) {
@@ -239,11 +239,11 @@ function handleReplacePreview(filePath, modelType) {
async function handleExampleImagesAccess(card, modelType) {
const modelHash = card.dataset.sha256;
-
+
try {
const response = await fetch(`/api/lm/has-example-images?model_hash=${modelHash}`);
const data = await response.json();
-
+
if (data.has_images) {
openExampleImagesFolder(modelHash);
} else {
@@ -257,7 +257,7 @@ async function handleExampleImagesAccess(card, modelType) {
function handleCardClick(card, modelType) {
const pageState = getCurrentPageState();
-
+
if (state.bulkMode) {
// Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card);
@@ -294,7 +294,7 @@ async function showModelModalFromCard(card, modelType) {
usage_tips: card.dataset.usage_tips,
})
};
-
+
await showModelModal(modelMeta, modelType);
}
@@ -310,9 +310,9 @@ function showExampleAccessModal(card, modelType) {
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
hasRemoteExamples = metaData.images &&
- Array.isArray(metaData.images) &&
- metaData.images.length > 0 &&
- metaData.images[0].url;
+ Array.isArray(metaData.images) &&
+ metaData.images.length > 0 &&
+ metaData.images[0].url;
} catch (e) {
console.error('Error parsing meta data:', e);
}
@@ -329,10 +329,10 @@ function showExampleAccessModal(card, modelType) {
showToast('modelCard.exampleImages.missingHash', {}, 'error');
return;
}
-
+
// Close the modal
modalManager.closeModal('exampleAccessModal');
-
+
try {
// Use the appropriate model API client to download examples
const apiClient = getModelApiClient(modelType);
@@ -456,7 +456,7 @@ export function createModelCard(model, modelType) {
if (model.civitai) {
card.dataset.meta = JSON.stringify(model.civitai || {});
}
-
+
// Store tags if available
if (model.tags && Array.isArray(model.tags)) {
card.dataset.tags = JSON.stringify(model.tags);
@@ -469,7 +469,7 @@ export function createModelCard(model, modelType) {
// Store NSFW level if available
const nsfwLevel = model.preview_nsfw_level !== undefined ? model.preview_nsfw_level : 0;
card.dataset.nsfwLevel = nsfwLevel;
-
+
// 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;
if (shouldBlur) {
@@ -500,7 +500,7 @@ export function createModelCard(model, modelType) {
// Check if autoplayOnHover is enabled for video previews
const autoplayOnHover = state.global?.settings?.autoplay_on_hover || false;
- const isVideo = previewUrl.endsWith('.mp4');
+ const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const videoAttrs = [
'controls',
'muted',
@@ -521,10 +521,10 @@ export function createModelCard(model, modelType) {
}
// 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.addToFavorites', {}, 'Add to favorites');
- const globeTitle = model.from_civitai ?
+ const globeTitle = model.from_civitai ?
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
let sendTitle;
@@ -576,13 +576,13 @@ export function createModelCard(model, modelType) {
card.innerHTML = `
- ${isVideo ?
- `
` :
- `

`
- }
+ ${isVideo ?
+ `
` :
+ `

`
+ }
`;
-
+
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement) {
@@ -756,7 +756,7 @@ function cleanupHoverHandlers(videoElement) {
function requestSafePlay(videoElement) {
const playPromise = videoElement.play();
if (playPromise && typeof playPromise.catch === 'function') {
- playPromise.catch(() => {});
+ playPromise.catch(() => { });
}
}
@@ -878,16 +878,16 @@ export function configureModelCardVideo(videoElement, autoplayOnHover) {
export function updateCardsForBulkMode(isBulkMode) {
// Update the state
state.bulkMode = isBulkMode;
-
+
document.body.classList.toggle('bulk-mode', isBulkMode);
-
+
// Get all lora cards - this can now be from the DOM or through the virtual scroller
const loraCards = document.querySelectorAll('.model-card');
-
+
loraCards.forEach(card => {
// Get all action containers for this card
const actions = card.querySelectorAll('.card-actions');
-
+
// Handle display property based on mode
if (isBulkMode) {
// 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 (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
state.virtualScroller.scheduleRender();
}
-
+
// Apply selection state to cards if entering bulk mode
if (isBulkMode) {
bulkManager.applySelectionState();
diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py
index 93d66300..728e0cec 100644
--- a/tests/routes/test_recipe_routes.py
+++ b/tests/routes/test_recipe_routes.py
@@ -29,6 +29,7 @@ class RecipeRouteHarness:
persistence: "StubPersistenceService"
sharing: "StubSharingService"
downloader: "StubDownloader"
+ civitai: "StubCivitaiClient"
tmp_dir: Path
@@ -122,7 +123,7 @@ class StubPersistenceService:
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
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(
{
"recipe_scanner": recipe_scanner,
@@ -131,6 +132,7 @@ class StubPersistenceService:
"name": name,
"tags": list(tags),
"metadata": metadata,
+ "extension": extension,
}
)
return self.save_result
@@ -189,6 +191,16 @@ class StubDownloader:
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
async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRouteHarness]:
"""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()
scanner = StubRecipeScanner(tmp_path)
+ civitai_client = StubCivitaiClient()
async def fake_get_recipe_scanner():
return scanner
async def fake_get_civitai_client():
- return object()
+ return civitai_client
downloader = StubDownloader()
@@ -232,6 +245,7 @@ async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRou
persistence=StubPersistenceService.instances[-1],
sharing=StubSharingService.instances[-1],
downloader=downloader,
+ civitai=civitai_client,
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]
+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 with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.raise_for_uploaded = RecipeValidationError("No image data provided")
diff --git a/tests/services/test_recipe_services.py b/tests/services/test_recipe_services.py
index d0fc1462..de72fb32 100644
--- a/tests/services/test_recipe_services.py
+++ b/tests/services/test_recipe_services.py
@@ -12,7 +12,12 @@ from py.services.recipes.persistence_service import RecipePersistenceService
class DummyExifUtils:
+ def __init__(self):
+ self.appended = None
+ self.optimized_calls = 0
+
def optimize_image(self, image_data, target_width, format, quality, preserve_metadata):
+ self.optimized_calls += 1
return image_data, ".webp"
def append_recipe_metadata(self, image_path, recipe_data):
@@ -22,6 +27,46 @@ class DummyExifUtils:
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
async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monkeypatch):
exif_utils = DummyExifUtils()