From 151a4675984fc32cf0a3341e9b8b07f53f756f86 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Wed, 3 Jun 2026 17:50:58 +0800 Subject: [PATCH] feat(recipe): add Create As Recipe from example images with import dedup check (#945) --- locales/de.json | 4 + locales/en.json | 4 + locales/es.json | 4 + locales/fr.json | 4 + locales/he.json | 4 + locales/ja.json | 4 + locales/ko.json | 4 + locales/ru.json | 4 + locales/zh-CN.json | 4 + locales/zh-TW.json | 4 + py/recipes/base.py | 49 +++- py/routes/handlers/recipe_handlers.py | 250 +++++++++++++++++- py/routes/recipe_route_registrar.py | 3 + py/services/recipes/persistence_service.py | 4 + py/utils/civitai_utils.py | 42 +++ static/js/components/shared/ModelModal.js | 2 +- .../components/shared/showcase/MediaUtils.js | 97 +++++++ .../shared/showcase/ShowcaseView.js | 16 ++ 18 files changed, 497 insertions(+), 6 deletions(-) diff --git a/locales/de.json b/locales/de.json index 004a98af..449c8ecd 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1668,6 +1668,10 @@ "noRecipeId": "Keine Rezept-ID verfügbar", "sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}", "copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}", + "createError": "Fehler beim Erstellen des Rezepts:{message}", + "createFailed": "Fehler beim Erstellen des Rezepts:{error}", + "createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen", + "created": "Rezept erfolgreich erstellt", "noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", "missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", "preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download", diff --git a/locales/en.json b/locales/en.json index b4a32469..f8af5892 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1668,6 +1668,10 @@ "noRecipeId": "No recipe ID available", "sendToWorkflowFailed": "Failed to send recipe to workflow: {message}", "copyFailed": "Error copying recipe syntax: {message}", + "createError": "Error creating recipe: {message}", + "createFailed": "Failed to create recipe: {error}", + "createMissingData": "Missing required data to create recipe", + "created": "Recipe created successfully", "noMissingLoras": "No missing LoRAs to download", "missingLorasInfoFailed": "Failed to get information for missing LoRAs", "preparingForDownloadFailed": "Error preparing LoRAs for download", diff --git a/locales/es.json b/locales/es.json index 02118d5e..99992111 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1668,6 +1668,10 @@ "noRecipeId": "No hay ID de receta disponible", "sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}", "copyFailed": "Error copiando sintaxis de receta: {message}", + "createError": "Error al crear la receta:{message}", + "createFailed": "Error al crear la receta:{error}", + "createMissingData": "Faltan datos necesarios para crear la receta", + "created": "Receta creada exitosamente", "noMissingLoras": "No hay LoRAs faltantes para descargar", "missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes", "preparingForDownloadFailed": "Error preparando LoRAs para descarga", diff --git a/locales/fr.json b/locales/fr.json index 1cc64a65..8e28d0cc 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1668,6 +1668,10 @@ "noRecipeId": "Aucun ID de recipe disponible", "sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}", "copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}", + "createError": "Erreur lors de la création du Recipe :{message}", + "createFailed": "Échec de la création du Recipe :{error}", + "createMissingData": "Données requises manquantes pour créer le Recipe", + "created": "Recipe créé avec succès", "noMissingLoras": "Aucun LoRA manquant à télécharger", "missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", "preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement", diff --git a/locales/he.json b/locales/he.json index e974aecc..e4e26828 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1668,6 +1668,10 @@ "noRecipeId": "אין מזהה מתכון זמין", "sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}", "copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}", + "createError": "שגיאה ביצירת המתכון:{message}", + "createFailed": "יצירת המתכון נכשלה:{error}", + "createMissingData": "חסרים נתונים נדרשים ליצירת המתכון", + "created": "המתכון נוצר בהצלחה", "noMissingLoras": "אין LoRAs חסרים להורדה", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה", diff --git a/locales/ja.json b/locales/ja.json index dab1065f..8fce6a27 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1668,6 +1668,10 @@ "noRecipeId": "レシピIDが利用できません", "sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}", "copyFailed": "レシピ構文のコピーエラー:{message}", + "createError": "レシピ作成中にエラーが発生しました:{message}", + "createFailed": "レシピの作成に失敗しました:{error}", + "createMissingData": "レシピ作成に必要なデータが不足しています", + "created": "レシピを作成しました", "noMissingLoras": "ダウンロードする不足LoRAがありません", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", "preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました", diff --git a/locales/ko.json b/locales/ko.json index 4e8abe85..41e19adf 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1668,6 +1668,10 @@ "noRecipeId": "사용 가능한 레시피 ID가 없습니다", "sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}", "copyFailed": "레시피 문법 복사 오류: {message}", + "createError": "레시피 생성 중 오류 발생:{message}", + "createFailed": "레시피 생성 실패:{error}", + "createMissingData": "레시피 생성에 필요한 데이터가 없습니다", + "created": "레시피가 생성되었습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "preparingForDownloadFailed": "LoRA 다운로드 준비 오류", diff --git a/locales/ru.json b/locales/ru.json index c9b626fd..61ddfe90 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1668,6 +1668,10 @@ "noRecipeId": "ID рецепта недоступен", "sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}", "copyFailed": "Ошибка копирования синтаксиса рецепта: {message}", + "createError": "Ошибка при создании рецепта:{message}", + "createFailed": "Не удалось создать рецепт:{error}", + "createMissingData": "Отсутствуют необходимые данные для создания рецепта", + "created": "Рецепт успешно создан", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 184a6893..e4976135 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1668,6 +1668,10 @@ "noRecipeId": "无配方 ID", "sendToWorkflowFailed": "发送配方到工作流失败:{message}", "copyFailed": "复制配方语法出错:{message}", + "createError": "创建配方时出错:{message}", + "createFailed": "创建配方失败:{error}", + "createMissingData": "缺少创建配方所需的数据", + "created": "配方创建成功", "noMissingLoras": "没有缺失的 LoRA 可下载", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败", "preparingForDownloadFailed": "准备下载 LoRA 时出错", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 0baf0b28..75b4b86e 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1668,6 +1668,10 @@ "noRecipeId": "無配方 ID", "sendToWorkflowFailed": "傳送配方到工作流失敗:{message}", "copyFailed": "複製配方語法錯誤:{message}", + "createError": "建立配方時發生錯誤:{message}", + "createFailed": "建立配方失敗:{error}", + "createMissingData": "缺少建立配方所需的資料", + "created": "配方建立成功", "noMissingLoras": "無缺少的 LoRA 可下載", "missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", "preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤", diff --git a/py/recipes/base.py b/py/recipes/base.py index cb57a056..036b9dd0 100644 --- a/py/recipes/base.py +++ b/py/recipes/base.py @@ -58,9 +58,52 @@ class RecipeMetadataParser(ABC): civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None) if not civitai_info or error_msg == "Model not found": - # Model not found or deleted - lora_entry['isDeleted'] = True - lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png' + # CivitAI may fail to resolve a hash that is still being + # computed (known CivitAI issue). Before marking as deleted, + # try to reconcile with a local model that has the same + # filename and matching AutoV3 hash. + reconciled = False + file_name = lora_entry.get("file_name") + if file_name and recipe_scanner and hash_value: + lora_scanner = getattr(recipe_scanner, "_lora_scanner", None) + if lora_scanner: + try: + # Local import to avoid circular dependency: + # base.py → file_utils → settings_manager → ... + # → recipe_scanner → enrichment → base.py + from ..utils.file_utils import calculate_autov3 # fmt: skip + cache = await lora_scanner.get_cached_data() + for item in getattr(cache, "raw_data", []): + if item.get("file_name") == file_name: + local_path = item.get("file_path") + if local_path and os.path.exists(local_path): + local_autov3 = calculate_autov3(local_path) + if local_autov3 and local_autov3 == hash_value: + lora_entry["existsLocally"] = True + lora_entry["localPath"] = local_path + lora_entry["hash"] = item.get("sha256", hash_value) + if "preview_url" in item: + lora_entry["thumbnailUrl"] = config.get_preview_static_url(item["preview_url"]) + civ = item.get("civitai") or {} + if isinstance(civ, dict): + if civ.get("id") is not None: + lora_entry["id"] = civ["id"] + if civ.get("modelId") is not None: + lora_entry["modelId"] = civ["modelId"] + if civ.get("name"): + lora_entry["version"] = civ["name"] + # model_name is the CivitAI model display + # name stored directly in the cache column. + cached_model_name = item.get("model_name") + if cached_model_name: + lora_entry["name"] = cached_model_name + reconciled = True + break + except Exception: + pass + if not reconciled: + lora_entry['isDeleted'] = True + lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png' return lora_entry # Get model type and validate diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index e34b662f..33d8aebe 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -16,7 +16,7 @@ from aiohttp import web from ...config import config from ...services.server_i18n import server_i18n as default_server_i18n -from ...services.settings_manager import SettingsManager +from ...services.settings_manager import SettingsManager, get_settings_manager from ...services.recipes import ( RecipeAnalysisService, RecipeDownloadError, @@ -26,7 +26,12 @@ from ...services.recipes import ( RecipeValidationError, ) from ...services.metadata_service import get_default_metadata_provider -from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url +from ...utils.civitai_utils import ( + build_civitai_image_page_url, + extract_civitai_image_id, + extract_civitai_image_id_from_cdn_url, + rewrite_preview_url, +) from ...utils.exif_utils import ExifUtils from ...recipes.merger import GenParamsMerger from ...recipes.enrichment import RecipeEnricher @@ -96,6 +101,7 @@ class RecipeHandlerSet: "browse_directory": self.batch_import.browse_directory, "check_image_exists": self.management.check_image_exists, "import_from_url": self.management.import_from_url, + "create_from_example": self.management.create_from_example, } @@ -1668,6 +1674,246 @@ class RecipeManagementHandler: ) return web.json_response(result.payload, status=result.status) + async def create_from_example(self, request: web.Request) -> web.Response: + """Create a recipe from a model's example image using cached metadata. + + Uses the image's meta data (already cached in .metadata.json from the + CivitAI model-versions API) to create a recipe without additional + CivitAI API calls. + + If the image metadata doesn't contain any resources of the parent + model's type (LoRA-type or Checkpoint), the parent model is + auto-populated as a fallback. + + Request body: + image_data (dict): The full image object from model-versions API + (includes meta, additionalResources, url, etc.) + model_hash (str): SHA256 hash of the parent model + model_name (str): Filename of the parent model + model_type (str): Page type (``"loras"``, ``"checkpoints"``, etc.) + local_image_path (str, optional): Local filesystem path to read + the image bytes for the recipe preview + """ + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + data = await request.json() + image_data = data.get("image_data") + model_hash = data.get("model_hash") + model_name = data.get("model_name") + model_type = data.get("model_type", "") + + if not image_data or not model_hash or not model_name: + raise RecipeValidationError( + "Missing required fields: image_data, model_hash, model_name" + ) + + # Merge nested meta into top level so the parser finds everything. + # CivitaiApiMetadataParser expects prompt, seed, resources, etc. + # at the top level or wrapped under a "meta" key. + inner_meta = image_data.get("meta") or {} + parsed_input = {**image_data, **inner_meta} + parsed_input.pop("meta", None) + + parser = self._analysis_service._recipe_parser_factory.create_parser( + parsed_input + ) + if not parser: + raise RecipeValidationError("Unable to parse image metadata") + + parsed = await parser.parse_metadata( + parsed_input, recipe_scanner=recipe_scanner + ) + + loras = list(parsed.get("loras") or []) + checkpoint = parsed.get("model") + is_lora_type = model_type.startswith("lora") + is_ckpt_type = model_type.startswith("checkpoint") + + # Look up parent model's cached CivitAI metadata (version ID, + # version name, model ID) from the scanner cache. Used to fix + # isDeleted entries and enrich auto-populated ones. + parent_civitai_id: int | None = None + parent_model_id: int | None = None + parent_version_name: str | None = None + parent_model_name: str | None = None + lora_scanner = getattr(recipe_scanner, "_lora_scanner", None) + if lora_scanner and model_hash: + try: + parent_cache = await lora_scanner.get_cached_data() + for item in getattr(parent_cache, "raw_data", []): + if item.get("sha256", "").lower() == model_hash.lower(): + civ = item.get("civitai") or {} + if isinstance(civ, dict): + parent_civitai_id = civ.get("id") + parent_model_id = civ.get("modelId") + parent_version_name = civ.get("name") + # model_name is a flat SQLite column holding the + # CivitAI model display name (not nested under + # civitai.model which only stores type). + parent_model_name = item.get("model_name") + + break + else: + pass + except Exception: + pass + + # Reconcile isDeleted entries against the parent model. + # When the CivitAI hash lookup fails (known issue — hashes not + # yet computed), the parser marks the entry isDeleted even though + # the model exists locally. + if is_lora_type: + for lora in loras: + if lora.get("isDeleted") and lora.get("file_name") == model_name: + lora["isDeleted"] = False + lora["existsLocally"] = True + lora["hash"] = model_hash + if parent_civitai_id is not None: + lora["id"] = parent_civitai_id + if parent_model_id is not None: + lora["modelId"] = parent_model_id + if parent_version_name is not None: + lora["version"] = parent_version_name + if parent_model_name is not None: + lora["name"] = parent_model_name + elif is_ckpt_type and checkpoint and checkpoint.get("isDeleted"): + if checkpoint.get("file_name") == model_name: + checkpoint["isDeleted"] = False + checkpoint["existsLocally"] = True + checkpoint["hash"] = model_hash + if parent_civitai_id is not None: + checkpoint["id"] = parent_civitai_id + if parent_model_id is not None: + checkpoint["modelId"] = parent_model_id + if parent_version_name is not None: + checkpoint["version"] = parent_version_name + + # Auto-populate parent model only when the image metadata didn't + # contain any resources of that type. + if is_lora_type and not loras: + lora_entry = { + "name": model_name, + "type": "lora", + "weight": 1.0, + "hash": model_hash, + "existsLocally": True, + "localPath": None, + "file_name": model_name, + "thumbnailUrl": "/loras_static/images/no-preview.png", + "baseModel": parsed.get("base_model", ""), + "size": 0, + "downloadUrl": "", + "isDeleted": False, + } + if parent_civitai_id is not None: + lora_entry["id"] = parent_civitai_id + if parent_model_id is not None: + lora_entry["modelId"] = parent_model_id + if parent_version_name is not None: + lora_entry["version"] = parent_version_name + if parent_model_name is not None: + lora_entry["name"] = parent_model_name + loras.insert(0, lora_entry) + elif is_ckpt_type and not checkpoint: + checkpoint = { + "name": model_name, + "type": "checkpoint", + "hash": model_hash, + "file_name": model_name, + "existsLocally": True, + "baseModel": parsed.get("base_model", ""), + "isDeleted": False, + } + if parent_civitai_id is not None: + checkpoint["id"] = parent_civitai_id + if parent_model_id is not None: + checkpoint["modelId"] = parent_model_id + if parent_version_name is not None: + checkpoint["version"] = parent_version_name + if parent_model_name is not None: + checkpoint["name"] = parent_model_name + + image_url = image_data.get("url") or "" + image_id = extract_civitai_image_id_from_cdn_url(image_url) + settings_mgr = get_settings_manager() + civitai_host = settings_mgr.get("civitai_host") if settings_mgr else None + page_url = build_civitai_image_page_url(image_id, host=civitai_host) or image_url + + recipe_metadata: dict[str, Any] = { + "base_model": parsed.get("base_model") or "", + "loras": loras, + "gen_params": parsed.get("gen_params") or {}, + "source_path": page_url, + } + nsfw_level = image_data.get("nsfwLevel") + if isinstance(nsfw_level, int): + recipe_metadata["preview_nsfw_level"] = nsfw_level + if checkpoint: + recipe_metadata["checkpoint"] = checkpoint + + image_bytes: bytes | None = None + extension: str | None = None + local_image_path = data.get("local_image_path") + if local_image_path and os.path.exists(local_image_path): + with open(local_image_path, "rb") as f: + image_bytes = f.read() + ext = os.path.splitext(local_image_path)[1].lower() + if ext in (".jpg", ".jpeg", ".png", ".webp", ".gif"): + extension = ext + elif image_data.get("url"): + try: + downloader = await self._downloader_factory() + url = image_data["url"] + tmp = tempfile.NamedTemporaryFile(delete=False) + tmp.close() + success, result = await downloader.download_file( + url, tmp.name, use_auth=False + ) + if success: + with open(tmp.name, "rb") as f: + image_bytes = f.read() + url_path = url.split("?")[0].split("#")[0] + ext = os.path.splitext(url_path)[1].lower() + if ext: + extension = ext + if os.path.exists(tmp.name): + os.unlink(tmp.name) + except Exception as exc: + self._logger.warning( + "Failed to download image for recipe: %s", exc + ) + + prompt = ( + (parsed.get("gen_params") or {}).get("prompt") or "" + ) + if prompt: + name = " ".join(str(prompt).split()[:10]) + else: + name = f"Recipe from {model_name}" + + save_result = await self._persistence_service.save_recipe( + recipe_scanner=recipe_scanner, + image_bytes=image_bytes, + image_base64=None, + name=name, + tags=[], + metadata=recipe_metadata, + extension=extension, + ) + return web.json_response(save_result.payload, status=save_result.status) + + except RecipeValidationError as exc: + return web.json_response({"error": str(exc)}, status=400) + except Exception as exc: + self._logger.error( + "Error creating recipe from example: %s", exc, exc_info=True + ) + return web.json_response({"error": str(exc)}, status=500) + class RecipeAnalysisHandler: """Analyze images to extract recipe metadata.""" diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 4b2a262f..98057190 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -75,6 +75,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( "GET", "/api/lm/recipes/check-image-exists", "check_image_exists" ), RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"), + RouteDefinition( + "POST", "/api/lm/recipes/create-from-example", "create_from_example" + ), ) diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index fdd06fd4..bf795f4a 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -115,6 +115,10 @@ class RecipePersistenceService: if metadata.get("source_path"): recipe_data["source_path"] = metadata.get("source_path") + nsfw_level = metadata.get("preview_nsfw_level") + if nsfw_level is not None and isinstance(nsfw_level, int): + recipe_data["preview_nsfw_level"] = nsfw_level + json_filename = f"{recipe_id}.recipe.json" json_path = os.path.join(recipes_dir, json_filename) json_path = os.path.normpath(json_path) diff --git a/py/utils/civitai_utils.py b/py/utils/civitai_utils.py index c9965473..194f573d 100644 --- a/py/utils/civitai_utils.py +++ b/py/utils/civitai_utils.py @@ -66,6 +66,46 @@ def build_civitai_model_page_url( return None +_RE_CDN_IMAGE_ID = re.compile(r"/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)") + + +def extract_civitai_image_id_from_cdn_url(url: str | None) -> str | None: + """Extract the numeric image ID from a Cloudflare CDN image URL. + + CivitAI image CDN URLs follow the pattern:: + + https://image.civitai.com/{cf_uuid}/{params}/{image_id}.{ext} + + The image database ID is always the last path segment (minus extension) + because ``getEdgeUrl(…, name=id.toString())`` embeds it explicitly + in the model-versions REST API response. + """ + if not url: + return None + match = _RE_CDN_IMAGE_ID.search(url) + return match.group(1) if match else None + + +def build_civitai_image_page_url( + image_id: str | int | None, + *, + host: str | None = None, +) -> str | None: + """Build a Civitai image page URL. + + Returns something like ``https://civitai.com/images/12345``. + The host is resolved through :func:`normalize_civitai_page_host` and + therefore respects the user's ``civitai_host`` setting. + """ + if not image_id: + return None + normalized_host = normalize_civitai_page_host(host) + normalized_id = str(image_id).strip() + if not normalized_id: + return None + return urlunparse(("https", normalized_host, f"/images/{normalized_id}", "", "", "")) + + def _parse_supported_civitai_page_url(url: str | None): if not url: return None @@ -328,8 +368,10 @@ def rewrite_preview_url( __all__ = [ + "build_civitai_image_page_url", "build_license_flags", "extract_civitai_image_id", + "extract_civitai_image_id_from_cdn_url", "extract_civitai_page_host", "extract_civitai_model_url_parts", "is_supported_civitai_page_host", diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index eabc1101..8b2e2296 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -522,7 +522,7 @@ export async function showModelModal(model, modelType) { -
+
${tabsContent}
diff --git a/static/js/components/shared/showcase/MediaUtils.js b/static/js/components/shared/showcase/MediaUtils.js index 5e82fda9..f9b04e17 100644 --- a/static/js/components/shared/showcase/MediaUtils.js +++ b/static/js/components/shared/showcase/MediaUtils.js @@ -135,6 +135,39 @@ export function initLazyLoading(container) { lazyElements.forEach(element => observer.observe(element)); } +/** + * Check which Create As Recipe buttons correspond to already-imported + * images and disable them. + */ +async function checkImportedRecipes(container) { + const recipeButtons = container.querySelectorAll('.create-recipe-btn'); + if (!recipeButtons.length) return; + + const imageIds = []; + recipeButtons.forEach(btn => { + const id = btn.dataset.imageId; + if (id) imageIds.push(id); + }); + if (!imageIds.length) return; + + try { + const response = await fetch(`/api/lm/recipes/check-image-exists?image_ids=${imageIds.join(',')}`); + const data = await response.json(); + if (!data.success || !data.results) return; + recipeButtons.forEach(btn => { + const id = btn.dataset.imageId; + if (id && data.results[id]?.in_library) { + btn.disabled = true; + btn.title = 'Already imported as recipe'; + btn.classList.add('disabled'); + } + }); + } catch (err) { + console.error('Failed to check imported recipes:', err); + } +} + + /** * Get the actual rendered rectangle of a media element with object-fit: contain * @param {HTMLElement} mediaElement - The img or video element @@ -471,6 +504,70 @@ export function initMediaControlHandlers(container) { }); }); + // Create As Recipe buttons + const recipeButtons = container.querySelectorAll('.create-recipe-btn'); + recipeButtons.forEach(btn => { + btn.addEventListener('click', async function(e) { + e.stopPropagation(); + + const imageMetaRaw = this.dataset.imageMeta; + const imageUrl = this.dataset.imageUrl; + const imageNsfw = this.dataset.imageNsfw; + const localPath = this.dataset.localPath || ''; + const showcaseSection = this.closest('.showcase-section'); + const modelHash = showcaseSection ? showcaseSection.dataset.modelHash : ''; + const modelName = showcaseSection ? showcaseSection.dataset.modelName : ''; + const modelType = showcaseSection ? showcaseSection.dataset.modelType : ''; + + if (!imageMetaRaw || !modelHash) { + showToast('toast.recipes.createMissingData', {}, 'error'); + return; + } + + // Show loading state + const originalHtml = this.innerHTML; + this.innerHTML = ''; + this.disabled = true; + + try { + const imageMeta = JSON.parse(decodeURIComponent(imageMetaRaw)); + + const response = await fetch('/api/lm/recipes/create-from-example', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image_data: { + meta: imageMeta, + url: imageUrl, + nsfwLevel: imageNsfw ? parseInt(imageNsfw, 10) : undefined, + }, + model_hash: modelHash, + model_name: modelName || modelHash, + model_type: modelType, + local_image_path: localPath, + }), + }); + + const result = await response.json(); + + if (result.success && result.recipe_id) { + showToast('toast.recipes.created', { recipeId: result.recipe_id }, 'success'); + } else { + showToast('toast.recipes.createFailed', { error: result.error || 'Unknown error' }, 'error'); + } + } catch (error) { + console.error('Failed to create recipe:', error); + showToast('toast.recipes.createError', { message: error.message }, 'error'); + } finally { + this.innerHTML = originalHtml; + this.disabled = false; + } + }); + }); + + // Check which images are already imported as recipes → disable button + checkImportedRecipes(container); + // Initialize set preview buttons initSetPreviewHandlers(container); diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js index e42d7a6f..309282ba 100644 --- a/static/js/components/shared/showcase/ShowcaseView.js +++ b/static/js/components/shared/showcase/ShowcaseView.js @@ -183,6 +183,9 @@ function renderMediaItem(img, index, exampleFiles) { Math.min(maxHeightPercent, aspectRatio) ); + // Extract CivitAI image ID from CDN URL for import status check + const cdnImageId = (img.url || '').match(/\/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)/)?.[1] || ''; + // Check if media should be blurred const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const matureBlurThreshold = getMatureBlurThreshold(state.settings); @@ -224,12 +227,25 @@ function renderMediaItem(img, index, exampleFiles) { // Determine if this is a custom image (has id property) const isCustomImage = Boolean(typeof img.id === 'string' && img.id); + const hasGenMeta = img.hasMeta || (img.meta && (img.meta.prompt || img.meta.seed || img.meta.resources)); + // Create the media control buttons HTML const mediaControlsHtml = `
+ ${hasGenMeta ? ` + + ` : ''}