feat(recipes): opt-in workflow embedding for widget recipe saves

Add a "Save Recipe with Workflow" action next to "Save Recipe" in the LoRA
widget context menu. It posts the current UI-format graph alongside the save
request so the stored preview embeds it and the recipe can send the graph back
to ComfyUI. Embedding stays opt-in rather than folded into "Save Recipe": the
workflow is by far the largest metadata field and its widget values may carry
sensitive data.

- web/comfyui: new menu entry; saveRecipeDirectly({ embedWorkflow }) posts the
  UI graph and reports the outcome (embedded / skipped) via toasts.
- save_recipe_from_widget handler: reads an optional JSON workflow field so the
  long-standing body-less POST keeps working, including from cached clients.
- RecipePersistenceService.save_recipe_from_widget: embeds the graph through
  the existing optimize_image workflow path, derives has_workflow by detection,
  and skips graphs above MAX_WORKFLOW_EMBED_BYTES with workflow_skipped.
This commit is contained in:
Will Miao
2026-09-29 09:03:26 +08:00
parent 69691b17a1
commit faeb66a23d
10 changed files with 607 additions and 25 deletions
+23
View File
@@ -1821,6 +1821,10 @@ class RecipeManagementHandler:
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
# Opt-in workflow embedding. The widget historically POSTs with no
# body at all, so a missing/empty body is not an error.
workflow = await self._read_optional_json_field(request, "workflow")
analysis = await self._analysis_service.analyze_widget_metadata(
recipe_scanner=recipe_scanner
)
@@ -1833,6 +1837,7 @@ class RecipeManagementHandler:
recipe_scanner=recipe_scanner,
metadata=metadata,
image_bytes=image_bytes,
workflow=workflow,
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
@@ -1897,6 +1902,24 @@ class RecipeManagementHandler:
return []
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
async def _read_optional_json_field(
self, request: web.Request, field: str
) -> Any:
"""Read one field from an optional JSON request body.
Some callers (notably the widget's long-standing "Save Recipe" action)
POST with no body at all, and a stale cached extension may still do so
after a body is introduced. A missing, empty or malformed body is
therefore treated as "no value" rather than a request error.
"""
if not request.can_read_body:
return None
try:
data = await request.json()
except Exception:
return None
return data.get(field) if isinstance(data, dict) else None
async def _count_recipe_loras(
self, recipe_scanner: Any, recipe_id: Optional[str]
) -> Optional[int]:
+36 -13
View File
@@ -18,6 +18,7 @@ from ...utils.base_model import (
RELATION_INCOMPATIBLE,
base_model_relation,
)
from ...utils.constants import MAX_WORKFLOW_EMBED_BYTES
from ...utils.utils import calculate_recipe_fingerprint
from ..pending_delete_service import get_pending_delete_service
from .errors import RecipeNotFoundError, RecipeValidationError
@@ -874,8 +875,15 @@ class RecipePersistenceService:
recipe_scanner,
metadata: dict[str, Any],
image_bytes: bytes,
workflow: Any = None,
) -> PersistenceResult:
"""Save a recipe constructed from widget metadata."""
"""Save a recipe constructed from widget metadata.
``workflow`` is the caller's ComfyUI graph (UI or API format) to embed
in the stored preview. Embedding is opt-in because the graph is by far
the largest metadata field and its widget values may contain sensitive
data; an oversized graph is dropped rather than inflating the preview.
"""
if not metadata:
raise RecipeValidationError("No generation metadata found")
@@ -884,12 +892,25 @@ class RecipePersistenceService:
os.makedirs(recipes_dir, exist_ok=True)
recipe_id = str(uuid.uuid4())
workflow_json = self._exif_utils.normalise_workflow(workflow)
workflow_skipped: Optional[str] = None
if workflow_json and len(workflow_json.encode("utf-8")) > MAX_WORKFLOW_EMBED_BYTES:
self._logger.warning(
"Widget workflow is %d bytes (limit %d); saving recipe without it",
len(workflow_json),
MAX_WORKFLOW_EMBED_BYTES,
)
workflow_json = None
workflow_skipped = "too_large"
optimized_image, extension = self._exif_utils.optimize_image(
image_data=image_bytes,
target_width=self._card_preview_width,
format="webp",
quality=85,
preserve_metadata=True,
workflow=workflow_json,
)
image_filename = f"{recipe_id}{extension}"
image_path = os.path.join(recipes_dir, image_filename)
@@ -943,9 +964,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,
# Set by detection below: the workflow is embedded during
# re-encoding only when the caller opted in and it fit the cap.
"has_workflow": self._detect_has_workflow(image_path),
# Widget saves read LoRAs straight from the current workflow; an
# empty list means the workflow used no LoRAs.
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
@@ -961,15 +982,17 @@ class RecipePersistenceService:
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
await recipe_scanner.add_recipe(recipe_data)
return PersistenceResult(
{
"success": True,
"recipe_id": recipe_id,
"image_path": image_path,
"json_path": json_path,
"recipe_name": recipe_name,
}
)
payload: dict[str, Any] = {
"success": True,
"recipe_id": recipe_id,
"image_path": image_path,
"json_path": json_path,
"recipe_name": recipe_name,
"has_workflow": recipe_data["has_workflow"],
}
if workflow_skipped:
payload["workflow_skipped"] = workflow_skipped
return PersistenceResult(payload)
# Helper methods ---------------------------------------------------
+8
View File
@@ -41,6 +41,14 @@ PREVIEW_EXTENSIONS = [
# Card preview image width
CARD_PREVIEW_WIDTH = 480
# Upper bound for a ComfyUI workflow embedded into a recipe preview on the
# opt-in widget save path. The workflow is by far the largest metadata field
# (tens of KB for a simple graph), so an anomalous graph — e.g. one carrying
# base64 blobs in widget values — is skipped instead of inflating the preview.
# Imports are deliberately not capped: their workflow comes from an image the
# user already chose, and preserving it is the point.
MAX_WORKFLOW_EMBED_BYTES = 256 * 1024
# Width for optimized example images
EXAMPLE_IMAGE_WIDTH = 832