mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-29 06:54:09 -03:00
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:
@@ -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]:
|
||||
|
||||
@@ -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 ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user