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
+12 -1
View File
@@ -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,
}
+45
View File
@@ -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,