feat(recipe): send embedded recipe workflow to ComfyUI canvas

This commit is contained in:
Will Miao
2026-08-20 22:18:13 +08:00
parent 0905e2be6e
commit 3ebf256c5d
24 changed files with 1145 additions and 27 deletions
+10 -1
View File
@@ -856,6 +856,12 @@
"sendCheckpoint": "Send to ComfyUI", "sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "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": { "controls": {
"import": { "import": {
"action": "Importieren", "action": "Importieren",
@@ -2031,7 +2037,10 @@
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})", "reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen", "reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden", "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": { "models": {
"noModelsSelected": "Keine Modelle ausgewählt", "noModelsSelected": "Keine Modelle ausgewählt",
+10 -1
View File
@@ -856,6 +856,12 @@
"sendCheckpoint": "Send to ComfyUI", "sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "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": { "controls": {
"import": { "import": {
"action": "Import", "action": "Import",
@@ -2031,7 +2037,10 @@
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})", "reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
"reimportBulkFailed": "Failed to re-import some recipes", "reimportBulkFailed": "Failed to re-import some recipes",
"noMissingLorasInSelection": "No missing LoRAs found in selected 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": { "models": {
"noModelsSelected": "No models selected", "noModelsSelected": "No models selected",
+10 -1
View File
@@ -856,6 +856,12 @@
"sendCheckpoint": "Enviar a ComfyUI", "sendCheckpoint": "Enviar a ComfyUI",
"sendRecipe": "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": { "controls": {
"import": { "import": {
"action": "Importar", "action": "Importar",
@@ -2031,7 +2037,10 @@
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})", "reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
"reimportBulkFailed": "Error al reimportar algunas recetas", "reimportBulkFailed": "Error al reimportar algunas recetas",
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas", "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": { "models": {
"noModelsSelected": "No hay modelos seleccionados", "noModelsSelected": "No hay modelos seleccionados",
+10 -1
View File
@@ -856,6 +856,12 @@
"sendCheckpoint": "Envoyer vers ComfyUI", "sendCheckpoint": "Envoyer vers ComfyUI",
"sendRecipe": "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": { "controls": {
"import": { "import": {
"action": "Importer", "action": "Importer",
@@ -2031,7 +2037,10 @@
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})", "reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
"reimportBulkFailed": "Échec du ré-import de certaines recettes", "reimportBulkFailed": "Échec du ré-import de certaines recettes",
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées", "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": { "models": {
"noModelsSelected": "Aucun modèle sélectionné", "noModelsSelected": "Aucun modèle sélectionné",
+10 -1
View File
@@ -856,6 +856,12 @@
"sendCheckpoint": "שלח ל-ComfyUI", "sendCheckpoint": "שלח ל-ComfyUI",
"sendRecipe": "שלח ל-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": { "controls": {
"import": { "import": {
"action": "ייבא", "action": "ייבא",
@@ -2031,7 +2037,10 @@
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})", "reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל", "reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו", "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": { "models": {
"noModelsSelected": "לא נבחרו מודלים", "noModelsSelected": "לא נבחרו מודלים",
+10 -1
View File
@@ -856,6 +856,12 @@
"sendCheckpoint": "ComfyUIへ送信", "sendCheckpoint": "ComfyUIへ送信",
"sendRecipe": "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": { "controls": {
"import": { "import": {
"action": "インポート", "action": "インポート",
@@ -2031,7 +2037,10 @@
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)", "reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました", "reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした", "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": { "models": {
"noModelsSelected": "モデルが選択されていません", "noModelsSelected": "モデルが選択されていません",
+10 -1
View File
@@ -856,6 +856,12 @@
"sendCheckpoint": "ComfyUI로 보내기", "sendCheckpoint": "ComfyUI로 보내기",
"sendRecipe": "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": { "controls": {
"import": { "import": {
"action": "가져오기", "action": "가져오기",
@@ -2031,7 +2037,10 @@
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)", "reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다", "reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다", "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": { "models": {
"noModelsSelected": "선택된 모델이 없습니다", "noModelsSelected": "선택된 모델이 없습니다",
+10 -1
View File
@@ -856,6 +856,12 @@
"sendCheckpoint": "Отправить в ComfyUI", "sendCheckpoint": "Отправить в ComfyUI",
"sendRecipe": "Отправить в 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": { "controls": {
"import": { "import": {
"action": "Импортировать", "action": "Импортировать",
@@ -2031,7 +2037,10 @@
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})", "reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты", "reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs", "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": { "models": {
"noModelsSelected": "Модели не выбраны", "noModelsSelected": "Модели не выбраны",
+10 -1
View File
@@ -856,6 +856,12 @@
"sendCheckpoint": "发送到 ComfyUI", "sendCheckpoint": "发送到 ComfyUI",
"sendRecipe": "发送到 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": { "controls": {
"import": { "import": {
"action": "导入", "action": "导入",
@@ -2031,7 +2037,10 @@
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)", "reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
"reimportBulkFailed": "重新导入某些配方失败", "reimportBulkFailed": "重新导入某些配方失败",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs", "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": { "models": {
"noModelsSelected": "未选中模型", "noModelsSelected": "未选中模型",
+10 -1
View File
@@ -856,6 +856,12 @@
"sendCheckpoint": "傳送到 ComfyUI", "sendCheckpoint": "傳送到 ComfyUI",
"sendRecipe": "傳送到 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": { "controls": {
"import": { "import": {
"action": "匯入", "action": "匯入",
@@ -2031,7 +2037,10 @@
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)", "reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
"reimportBulkFailed": "重新匯入某些配方失敗", "reimportBulkFailed": "重新匯入某些配方失敗",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs", "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": { "models": {
"noModelsSelected": "未選擇模型", "noModelsSelected": "未選擇模型",
+8
View File
@@ -778,6 +778,14 @@ class SaveImageLM:
if checkpoint_entry: if checkpoint_entry:
recipe_data["checkpoint"] = 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( json_path = os.path.normpath(
os.path.join(recipes_dir, f"{recipe_id}.recipe.json") os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
) )
+14
View File
@@ -32,6 +32,7 @@ from .handlers.recipe_handlers import (
RecipePageView, RecipePageView,
RecipeQueryHandler, RecipeQueryHandler,
RecipeSharingHandler, RecipeSharingHandler,
RecipeWorkflowHandler,
) )
from .recipe_route_registrar import ROUTE_DEFINITIONS from .recipe_route_registrar import ROUTE_DEFINITIONS
@@ -200,6 +201,18 @@ class BaseRecipeRoutes:
sharing_service=sharing_service, 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 from ..services.websocket_manager import ws_manager
batch_import_service = BatchImportService( batch_import_service = BatchImportService(
@@ -224,4 +237,5 @@ class BaseRecipeRoutes:
analysis=analysis, analysis=analysis,
sharing=sharing, sharing=sharing,
batch_import=batch_import, batch_import=batch_import,
workflow=workflow,
) )
+99 -1
View File
@@ -10,7 +10,7 @@ import asyncio
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path 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 from aiohttp import web
@@ -45,6 +45,17 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
RecipeScannerGetter = Callable[[], Any] RecipeScannerGetter = Callable[[], Any]
CivitaiClientGetter = 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 # 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 # cache one page can touch up to page_size image files; 16 balances SSD and
# HDD throughput without starving the event loop. # HDD throughput without starving the event loop.
@@ -73,6 +84,7 @@ class RecipeHandlerSet:
analysis: "RecipeAnalysisHandler" analysis: "RecipeAnalysisHandler"
sharing: "RecipeSharingHandler" sharing: "RecipeSharingHandler"
batch_import: "BatchImportHandler" batch_import: "BatchImportHandler"
workflow: "RecipeWorkflowHandler"
def to_route_mapping( def to_route_mapping(
self, self,
@@ -128,6 +140,7 @@ class RecipeHandlerSet:
"import_from_url": self.management.import_from_url, "import_from_url": self.management.import_from_url,
"create_from_example": self.management.create_from_example, "create_from_example": self.management.create_from_example,
"reimport_recipe": self.management.reimport_recipe, "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) 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: class BatchImportHandler:
"""Handle batch import operations for recipes.""" """Handle batch import operations for recipes."""
+3
View File
@@ -90,6 +90,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe" "POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
), ),
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
),
) )
+12 -1
View File
@@ -58,6 +58,7 @@ class PersistentRecipeCache:
"checkpoint_json", "checkpoint_json",
"gen_params_json", "gen_params_json",
"tags_json", "tags_json",
"has_workflow",
) )
_instances: Dict[str, "PersistentRecipeCache"] = {} _instances: Dict[str, "PersistentRecipeCache"] = {}
_instance_lock = threading.Lock() _instance_lock = threading.Lock()
@@ -407,7 +408,8 @@ class PersistentRecipeCache:
loras_json TEXT, loras_json TEXT,
checkpoint_json TEXT, checkpoint_json TEXT,
gen_params_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); CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path);
@@ -426,6 +428,13 @@ class PersistentRecipeCache:
) )
except Exception: except Exception:
pass # column already exists 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() conn.commit()
self._schema_initialized = True self._schema_initialized = True
except Exception as exc: except Exception as exc:
@@ -488,6 +497,7 @@ class PersistentRecipeCache:
checkpoint_json, checkpoint_json,
gen_params_json, gen_params_json,
tags_json, tags_json,
1 if recipe.get("has_workflow") else 0,
) )
def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]: def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]:
@@ -533,6 +543,7 @@ class PersistentRecipeCache:
"favorite": bool(row["favorite"]), "favorite": bool(row["favorite"]),
"repair_version": row["repair_version"] or 0, "repair_version": row["repair_version"] or 0,
"preview_nsfw_level": row["preview_nsfw_level"] or 0, "preview_nsfw_level": row["preview_nsfw_level"] or 0,
"has_workflow": bool(row["has_workflow"]),
"loras": loras, "loras": loras,
"gen_params": gen_params, "gen_params": gen_params,
} }
+45
View File
@@ -13,6 +13,7 @@ import time
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
from ..config import config from ..config import config
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES 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.file_utils import calculate_autov3
from ..utils.recipe_open_stats import RecipeOpenStats from ..utils.recipe_open_stats import RecipeOpenStats
from .model_scanner import WEIGHT_FILE_EXTENSIONS from .model_scanner import WEIGHT_FILE_EXTENSIONS
@@ -1729,6 +1730,23 @@ class RecipeScanner:
return recipes, json_paths 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]]: def _load_recipe_file_sync(self, recipe_path: str) -> Optional[Dict[str, Any]]:
"""Load a single recipe file synchronously. """Load a single recipe file synchronously.
@@ -1785,6 +1803,19 @@ class RecipeScanner:
except Exception as e: except Exception as e:
logger.warning(f"Failed to persist repair for {recipe_path}: {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 # Track folder placement relative to recipes directory
recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder( recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder(
recipe_path recipe_path
@@ -2470,6 +2501,13 @@ class RecipeScanner:
if path_updated: if path_updated:
self._write_recipe_file(recipe_path, recipe_data) 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 # Track folder placement relative to recipes directory
recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder( recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder(
recipe_path recipe_path
@@ -3302,6 +3340,13 @@ class RecipeScanner:
# Format the recipe with all needed information # Format the recipe with all needed information
formatted_recipe = {**merged_recipe} 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 # Format file path to URL
if "file_path" in formatted_recipe: if "file_path" in formatted_recipe:
formatted_recipe["file_url"] = self._format_file_url( formatted_recipe["file_url"] = self._format_file_url(
@@ -117,6 +117,7 @@ class RecipePersistenceService:
"loras": loras_data, "loras": loras_data,
"gen_params": gen_params, "gen_params": gen_params,
"fingerprint": fingerprint, "fingerprint": fingerprint,
"has_workflow": self._detect_has_workflow(normalized_image_path),
} }
if checkpoint_entry: if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry recipe_data["checkpoint"] = checkpoint_entry
@@ -615,6 +616,9 @@ class RecipePersistenceService:
if key not in ["checkpoint", "loras"] if key not in ["checkpoint", "loras"]
}, },
"loras_stack": lora_stack, "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: if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry recipe_data["checkpoint"] = checkpoint_entry
@@ -639,6 +643,20 @@ class RecipePersistenceService:
# Helper methods --------------------------------------------------- # 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( async def _build_widget_checkpoint_entry(
self, self,
recipe_scanner, recipe_scanner,
+22
View File
@@ -49,6 +49,28 @@ export async function fetchRecipeDetails(recipeId) {
return response.json(); 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 * Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch * @param {number} page - Page number to fetch
+54 -14
View File
@@ -4,7 +4,7 @@ import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.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 { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js'; import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js'; import { openMediaViewer } from './shared/MediaViewer.js';
@@ -300,7 +300,7 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params); this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe); this.syncResourcesSection(hydratedRecipe);
this.syncSourceUrlAction(); this.syncHeaderActions();
// Show the modal // Show the modal
modalManager.showModal('recipeModal'); modalManager.showModal('recipeModal');
@@ -385,6 +385,10 @@ class RecipeModal {
nextRecipe.gen_params = preservedGenParams; nextRecipe.gen_params = preservedGenParams;
} }
if (fullRecipe.has_workflow !== undefined) {
nextRecipe.has_workflow = fullRecipe.has_workflow;
}
if (fullRecipe.checkpoint !== undefined) { if (fullRecipe.checkpoint !== undefined) {
nextRecipe.checkpoint = fullRecipe.checkpoint; nextRecipe.checkpoint = fullRecipe.checkpoint;
} else { } else {
@@ -441,7 +445,7 @@ class RecipeModal {
} else { } else {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || ''); this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
} }
this.syncSourceUrlAction(); this.syncHeaderActions();
} }
getPreviewMediaUrl(recipe = {}) { getPreviewMediaUrl(recipe = {}) {
@@ -509,7 +513,7 @@ class RecipeModal {
} }
} }
syncSourceUrlAction() { syncHeaderActions() {
const actionsContainer = document.getElementById('recipeHeaderActions'); const actionsContainer = document.getElementById('recipeHeaderActions');
if (!actionsContainer) { if (!actionsContainer) {
return; return;
@@ -517,20 +521,56 @@ class RecipeModal {
actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove()); 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 = '<i class="fas fa-project-diagram"></i> Send Workflow to ComfyUI';
workflowBtn.addEventListener('click', () => {
this.sendWorkflowToComfyUI();
});
actionsContainer.appendChild(workflowBtn);
}
const sourcePath = this.currentRecipe?.source_path || ''; const sourcePath = this.currentRecipe?.source_path || '';
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://'); 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 = '<i class="fas fa-globe"></i> Open Source URL';
btn.addEventListener('click', () => {
window.open(sourcePath, '_blank');
});
actionsContainer.appendChild(btn);
}
}
async sendWorkflowToComfyUI() {
if (!this.recipeId) {
return; return;
} }
const btn = document.createElement('button'); try {
btn.className = 'recipe-source-url-btn'; const result = await sendRecipeWorkflow(this.recipeId);
btn.title = sourcePath; if (result?.success) {
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL'; showToast('toast.recipes.workflowSent', {}, 'success', 'Workflow sent to ComfyUI');
btn.addEventListener('click', () => { return;
window.open(sourcePath, '_blank'); }
});
actionsContainer.appendChild(btn); 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) { syncTagsDisplay(tags) {
@@ -1153,7 +1193,7 @@ class RecipeModal {
// Update source URL in the UI // Update source URL in the UI
this.commitField('source_path'); this.commitField('source_path');
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true }); this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
this.syncSourceUrlAction(); this.syncHeaderActions();
// Update the current recipe object // Update the current recipe object
this.currentRecipe.source_path = newSourceUrl; this.currentRecipe.source_path = newSourceUrl;
@@ -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
+255 -1
View File
@@ -393,6 +393,214 @@ async def test_load_recipe_upgrades_string_checkpoint(tmp_path: Path, recipe_sca
assert loaded["checkpoint"]["file_name"] == "sd15" 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 @pytest.mark.asyncio
async def test_get_paginated_data_normalizes_legacy_checkpoint(recipe_scanner): async def test_get_paginated_data_normalizes_legacy_checkpoint(recipe_scanner):
scanner, _ = 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["name"] == "legacy.safetensors"
assert checkpoint["file_name"] == "legacy" assert checkpoint["file_name"] == "legacy"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner): async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
scanner, _ = 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" 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 @pytest.mark.asyncio
async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner): async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner
+87
View File
@@ -26,6 +26,7 @@ class DummyExifUtils:
def __init__(self): def __init__(self):
self.appended = None self.appended = None
self.optimized_calls = 0 self.optimized_calls = 0
self.workflow_value = None
def optimize_image(self, image_data, target_width, format, quality, preserve_metadata): def optimize_image(self, image_data, target_width, format, quality, preserve_metadata):
self.optimized_calls += 1 self.optimized_calls += 1
@@ -37,6 +38,14 @@ class DummyExifUtils:
def extract_image_metadata(self, path): def extract_image_metadata(self, path):
return {} return {}
def _load_structured_metadata(self, image_path):
return {
"parameters": None,
"prompt": None,
"workflow": self.workflow_value,
"comment": None,
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_recipe_video_bypasses_optimization(tmp_path): 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 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 @pytest.mark.asyncio
async def test_save_recipe_persists_checkpoint_metadata(tmp_path): async def test_save_recipe_persists_checkpoint_metadata(tmp_path):
exif_utils = DummyExifUtils() exif_utils = DummyExifUtils()
+83
View File
@@ -551,3 +551,86 @@ class TestPersistentRecipeCache:
loaded = cache.load_cache() loaded = cache.load_cache()
assert loaded is not None assert loaded is not None
assert loaded.image_id_map == {"222": "new-only"} 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
+52
View File
@@ -218,6 +218,10 @@ app.registerExtension({
this.applyWidgetUpdate(event?.detail ?? {}); this.applyWidgetUpdate(event?.detail ?? {});
}); });
api.addEventListener("lm_load_workflow", (event) => {
this.loadWorkflowFromMessage(event?.detail ?? {});
});
window.addEventListener("lm_marker_changed", () => { window.addEventListener("lm_marker_changed", () => {
this.refreshRegistry(); 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: <workflow JSON>, name: <string>, recipe_id: <string> }
*
* 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) { applyWidgetUpdate(message) {
const nodeId = message?.node_id ?? message?.id; const nodeId = message?.node_id ?? message?.id;
const graphId = message?.graph_id; const graphId = message?.graph_id;