mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(recipes): explain empty LoRA lists with collapsible "Why no LoRAs?" panel
Record import provenance on every recipe: a new import_info block (channel, machine-readable no-LoRA reason, diagnostic details) built at import time across all channels (batch import, single URL, local file, upload, widget save, re-imports) and persisted in the recipe JSON plus the SQLite persistent cache (new import_info_json column with ALTER TABLE migration). The recipe modal renders the empty LoRA list with a collapsed details panel showing the import method, the reason (CivitAI API returned no LoRA resource data, API meta missing, no embedded metadata, ComfyUI workflow metadata, video, unparsable format), and recorded diagnostics. Legacy recipes without import_info fall back to heuristics labeled as inferred. Genuine no-LoRA generations show no panel. CivitAI images are always classified by API meta shape: the onsite generator writes A1111-style EXIF without LoRA references, so parsed EXIF cannot prove "no LoRAs used". Adds recipes.resources.noLoras* i18n keys (all 10 locales) plus frontend vitest and backend pytest coverage.
This commit is contained in:
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "Dieses Modell ist nicht in Ihrer Bibliothek",
|
||||
"deletedTooltip": "Dieses LoRA wurde an der Quelle gelöscht und kann nicht mehr heruntergeladen werden",
|
||||
"hashInvalidTooltip": "Dieser LoRA-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert",
|
||||
"noLorasAssociated": "Keine LoRAs mit diesem Rezept verknüpft",
|
||||
"noLorasWhyToggle": "Warum keine LoRAs?",
|
||||
"noLorasImportMethod": "Importmethode",
|
||||
"noLorasInferredNote": "Mögliche Ursache (abgeleitet) — dieses Rezept wurde importiert, bevor Importdiagnosen aufgezeichnet wurden.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Massenimport (Bild-URL)",
|
||||
"batch_import_local": "Massenimport (lokale Datei)",
|
||||
"url": "Bild-URL-Import",
|
||||
"local": "Import lokaler Datei",
|
||||
"upload": "Bild-Upload",
|
||||
"widget": "Aus Workflow gespeichert",
|
||||
"reimport_url": "Neuimport (Bild-URL)",
|
||||
"reimport_local": "Neuimport (lokale Datei)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Die Generierungsmetadaten sind vollständig und verweisen auf keine LoRAs.",
|
||||
"api_meta_no_lora_resources": "Die Quell-API hat für dieses Bild keine LoRA-Ressourcendaten zurückgegeben. Auf der CivitAI-Seite angezeigte LoRAs stammen möglicherweise aus internen Daten, die die öffentliche API nicht bereitstellt.",
|
||||
"api_meta_missing": "Die Quell-API hat für dieses Bild keine Generierungsmetadaten zurückgegeben.",
|
||||
"no_embedded_metadata": "Das Bild enthält keine eingebetteten Generierungsmetadaten, sodass LoRA-Informationen nicht wiederhergestellt werden konnten.",
|
||||
"workflow_metadata_limited": "Die eingebetteten Metadaten des Bildes sind ein ComfyUI-Workflow; das Extrahieren von LoRA-Informationen aus Workflows ist eingeschränkt.",
|
||||
"video_no_metadata": "Videodateien enthalten keine eingebetteten Generierungsmetadaten.",
|
||||
"metadata_unsupported": "Das Bild enthält Metadaten in einem Format, das nicht analysiert werden konnte.",
|
||||
"unknown": "Die Ursache konnte aus den gespeicherten Rezeptdaten nicht ermittelt werden."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API-Metadatenfelder",
|
||||
"modelVersionIds": "Gemeldete Modellversions-IDs",
|
||||
"embeddedMetadata": "Eingebettete Metadaten",
|
||||
"present": "gefunden",
|
||||
"absent": "keine"
|
||||
},
|
||||
"download": "Herunterladen",
|
||||
"downloadLoraTooltip": "Dieses LoRA herunterladen",
|
||||
"preparingDownload": "Download wird vorbereitet...",
|
||||
|
||||
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "This model is not in your library",
|
||||
"deletedTooltip": "This LoRA was deleted from the source and is no longer available for download",
|
||||
"hashInvalidTooltip": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated",
|
||||
"noLorasAssociated": "No LoRAs associated with this recipe",
|
||||
"noLorasWhyToggle": "Why no LoRAs?",
|
||||
"noLorasImportMethod": "Import method",
|
||||
"noLorasInferredNote": "Possible reason (inferred) — this recipe was imported before import diagnostics were recorded.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Batch import (image URL)",
|
||||
"batch_import_local": "Batch import (local file)",
|
||||
"url": "Image URL import",
|
||||
"local": "Local file import",
|
||||
"upload": "Image upload",
|
||||
"widget": "Saved from workflow",
|
||||
"reimport_url": "Re-import (image URL)",
|
||||
"reimport_local": "Re-import (local file)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "The generation metadata is complete and does not reference any LoRAs.",
|
||||
"api_meta_no_lora_resources": "The source API returned no LoRA resource data for this image. LoRAs shown on the CivitAI page may come from internal data that the public API does not expose.",
|
||||
"api_meta_missing": "The source API returned no generation metadata for this image.",
|
||||
"no_embedded_metadata": "The image has no embedded generation metadata, so LoRA information could not be recovered.",
|
||||
"workflow_metadata_limited": "The image's embedded metadata is a ComfyUI workflow; extracting LoRA information from workflows is limited.",
|
||||
"video_no_metadata": "Video files do not carry embedded generation metadata.",
|
||||
"metadata_unsupported": "The image contains metadata in a format that could not be parsed.",
|
||||
"unknown": "The reason could not be determined from the stored recipe data."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API metadata fields",
|
||||
"modelVersionIds": "Model version IDs reported",
|
||||
"embeddedMetadata": "Embedded metadata",
|
||||
"present": "found",
|
||||
"absent": "none"
|
||||
},
|
||||
"download": "Download",
|
||||
"downloadLoraTooltip": "Download this LoRA",
|
||||
"preparingDownload": "Preparing download...",
|
||||
|
||||
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "Este modelo no está en tu biblioteca",
|
||||
"deletedTooltip": "Este LoRA fue eliminado de la fuente y ya no se puede descargar",
|
||||
"hashInvalidTooltip": "Este hash de LoRA no se puede resolver en CivitAI - el modelo puede haber sido actualizado",
|
||||
"noLorasAssociated": "No hay LoRAs asociados con esta receta",
|
||||
"noLorasWhyToggle": "¿Por qué no hay LoRAs?",
|
||||
"noLorasImportMethod": "Método de importación",
|
||||
"noLorasInferredNote": "Posible motivo (inferido): esta receta se importó antes de que se registraran los diagnósticos de importación.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Importación por lotes (URL de imagen)",
|
||||
"batch_import_local": "Importación por lotes (archivo local)",
|
||||
"url": "Importación desde URL de imagen",
|
||||
"local": "Importación de archivo local",
|
||||
"upload": "Carga de imagen",
|
||||
"widget": "Guardada desde el workflow",
|
||||
"reimport_url": "Reimportación (URL de imagen)",
|
||||
"reimport_local": "Reimportación (archivo local)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Los metadatos de generación están completos y no hacen referencia a ningún LoRA.",
|
||||
"api_meta_no_lora_resources": "La API de origen no devolvió datos de recursos LoRA para esta imagen. Los LoRAs que se muestran en la página de CivitAI pueden proceder de datos internos que la API pública no expone.",
|
||||
"api_meta_missing": "La API de origen no devolvió metadatos de generación para esta imagen.",
|
||||
"no_embedded_metadata": "La imagen no tiene metadatos de generación incrustados, por lo que no se pudo recuperar la información de LoRAs.",
|
||||
"workflow_metadata_limited": "Los metadatos incrustados en la imagen son un workflow de ComfyUI; la extracción de información de LoRAs a partir de workflows es limitada.",
|
||||
"video_no_metadata": "Los archivos de vídeo no contienen metadatos de generación incrustados.",
|
||||
"metadata_unsupported": "La imagen contiene metadatos en un formato que no se pudo analizar.",
|
||||
"unknown": "No se pudo determinar el motivo a partir de los datos de la receta almacenados."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "Campos de metadatos de la API",
|
||||
"modelVersionIds": "IDs de versión de modelo informados",
|
||||
"embeddedMetadata": "Metadatos incrustados",
|
||||
"present": "encontrados",
|
||||
"absent": "ninguno"
|
||||
},
|
||||
"download": "Descargar",
|
||||
"downloadLoraTooltip": "Descargar este LoRA",
|
||||
"preparingDownload": "Preparando descarga...",
|
||||
|
||||
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "Ce modèle n'est pas dans votre bibliothèque",
|
||||
"deletedTooltip": "Ce LoRA a été supprimé de la source et ne peut plus être téléchargé",
|
||||
"hashInvalidTooltip": "Ce hash de LoRA ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour",
|
||||
"noLorasAssociated": "Aucune LoRA associée à cette Recipe",
|
||||
"noLorasWhyToggle": "Pourquoi aucune LoRA ?",
|
||||
"noLorasImportMethod": "Méthode d'import",
|
||||
"noLorasInferredNote": "Raison possible (déduite) — cette Recipe a été importée avant l'enregistrement des diagnostics d'import.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Import groupé (URL d'image)",
|
||||
"batch_import_local": "Import groupé (fichier local)",
|
||||
"url": "Import d'une URL d'image",
|
||||
"local": "Import d'un fichier local",
|
||||
"upload": "Téléversement d'image",
|
||||
"widget": "Enregistrée depuis le Workflow",
|
||||
"reimport_url": "Réimport (URL d'image)",
|
||||
"reimport_local": "Réimport (fichier local)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Les métadonnées de génération sont complètes et ne référencent aucune LoRA.",
|
||||
"api_meta_no_lora_resources": "L'API source n'a renvoyé aucune donnée de ressource LoRA pour cette image. Les LoRAs affichées sur la page CivitAI peuvent provenir de données internes que l'API publique n'expose pas.",
|
||||
"api_meta_missing": "L'API source n'a renvoyé aucune métadonnée de génération pour cette image.",
|
||||
"no_embedded_metadata": "L'image ne contient aucune métadonnée de génération intégrée ; les informations LoRA n'ont donc pas pu être récupérées.",
|
||||
"workflow_metadata_limited": "Les métadonnées intégrées à l'image sont un Workflow ComfyUI ; l'extraction des informations LoRA à partir des Workflows est limitée.",
|
||||
"video_no_metadata": "Les fichiers vidéo ne contiennent pas de métadonnées de génération intégrées.",
|
||||
"metadata_unsupported": "L'image contient des métadonnées dans un format non analysable.",
|
||||
"unknown": "La raison n'a pas pu être déterminée à partir des données de la Recipe enregistrée."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "Champs de métadonnées de l'API",
|
||||
"modelVersionIds": "IDs de version de modèle signalés",
|
||||
"embeddedMetadata": "Métadonnées intégrées",
|
||||
"present": "trouvées",
|
||||
"absent": "aucune"
|
||||
},
|
||||
"download": "Télécharger",
|
||||
"downloadLoraTooltip": "Télécharger ce LoRA",
|
||||
"preparingDownload": "Préparation du téléchargement...",
|
||||
|
||||
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "מודל זה לא נמצא בספרייה שלך",
|
||||
"deletedTooltip": "LoRA זה נמחק מהמקור ואינו זמין יותר להורדה",
|
||||
"hashInvalidTooltip": "לא ניתן לפתור את ה-hash של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן",
|
||||
"noLorasAssociated": "אין LoRAs המשויכים למתכון זה",
|
||||
"noLorasWhyToggle": "למה אין LoRAs?",
|
||||
"noLorasImportMethod": "שיטת ייבוא",
|
||||
"noLorasInferredNote": "סיבה אפשרית (משוערת) — מתכון זה יובא לפני שנרשמו אבחוני ייבוא.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "ייבוא בכמות גדולה (URL של תמונה)",
|
||||
"batch_import_local": "ייבוא בכמות גדולה (קובץ מקומי)",
|
||||
"url": "ייבוא מ-URL של תמונה",
|
||||
"local": "ייבוא קובץ מקומי",
|
||||
"upload": "העלאת תמונה",
|
||||
"widget": "נשמר מה-workflow",
|
||||
"reimport_url": "ייבוא מחדש (URL של תמונה)",
|
||||
"reimport_local": "ייבוא מחדש (קובץ מקומי)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "מטא-הנתונים של היצירה שלמים ואינם מפנים ל-LoRAs כלשהם.",
|
||||
"api_meta_no_lora_resources": "ה-API של המקור לא החזיר נתוני משאבי LoRA עבור תמונה זו. LoRAs המוצגים בעמוד CivitAI עשויים להגיע מנתונים פנימיים שה-API הציבורי אינו חושף.",
|
||||
"api_meta_missing": "ה-API של המקור לא החזיר מטא-נתוני יצירה עבור תמונה זו.",
|
||||
"no_embedded_metadata": "לתמונה אין מטא-נתוני יצירה מוטבעים, ולכן לא ניתן היה לשחזר את מידע ה-LoRA.",
|
||||
"workflow_metadata_limited": "המטא-נתונים המוטבעים של התמונה הם workflow של ComfyUI; חילוץ מידע LoRA מתוך workflows מוגבל.",
|
||||
"video_no_metadata": "קבצי וידאו אינם נושאים מטא-נתוני יצירה מוטבעים.",
|
||||
"metadata_unsupported": "התמונה מכילה מטא-נתונים בפורמט שלא ניתן לנתח.",
|
||||
"unknown": "לא ניתן היה לקבוע את הסיבה מנתוני המתכון השמורים."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "שדות מטא-נתונים של API",
|
||||
"modelVersionIds": "מספר מזהי גרסת מודל שדווחו",
|
||||
"embeddedMetadata": "מטא-נתונים מוטבעים",
|
||||
"present": "נמצאו",
|
||||
"absent": "אין"
|
||||
},
|
||||
"download": "הורדה",
|
||||
"downloadLoraTooltip": "הורד את ה-LoRA הזה",
|
||||
"preparingDownload": "מכין את ההורדה...",
|
||||
|
||||
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "このモデルはライブラリにありません",
|
||||
"deletedTooltip": "この LoRA は配信元から削除されたため、ダウンロードできません",
|
||||
"hashInvalidTooltip": "このLoRAハッシュはCivitAIで解決できません - モデルが更新された可能性があります",
|
||||
"noLorasAssociated": "このレシピに関連付けられた LoRA はありません",
|
||||
"noLorasWhyToggle": "LoRA がない理由",
|
||||
"noLorasImportMethod": "インポート方法",
|
||||
"noLorasInferredNote": "考えられる理由(推定)— このレシピはインポート診断が記録される前にインポートされました。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "一括インポート(画像 URL)",
|
||||
"batch_import_local": "一括インポート(ローカルファイル)",
|
||||
"url": "画像 URL からのインポート",
|
||||
"local": "ローカルファイルのインポート",
|
||||
"upload": "画像のアップロード",
|
||||
"widget": "ワークフローから保存",
|
||||
"reimport_url": "再インポート(画像 URL)",
|
||||
"reimport_local": "再インポート(ローカルファイル)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成メタデータは完全で、LoRA への参照は含まれていません。",
|
||||
"api_meta_no_lora_resources": "ソース API がこの画像の LoRA リソースデータを返しませんでした。CivitAI ページに表示される LoRA は、公開 API では公開されない内部データに由来する場合があります。",
|
||||
"api_meta_missing": "ソース API がこの画像の生成メタデータを返しませんでした。",
|
||||
"no_embedded_metadata": "画像に埋め込まれた生成メタデータがないため、LoRA 情報を復元できませんでした。",
|
||||
"workflow_metadata_limited": "画像に埋め込まれたメタデータは ComfyUI ワークフローです。ワークフローからの LoRA 情報の抽出には限界があります。",
|
||||
"video_no_metadata": "動画ファイルには埋め込み生成メタデータがありません。",
|
||||
"metadata_unsupported": "画像に解析できない形式のメタデータが含まれています。",
|
||||
"unknown": "保存されたレシピデータから理由を特定できませんでした。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API メタデータフィールド",
|
||||
"modelVersionIds": "報告されたモデルバージョン ID 数",
|
||||
"embeddedMetadata": "埋め込みメタデータ",
|
||||
"present": "あり",
|
||||
"absent": "なし"
|
||||
},
|
||||
"download": "ダウンロード",
|
||||
"downloadLoraTooltip": "この LoRA をダウンロード",
|
||||
"preparingDownload": "ダウンロードを準備中...",
|
||||
|
||||
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "이 모델은 라이브러리에 없습니다",
|
||||
"deletedTooltip": "이 LoRA는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다",
|
||||
"hashInvalidTooltip": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었을 수 있습니다",
|
||||
"noLorasAssociated": "이 레시피에 연결된 LoRA가 없습니다",
|
||||
"noLorasWhyToggle": "LoRA가 없는 이유",
|
||||
"noLorasImportMethod": "가져오기 방법",
|
||||
"noLorasInferredNote": "가능한 이유(추정) — 이 레시피는 가져오기 진단이 기록되기 전에 가져온 것입니다.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "일괄 가져오기(이미지 URL)",
|
||||
"batch_import_local": "일괄 가져오기(로컬 파일)",
|
||||
"url": "이미지 URL 가져오기",
|
||||
"local": "로컬 파일 가져오기",
|
||||
"upload": "이미지 업로드",
|
||||
"widget": "워크플로에서 저장",
|
||||
"reimport_url": "다시 가져오기(이미지 URL)",
|
||||
"reimport_local": "다시 가져오기(로컬 파일)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "생성 메타데이터가 완전하며 LoRA를 참조하지 않습니다.",
|
||||
"api_meta_no_lora_resources": "소스 API가 이 이미지에 대한 LoRA 리소스 데이터를 반환하지 않았습니다. CivitAI 페이지에 표시되는 LoRA는 공개 API가 노출하지 않는 내부 데이터에서 비롯될 수 있습니다.",
|
||||
"api_meta_missing": "소스 API가 이 이미지에 대한 생성 메타데이터를 반환하지 않았습니다.",
|
||||
"no_embedded_metadata": "이미지에 내장된 생성 메타데이터가 없어 LoRA 정보를 복구할 수 없습니다.",
|
||||
"workflow_metadata_limited": "이미지에 내장된 메타데이터는 ComfyUI 워크플로입니다. 워크플로에서 LoRA 정보를 추출하는 것은 제한적입니다.",
|
||||
"video_no_metadata": "동영상 파일에는 내장 생성 메타데이터가 없습니다.",
|
||||
"metadata_unsupported": "이미지에 파싱할 수 없는 형식의 메타데이터가 포함되어 있습니다.",
|
||||
"unknown": "저장된 레시피 데이터에서 이유를 확인할 수 없습니다."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 메타데이터 필드",
|
||||
"modelVersionIds": "보고된 모델 버전 ID 수",
|
||||
"embeddedMetadata": "내장 메타데이터",
|
||||
"present": "있음",
|
||||
"absent": "없음"
|
||||
},
|
||||
"download": "다운로드",
|
||||
"downloadLoraTooltip": "이 LoRA 다운로드",
|
||||
"preparingDownload": "다운로드 준비 중...",
|
||||
|
||||
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "Этой модели нет в вашей библиотеке",
|
||||
"deletedTooltip": "Этот LoRA был удалён из источника и больше недоступен для скачивания",
|
||||
"hashInvalidTooltip": "Этот хеш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена",
|
||||
"noLorasAssociated": "С этим рецептом не связаны LoRA",
|
||||
"noLorasWhyToggle": "Почему нет LoRA?",
|
||||
"noLorasImportMethod": "Способ импорта",
|
||||
"noLorasInferredNote": "Возможная причина (выведена) — этот рецепт был импортирован до того, как стала записываться диагностика импорта.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Пакетный импорт (URL изображения)",
|
||||
"batch_import_local": "Пакетный импорт (локальный файл)",
|
||||
"url": "Импорт по URL изображения",
|
||||
"local": "Импорт локального файла",
|
||||
"upload": "Загрузка изображения",
|
||||
"widget": "Сохранён из Workflow",
|
||||
"reimport_url": "Повторный импорт (URL изображения)",
|
||||
"reimport_local": "Повторный импорт (локальный файл)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Метаданные генерации полны и не содержат ссылок на LoRA.",
|
||||
"api_meta_no_lora_resources": "Исходный API не вернул данные о ресурсах LoRA для этого изображения. LoRA, отображаемые на странице CivitAI, могут поступать из внутренних данных, которые публичный API не раскрывает.",
|
||||
"api_meta_missing": "Исходный API не вернул метаданные генерации для этого изображения.",
|
||||
"no_embedded_metadata": "Изображение не содержит встроенных метаданных генерации, поэтому восстановить информацию о LoRA невозможно.",
|
||||
"workflow_metadata_limited": "Встроенные метаданные изображения представляют собой Workflow ComfyUI; извлечение информации о LoRA из Workflow ограничено.",
|
||||
"video_no_metadata": "Видеофайлы не содержат встроенных метаданных генерации.",
|
||||
"metadata_unsupported": "Изображение содержит метаданные в формате, который не удалось разобрать.",
|
||||
"unknown": "Причину не удалось определить по сохранённым данным рецепта."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "Поля метаданных API",
|
||||
"modelVersionIds": "Сообщено ID версий моделей",
|
||||
"embeddedMetadata": "Встроенные метаданные",
|
||||
"present": "найдены",
|
||||
"absent": "нет"
|
||||
},
|
||||
"download": "Скачать",
|
||||
"downloadLoraTooltip": "Скачать этот LoRA",
|
||||
"preparingDownload": "Подготовка к скачиванию...",
|
||||
|
||||
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "该模型不在你的本地库中",
|
||||
"deletedTooltip": "该 LoRA 已从来源站删除,无法下载",
|
||||
"hashInvalidTooltip": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新",
|
||||
"noLorasAssociated": "此配方没有关联任何 LoRA",
|
||||
"noLorasWhyToggle": "为什么没有 LoRA?",
|
||||
"noLorasImportMethod": "导入方式",
|
||||
"noLorasInferredNote": "可能的原因(推断)——该配方是在记录导入诊断信息之前导入的。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "批量导入(图片 URL)",
|
||||
"batch_import_local": "批量导入(本地文件)",
|
||||
"url": "图片 URL 导入",
|
||||
"local": "本地文件导入",
|
||||
"upload": "图片上传",
|
||||
"widget": "从工作流保存",
|
||||
"reimport_url": "重新导入(图片 URL)",
|
||||
"reimport_local": "重新导入(本地文件)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成元数据完整,且未引用任何 LoRA。",
|
||||
"api_meta_no_lora_resources": "来源 API 未返回此图片的 LoRA 资源数据。CivitAI 页面上显示的 LoRA 可能来自公开 API 未开放的内部数据。",
|
||||
"api_meta_missing": "来源 API 未返回此图片的生成元数据。",
|
||||
"no_embedded_metadata": "图片没有内嵌生成元数据,因此无法恢复 LoRA 信息。",
|
||||
"workflow_metadata_limited": "图片内嵌的元数据是 ComfyUI 工作流;从工作流中提取 LoRA 信息的能力有限。",
|
||||
"video_no_metadata": "视频文件不携带内嵌生成元数据。",
|
||||
"metadata_unsupported": "图片包含的元数据格式无法解析。",
|
||||
"unknown": "无法从存储的配方数据中确定原因。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 元数据字段",
|
||||
"modelVersionIds": "报告的模型版本 ID 数",
|
||||
"embeddedMetadata": "内嵌元数据",
|
||||
"present": "已找到",
|
||||
"absent": "无"
|
||||
},
|
||||
"download": "下载",
|
||||
"downloadLoraTooltip": "下载此 LoRA",
|
||||
"preparingDownload": "正在准备下载...",
|
||||
|
||||
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "此模型不在您的本地庫中",
|
||||
"deletedTooltip": "此 LoRA 已從來源站刪除,無法下載",
|
||||
"hashInvalidTooltip": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新",
|
||||
"noLorasAssociated": "此配方未關聯任何 LoRA",
|
||||
"noLorasWhyToggle": "為什麼沒有 LoRA?",
|
||||
"noLorasImportMethod": "匯入方式",
|
||||
"noLorasInferredNote": "可能的原因(推斷)——此配方是在記錄匯入診斷資訊之前匯入的。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "批量匯入(圖片 URL)",
|
||||
"batch_import_local": "批量匯入(本機檔案)",
|
||||
"url": "圖片 URL 匯入",
|
||||
"local": "本機檔案匯入",
|
||||
"upload": "圖片上傳",
|
||||
"widget": "從工作流儲存",
|
||||
"reimport_url": "重新匯入(圖片 URL)",
|
||||
"reimport_local": "重新匯入(本機檔案)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成中繼資料完整,且未引用任何 LoRA。",
|
||||
"api_meta_no_lora_resources": "來源 API 未回傳此圖片的 LoRA 資源資料。CivitAI 頁面上顯示的 LoRA 可能來自公開 API 未開放的內部資料。",
|
||||
"api_meta_missing": "來源 API 未回傳此圖片的生成中繼資料。",
|
||||
"no_embedded_metadata": "圖片沒有內嵌生成中繼資料,因此無法復原 LoRA 資訊。",
|
||||
"workflow_metadata_limited": "圖片內嵌的中繼資料是 ComfyUI 工作流;從工作流中提取 LoRA 資訊的能力有限。",
|
||||
"video_no_metadata": "影片檔案不攜帶內嵌生成中繼資料。",
|
||||
"metadata_unsupported": "圖片包含的中繼資料格式無法解析。",
|
||||
"unknown": "無法從儲存的配方資料中確定原因。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 中繼資料欄位",
|
||||
"modelVersionIds": "回報的模型版本 ID 數",
|
||||
"embeddedMetadata": "內嵌中繼資料",
|
||||
"present": "已找到",
|
||||
"absent": "無"
|
||||
},
|
||||
"download": "下載",
|
||||
"downloadLoraTooltip": "下載此 LoRA",
|
||||
"preparingDownload": "正在準備下載...",
|
||||
|
||||
@@ -2115,6 +2115,23 @@ class RecipeManagementHandler:
|
||||
await self._download_remote_media(image_url)
|
||||
)
|
||||
|
||||
# Diagnostics for the recipe modal's "Why no LoRAs?" panel. This path
|
||||
# always comes from a CivitAI image URL (import_from_url validates the
|
||||
# image id), so civitai_image is True.
|
||||
diagnostics: Dict[str, Any] = {
|
||||
"civitai_image": True,
|
||||
"is_video": extension in (".mp4", ".webm"),
|
||||
}
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
raw_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||
diagnostics["api_model_version_ids"] = (
|
||||
len(raw_mvids) if isinstance(raw_mvids, list) else 0
|
||||
)
|
||||
inner_meta_for_diag = civitai_meta_raw.get("meta")
|
||||
if isinstance(inner_meta_for_diag, dict):
|
||||
diagnostics["api_meta_present"] = True
|
||||
diagnostics["api_meta_keys"] = sorted(inner_meta_for_diag.keys())
|
||||
|
||||
# Build a version-cached map of local model hashes to cache items so
|
||||
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
||||
# exist on disk. Built once and shared by every parse pass below.
|
||||
@@ -2135,6 +2152,7 @@ class RecipeManagementHandler:
|
||||
raw_embedded = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, temp_img_path
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_embedded)
|
||||
if raw_embedded:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
@@ -2142,6 +2160,7 @@ class RecipeManagementHandler:
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_embedded,
|
||||
@@ -2182,6 +2201,7 @@ class RecipeManagementHandler:
|
||||
raw_orig = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, orig_tmp_path
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_orig)
|
||||
if raw_orig:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
@@ -2189,6 +2209,7 @@ class RecipeManagementHandler:
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig,
|
||||
@@ -2310,6 +2331,20 @@ class RecipeManagementHandler:
|
||||
else:
|
||||
name = f"Civitai Image {image_id}"
|
||||
|
||||
# Record why this import ended up with no LoRAs so the recipe modal
|
||||
# can explain it (collapsed by default).
|
||||
from ...services.recipes.import_info import (
|
||||
CHANNEL_REIMPORT_URL,
|
||||
CHANNEL_URL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
metadata["import_info"] = build_import_info(
|
||||
CHANNEL_REIMPORT_URL if recipe_id else CHANNEL_URL,
|
||||
diagnostics,
|
||||
metadata.get("loras"),
|
||||
)
|
||||
|
||||
result = await self._persistence_service.save_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_bytes=image_bytes,
|
||||
@@ -2369,6 +2404,17 @@ class RecipeManagementHandler:
|
||||
if checkpoint:
|
||||
metadata["checkpoint"] = checkpoint
|
||||
|
||||
from ...services.recipes.import_info import (
|
||||
CHANNEL_REIMPORT_LOCAL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
metadata["import_info"] = build_import_info(
|
||||
CHANNEL_REIMPORT_LOCAL,
|
||||
analysis_payload.get("diagnostics"),
|
||||
loras,
|
||||
)
|
||||
|
||||
prompt = (
|
||||
gen_params.get("prompt")
|
||||
or gen_params.get("positivePrompt")
|
||||
|
||||
@@ -20,6 +20,11 @@ from .recipes import (
|
||||
RecipeDownloadError,
|
||||
RecipeNotFoundError,
|
||||
)
|
||||
from .recipes.import_info import (
|
||||
CHANNEL_BATCH_IMPORT_LOCAL,
|
||||
CHANNEL_BATCH_IMPORT_URL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
|
||||
class ImportItemType(Enum):
|
||||
@@ -624,6 +629,17 @@ class BatchImportService:
|
||||
"loras": loras,
|
||||
"gen_params": payload.get("gen_params", {}),
|
||||
"source_path": item.source,
|
||||
# Record why this import ended up with no LoRAs so the
|
||||
# recipe modal can explain it (collapsed by default).
|
||||
"import_info": build_import_info(
|
||||
(
|
||||
CHANNEL_BATCH_IMPORT_URL
|
||||
if item.item_type == ImportItemType.URL
|
||||
else CHANNEL_BATCH_IMPORT_LOCAL
|
||||
),
|
||||
payload.get("diagnostics"),
|
||||
loras,
|
||||
),
|
||||
}
|
||||
|
||||
if payload.get("checkpoint"):
|
||||
|
||||
@@ -59,6 +59,7 @@ class PersistentRecipeCache:
|
||||
"gen_params_json",
|
||||
"tags_json",
|
||||
"has_workflow",
|
||||
"import_info_json",
|
||||
)
|
||||
_instances: Dict[str, "PersistentRecipeCache"] = {}
|
||||
_instance_lock = threading.Lock()
|
||||
@@ -447,7 +448,8 @@ class PersistentRecipeCache:
|
||||
checkpoint_json TEXT,
|
||||
gen_params_json TEXT,
|
||||
tags_json TEXT,
|
||||
has_workflow INTEGER DEFAULT 0
|
||||
has_workflow INTEGER DEFAULT 0,
|
||||
import_info_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path);
|
||||
@@ -473,6 +475,13 @@ class PersistentRecipeCache:
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
# Migration: add import_info_json column to existing databases
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE recipes ADD COLUMN import_info_json TEXT"
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
except Exception as exc:
|
||||
@@ -504,6 +513,9 @@ class PersistentRecipeCache:
|
||||
tags = recipe.get("tags")
|
||||
tags_json = json.dumps(tags) if tags else None
|
||||
|
||||
import_info = recipe.get("import_info")
|
||||
import_info_json = json.dumps(import_info) if import_info else None
|
||||
|
||||
# Get file stats if json_path exists
|
||||
file_mtime = 0.0
|
||||
file_size = 0
|
||||
@@ -536,6 +548,7 @@ class PersistentRecipeCache:
|
||||
gen_params_json,
|
||||
tags_json,
|
||||
1 if recipe.get("has_workflow") else 0,
|
||||
import_info_json,
|
||||
)
|
||||
|
||||
def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]:
|
||||
@@ -568,6 +581,13 @@ class PersistentRecipeCache:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
import_info = None
|
||||
if row["import_info_json"]:
|
||||
try:
|
||||
import_info = json.loads(row["import_info_json"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
recipe = {
|
||||
"id": row["recipe_id"],
|
||||
"file_path": row["file_path"] or "",
|
||||
@@ -592,6 +612,9 @@ class PersistentRecipeCache:
|
||||
if checkpoint:
|
||||
recipe["checkpoint"] = checkpoint
|
||||
|
||||
if import_info:
|
||||
recipe["import_info"] = import_info
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Recipe service layer implementations."""
|
||||
|
||||
from .analysis_service import RecipeAnalysisService
|
||||
from .import_info import build_import_info, compute_no_loras_reason
|
||||
from .persistence_service import RecipePersistenceService
|
||||
from .sharing_service import RecipeSharingService
|
||||
from .errors import (
|
||||
@@ -15,6 +16,8 @@ __all__ = [
|
||||
"RecipeAnalysisService",
|
||||
"RecipePersistenceService",
|
||||
"RecipeSharingService",
|
||||
"build_import_info",
|
||||
"compute_no_loras_reason",
|
||||
"RecipeServiceError",
|
||||
"RecipeValidationError",
|
||||
"RecipeNotFoundError",
|
||||
|
||||
@@ -72,15 +72,28 @@ class RecipeAnalysisService:
|
||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
||||
if not metadata:
|
||||
return AnalysisResult(
|
||||
{"error": "No metadata found in this image", "loras": []}
|
||||
{
|
||||
"error": "No metadata found in this image",
|
||||
"loras": [],
|
||||
"diagnostics": {
|
||||
"channel": "upload",
|
||||
"exif_present": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return await self._parse_metadata(
|
||||
result = await self._parse_metadata(
|
||||
metadata,
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=None,
|
||||
include_image_base64=False,
|
||||
)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "upload",
|
||||
"exif_present": True,
|
||||
"exif_parser": result.payload.get("parser"),
|
||||
}
|
||||
return result
|
||||
finally:
|
||||
self._safe_cleanup(temp_path)
|
||||
|
||||
@@ -104,9 +117,13 @@ class RecipeAnalysisService:
|
||||
image_info: Optional[dict[str, Any]] = None
|
||||
is_video = False
|
||||
extension = ".jpg" # Default
|
||||
# Diagnostics collected during analysis; surfaced in the payload so
|
||||
# callers can persist an import_info block explaining empty LoRA lists.
|
||||
diagnostics: dict[str, Any] = {"channel": "url"}
|
||||
|
||||
try:
|
||||
civitai_image_id = extract_civitai_image_id(url)
|
||||
diagnostics["civitai_image"] = bool(civitai_image_id)
|
||||
if civitai_image_id:
|
||||
image_info = await civitai_client.get_image_info(
|
||||
civitai_image_id, source_url=url
|
||||
@@ -147,11 +164,23 @@ class RecipeAnalysisService:
|
||||
):
|
||||
metadata = metadata["meta"]
|
||||
|
||||
# Diagnostics: capture the API meta shape before injecting
|
||||
# modelVersionIds / browsingLevel so the recipe modal can
|
||||
# explain why an import ended up without LoRAs.
|
||||
diagnostics["api_meta_present"] = isinstance(metadata, dict)
|
||||
if isinstance(metadata, dict):
|
||||
diagnostics["api_meta_keys"] = sorted(metadata.keys())
|
||||
|
||||
# Include modelVersionIds from root level if available.
|
||||
# CivitAI API returns modelVersionIds at root level, not in meta.
|
||||
# When meta is null (None), create a minimal dict so downstream
|
||||
# parsers can still discover LoRAs and checkpoints.
|
||||
model_version_ids = image_info.get("modelVersionIds")
|
||||
diagnostics["api_model_version_ids"] = (
|
||||
len(model_version_ids)
|
||||
if isinstance(model_version_ids, list)
|
||||
else 0
|
||||
)
|
||||
if model_version_ids:
|
||||
if isinstance(metadata, dict):
|
||||
metadata["modelVersionIds"] = model_version_ids
|
||||
@@ -229,6 +258,8 @@ class RecipeAnalysisService:
|
||||
finally:
|
||||
self._safe_cleanup(orig_temp_path)
|
||||
|
||||
diagnostics["exif_present"] = bool(exif_metadata)
|
||||
|
||||
# Parse EXIF data (typically a string like parameters/prompt/workflow)
|
||||
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
|
||||
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
|
||||
@@ -237,6 +268,7 @@ class RecipeAnalysisService:
|
||||
if isinstance(exif_metadata, str):
|
||||
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
|
||||
if exif_parser:
|
||||
diagnostics["exif_parser"] = exif_parser.__class__.__name__
|
||||
exif_data = await exif_parser.parse_metadata(
|
||||
exif_metadata, recipe_scanner=recipe_scanner,
|
||||
)
|
||||
@@ -324,6 +356,8 @@ class RecipeAnalysisService:
|
||||
if isinstance(bl, int) and bl > 0:
|
||||
result.payload["preview_nsfw_level"] = bl
|
||||
|
||||
diagnostics["is_video"] = is_video
|
||||
result.payload["diagnostics"] = diagnostics
|
||||
return result
|
||||
finally:
|
||||
if temp_path:
|
||||
@@ -348,14 +382,25 @@ class RecipeAnalysisService:
|
||||
self._exif_utils.extract_image_metadata, normalized_path
|
||||
)
|
||||
if not metadata:
|
||||
return self._metadata_not_found_response(normalized_path)
|
||||
result = self._metadata_not_found_response(normalized_path)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "local",
|
||||
"exif_present": False,
|
||||
}
|
||||
return result
|
||||
|
||||
return await self._parse_metadata(
|
||||
result = await self._parse_metadata(
|
||||
metadata,
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=normalized_path,
|
||||
include_image_base64=True,
|
||||
)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "local",
|
||||
"exif_present": True,
|
||||
"exif_parser": result.payload.get("parser"),
|
||||
}
|
||||
return result
|
||||
|
||||
async def analyze_widget_metadata(self, *, recipe_scanner) -> AnalysisResult:
|
||||
"""Analyse the most recent generation metadata for widget saves."""
|
||||
@@ -452,6 +497,10 @@ class RecipeAnalysisService:
|
||||
metadata, recipe_scanner=recipe_scanner
|
||||
)
|
||||
|
||||
# Record which parser handled the metadata so import diagnostics
|
||||
# can distinguish e.g. ComfyUI workflow sources.
|
||||
result["parser"] = parser.__class__.__name__
|
||||
|
||||
if include_image_base64 and image_path:
|
||||
result["image_base64"] = self._encode_file(image_path)
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Import provenance helpers for recipes.
|
||||
|
||||
Builds the ``import_info`` block persisted on a recipe: the import channel
|
||||
(batch import / single URL / local file / upload / widget) and, when the
|
||||
recipe ended up with no LoRAs, a machine-readable reason plus the diagnostic
|
||||
details that led to it. The recipe modal renders this block in a collapsed
|
||||
"Why no LoRAs?" panel; legacy recipes without ``import_info`` fall back to a
|
||||
frontend heuristic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Import channels (how the recipe entered the library).
|
||||
CHANNEL_BATCH_IMPORT_URL = "batch_import_url"
|
||||
CHANNEL_BATCH_IMPORT_LOCAL = "batch_import_local"
|
||||
CHANNEL_URL = "url"
|
||||
CHANNEL_LOCAL = "local"
|
||||
CHANNEL_UPLOAD = "upload"
|
||||
CHANNEL_WIDGET = "widget"
|
||||
CHANNEL_REIMPORT_URL = "reimport_url"
|
||||
CHANNEL_REIMPORT_LOCAL = "reimport_local"
|
||||
|
||||
_URL_CHANNELS = frozenset(
|
||||
{CHANNEL_BATCH_IMPORT_URL, CHANNEL_URL, CHANNEL_REIMPORT_URL}
|
||||
)
|
||||
|
||||
# No-LoRA reason codes (persisted, consumed by the recipe modal).
|
||||
REASON_NO_LORAS_USED = "no_loras_used"
|
||||
REASON_API_NO_LORA_RESOURCES = "api_meta_no_lora_resources"
|
||||
REASON_API_META_MISSING = "api_meta_missing"
|
||||
REASON_NO_EMBEDDED_METADATA = "no_embedded_metadata"
|
||||
REASON_WORKFLOW_METADATA_LIMITED = "workflow_metadata_limited"
|
||||
REASON_VIDEO_NO_METADATA = "video_no_metadata"
|
||||
REASON_METADATA_UNSUPPORTED = "metadata_unsupported"
|
||||
REASON_UNKNOWN = "unknown"
|
||||
|
||||
_COMFY_PARSER_NAME = "ComfyMetadataParser"
|
||||
|
||||
# Cap for api_meta_keys kept in details — enough for the UI bullet without
|
||||
# bloating the recipe JSON.
|
||||
_MAX_DETAIL_KEYS = 12
|
||||
|
||||
|
||||
def compute_no_loras_reason(
|
||||
channel: str, diagnostics: Optional[Dict[str, Any]]
|
||||
) -> str:
|
||||
"""Classify why an import produced no LoRA entries.
|
||||
|
||||
Args:
|
||||
channel: One of the CHANNEL_* constants.
|
||||
diagnostics: Signals collected during analysis (see
|
||||
``RecipeAnalysisService``), or None for channels without analysis
|
||||
(e.g. widget saves).
|
||||
"""
|
||||
diag = diagnostics or {}
|
||||
|
||||
if diag.get("is_video"):
|
||||
return REASON_VIDEO_NO_METADATA
|
||||
|
||||
# Embedded metadata that is a ComfyUI workflow: LoRA extraction from
|
||||
# workflows is limited, so report that specifically.
|
||||
parser = diag.get("exif_parser") or diag.get("parser")
|
||||
if parser == _COMFY_PARSER_NAME:
|
||||
return REASON_WORKFLOW_METADATA_LIMITED
|
||||
|
||||
if channel in _URL_CHANNELS:
|
||||
if not diag.get("civitai_image"):
|
||||
# Generic (non-CivitAI) URL: only embedded metadata is available.
|
||||
if not diag.get("exif_present"):
|
||||
return REASON_NO_EMBEDDED_METADATA
|
||||
return (
|
||||
REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
# NOTE: no "parsed EXIF means no LoRAs were used" shortcut here.
|
||||
# CivitAI's onsite generator writes A1111-style EXIF (prompt, seed,
|
||||
# steps, ...) WITHOUT LoRA references — LoRA usage lives only in
|
||||
# CivitAI-internal data — so cleanly parsed EXIF cannot prove the
|
||||
# generation used no LoRAs. Report the API meta shape instead.
|
||||
api_keys = diag.get("api_meta_keys") or []
|
||||
api_mvids = diag.get("api_model_version_ids") or 0
|
||||
if api_keys or api_mvids:
|
||||
return REASON_API_NO_LORA_RESOURCES
|
||||
return REASON_API_META_MISSING
|
||||
|
||||
if channel == CHANNEL_WIDGET:
|
||||
return REASON_NO_LORAS_USED
|
||||
|
||||
# Local file / upload / local re-import: embedded metadata only.
|
||||
if not diag.get("exif_present"):
|
||||
return REASON_NO_EMBEDDED_METADATA
|
||||
return REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
|
||||
|
||||
|
||||
def build_import_info(
|
||||
channel: str,
|
||||
diagnostics: Optional[Dict[str, Any]],
|
||||
loras: Optional[List[Dict[str, Any]]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the ``import_info`` block persisted on a recipe.
|
||||
|
||||
Always records the import channel; adds ``reason`` and ``details`` only
|
||||
when the recipe has no LoRAs.
|
||||
"""
|
||||
info: Dict[str, Any] = {"channel": channel}
|
||||
if loras:
|
||||
return info
|
||||
|
||||
info["reason"] = compute_no_loras_reason(channel, diagnostics)
|
||||
|
||||
diag = diagnostics or {}
|
||||
details: Dict[str, Any] = {}
|
||||
api_keys = diag.get("api_meta_keys")
|
||||
if api_keys:
|
||||
details["api_meta_keys"] = list(api_keys)[:_MAX_DETAIL_KEYS]
|
||||
api_mvids = diag.get("api_model_version_ids")
|
||||
if api_mvids is not None:
|
||||
details["api_model_version_ids"] = api_mvids
|
||||
if "exif_present" in diag:
|
||||
details["exif_present"] = bool(diag.get("exif_present"))
|
||||
if diag.get("exif_parser"):
|
||||
details["exif_parser"] = diag["exif_parser"]
|
||||
if diag.get("is_video"):
|
||||
details["is_video"] = True
|
||||
if details:
|
||||
info["details"] = details
|
||||
|
||||
return info
|
||||
@@ -21,6 +21,7 @@ from ...utils.base_model import (
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from ..pending_delete_service import get_pending_delete_service
|
||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||
from .import_info import CHANNEL_UPLOAD, CHANNEL_WIDGET, build_import_info
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -134,6 +135,22 @@ class RecipePersistenceService:
|
||||
if metadata.get("source_path"):
|
||||
recipe_data["source_path"] = metadata.get("source_path")
|
||||
|
||||
# Persist import provenance. Batch import / re-import paths pass a
|
||||
# prebuilt import_info; frontend-driven saves (upload, single URL,
|
||||
# local path) carry the analysis payload's diagnostics, from which
|
||||
# import_info is derived here.
|
||||
import_info = metadata.get("import_info")
|
||||
if not isinstance(import_info, dict):
|
||||
diagnostics = metadata.get("diagnostics")
|
||||
if isinstance(diagnostics, dict):
|
||||
import_info = build_import_info(
|
||||
diagnostics.get("channel") or CHANNEL_UPLOAD,
|
||||
diagnostics,
|
||||
loras_data,
|
||||
)
|
||||
if isinstance(import_info, dict) and import_info:
|
||||
recipe_data["import_info"] = import_info
|
||||
|
||||
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
|
||||
@@ -731,6 +748,9 @@ class RecipePersistenceService:
|
||||
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
|
||||
# embedded metadata chunks, so a workflow can never be present.
|
||||
"has_workflow": False,
|
||||
# Widget saves read LoRAs straight from the current workflow; an
|
||||
# empty list means the workflow used no LoRAs.
|
||||
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
|
||||
}
|
||||
if checkpoint_entry:
|
||||
recipe_data["checkpoint"] = checkpoint_entry
|
||||
|
||||
@@ -715,6 +715,72 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Empty LoRA list + collapsible "Why no LoRAs?" explanation */
|
||||
.no-loras {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.no-loras-reason {
|
||||
margin: var(--space-1) 0 var(--space-2);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.no-loras-reason summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Hide the native disclosure triangle; rotate the icon instead. */
|
||||
.no-loras-reason summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-loras-reason summary i {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.no-loras-reason[open] summary i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.no-loras-reason summary:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.no-loras-reason-body {
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-loras-reason-body ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.no-loras-reason-body li {
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.no-loras-bullet-label {
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.no-loras-inferred-note {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recipe-checkpoint-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -66,6 +66,29 @@ function escapeHtml(value) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Fallback English strings for the collapsed "Why no LoRAs?" panel.
|
||||
// Translations live in locales/*.json under recipes.resources.
|
||||
const NO_LORAS_REASON_FALLBACKS = {
|
||||
api_meta_no_lora_resources: 'The source API returned no LoRA resource data for this image. LoRAs shown on the CivitAI page may come from internal data that the public API does not expose.',
|
||||
api_meta_missing: 'The source API returned no generation metadata for this image.',
|
||||
no_embedded_metadata: 'The image has no embedded generation metadata, so LoRA information could not be recovered.',
|
||||
workflow_metadata_limited: "The image's embedded metadata is a ComfyUI workflow; extracting LoRA information from workflows is limited.",
|
||||
video_no_metadata: 'Video files do not carry embedded generation metadata.',
|
||||
metadata_unsupported: 'The image contains metadata in a format that could not be parsed.',
|
||||
unknown: 'The reason could not be determined from the stored recipe data.',
|
||||
};
|
||||
|
||||
const NO_LORAS_CHANNEL_FALLBACKS = {
|
||||
batch_import_url: 'Batch import (image URL)',
|
||||
batch_import_local: 'Batch import (local file)',
|
||||
url: 'Image URL import',
|
||||
local: 'Local file import',
|
||||
upload: 'Image upload',
|
||||
widget: 'Saved from workflow',
|
||||
reimport_url: 'Re-import (image URL)',
|
||||
reimport_local: 'Re-import (local file)',
|
||||
};
|
||||
|
||||
class RecipeModal {
|
||||
constructor() {
|
||||
this.promptEditorState = {};
|
||||
@@ -1017,7 +1040,7 @@ class RecipeModal {
|
||||
this.recipeLorasSyntax = '';
|
||||
} else if (lorasListElement) {
|
||||
this._destroyAllReconnectComboboxes();
|
||||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||
lorasListElement.innerHTML = this.renderNoLorasState(recipe);
|
||||
this.recipeLorasSyntax = '';
|
||||
}
|
||||
|
||||
@@ -1028,6 +1051,156 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the empty LoRA list, including a collapsed "Why no LoRAs?"
|
||||
* explanation panel when the cause is known or can be inferred.
|
||||
* @param {Object} recipe
|
||||
* @returns {string}
|
||||
*/
|
||||
renderNoLorasState(recipe) {
|
||||
const emptyText = translate(
|
||||
'recipes.resources.noLorasAssociated',
|
||||
{},
|
||||
'No LoRAs associated with this recipe'
|
||||
);
|
||||
const reason = this.resolveNoLorasReason(recipe);
|
||||
let html = `<div class="no-loras">${escapeHtml(emptyText)}</div>`;
|
||||
|
||||
// 'no_loras_used' is the normal case — the generation simply used no
|
||||
// LoRAs, nothing to explain.
|
||||
if (!reason || reason.code === 'no_loras_used') {
|
||||
return html;
|
||||
}
|
||||
|
||||
const toggle = translate('recipes.resources.noLorasWhyToggle', {}, 'Why no LoRAs?');
|
||||
const reasonText = translate(
|
||||
`recipes.resources.noLorasReasons.${reason.code}`,
|
||||
{},
|
||||
NO_LORAS_REASON_FALLBACKS[reason.code] || NO_LORAS_REASON_FALLBACKS.unknown
|
||||
);
|
||||
|
||||
const bullets = [];
|
||||
if (reason.channel) {
|
||||
const channelText = translate(
|
||||
`recipes.resources.noLorasChannels.${reason.channel}`,
|
||||
{},
|
||||
NO_LORAS_CHANNEL_FALLBACKS[reason.channel] || reason.channel
|
||||
);
|
||||
bullets.push(
|
||||
`<li><span class="no-loras-bullet-label">${escapeHtml(translate('recipes.resources.noLorasImportMethod', {}, 'Import method'))}:</span> ${escapeHtml(channelText)}</li>`
|
||||
);
|
||||
}
|
||||
bullets.push(`<li>${escapeHtml(reasonText)}</li>`);
|
||||
bullets.push(...this.renderNoLorasDetailBullets(reason));
|
||||
if (reason.inferred) {
|
||||
bullets.push(
|
||||
`<li class="no-loras-inferred-note">${escapeHtml(translate('recipes.resources.noLorasInferredNote', {}, 'Possible reason (inferred) — this recipe was imported before import diagnostics were recorded.'))}</li>`
|
||||
);
|
||||
}
|
||||
|
||||
html += `
|
||||
<details class="no-loras-reason">
|
||||
<summary><i class="fas fa-circle-question" aria-hidden="true"></i> ${escapeHtml(toggle)}</summary>
|
||||
<div class="no-loras-reason-body"><ul>${bullets.join('')}</ul></div>
|
||||
</details>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the no-LoRA reason: recorded import_info takes precedence;
|
||||
* legacy recipes without it fall back to heuristics on the stored data.
|
||||
* @param {Object} recipe
|
||||
* @returns {{code: string, channel: ?string, details: ?Object, inferred: boolean}|null}
|
||||
*/
|
||||
resolveNoLorasReason(recipe) {
|
||||
const importInfo =
|
||||
recipe && typeof recipe.import_info === 'object' && recipe.import_info !== null
|
||||
? recipe.import_info
|
||||
: null;
|
||||
if (importInfo && typeof importInfo.reason === 'string' && importInfo.reason) {
|
||||
return {
|
||||
code: importInfo.reason,
|
||||
channel: typeof importInfo.channel === 'string' ? importInfo.channel : null,
|
||||
details:
|
||||
typeof importInfo.details === 'object' && importInfo.details !== null
|
||||
? importInfo.details
|
||||
: null,
|
||||
inferred: false,
|
||||
};
|
||||
}
|
||||
return this.inferNoLorasReason(recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic reason for legacy recipes that predate import_info.
|
||||
* @param {Object} recipe
|
||||
* @returns {{code: string, channel: ?string, details: ?Object, inferred: boolean}}
|
||||
*/
|
||||
inferNoLorasReason(recipe) {
|
||||
const sourcePath = recipe && recipe.source_path ? String(recipe.source_path).trim() : '';
|
||||
const genParams =
|
||||
recipe && recipe.gen_params && typeof recipe.gen_params === 'object'
|
||||
? recipe.gen_params
|
||||
: {};
|
||||
const paramKeys = Object.keys(genParams).filter(
|
||||
(key) => genParams[key] !== '' && genParams[key] !== null && genParams[key] !== undefined
|
||||
);
|
||||
|
||||
if (recipe && recipe.has_workflow) {
|
||||
return { code: 'workflow_metadata_limited', channel: null, details: null, inferred: true };
|
||||
}
|
||||
if (/^https?:\/\//i.test(sourcePath)) {
|
||||
// URL imports come from CivitAI; a missing LoRA list there almost
|
||||
// always means the public API did not report LoRA resources.
|
||||
return { code: 'api_meta_no_lora_resources', channel: 'url', details: null, inferred: true };
|
||||
}
|
||||
if (sourcePath) {
|
||||
return paramKeys.length === 0
|
||||
? { code: 'no_embedded_metadata', channel: 'local', details: null, inferred: true }
|
||||
: { code: 'no_loras_used', channel: 'local', details: null, inferred: true };
|
||||
}
|
||||
if (paramKeys.length > 0) {
|
||||
return { code: 'no_loras_used', channel: null, details: null, inferred: true };
|
||||
}
|
||||
return { code: 'unknown', channel: null, details: null, inferred: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the recorded diagnostic detail bullets (API meta shape, EXIF
|
||||
* presence). Only shown for recorded (non-inferred) import_info.
|
||||
* @param {{details: ?Object}} reason
|
||||
* @returns {string[]}
|
||||
*/
|
||||
renderNoLorasDetailBullets(reason) {
|
||||
const details = reason.details;
|
||||
if (!details) {
|
||||
return [];
|
||||
}
|
||||
const bullets = [];
|
||||
if (Array.isArray(details.api_meta_keys) && details.api_meta_keys.length > 0) {
|
||||
const label = translate('recipes.resources.noLorasDetails.apiMetaFields', {}, 'API metadata fields');
|
||||
bullets.push(
|
||||
`<li><span class="no-loras-bullet-label">${escapeHtml(label)}:</span> ${escapeHtml(details.api_meta_keys.join(', '))}</li>`
|
||||
);
|
||||
}
|
||||
if (typeof details.api_model_version_ids === 'number') {
|
||||
const label = translate('recipes.resources.noLorasDetails.modelVersionIds', {}, 'Model version IDs reported');
|
||||
bullets.push(
|
||||
`<li><span class="no-loras-bullet-label">${escapeHtml(label)}:</span> ${details.api_model_version_ids}</li>`
|
||||
);
|
||||
}
|
||||
if (typeof details.exif_present === 'boolean') {
|
||||
const label = translate('recipes.resources.noLorasDetails.embeddedMetadata', {}, 'Embedded metadata');
|
||||
const value = details.exif_present
|
||||
? translate('recipes.resources.noLorasDetails.present', {}, 'found')
|
||||
: translate('recipes.resources.noLorasDetails.absent', {}, 'none');
|
||||
bullets.push(
|
||||
`<li><span class="no-loras-bullet-label">${escapeHtml(label)}:</span> ${escapeHtml(value)}</li>`
|
||||
);
|
||||
}
|
||||
return bullets;
|
||||
}
|
||||
|
||||
updateSourceUrlDisplay(sourcePath, options = {}) {
|
||||
const sourceUrlContainer = document.querySelector('.source-url-container');
|
||||
const sourceUrlEditor = document.querySelector('.source-url-editor');
|
||||
|
||||
@@ -65,6 +65,13 @@ export class DownloadManager {
|
||||
raw_metadata: this.importManager.recipeData.raw_metadata || {},
|
||||
};
|
||||
|
||||
// Pass analysis diagnostics through so the backend can record
|
||||
// why the recipe ended up with no LoRAs (recipe modal panel).
|
||||
const diagnostics = this.importManager.recipeData.diagnostics;
|
||||
if (diagnostics && typeof diagnostics === 'object') {
|
||||
completeMetadata.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
// Preserve preview_nsfw_level from analysis so the saved
|
||||
// recipe applies the correct NSFW blur on the preview image.
|
||||
const nsfwLevel = this.importManager.recipeData.preview_nsfw_level;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
|
||||
|
||||
const loadingManagerStub = {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
show: vi.fn(),
|
||||
restoreProgressBar: vi.fn(),
|
||||
};
|
||||
|
||||
const virtualScrollerStub = {
|
||||
updateSingleItem: vi.fn(),
|
||||
getNavigationState: vi.fn(() => ({
|
||||
index: 0,
|
||||
hasPrev: false,
|
||||
hasNext: false,
|
||||
loadedItems: 1,
|
||||
totalItems: 1,
|
||||
})),
|
||||
getAdjacentItemByFilePath: vi.fn(async () => null),
|
||||
};
|
||||
|
||||
const stateStub = {
|
||||
global: { settings: {}, loadingManager: loadingManagerStub },
|
||||
loadingManager: loadingManagerStub,
|
||||
virtualScroller: virtualScrollerStub,
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
sendModelPathToWorkflow: vi.fn(),
|
||||
openCivitaiByMetadata: vi.fn(),
|
||||
stripLoraTags: vi.fn((text) => text),
|
||||
sendPromptToWorkflow: vi.fn(),
|
||||
sendGenParamsToWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: translateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: stateStub,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
setSessionItem: vi.fn(),
|
||||
removeSessionItem: vi.fn(),
|
||||
getStorageItem: vi.fn(() => null),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
fetchRecipeDetails: vi.fn(),
|
||||
updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })),
|
||||
sendRecipeWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: {
|
||||
LORA: 'loras',
|
||||
CHECKPOINT: 'checkpoints',
|
||||
EMBEDDING: 'embeddings',
|
||||
},
|
||||
}));
|
||||
|
||||
function recipeModalFixture() {
|
||||
return `
|
||||
<div id="recipeModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-actions">
|
||||
<span id="recipeLorasCount"></span>
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn"></button>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
describe('RecipeModal no-LoRA reason panel', () => {
|
||||
let recipeModal;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = recipeModalFixture();
|
||||
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||
recipeModal = new RecipeModal();
|
||||
});
|
||||
|
||||
function sync(recipe) {
|
||||
recipeModal.syncResourcesSection(recipe);
|
||||
return document.getElementById('recipeLorasList');
|
||||
}
|
||||
|
||||
it('shows the base message only when generation genuinely used no LoRAs', () => {
|
||||
const list = sync({
|
||||
id: 'r1',
|
||||
loras: [],
|
||||
import_info: { channel: 'url', reason: 'no_loras_used' },
|
||||
});
|
||||
|
||||
expect(list.querySelector('.no-loras')).not.toBeNull();
|
||||
expect(list.textContent).toContain('No LoRAs associated with this recipe');
|
||||
expect(list.querySelector('details.no-loras-reason')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a collapsed reason panel from recorded import_info', () => {
|
||||
const list = sync({
|
||||
id: 'r2',
|
||||
loras: [],
|
||||
import_info: {
|
||||
channel: 'batch_import_url',
|
||||
reason: 'api_meta_no_lora_resources',
|
||||
details: {
|
||||
api_meta_keys: ['prompt'],
|
||||
api_model_version_ids: 0,
|
||||
exif_present: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
// Collapsed by default (no `open` attribute).
|
||||
expect(details.hasAttribute('open')).toBe(false);
|
||||
expect(details.querySelector('summary').textContent).toContain('Why no LoRAs?');
|
||||
|
||||
const body = details.querySelector('.no-loras-reason-body');
|
||||
expect(body.textContent).toContain('Batch import (image URL)');
|
||||
expect(body.textContent).toContain('The source API returned no LoRA resource data');
|
||||
expect(body.textContent).toContain('API metadata fields');
|
||||
expect(body.textContent).toContain('prompt');
|
||||
expect(body.textContent).toContain('Model version IDs reported');
|
||||
expect(body.textContent).toContain('Embedded metadata');
|
||||
// Recorded diagnostics are not labeled as inferred.
|
||||
expect(body.querySelector('.no-loras-inferred-note')).toBeNull();
|
||||
});
|
||||
|
||||
it('infers a possible reason for legacy URL recipes without import_info', () => {
|
||||
const list = sync({
|
||||
id: 'r3',
|
||||
loras: [],
|
||||
source_path: 'https://civitai.red/images/139995974',
|
||||
gen_params: { prompt: 'a castle' },
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
const body = details.querySelector('.no-loras-reason-body');
|
||||
expect(body.textContent).toContain('The source API returned no LoRA resource data');
|
||||
// Heuristic results must be labeled as inferred.
|
||||
expect(body.querySelector('.no-loras-inferred-note')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reports missing embedded metadata for legacy local recipes with no params', () => {
|
||||
const list = sync({
|
||||
id: 'r4',
|
||||
loras: [],
|
||||
source_path: '/data/images/photo.png',
|
||||
gen_params: {},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.querySelector('.no-loras-reason-body').textContent).toContain(
|
||||
'The image has no embedded generation metadata'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show the panel for legacy local recipes with complete params', () => {
|
||||
const list = sync({
|
||||
id: 'r5',
|
||||
loras: [],
|
||||
source_path: '/data/images/photo.png',
|
||||
gen_params: { prompt: 'a castle', steps: 20, seed: 42 },
|
||||
});
|
||||
|
||||
expect(list.querySelector('details.no-loras-reason')).toBeNull();
|
||||
});
|
||||
|
||||
it('flags ComfyUI workflow sources via has_workflow', () => {
|
||||
const list = sync({
|
||||
id: 'r6',
|
||||
loras: [],
|
||||
has_workflow: true,
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.querySelector('.no-loras-reason-body').textContent).toContain(
|
||||
'ComfyUI workflow'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes HTML in recorded diagnostic values', () => {
|
||||
const list = sync({
|
||||
id: 'r7',
|
||||
loras: [],
|
||||
import_info: {
|
||||
channel: 'url',
|
||||
reason: 'api_meta_no_lora_resources',
|
||||
details: { api_meta_keys: ['<img src=x onerror=alert(1)>'] },
|
||||
},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.innerHTML).not.toContain('<img src=x');
|
||||
expect(details.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for the recipe import_info helpers (no-LoRA reason computation)."""
|
||||
|
||||
from py.services.recipes.import_info import (
|
||||
CHANNEL_BATCH_IMPORT_LOCAL,
|
||||
CHANNEL_BATCH_IMPORT_URL,
|
||||
CHANNEL_LOCAL,
|
||||
CHANNEL_REIMPORT_URL,
|
||||
CHANNEL_UPLOAD,
|
||||
CHANNEL_URL,
|
||||
CHANNEL_WIDGET,
|
||||
REASON_API_META_MISSING,
|
||||
REASON_API_NO_LORA_RESOURCES,
|
||||
REASON_METADATA_UNSUPPORTED,
|
||||
REASON_NO_EMBEDDED_METADATA,
|
||||
REASON_NO_LORAS_USED,
|
||||
REASON_VIDEO_NO_METADATA,
|
||||
REASON_WORKFLOW_METADATA_LIMITED,
|
||||
build_import_info,
|
||||
compute_no_loras_reason,
|
||||
)
|
||||
|
||||
|
||||
class TestComputeNoLorasReason:
|
||||
def test_video_takes_priority(self):
|
||||
diag = {"is_video": True, "exif_parser": "ComfyMetadataParser"}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_VIDEO_NO_METADATA
|
||||
)
|
||||
|
||||
def test_comfy_workflow_parser(self):
|
||||
diag = {"exif_parser": "ComfyMetadataParser", "exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_UPLOAD, diag)
|
||||
== REASON_WORKFLOW_METADATA_LIMITED
|
||||
)
|
||||
|
||||
def test_merged_comfy_parser(self):
|
||||
# ComfyUI workflow parsed from the merged dict (no string EXIF).
|
||||
diag = {"parser": "ComfyMetadataParser"}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, diag)
|
||||
== REASON_WORKFLOW_METADATA_LIMITED
|
||||
)
|
||||
|
||||
def test_civitai_url_with_api_meta_but_no_lora_resources(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt"],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_civitai_url_with_model_version_ids_only(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [],
|
||||
"api_model_version_ids": 2,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_civitai_url_with_no_meta_at_all(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_REIMPORT_URL, diag)
|
||||
== REASON_API_META_MISSING
|
||||
)
|
||||
|
||||
def test_civitai_url_with_parsed_exif_still_reports_api_gap(self):
|
||||
# CivitAI's onsite generator writes A1111-style EXIF WITHOUT LoRA
|
||||
# references (LoRA usage lives in CivitAI-internal data), so cleanly
|
||||
# parsed EXIF must NOT be read as "no LoRAs were used".
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt", "steps", "seed", "resources"],
|
||||
"api_model_version_ids": 1,
|
||||
"exif_present": True,
|
||||
"exif_parser": "AutomaticMetadataParser",
|
||||
"parser": "CivitaiApiMetadataParser",
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_generic_url_without_embedded_metadata(self):
|
||||
diag = {"civitai_image": False, "exif_present": False}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag) == REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
def test_generic_url_with_unsupported_metadata(self):
|
||||
diag = {"civitai_image": False, "exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag) == REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
|
||||
def test_generic_url_with_parsed_metadata_means_no_loras(self):
|
||||
diag = {
|
||||
"civitai_image": False,
|
||||
"exif_present": True,
|
||||
"exif_parser": "AutomaticMetadataParser",
|
||||
}
|
||||
assert compute_no_loras_reason(CHANNEL_URL, diag) == REASON_NO_LORAS_USED
|
||||
|
||||
def test_widget(self):
|
||||
assert compute_no_loras_reason(CHANNEL_WIDGET, None) == REASON_NO_LORAS_USED
|
||||
|
||||
def test_local_without_embedded_metadata(self):
|
||||
diag = {"exif_present": False}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, diag)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_LOCAL, diag)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
def test_local_with_unsupported_metadata(self):
|
||||
diag = {"exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_UPLOAD, diag)
|
||||
== REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
|
||||
def test_missing_diagnostics_falls_back_safely(self):
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, None)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
# URL channel without diagnostics is treated as a generic URL (the
|
||||
# civitai_image flag defaults to False).
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, None)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
|
||||
class TestBuildImportInfo:
|
||||
def test_channel_always_recorded(self):
|
||||
info = build_import_info(
|
||||
CHANNEL_URL, None, loras=[{"file_name": "x", "hash": "abc"}]
|
||||
)
|
||||
assert info == {"channel": CHANNEL_URL}
|
||||
|
||||
def test_reason_and_details_when_no_loras(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt"],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
"exif_parser": None,
|
||||
}
|
||||
info = build_import_info(CHANNEL_BATCH_IMPORT_URL, diag, loras=[])
|
||||
assert info["channel"] == CHANNEL_BATCH_IMPORT_URL
|
||||
assert info["reason"] == REASON_API_NO_LORA_RESOURCES
|
||||
assert info["details"]["api_meta_keys"] == ["prompt"]
|
||||
assert info["details"]["api_model_version_ids"] == 0
|
||||
assert info["details"]["exif_present"] is False
|
||||
# Empty exif_parser must not leak into details.
|
||||
assert "exif_parser" not in info["details"]
|
||||
|
||||
def test_details_omitted_when_nothing_to_report(self):
|
||||
info = build_import_info(CHANNEL_WIDGET, None, loras=[])
|
||||
assert info == {"channel": CHANNEL_WIDGET, "reason": REASON_NO_LORAS_USED}
|
||||
|
||||
def test_api_meta_keys_capped(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [f"k{i}" for i in range(50)],
|
||||
"api_model_version_ids": 1,
|
||||
}
|
||||
info = build_import_info(CHANNEL_URL, diag, loras=[])
|
||||
assert len(info["details"]["api_meta_keys"]) == 12
|
||||
@@ -120,6 +120,76 @@ class TestPersistentRecipeCache:
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is None
|
||||
|
||||
def test_import_info_roundtrip(self, temp_db_path, sample_recipes):
|
||||
"""import_info (import provenance + no-LoRA reason) survives the cache."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
|
||||
sample_recipes[0]["import_info"] = {
|
||||
"channel": "batch_import_url",
|
||||
"reason": "api_meta_no_lora_resources",
|
||||
"details": {"api_meta_keys": ["prompt"], "api_model_version_ids": 0},
|
||||
}
|
||||
cache.save_cache(sample_recipes)
|
||||
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is not None
|
||||
r1 = next(r for r in loaded.raw_data if r["id"] == "recipe-001")
|
||||
assert r1["import_info"]["channel"] == "batch_import_url"
|
||||
assert r1["import_info"]["reason"] == "api_meta_no_lora_resources"
|
||||
assert r1["import_info"]["details"]["api_meta_keys"] == ["prompt"]
|
||||
|
||||
# Recipes without import_info simply omit the key.
|
||||
r2 = next(r for r in loaded.raw_data if r["id"] == "recipe-002")
|
||||
assert "import_info" not in r2
|
||||
|
||||
def test_import_info_column_migration(self, temp_db_path, sample_recipes):
|
||||
"""Existing databases gain the import_info_json column via ALTER TABLE."""
|
||||
import sqlite3
|
||||
|
||||
# Simulate a legacy database without the new column.
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE recipes (
|
||||
recipe_id TEXT PRIMARY KEY,
|
||||
file_path TEXT,
|
||||
json_path TEXT,
|
||||
title TEXT,
|
||||
folder TEXT,
|
||||
source_path TEXT,
|
||||
base_model TEXT,
|
||||
fingerprint TEXT,
|
||||
created_date REAL,
|
||||
modified REAL,
|
||||
file_mtime REAL,
|
||||
file_size INTEGER,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
repair_version INTEGER DEFAULT 0,
|
||||
preview_nsfw_level INTEGER DEFAULT 0,
|
||||
loras_json TEXT,
|
||||
checkpoint_json TEXT,
|
||||
gen_params_json TEXT,
|
||||
tags_json TEXT,
|
||||
has_workflow INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE cache_metadata (key TEXT PRIMARY KEY, value TEXT);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
cache.save_cache(sample_recipes)
|
||||
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(recipes)")}
|
||||
conn.close()
|
||||
assert "import_info_json" in columns
|
||||
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is not None
|
||||
assert len(loaded.raw_data) == 2
|
||||
|
||||
def test_update_single_recipe(self, temp_db_path, sample_recipes):
|
||||
"""Test updating a single recipe."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
@@ -595,15 +665,19 @@ class TestHasWorkflowColumn:
|
||||
assert by_id["wf-3"]["has_workflow"] is False
|
||||
|
||||
def test_prepare_recipe_row_matches_column_order(self, temp_db_path):
|
||||
"""The prepared row must append has_workflow in column order."""
|
||||
"""The prepared row must append has_workflow/import_info in column order."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
row_true = cache._prepare_recipe_row({"id": "r1", "has_workflow": True}, "")
|
||||
row_false = cache._prepare_recipe_row({"id": "r2", "has_workflow": False}, "")
|
||||
|
||||
assert row_true[-1] == 1
|
||||
assert row_false[-1] == 0
|
||||
assert row_true[-2] == 1
|
||||
assert row_false[-2] == 0
|
||||
# import_info_json is the trailing column, unset by default.
|
||||
assert row_true[-1] is None
|
||||
assert row_false[-1] is None
|
||||
assert len(row_true) == len(cache._RECIPE_COLUMNS)
|
||||
assert cache._RECIPE_COLUMNS[-1] == "has_workflow"
|
||||
assert cache._RECIPE_COLUMNS[-2] == "has_workflow"
|
||||
assert cache._RECIPE_COLUMNS[-1] == "import_info_json"
|
||||
|
||||
def test_update_recipe_preserves_has_workflow(self, temp_db_path):
|
||||
"""update_recipe() must write the has_workflow column correctly."""
|
||||
|
||||
Reference in New Issue
Block a user