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

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
// Recipe Card Component // Recipe Card Component
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { configureModelCardVideo } from './shared/ModelCard.js';
import { modalManager } from '../managers/ModalManager.js'; import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js'; import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
@@ -38,9 +39,25 @@ class RecipeCard {
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();
@@ -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>
@@ -104,6 +124,13 @@ class RecipeCard {
`; `;
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;
} }
@@ -235,13 +262,19 @@ class RecipeCard {
} }
// 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>
@@ -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() {

View File

@@ -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);
} }
@@ -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',
@@ -577,12 +577,12 @@ 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">
@@ -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(() => { });
} }
} }

View File

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

View File

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