mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-09 12:39:23 -03:00
feat(recipe): add Create As Recipe from example images with import dedup check (#945)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1668,6 +1668,10 @@
|
||||
"noRecipeId": "אין מזהה מתכון זמין",
|
||||
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
||||
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
||||
"createError": "שגיאה ביצירת המתכון:{message}",
|
||||
"createFailed": "יצירת המתכון נכשלה:{error}",
|
||||
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||
"created": "המתכון נוצר בהצלחה",
|
||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
|
||||
|
||||
@@ -1668,6 +1668,10 @@
|
||||
"noRecipeId": "レシピIDが利用できません",
|
||||
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
||||
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
||||
"createError": "レシピ作成中にエラーが発生しました:{message}",
|
||||
"createFailed": "レシピの作成に失敗しました:{error}",
|
||||
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||
"created": "レシピを作成しました",
|
||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
|
||||
|
||||
@@ -1668,6 +1668,10 @@
|
||||
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
||||
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
||||
"copyFailed": "레시피 문법 복사 오류: {message}",
|
||||
"createError": "레시피 생성 중 오류 발생:{message}",
|
||||
"createFailed": "레시피 생성 실패:{error}",
|
||||
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||
"created": "레시피가 생성되었습니다",
|
||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
|
||||
|
||||
@@ -1668,6 +1668,10 @@
|
||||
"noRecipeId": "ID рецепта недоступен",
|
||||
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
||||
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
||||
"createError": "Ошибка при создании рецепта:{message}",
|
||||
"createFailed": "Не удалось создать рецепт:{error}",
|
||||
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||
"created": "Рецепт успешно создан",
|
||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
|
||||
|
||||
@@ -1668,6 +1668,10 @@
|
||||
"noRecipeId": "无配方 ID",
|
||||
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
||||
"copyFailed": "复制配方语法出错:{message}",
|
||||
"createError": "创建配方时出错:{message}",
|
||||
"createFailed": "创建配方失败:{error}",
|
||||
"createMissingData": "缺少创建配方所需的数据",
|
||||
"created": "配方创建成功",
|
||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
|
||||
|
||||
@@ -1668,6 +1668,10 @@
|
||||
"noRecipeId": "無配方 ID",
|
||||
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
||||
"copyFailed": "複製配方語法錯誤:{message}",
|
||||
"createError": "建立配方時發生錯誤:{message}",
|
||||
"createFailed": "建立配方失敗:{error}",
|
||||
"createMissingData": "缺少建立配方所需的資料",
|
||||
"created": "配方建立成功",
|
||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -522,7 +522,7 @@ export async function showModelModal(model, modelType) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-filepath="${escapedFilePathAttr}">
|
||||
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-model-name="${escapeAttribute(modelWithFullData.file_name || modelWithFullData.model_name || '')}" data-model-type="${modelType}" data-filepath="${escapedFilePathAttr}">
|
||||
<div class="showcase-tabs">
|
||||
${tabsContent}
|
||||
</div>
|
||||
|
||||
@@ -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 = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<div class="media-controls">
|
||||
<button class="media-control-btn set-preview-btn" title="Set as preview">
|
||||
<i class="fas fa-image"></i>
|
||||
</button>
|
||||
${hasGenMeta ? `
|
||||
<button class="media-control-btn create-recipe-btn"
|
||||
title="Create As Recipe"
|
||||
data-image-meta="${encodeURIComponent(JSON.stringify(img.meta || {}))}"
|
||||
data-image-url="${img.url || ''}"
|
||||
data-image-nsfw="${img.nsfwLevel ?? ''}"
|
||||
data-image-id="${cdnImageId}"
|
||||
data-local-path="${localFile ? localFile.path : ''}">
|
||||
<i class="fas fa-book-open"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="media-control-btn set-nsfw-btn"
|
||||
title="Set content rating"
|
||||
data-media-index="${index}"
|
||||
|
||||
Reference in New Issue
Block a user