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;