From 3ebf256c5dd35e12c63d35c9a504d80a83fb0bec Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 20 Aug 2026 22:18:13 +0800 Subject: [PATCH] feat(recipe): send embedded recipe workflow to ComfyUI canvas --- locales/de.json | 11 +- locales/en.json | 11 +- locales/es.json | 11 +- locales/fr.json | 11 +- locales/he.json | 11 +- locales/ja.json | 11 +- locales/ko.json | 11 +- locales/ru.json | 11 +- locales/zh-CN.json | 11 +- locales/zh-TW.json | 11 +- py/nodes/save_image.py | 8 + py/routes/base_recipe_routes.py | 14 + py/routes/handlers/recipe_handlers.py | 100 +++++- py/routes/recipe_route_registrar.py | 3 + py/services/persistent_recipe_cache.py | 13 +- py/services/recipe_scanner.py | 45 +++ py/services/recipes/persistence_service.py | 18 ++ static/js/api/recipeApi.js | 22 ++ static/js/components/RecipeModal.js | 68 +++- .../test_recipe_route_registrar_handlers.py | 293 ++++++++++++++++++ tests/services/test_recipe_scanner.py | 256 ++++++++++++++- tests/services/test_recipe_services.py | 87 ++++++ tests/test_persistent_recipe_cache.py | 83 +++++ web/comfyui/workflow_registry.js | 52 ++++ 24 files changed, 1145 insertions(+), 27 deletions(-) create mode 100644 tests/routes/test_recipe_route_registrar_handlers.py diff --git a/locales/de.json b/locales/de.json index c833c968..312ea288 100644 --- a/locales/de.json +++ b/locales/de.json @@ -856,6 +856,12 @@ "sendCheckpoint": "Send to ComfyUI", "sendRecipe": "Send to ComfyUI" }, + "workflow": { + "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", + "sent": "[TODO: Translate] Workflow sent to ComfyUI", + "sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI", + "noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" + }, "controls": { "import": { "action": "Importieren", @@ -2031,7 +2037,10 @@ "reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})", "reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen", "noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden", - "noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest." + "noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest.", + "workflowSent": "[TODO: Translate] Workflow sent to ComfyUI", + "workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}", + "workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" }, "models": { "noModelsSelected": "Keine Modelle ausgewählt", diff --git a/locales/en.json b/locales/en.json index f64baca9..dc728f7b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -856,6 +856,12 @@ "sendCheckpoint": "Send to ComfyUI", "sendRecipe": "Send to ComfyUI" }, + "workflow": { + "sendWorkflow": "Send Workflow to ComfyUI", + "sent": "Workflow sent to ComfyUI", + "sendFailed": "Failed to send workflow to ComfyUI", + "noWorkflow": "No embedded workflow found in this recipe" + }, "controls": { "import": { "action": "Import", @@ -2031,7 +2037,10 @@ "reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})", "reimportBulkFailed": "Failed to re-import some recipes", "noMissingLorasInSelection": "No missing LoRAs found in selected recipes", - "noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings." + "noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings.", + "workflowSent": "Workflow sent to ComfyUI", + "workflowSendFailed": "Failed to send workflow to ComfyUI: {error}", + "workflowNoWorkflow": "No embedded workflow found in this recipe" }, "models": { "noModelsSelected": "No models selected", diff --git a/locales/es.json b/locales/es.json index 768692b8..341aa9e1 100644 --- a/locales/es.json +++ b/locales/es.json @@ -856,6 +856,12 @@ "sendCheckpoint": "Enviar a ComfyUI", "sendRecipe": "Enviar a ComfyUI" }, + "workflow": { + "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", + "sent": "[TODO: Translate] Workflow sent to ComfyUI", + "sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI", + "noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" + }, "controls": { "import": { "action": "Importar", @@ -2031,7 +2037,10 @@ "reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})", "reimportBulkFailed": "Error al reimportar algunas recetas", "noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas", - "noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración." + "noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración.", + "workflowSent": "[TODO: Translate] Workflow sent to ComfyUI", + "workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}", + "workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" }, "models": { "noModelsSelected": "No hay modelos seleccionados", diff --git a/locales/fr.json b/locales/fr.json index 4be323bf..6c8fa3e3 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -856,6 +856,12 @@ "sendCheckpoint": "Envoyer vers ComfyUI", "sendRecipe": "Envoyer vers ComfyUI" }, + "workflow": { + "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", + "sent": "[TODO: Translate] Workflow sent to ComfyUI", + "sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI", + "noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" + }, "controls": { "import": { "action": "Importer", @@ -2031,7 +2037,10 @@ "reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})", "reimportBulkFailed": "Échec du ré-import de certaines recettes", "noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées", - "noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres." + "noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres.", + "workflowSent": "[TODO: Translate] Workflow sent to ComfyUI", + "workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}", + "workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" }, "models": { "noModelsSelected": "Aucun modèle sélectionné", diff --git a/locales/he.json b/locales/he.json index ed544a0d..f0565a56 100644 --- a/locales/he.json +++ b/locales/he.json @@ -856,6 +856,12 @@ "sendCheckpoint": "שלח ל-ComfyUI", "sendRecipe": "שלח ל-ComfyUI" }, + "workflow": { + "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", + "sent": "[TODO: Translate] Workflow sent to ComfyUI", + "sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI", + "noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" + }, "controls": { "import": { "action": "ייבא", @@ -2031,7 +2037,10 @@ "reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})", "reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל", "noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו", - "noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות." + "noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות.", + "workflowSent": "[TODO: Translate] Workflow sent to ComfyUI", + "workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}", + "workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" }, "models": { "noModelsSelected": "לא נבחרו מודלים", diff --git a/locales/ja.json b/locales/ja.json index 30ddca46..990422d1 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -856,6 +856,12 @@ "sendCheckpoint": "ComfyUIへ送信", "sendRecipe": "ComfyUIへ送信" }, + "workflow": { + "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", + "sent": "[TODO: Translate] Workflow sent to ComfyUI", + "sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI", + "noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" + }, "controls": { "import": { "action": "インポート", @@ -2031,7 +2037,10 @@ "reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)", "reimportBulkFailed": "一部のレシピの再インポートに失敗しました", "noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした", - "noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。" + "noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。", + "workflowSent": "[TODO: Translate] Workflow sent to ComfyUI", + "workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}", + "workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" }, "models": { "noModelsSelected": "モデルが選択されていません", diff --git a/locales/ko.json b/locales/ko.json index cbc15b72..e4e33712 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -856,6 +856,12 @@ "sendCheckpoint": "ComfyUI로 보내기", "sendRecipe": "ComfyUI로 보내기" }, + "workflow": { + "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", + "sent": "[TODO: Translate] Workflow sent to ComfyUI", + "sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI", + "noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" + }, "controls": { "import": { "action": "가져오기", @@ -2031,7 +2037,10 @@ "reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)", "reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다", "noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다", - "noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요." + "noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요.", + "workflowSent": "[TODO: Translate] Workflow sent to ComfyUI", + "workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}", + "workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" }, "models": { "noModelsSelected": "선택된 모델이 없습니다", diff --git a/locales/ru.json b/locales/ru.json index 822d09ee..2929a885 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -856,6 +856,12 @@ "sendCheckpoint": "Отправить в ComfyUI", "sendRecipe": "Отправить в ComfyUI" }, + "workflow": { + "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", + "sent": "[TODO: Translate] Workflow sent to ComfyUI", + "sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI", + "noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" + }, "controls": { "import": { "action": "Импортировать", @@ -2031,7 +2037,10 @@ "reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})", "reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты", "noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs", - "noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках." + "noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках.", + "workflowSent": "[TODO: Translate] Workflow sent to ComfyUI", + "workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}", + "workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" }, "models": { "noModelsSelected": "Модели не выбраны", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index da9f30a8..3c916759 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -856,6 +856,12 @@ "sendCheckpoint": "发送到 ComfyUI", "sendRecipe": "发送到 ComfyUI" }, + "workflow": { + "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", + "sent": "[TODO: Translate] Workflow sent to ComfyUI", + "sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI", + "noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" + }, "controls": { "import": { "action": "导入", @@ -2031,7 +2037,10 @@ "reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)", "reimportBulkFailed": "重新导入某些配方失败", "noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs", - "noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。" + "noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。", + "workflowSent": "[TODO: Translate] Workflow sent to ComfyUI", + "workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}", + "workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" }, "models": { "noModelsSelected": "未选中模型", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index bfa354ca..8e274a4c 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -856,6 +856,12 @@ "sendCheckpoint": "傳送到 ComfyUI", "sendRecipe": "傳送到 ComfyUI" }, + "workflow": { + "sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI", + "sent": "[TODO: Translate] Workflow sent to ComfyUI", + "sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI", + "noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" + }, "controls": { "import": { "action": "匯入", @@ -2031,7 +2037,10 @@ "reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)", "reimportBulkFailed": "重新匯入某些配方失敗", "noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs", - "noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。" + "noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。", + "workflowSent": "[TODO: Translate] Workflow sent to ComfyUI", + "workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}", + "workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe" }, "models": { "noModelsSelected": "未選擇模型", diff --git a/py/nodes/save_image.py b/py/nodes/save_image.py index 82e03124..e4c3001e 100644 --- a/py/nodes/save_image.py +++ b/py/nodes/save_image.py @@ -778,6 +778,14 @@ class SaveImageLM: if checkpoint_entry: recipe_data["checkpoint"] = checkpoint_entry + # The recipe image is the WebP produced above from the output file; + # reuse the same metadata extraction to record workflow presence. + try: + metadata = ExifUtils._load_structured_metadata(image_path) + recipe_data["has_workflow"] = bool(metadata.get("workflow")) + except Exception: + recipe_data["has_workflow"] = False + json_path = os.path.normpath( os.path.join(recipes_dir, f"{recipe_id}.recipe.json") ) diff --git a/py/routes/base_recipe_routes.py b/py/routes/base_recipe_routes.py index 59b7c51d..79fcd048 100644 --- a/py/routes/base_recipe_routes.py +++ b/py/routes/base_recipe_routes.py @@ -32,6 +32,7 @@ from .handlers.recipe_handlers import ( RecipePageView, RecipeQueryHandler, RecipeSharingHandler, + RecipeWorkflowHandler, ) from .recipe_route_registrar import ROUTE_DEFINITIONS @@ -200,6 +201,18 @@ class BaseRecipeRoutes: sharing_service=sharing_service, ) + # Lazy import: standalone mode replaces the ``server`` module with a + # mock, so resolve PromptServer at handler-set build time instead of + # module import time. The handler's standalone check guards UX. + from server import PromptServer # pyright: ignore[reportMissingImports] + + workflow = RecipeWorkflowHandler( + ensure_dependencies_ready=self.ensure_dependencies_ready, + recipe_scanner_getter=recipe_scanner_getter, + prompt_server=PromptServer, + logger=logger, + ) + from ..services.websocket_manager import ws_manager batch_import_service = BatchImportService( @@ -224,4 +237,5 @@ class BaseRecipeRoutes: analysis=analysis, sharing=sharing, batch_import=batch_import, + workflow=workflow, ) diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 609bfe3f..c7761ac5 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -10,7 +10,7 @@ import asyncio import tempfile from dataclasses import dataclass from pathlib import Path -from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple +from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple from aiohttp import web @@ -45,6 +45,17 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]] RecipeScannerGetter = Callable[[], Any] CivitaiClientGetter = Callable[[], Any] + +class PromptServerProtocol(Protocol): + """Subset of PromptServer used by the recipe workflow handler.""" + + instance: "PromptServerProtocol" + + def send_sync( + self, event: str, payload: dict[str, Any] | None = None, sid: str | None = None + ) -> None: # pragma: no cover - protocol + ... + # Cap concurrent preview-dimension reads across requests. With a cold LRU # cache one page can touch up to page_size image files; 16 balances SSD and # HDD throughput without starving the event loop. @@ -73,6 +84,7 @@ class RecipeHandlerSet: analysis: "RecipeAnalysisHandler" sharing: "RecipeSharingHandler" batch_import: "BatchImportHandler" + workflow: "RecipeWorkflowHandler" def to_route_mapping( self, @@ -128,6 +140,7 @@ class RecipeHandlerSet: "import_from_url": self.management.import_from_url, "create_from_example": self.management.create_from_example, "reimport_recipe": self.management.reimport_recipe, + "send_recipe_workflow": self.workflow.send_recipe_workflow, } @@ -2755,6 +2768,91 @@ class RecipeSharingHandler: return web.json_response({"error": str(exc)}, status=500) +class RecipeWorkflowHandler: + """Extract an embedded workflow from a recipe image and broadcast it.""" + + def __init__( + self, + *, + ensure_dependencies_ready: EnsureDependenciesCallable, + recipe_scanner_getter: RecipeScannerGetter, + prompt_server: type[PromptServerProtocol], + logger: Logger, + ) -> None: + self._ensure_dependencies_ready = ensure_dependencies_ready + self._recipe_scanner_getter = recipe_scanner_getter + self._prompt_server = prompt_server + self._logger = logger + + async def send_recipe_workflow(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + recipe_id = request.match_info["recipe_id"] + recipe = await recipe_scanner.get_recipe_by_id(recipe_id) + if not recipe: + return web.json_response({"error": "Recipe not found"}, status=404) + + if os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1": + return web.json_response( + {"error": "Standalone Mode Active"}, status=400 + ) + + image_path = recipe.get("file_path") + if not image_path: + return web.json_response({"error": "no_workflow"}, status=404) + + metadata = await asyncio.to_thread( + ExifUtils._load_structured_metadata, image_path + ) + workflow_raw = metadata.get("workflow") + if not workflow_raw: + return web.json_response( + { + "error": "no_workflow", + "message": "No embedded workflow found in recipe image", + }, + status=404, + ) + + # _load_structured_metadata always yields workflow as a JSON string; + # the frontend extension expects a parsed object for loadGraphData. + try: + workflow = ( + json.loads(workflow_raw) + if isinstance(workflow_raw, str) + else workflow_raw + ) + except (TypeError, ValueError): + self._logger.warning( + "Recipe %s embeds a non-JSON workflow payload; skipping send", + recipe_id, + ) + return web.json_response( + { + "error": "no_workflow", + "message": "Embedded workflow data is not valid JSON", + }, + status=404, + ) + + self._prompt_server.instance.send_sync( + "lm_load_workflow", + { + "workflow": workflow, + "name": recipe.get("title") or "", + "recipe_id": recipe_id, + }, + ) + return web.json_response({"success": True, "sent": True}) + except Exception as exc: + self._logger.error("Error sending recipe workflow: %s", exc, exc_info=True) + return web.json_response({"error": str(exc)}, status=500) + + class BatchImportHandler: """Handle batch import operations for recipes.""" diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 3c4d3d85..73420ace 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -90,6 +90,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe" ), + RouteDefinition( + "POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow" + ), ) diff --git a/py/services/persistent_recipe_cache.py b/py/services/persistent_recipe_cache.py index 6c4af1b2..f035bd25 100644 --- a/py/services/persistent_recipe_cache.py +++ b/py/services/persistent_recipe_cache.py @@ -58,6 +58,7 @@ class PersistentRecipeCache: "checkpoint_json", "gen_params_json", "tags_json", + "has_workflow", ) _instances: Dict[str, "PersistentRecipeCache"] = {} _instance_lock = threading.Lock() @@ -407,7 +408,8 @@ class PersistentRecipeCache: loras_json TEXT, checkpoint_json TEXT, gen_params_json TEXT, - tags_json TEXT + tags_json TEXT, + has_workflow INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path); @@ -426,6 +428,13 @@ class PersistentRecipeCache: ) except Exception: pass # column already exists + # Migration: add has_workflow column to existing databases + try: + conn.execute( + "ALTER TABLE recipes ADD COLUMN has_workflow INTEGER DEFAULT 0" + ) + except Exception: + pass # column already exists conn.commit() self._schema_initialized = True except Exception as exc: @@ -488,6 +497,7 @@ class PersistentRecipeCache: checkpoint_json, gen_params_json, tags_json, + 1 if recipe.get("has_workflow") else 0, ) def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]: @@ -533,6 +543,7 @@ class PersistentRecipeCache: "favorite": bool(row["favorite"]), "repair_version": row["repair_version"] or 0, "preview_nsfw_level": row["preview_nsfw_level"] or 0, + "has_workflow": bool(row["has_workflow"]), "loras": loras, "gen_params": gen_params, } diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index af6ce44d..af885192 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -13,6 +13,7 @@ import time from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast from ..config import config from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES +from ..utils.exif_utils import ExifUtils from ..utils.file_utils import calculate_autov3 from ..utils.recipe_open_stats import RecipeOpenStats from .model_scanner import WEIGHT_FILE_EXTENSIONS @@ -1729,6 +1730,23 @@ class RecipeScanner: return recipes, json_paths + @staticmethod + def _detect_has_workflow(image_path: Optional[str]) -> bool: + """Detect whether the recipe image embeds a ComfyUI workflow. + + Reuses ``ExifUtils._load_structured_metadata`` so the metadata parsing + stays in one place. Any failure (missing/corrupt image, unsupported + format, unexpected exception) maps to ``False`` and never propagates — + recipe loading must remain resilient. + """ + if not image_path or not os.path.exists(image_path): + return False + try: + metadata = ExifUtils._load_structured_metadata(image_path) + return bool(metadata.get("workflow")) + except Exception: + return False + def _load_recipe_file_sync(self, recipe_path: str) -> Optional[Dict[str, Any]]: """Load a single recipe file synchronously. @@ -1785,6 +1803,19 @@ class RecipeScanner: except Exception as e: logger.warning(f"Failed to persist repair for {recipe_path}: {e}") + # Detect embedded ComfyUI workflow and persist when it changed + if "has_workflow" not in recipe_data: + has_workflow = self._detect_has_workflow(recipe_data.get("file_path")) + if has_workflow != recipe_data.get("has_workflow"): + recipe_data["has_workflow"] = has_workflow + try: + with open(recipe_path, "w", encoding="utf-8") as f: + json.dump(recipe_data, f, indent=4, ensure_ascii=False) + except Exception as e: + logger.warning( + f"Failed to persist has_workflow for {recipe_path}: {e}" + ) + # Track folder placement relative to recipes directory recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder( recipe_path @@ -2470,6 +2501,13 @@ class RecipeScanner: if path_updated: self._write_recipe_file(recipe_path, recipe_data) + # Detect embedded ComfyUI workflow and persist when it changed + if "has_workflow" not in recipe_data: + has_workflow = self._detect_has_workflow(recipe_data.get("file_path")) + if has_workflow != recipe_data.get("has_workflow"): + recipe_data["has_workflow"] = has_workflow + self._write_recipe_file(recipe_path, recipe_data) + # Track folder placement relative to recipes directory recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder( recipe_path @@ -3302,6 +3340,13 @@ class RecipeScanner: # Format the recipe with all needed information formatted_recipe = {**merged_recipe} + # Fallback for recipes saved before has_workflow existed: detect once + # on demand so the modal button works without a rescan. + if "has_workflow" not in formatted_recipe: + formatted_recipe["has_workflow"] = self._detect_has_workflow( + formatted_recipe.get("file_path") + ) + # Format file path to URL if "file_path" in formatted_recipe: formatted_recipe["file_url"] = self._format_file_url( diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index bfb62429..3147d48e 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -117,6 +117,7 @@ class RecipePersistenceService: "loras": loras_data, "gen_params": gen_params, "fingerprint": fingerprint, + "has_workflow": self._detect_has_workflow(normalized_image_path), } if checkpoint_entry: recipe_data["checkpoint"] = checkpoint_entry @@ -615,6 +616,9 @@ class RecipePersistenceService: if key not in ["checkpoint", "loras"] }, "loras_stack": lora_stack, + # 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, } if checkpoint_entry: recipe_data["checkpoint"] = checkpoint_entry @@ -639,6 +643,20 @@ class RecipePersistenceService: # Helper methods --------------------------------------------------- + def _detect_has_workflow(self, image_path: str) -> bool: + """Detect whether the saved recipe image embeds a ComfyUI workflow. + + Extraction failures (missing file, corrupt image, unsupported format) + map to ``False`` and never propagate, mirroring the scanner's behavior. + """ + if not image_path or not os.path.exists(image_path): + return False + try: + metadata = self._exif_utils._load_structured_metadata(image_path) + return bool(metadata.get("workflow")) + except Exception: + return False + async def _build_widget_checkpoint_entry( self, recipe_scanner, diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 215838c9..626368e8 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -49,6 +49,28 @@ export async function fetchRecipeDetails(recipeId) { return response.json(); } +export async function sendRecipeWorkflow(recipeId) { + if (!recipeId) { + throw new Error('Unable to determine recipe ID'); + } + + const encodedRecipeId = encodeURIComponent(recipeId); + const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}/send-workflow`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const result = await response.json(); + + if (!response.ok) { + return { success: false, error: result.error || response.statusText }; + } + + return result; +} + /** * Fetch recipes with pagination for virtual scrolling * @param {number} page - Page number to fetch diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 1f9d46f4..c17f5e52 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -4,7 +4,7 @@ import { isModelWeightFile } from '../utils/modelFileTypes.js'; import { translate } from '../utils/i18nHelpers.js'; import { state } from '../state/index.js'; import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; -import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js'; +import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow } from '../api/recipeApi.js'; import { downloadManager } from '../managers/DownloadManager.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; import { openMediaViewer } from './shared/MediaViewer.js'; @@ -300,7 +300,7 @@ class RecipeModal { this.syncGenerationParams(hydratedRecipe.gen_params); this.syncResourcesSection(hydratedRecipe); - this.syncSourceUrlAction(); + this.syncHeaderActions(); // Show the modal modalManager.showModal('recipeModal'); @@ -385,6 +385,10 @@ class RecipeModal { nextRecipe.gen_params = preservedGenParams; } + if (fullRecipe.has_workflow !== undefined) { + nextRecipe.has_workflow = fullRecipe.has_workflow; + } + if (fullRecipe.checkpoint !== undefined) { nextRecipe.checkpoint = fullRecipe.checkpoint; } else { @@ -441,7 +445,7 @@ class RecipeModal { } else { this.updateSourceUrlDisplay(this.currentRecipe.source_path || ''); } - this.syncSourceUrlAction(); + this.syncHeaderActions(); } getPreviewMediaUrl(recipe = {}) { @@ -509,7 +513,7 @@ class RecipeModal { } } - syncSourceUrlAction() { + syncHeaderActions() { const actionsContainer = document.getElementById('recipeHeaderActions'); if (!actionsContainer) { return; @@ -517,20 +521,56 @@ class RecipeModal { actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove()); + if (this.currentRecipe?.has_workflow === true) { + const workflowBtn = document.createElement('button'); + workflowBtn.className = 'recipe-source-url-btn'; + workflowBtn.id = 'sendWorkflowBtn'; + workflowBtn.title = 'Send Workflow to ComfyUI'; + workflowBtn.innerHTML = ' Send Workflow to ComfyUI'; + workflowBtn.addEventListener('click', () => { + this.sendWorkflowToComfyUI(); + }); + actionsContainer.appendChild(workflowBtn); + } + const sourcePath = this.currentRecipe?.source_path || ''; const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://'); - if (!isValidUrl) { + if (isValidUrl) { + const btn = document.createElement('button'); + btn.className = 'recipe-source-url-btn'; + btn.title = sourcePath; + btn.innerHTML = ' Open Source URL'; + btn.addEventListener('click', () => { + window.open(sourcePath, '_blank'); + }); + actionsContainer.appendChild(btn); + } + } + + async sendWorkflowToComfyUI() { + if (!this.recipeId) { return; } - const btn = document.createElement('button'); - btn.className = 'recipe-source-url-btn'; - btn.title = sourcePath; - btn.innerHTML = ' Open Source URL'; - btn.addEventListener('click', () => { - window.open(sourcePath, '_blank'); - }); - actionsContainer.appendChild(btn); + try { + const result = await sendRecipeWorkflow(this.recipeId); + if (result?.success) { + showToast('toast.recipes.workflowSent', {}, 'success', 'Workflow sent to ComfyUI'); + return; + } + + const error = result?.error || ''; + if (error === 'Standalone Mode Active') { + showToast('toast.general.cannotInteractStandalone', {}, 'warning', 'Cannot interact with ComfyUI in standalone mode'); + } else if (error === 'no_workflow') { + showToast('toast.recipes.workflowNoWorkflow', {}, 'warning', 'No embedded workflow found in this recipe'); + } else { + showToast('toast.recipes.workflowSendFailed', { error }, 'error', `Failed to send workflow to ComfyUI: ${error}`); + } + } catch (error) { + console.error('Failed to send workflow to ComfyUI:', error); + showToast('toast.recipes.workflowSendFailed', { error: error.message }, 'error', `Failed to send workflow to ComfyUI: ${error.message}`); + } } syncTagsDisplay(tags) { @@ -1153,7 +1193,7 @@ class RecipeModal { // Update source URL in the UI this.commitField('source_path'); this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true }); - this.syncSourceUrlAction(); + this.syncHeaderActions(); // Update the current recipe object this.currentRecipe.source_path = newSourceUrl; diff --git a/tests/routes/test_recipe_route_registrar_handlers.py b/tests/routes/test_recipe_route_registrar_handlers.py new file mode 100644 index 00000000..ee64b2b6 --- /dev/null +++ b/tests/routes/test_recipe_route_registrar_handlers.py @@ -0,0 +1,293 @@ +"""Handler tests for the recipe workflow send endpoint. + +Covers ``RecipeWorkflowHandler.send_recipe_workflow`` and the wiring of the +``send_recipe_workflow`` key in ``RecipeHandlerSet.to_route_mapping``. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from py.routes.handlers.recipe_handlers import ( + RecipeHandlerSet, + RecipeWorkflowHandler, +) +from py.routes.handlers import recipe_handlers + + +async def _noop_ensure() -> None: + return None + + +@pytest.fixture(autouse=True) +def _clear_standalone_env(monkeypatch: pytest.MonkeyPatch): + """Clear the standalone env flag leaked by other modules. + + ``standalone.py`` sets ``LORA_MANAGER_STANDALONE=1`` at import time, and + unrelated tests import that module; without cleanup the flag bleeds into + later tests and flips the handler's standalone branch. + """ + monkeypatch.delenv("LORA_MANAGER_STANDALONE", raising=False) + + +class FakeRequest: + """Minimal request double exposing ``match_info``.""" + + def __init__(self, *, match_info: dict[str, Any] | None = None) -> None: + self.match_info = match_info or {} + + +class StubRecipeScanner: + """Scanner double returning a configurable recipe for an id.""" + + def __init__(self, recipe: dict[str, Any] | None = None) -> None: + self.recipe = recipe + self.lookup_calls: list[str] = [] + + async def get_recipe_by_id(self, recipe_id: str) -> dict[str, Any] | None: + self.lookup_calls.append(recipe_id) + return self.recipe + + +def _json_payload(response) -> dict[str, Any]: + """Decode the JSON body of a web.Response, asserting it is not null.""" + text = response.text + assert text is not None + return json.loads(text) + + +def _make_prompt_server(send_calls: list[tuple[str, Any]], *, send_error: Exception | None = None): + """Return a PromptServer-like class whose instance records send_sync calls.""" + + class RecordingPromptServer: + class Instance: + def send_sync(self, event, payload, sid=None): + if send_error is not None: + raise send_error + send_calls.append((event, payload)) + + instance = Instance() + + return RecordingPromptServer + + +def _make_handler( + scanner: StubRecipeScanner, + prompt_server, + *, + ensure=_noop_ensure, +) -> RecipeWorkflowHandler: + return RecipeWorkflowHandler( + ensure_dependencies_ready=ensure, + recipe_scanner_getter=lambda: scanner, + prompt_server=prompt_server, # pyright: ignore[reportArgumentType] + logger=logging.getLogger(__name__), + ) + + +async def test_send_recipe_workflow_broadcasts_embedded_workflow(monkeypatch: pytest.MonkeyPatch): + recipe = { + "file_path": "/models/recipes/sample.png", + "title": "My Recipe", + } + scanner = StubRecipeScanner(recipe=recipe) + send_calls: list[tuple[str, Any]] = [] + prompt_server = _make_prompt_server(send_calls) + + monkeypatch.setattr( + recipe_handlers.ExifUtils, + "_load_structured_metadata", + lambda _image_path: {"workflow": '{"nodes":[]}'}, + ) + + handler = _make_handler(scanner, prompt_server) + response = await handler.send_recipe_workflow( + FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType] + ) + + assert response.status == 200 + payload = _json_payload(response) + assert payload == {"success": True, "sent": True} + assert send_calls == [ + ( + "lm_load_workflow", + { + "workflow": {"nodes": []}, + "name": "My Recipe", + "recipe_id": "r1", + }, + ) + ] + assert scanner.lookup_calls == ["r1"] + + +async def test_send_recipe_workflow_defaults_empty_title(monkeypatch: pytest.MonkeyPatch): + recipe = {"file_path": "/models/recipes/sample.png"} + scanner = StubRecipeScanner(recipe=recipe) + send_calls: list[tuple[str, Any]] = [] + prompt_server = _make_prompt_server(send_calls) + + monkeypatch.setattr( + recipe_handlers.ExifUtils, + "_load_structured_metadata", + lambda _image_path: {"workflow": "{}"}, + ) + + handler = _make_handler(scanner, prompt_server) + response = await handler.send_recipe_workflow( + FakeRequest(match_info={"recipe_id": "r2"}) # pyright: ignore[reportArgumentType] + ) + + assert response.status == 200 + assert send_calls[0][1]["workflow"] == {} + assert send_calls[0][1]["name"] == "" + assert send_calls[0][1]["recipe_id"] == "r2" + + +async def test_send_recipe_workflow_recipe_not_found(): + scanner = StubRecipeScanner(recipe=None) + prompt_server = _make_prompt_server([]) + + handler = _make_handler(scanner, prompt_server) + response = await handler.send_recipe_workflow( + FakeRequest(match_info={"recipe_id": "missing"}) # pyright: ignore[reportArgumentType] + ) + + assert response.status == 404 + assert _json_payload(response) == {"error": "Recipe not found"} + + +async def test_send_recipe_workflow_missing_file_path(): + scanner = StubRecipeScanner(recipe={"title": "No File"}) + prompt_server = _make_prompt_server([]) + + handler = _make_handler(scanner, prompt_server) + response = await handler.send_recipe_workflow( + FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType] + ) + + assert response.status == 404 + assert _json_payload(response) == {"error": "no_workflow"} + + +async def test_send_recipe_workflow_no_embedded_workflow(monkeypatch: pytest.MonkeyPatch): + recipe = {"file_path": "/models/recipes/sample.png", "title": "No Wf"} + scanner = StubRecipeScanner(recipe=recipe) + prompt_server = _make_prompt_server([]) + + monkeypatch.setattr( + recipe_handlers.ExifUtils, + "_load_structured_metadata", + lambda _image_path: {"parameters": "some params"}, + ) + + handler = _make_handler(scanner, prompt_server) + response = await handler.send_recipe_workflow( + FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType] + ) + + assert response.status == 404 + assert _json_payload(response) == { + "error": "no_workflow", + "message": "No embedded workflow found in recipe image", + } + + +async def test_send_recipe_workflow_invalid_json_payload(monkeypatch: pytest.MonkeyPatch): + recipe = {"file_path": "/models/recipes/sample.png", "title": "Bad Wf"} + scanner = StubRecipeScanner(recipe=recipe) + prompt_server = _make_prompt_server([]) + + monkeypatch.setattr( + recipe_handlers.ExifUtils, + "_load_structured_metadata", + lambda _image_path: {"workflow": "not-json{"}, + ) + + handler = _make_handler(scanner, prompt_server) + response = await handler.send_recipe_workflow( + FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType] + ) + + assert response.status == 404 + assert _json_payload(response)["error"] == "no_workflow" + + +async def test_send_recipe_workflow_standalone_mode(monkeypatch: pytest.MonkeyPatch): + recipe = {"file_path": "/models/recipes/sample.png", "title": "Recipe"} + scanner = StubRecipeScanner(recipe=recipe) + prompt_server = _make_prompt_server([]) + + monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1") + + handler = _make_handler(scanner, prompt_server) + response = await handler.send_recipe_workflow( + FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType] + ) + + assert response.status == 400 + assert _json_payload(response) == {"error": "Standalone Mode Active"} + + +async def test_send_recipe_workflow_send_sync_error(monkeypatch: pytest.MonkeyPatch): + recipe = {"file_path": "/models/recipes/sample.png", "title": "Recipe"} + scanner = StubRecipeScanner(recipe=recipe) + prompt_server = _make_prompt_server([], send_error=RuntimeError("boom")) + + monkeypatch.setattr( + recipe_handlers.ExifUtils, + "_load_structured_metadata", + lambda _image_path: {"workflow": "{}"}, + ) + + handler = _make_handler(scanner, prompt_server) + response = await handler.send_recipe_workflow( + FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType] + ) + + assert response.status == 500 + assert _json_payload(response) == {"error": "boom"} + + +async def test_send_recipe_workflow_scanner_unavailable(): + handler = RecipeWorkflowHandler( + ensure_dependencies_ready=_noop_ensure, + recipe_scanner_getter=lambda: None, + prompt_server=_make_prompt_server([]), # pyright: ignore[reportArgumentType] + logger=logging.getLogger(__name__), + ) + response = await handler.send_recipe_workflow( + FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType] + ) + + assert response.status == 500 + assert _json_payload(response) == {"error": "Recipe scanner unavailable"} + + +def test_route_mapping_includes_send_recipe_workflow(): + workflow = RecipeWorkflowHandler( + ensure_dependencies_ready=_noop_ensure, + recipe_scanner_getter=lambda: None, + prompt_server=_make_prompt_server([]), # pyright: ignore[reportArgumentType] + logger=logging.getLogger(__name__), + ) + handler_set = RecipeHandlerSet( + page_view=MagicMock(), + listing=MagicMock(), + query=MagicMock(), + management=MagicMock(), + analysis=MagicMock(), + sharing=MagicMock(), + batch_import=MagicMock(), + workflow=workflow, + ) + + mapping = handler_set.to_route_mapping() + + assert "send_recipe_workflow" in mapping + assert mapping["send_recipe_workflow"] == workflow.send_recipe_workflow diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index 3a066cf1..a6ef40dd 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -393,6 +393,214 @@ async def test_load_recipe_upgrades_string_checkpoint(tmp_path: Path, recipe_sca assert loaded["checkpoint"]["file_name"] == "sd15" +# --------------------------------------------------------------------------- +# has_workflow detection (plan 3.1) +# --------------------------------------------------------------------------- + + +def _recipe_json(recipes_dir: Path, recipe_id: str, image_path: Path, **extra: Any) -> Path: + recipe_path = recipes_dir / f"{recipe_id}.recipe.json" + data: Dict[str, Any] = { + "id": recipe_id, + "file_path": str(image_path), + "title": recipe_id, + "modified": 0.0, + "created_date": 0.0, + "loras": [], + } + data.update(extra) + recipe_path.write_text(json.dumps(data)) + return recipe_path + + +def _mock_metadata(monkeypatch, workflow=None, raises=False): + def _load_structured_metadata(_image_path): + if raises: + raise RuntimeError("metadata parse failure") + return { + "parameters": None, + "prompt": "a test prompt", + "workflow": workflow, + "comment": None, + } + + monkeypatch.setattr( + "py.services.recipe_scanner.ExifUtils._load_structured_metadata", + _load_structured_metadata, + ) + + +@pytest.mark.asyncio +async def test_load_recipe_detects_embedded_workflow(tmp_path: Path, recipe_scanner, monkeypatch): + scanner, _ = recipe_scanner + recipes_dir = Path(config.loras_roots[0]) / "recipes" + recipes_dir.mkdir(parents=True, exist_ok=True) + + image_path = recipes_dir / "with-workflow.webp" + image_path.write_bytes(b"fake-webp") + recipe_path = _recipe_json(recipes_dir, "with-workflow", image_path) + + _mock_metadata(monkeypatch, workflow='{"nodes": []}') + + loaded = await scanner._load_recipe_file(str(recipe_path)) + + assert loaded["has_workflow"] is True + persisted = json.loads(recipe_path.read_text()) + assert persisted["has_workflow"] is True + + +@pytest.mark.asyncio +async def test_load_recipe_detects_no_workflow(tmp_path: Path, recipe_scanner, monkeypatch): + scanner, _ = recipe_scanner + recipes_dir = Path(config.loras_roots[0]) / "recipes" + recipes_dir.mkdir(parents=True, exist_ok=True) + + image_path = recipes_dir / "without-workflow.webp" + image_path.write_bytes(b"fake-webp") + recipe_path = _recipe_json(recipes_dir, "without-workflow", image_path) + + _mock_metadata(monkeypatch, workflow=None) + + loaded = await scanner._load_recipe_file(str(recipe_path)) + + assert loaded["has_workflow"] is False + persisted = json.loads(recipe_path.read_text()) + assert persisted["has_workflow"] is False + + +@pytest.mark.asyncio +async def test_load_recipe_metadata_exception_yields_false( + tmp_path: Path, recipe_scanner, monkeypatch +): + scanner, _ = recipe_scanner + recipes_dir = Path(config.loras_roots[0]) / "recipes" + recipes_dir.mkdir(parents=True, exist_ok=True) + + image_path = recipes_dir / "broken-meta.webp" + image_path.write_bytes(b"fake-webp") + recipe_path = _recipe_json(recipes_dir, "broken-meta", image_path) + + _mock_metadata(monkeypatch, raises=True) + + loaded = await scanner._load_recipe_file(str(recipe_path)) + + assert loaded["has_workflow"] is False + + +@pytest.mark.asyncio +async def test_load_recipe_missing_image_yields_false( + tmp_path: Path, recipe_scanner, monkeypatch +): + scanner, _ = recipe_scanner + recipes_dir = Path(config.loras_roots[0]) / "recipes" + recipes_dir.mkdir(parents=True, exist_ok=True) + + image_path = recipes_dir / "missing-image.webp" + recipe_path = _recipe_json(recipes_dir, "missing-image", image_path) + + _mock_metadata(monkeypatch, workflow='{"nodes": []}') + + loaded = await scanner._load_recipe_file(str(recipe_path)) + + assert loaded["has_workflow"] is False + + +@pytest.mark.asyncio +async def test_load_recipe_keeps_existing_has_workflow( + tmp_path: Path, recipe_scanner, monkeypatch +): + scanner, _ = recipe_scanner + recipes_dir = Path(config.loras_roots[0]) / "recipes" + recipes_dir.mkdir(parents=True, exist_ok=True) + + image_path = recipes_dir / "pre-recorded.webp" + image_path.write_bytes(b"fake-webp") + recipe_path = _recipe_json(recipes_dir, "pre-recorded", image_path, has_workflow=True) + + _mock_metadata(monkeypatch, workflow=None) + + loaded = await scanner._load_recipe_file(str(recipe_path)) + + assert loaded["has_workflow"] is True + persisted = json.loads(recipe_path.read_text()) + assert persisted["has_workflow"] is True + + +def test_load_recipe_file_sync_detects_embedded_workflow(tmp_path: Path, monkeypatch): + RecipeScanner._instance = None + settings_manager_module.reset_settings_manager() + monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) + scanner = RecipeScanner(lora_scanner=StubLoraScanner()) # pyright: ignore[reportArgumentType] + try: + recipes_dir = Path(config.loras_roots[0]) / "recipes" + recipes_dir.mkdir(parents=True, exist_ok=True) + + image_path = recipes_dir / "sync-workflow.webp" + image_path.write_bytes(b"fake-webp") + recipe_path = _recipe_json(recipes_dir, "sync-workflow", image_path) + + _mock_metadata(monkeypatch, workflow='{"nodes": []}') + + loaded = scanner._load_recipe_file_sync(str(recipe_path)) + + assert loaded["has_workflow"] is True + persisted = json.loads(recipe_path.read_text()) + assert persisted["has_workflow"] is True + finally: + RecipeScanner._instance = None + settings_manager_module.reset_settings_manager() + + +def test_load_recipe_file_sync_detects_no_workflow(tmp_path: Path, monkeypatch): + RecipeScanner._instance = None + settings_manager_module.reset_settings_manager() + monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) + scanner = RecipeScanner(lora_scanner=StubLoraScanner()) # pyright: ignore[reportArgumentType] + try: + recipes_dir = Path(config.loras_roots[0]) / "recipes" + recipes_dir.mkdir(parents=True, exist_ok=True) + + image_path = recipes_dir / "sync-no-workflow.webp" + image_path.write_bytes(b"fake-webp") + recipe_path = _recipe_json(recipes_dir, "sync-no-workflow", image_path) + + _mock_metadata(monkeypatch, workflow=None) + + loaded = scanner._load_recipe_file_sync(str(recipe_path)) + + assert loaded["has_workflow"] is False + persisted = json.loads(recipe_path.read_text()) + assert persisted["has_workflow"] is False + finally: + RecipeScanner._instance = None + settings_manager_module.reset_settings_manager() + + +def test_load_recipe_file_sync_metadata_exception_yields_false( + tmp_path: Path, monkeypatch +): + RecipeScanner._instance = None + settings_manager_module.reset_settings_manager() + monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) + scanner = RecipeScanner(lora_scanner=StubLoraScanner()) # pyright: ignore[reportArgumentType] + try: + recipes_dir = Path(config.loras_roots[0]) / "recipes" + recipes_dir.mkdir(parents=True, exist_ok=True) + + image_path = recipes_dir / "sync-broken-meta.webp" + image_path.write_bytes(b"fake-webp") + recipe_path = _recipe_json(recipes_dir, "sync-broken-meta", image_path) + + _mock_metadata(monkeypatch, raises=True) + + loaded = scanner._load_recipe_file_sync(str(recipe_path)) + + assert loaded["has_workflow"] is False + finally: + RecipeScanner._instance = None + settings_manager_module.reset_settings_manager() + + @pytest.mark.asyncio async def test_get_paginated_data_normalizes_legacy_checkpoint(recipe_scanner): scanner, _ = recipe_scanner @@ -417,7 +625,6 @@ async def test_get_paginated_data_normalizes_legacy_checkpoint(recipe_scanner): assert checkpoint["name"] == "legacy.safetensors" assert checkpoint["file_name"] == "legacy" - @pytest.mark.asyncio async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner): scanner, _ = recipe_scanner @@ -440,6 +647,53 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner): assert recipe["checkpoint"]["file_name"] == "by-id" +@pytest.mark.asyncio +async def test_get_recipe_by_id_detects_workflow_for_legacy_recipes( + recipe_scanner, monkeypatch +): + scanner, _ = recipe_scanner + await scanner.add_recipe( + { + "id": "legacy-no-flag", + "file_path": "/models/recipes/legacy.webp", + "title": "Legacy", + "modified": 0.0, + "created_date": 0.0, + "loras": [], + } + ) + + monkeypatch.setattr( + RecipeScanner, "_detect_has_workflow", lambda self, path: True + ) + + recipe = await scanner.get_recipe_by_id("legacy-no-flag") + + assert recipe is not None + assert recipe["has_workflow"] is True + + +@pytest.mark.asyncio +async def test_get_recipe_by_id_keeps_existing_has_workflow_flag(recipe_scanner): + scanner, _ = recipe_scanner + await scanner.add_recipe( + { + "id": "flagged", + "file_path": "/models/recipes/flagged.webp", + "title": "Flagged", + "modified": 0.0, + "created_date": 0.0, + "loras": [], + "has_workflow": False, + } + ) + + recipe = await scanner.get_recipe_by_id("flagged") + + assert recipe is not None + assert recipe["has_workflow"] is False + + @pytest.mark.asyncio async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner): scanner, _ = recipe_scanner diff --git a/tests/services/test_recipe_services.py b/tests/services/test_recipe_services.py index f63c56e4..7b8b9f98 100644 --- a/tests/services/test_recipe_services.py +++ b/tests/services/test_recipe_services.py @@ -26,6 +26,7 @@ class DummyExifUtils: def __init__(self): self.appended = None self.optimized_calls = 0 + self.workflow_value = None def optimize_image(self, image_data, target_width, format, quality, preserve_metadata): self.optimized_calls += 1 @@ -37,6 +38,14 @@ class DummyExifUtils: def extract_image_metadata(self, path): return {} + def _load_structured_metadata(self, image_path): + return { + "parameters": None, + "prompt": None, + "workflow": self.workflow_value, + "comment": None, + } + @pytest.mark.asyncio async def test_save_recipe_video_bypasses_optimization(tmp_path): @@ -213,6 +222,84 @@ async def test_save_recipe_reports_duplicates(tmp_path): assert service._exif_utils.appended[0] == expected_image_path +@pytest.mark.asyncio +async def test_save_recipe_records_has_workflow(tmp_path): + exif_utils = DummyExifUtils() + exif_utils.workflow_value = '{"nodes": []}' + + class DummyCache: + def __init__(self): + self.raw_data = [] + + async def resort(self): + pass + + class DummyScanner: + def __init__(self, root): + self.recipes_dir = str(root) + self._cache = DummyCache() + + async def find_recipes_by_fingerprint(self, fingerprint): + return [] + + async def add_recipe(self, recipe_data): + self._cache.raw_data.append(recipe_data) + + scanner = DummyScanner(tmp_path) + service = RecipePersistenceService( + exif_utils=exif_utils, + card_preview_width=512, + logger=logging.getLogger("test"), + ) + + result = await service.save_recipe( + recipe_scanner=scanner, + image_bytes=b"image-bytes", + image_base64=None, + name="Workflow Recipe", + tags=[], + metadata={"base_model": "sd", "loras": []}, + ) + + stored = json.loads(Path(result.payload["json_path"]).read_text()) + assert stored["has_workflow"] is True + assert scanner._cache.raw_data[0]["has_workflow"] is True + + +@pytest.mark.asyncio +async def test_save_recipe_records_no_workflow(tmp_path): + exif_utils = DummyExifUtils() + + class DummyScanner: + def __init__(self, root): + self.recipes_dir = str(root) + + async def find_recipes_by_fingerprint(self, fingerprint): + return [] + + async def add_recipe(self, recipe_data): + return None + + scanner = DummyScanner(tmp_path) + service = RecipePersistenceService( + exif_utils=exif_utils, + card_preview_width=512, + logger=logging.getLogger("test"), + ) + + result = await service.save_recipe( + recipe_scanner=scanner, + image_bytes=b"image-bytes", + image_base64=None, + name="Plain Recipe", + tags=[], + metadata={"base_model": "sd", "loras": []}, + ) + + stored = json.loads(Path(result.payload["json_path"]).read_text()) + assert stored["has_workflow"] is False + + @pytest.mark.asyncio async def test_save_recipe_persists_checkpoint_metadata(tmp_path): exif_utils = DummyExifUtils() diff --git a/tests/test_persistent_recipe_cache.py b/tests/test_persistent_recipe_cache.py index 94ddc003..9502c8f8 100644 --- a/tests/test_persistent_recipe_cache.py +++ b/tests/test_persistent_recipe_cache.py @@ -551,3 +551,86 @@ class TestPersistentRecipeCache: loaded = cache.load_cache() assert loaded is not None assert loaded.image_id_map == {"222": "new-only"} + + +class TestHasWorkflowColumn: + """has_workflow column persistence (plan 3.1).""" + + def test_save_and_load_roundtrip(self, temp_db_path): + """has_workflow must round-trip through save_cache()/load_cache().""" + cache = PersistentRecipeCache(db_path=temp_db_path) + recipes = [ + {"id": "wf-1", "title": "Has Workflow", "has_workflow": True}, + {"id": "wf-2", "title": "No Workflow", "has_workflow": False}, + {"id": "wf-3", "title": "Unset Workflow"}, + ] + cache.save_cache(recipes) + + loaded = cache.load_cache() + assert loaded is not None + by_id = {r["id"]: r for r in loaded.raw_data} + assert by_id["wf-1"]["has_workflow"] is True + assert by_id["wf-2"]["has_workflow"] is False + 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.""" + 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 len(row_true) == len(cache._RECIPE_COLUMNS) + assert cache._RECIPE_COLUMNS[-1] == "has_workflow" + + def test_update_recipe_preserves_has_workflow(self, temp_db_path): + """update_recipe() must write the has_workflow column correctly.""" + cache = PersistentRecipeCache(db_path=temp_db_path) + cache.save_cache([{"id": "wf-update", "title": "x", "has_workflow": True}]) + cache.update_recipe({"id": "wf-update", "title": "y", "has_workflow": False}) + + loaded = cache.load_cache() + assert loaded is not None + assert loaded.raw_data[0]["has_workflow"] is False + + def test_migrates_legacy_database_adds_has_workflow(self, temp_db_path): + """A database created before has_workflow existed must still load.""" + import sqlite3 + + conn = sqlite3.connect(temp_db_path) + conn.execute( + """ + 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 + ) + """ + ) + conn.execute("INSERT INTO recipes (recipe_id, title) VALUES ('legacy-1', 'Legacy')") + conn.commit() + conn.close() + + cache = PersistentRecipeCache(db_path=temp_db_path) + + loaded = cache.load_cache() + assert loaded is not None + assert loaded.raw_data[0]["id"] == "legacy-1" + assert loaded.raw_data[0]["has_workflow"] is False diff --git a/web/comfyui/workflow_registry.js b/web/comfyui/workflow_registry.js index 0a360f55..2b5b687f 100644 --- a/web/comfyui/workflow_registry.js +++ b/web/comfyui/workflow_registry.js @@ -218,6 +218,10 @@ app.registerExtension({ this.applyWidgetUpdate(event?.detail ?? {}); }); + api.addEventListener("lm_load_workflow", (event) => { + this.loadWorkflowFromMessage(event?.detail ?? {}); + }); + window.addEventListener("lm_marker_changed", () => { this.refreshRegistry(); }); @@ -415,6 +419,54 @@ app.registerExtension({ } }, + /** + * Load a workflow received from the backend onto the ComfyUI canvas. + * + * The payload (`detail`) is expected to be: + * { workflow: , name: , recipe_id: } + * + * Two formats are supported, detected by the presence of `class_type` + * entries (API/prompt format: { node_id: { class_type, inputs } }) versus + * the UI format ({ nodes: [...], links: [...] }). + * + * @param {Object} detail - message payload from the backend + */ + async loadWorkflowFromMessage(detail) { + let workflow = detail?.workflow; + if (!workflow) { + console.warn("LoRA Manager: lm_load_workflow received without a workflow payload", detail); + return; + } + + if (typeof workflow === "string") { + try { + workflow = JSON.parse(workflow); + } catch (error) { + console.warn("LoRA Manager: lm_load_workflow carried a non-JSON workflow string", error); + return; + } + } + + // Mirror ComfyUI_frontend's isApiJson detection: API format maps every + // node id to an object with a string class_type; UI format has arrays + // of nodes/links instead. + const values = Object.values(workflow); + const isApiFormat = values.length > 0 && values.every((v) => v && typeof v.class_type === "string"); + + const workflowName = detail.name || "Recipe Workflow"; + + try { + if (isApiFormat) { + await app.loadApiJson(workflow, workflowName); + } else { + await app.loadGraphData(workflow, true, true, workflowName, { openSource: "file_button" }); + } + this._log("workflow loaded from backend message (%s): %s", isApiFormat ? "api" : "graph", workflowName); + } catch (error) { + console.error("LoRA Manager: failed to load workflow from backend message", error); + } + }, + applyWidgetUpdate(message) { const nodeId = message?.node_id ?? message?.id; const graphId = message?.graph_id;