mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-21 21:11:26 -03:00
feat(recipe): send embedded recipe workflow to ComfyUI canvas
This commit is contained in:
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user