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:
|
if recipe_scanner is None:
|
||||||
raise RuntimeError("Recipe scanner unavailable")
|
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(
|
analysis = await self._analysis_service.analyze_widget_metadata(
|
||||||
recipe_scanner=recipe_scanner
|
recipe_scanner=recipe_scanner
|
||||||
)
|
)
|
||||||
@@ -1833,6 +1837,7 @@ class RecipeManagementHandler:
|
|||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
image_bytes=image_bytes,
|
image_bytes=image_bytes,
|
||||||
|
workflow=workflow,
|
||||||
)
|
)
|
||||||
return web.json_response(result.payload, status=result.status)
|
return web.json_response(result.payload, status=result.status)
|
||||||
except RecipeValidationError as exc:
|
except RecipeValidationError as exc:
|
||||||
@@ -1897,6 +1902,24 @@ class RecipeManagementHandler:
|
|||||||
return []
|
return []
|
||||||
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
|
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(
|
async def _count_recipe_loras(
|
||||||
self, recipe_scanner: Any, recipe_id: Optional[str]
|
self, recipe_scanner: Any, recipe_id: Optional[str]
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from ...utils.base_model import (
|
|||||||
RELATION_INCOMPATIBLE,
|
RELATION_INCOMPATIBLE,
|
||||||
base_model_relation,
|
base_model_relation,
|
||||||
)
|
)
|
||||||
|
from ...utils.constants import MAX_WORKFLOW_EMBED_BYTES
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
from ..pending_delete_service import get_pending_delete_service
|
from ..pending_delete_service import get_pending_delete_service
|
||||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||||
@@ -874,8 +875,15 @@ class RecipePersistenceService:
|
|||||||
recipe_scanner,
|
recipe_scanner,
|
||||||
metadata: dict[str, Any],
|
metadata: dict[str, Any],
|
||||||
image_bytes: bytes,
|
image_bytes: bytes,
|
||||||
|
workflow: Any = None,
|
||||||
) -> PersistenceResult:
|
) -> 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:
|
if not metadata:
|
||||||
raise RecipeValidationError("No generation metadata found")
|
raise RecipeValidationError("No generation metadata found")
|
||||||
@@ -884,12 +892,25 @@ class RecipePersistenceService:
|
|||||||
os.makedirs(recipes_dir, exist_ok=True)
|
os.makedirs(recipes_dir, exist_ok=True)
|
||||||
|
|
||||||
recipe_id = str(uuid.uuid4())
|
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(
|
optimized_image, extension = self._exif_utils.optimize_image(
|
||||||
image_data=image_bytes,
|
image_data=image_bytes,
|
||||||
target_width=self._card_preview_width,
|
target_width=self._card_preview_width,
|
||||||
format="webp",
|
format="webp",
|
||||||
quality=85,
|
quality=85,
|
||||||
preserve_metadata=True,
|
preserve_metadata=True,
|
||||||
|
workflow=workflow_json,
|
||||||
)
|
)
|
||||||
image_filename = f"{recipe_id}{extension}"
|
image_filename = f"{recipe_id}{extension}"
|
||||||
image_path = os.path.join(recipes_dir, image_filename)
|
image_path = os.path.join(recipes_dir, image_filename)
|
||||||
@@ -943,9 +964,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
|
# Set by detection below: the workflow is embedded during
|
||||||
# embedded metadata chunks, so a workflow can never be present.
|
# re-encoding only when the caller opted in and it fit the cap.
|
||||||
"has_workflow": False,
|
"has_workflow": self._detect_has_workflow(image_path),
|
||||||
# Widget saves read LoRAs straight from the current workflow; an
|
# Widget saves read LoRAs straight from the current workflow; an
|
||||||
# empty list means the workflow used no LoRAs.
|
# empty list means the workflow used no LoRAs.
|
||||||
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
|
"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)
|
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||||
await recipe_scanner.add_recipe(recipe_data)
|
await recipe_scanner.add_recipe(recipe_data)
|
||||||
|
|
||||||
return PersistenceResult(
|
payload: dict[str, Any] = {
|
||||||
{
|
"success": True,
|
||||||
"success": True,
|
"recipe_id": recipe_id,
|
||||||
"recipe_id": recipe_id,
|
"image_path": image_path,
|
||||||
"image_path": image_path,
|
"json_path": json_path,
|
||||||
"json_path": json_path,
|
"recipe_name": recipe_name,
|
||||||
"recipe_name": recipe_name,
|
"has_workflow": recipe_data["has_workflow"],
|
||||||
}
|
}
|
||||||
)
|
if workflow_skipped:
|
||||||
|
payload["workflow_skipped"] = workflow_skipped
|
||||||
|
return PersistenceResult(payload)
|
||||||
|
|
||||||
# Helper methods ---------------------------------------------------
|
# Helper methods ---------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ PREVIEW_EXTENSIONS = [
|
|||||||
# Card preview image width
|
# Card preview image width
|
||||||
CARD_PREVIEW_WIDTH = 480
|
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
|
# Width for optimized example images
|
||||||
EXAMPLE_IMAGE_WIDTH = 832
|
EXAMPLE_IMAGE_WIDTH = 832
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { EVENTS_MODULE, API_MODULE, APP_MODULE, COMPONENTS_MODULE, UTILS_MODULE } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
EVENTS_MODULE: new URL('../../../web/comfyui/loras_widget_events.js', import.meta.url)
|
||||||
|
.pathname,
|
||||||
|
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
||||||
|
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
||||||
|
COMPONENTS_MODULE: new URL('../../../web/comfyui/loras_widget_components.js', import.meta.url)
|
||||||
|
.pathname,
|
||||||
|
UTILS_MODULE: new URL('../../../web/comfyui/loras_widget_utils.js', import.meta.url)
|
||||||
|
.pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const saveRecipeDirectly = vi.fn();
|
||||||
|
|
||||||
|
vi.mock(API_MODULE, () => ({ api: {} }));
|
||||||
|
vi.mock(APP_MODULE, () => ({ app: {} }));
|
||||||
|
|
||||||
|
vi.mock(COMPONENTS_MODULE, () => ({
|
||||||
|
createMenuItem: (text, icon, onClick) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'lm-lora-menu-item';
|
||||||
|
el.textContent = text;
|
||||||
|
if (onClick) el.addEventListener('click', onClick);
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
createDropIndicator: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UTILS_MODULE, () => ({
|
||||||
|
parseLoraValue: vi.fn(() => []),
|
||||||
|
formatLoraValue: vi.fn((value) => value),
|
||||||
|
syncClipStrengthIfCollapsed: vi.fn(),
|
||||||
|
saveRecipeDirectly,
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
moveLoraByDirection: vi.fn(),
|
||||||
|
getDropTargetIndex: vi.fn(),
|
||||||
|
getLoraStrengthRange: vi.fn(),
|
||||||
|
applyStrengthRangeCue: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function findMenuItem(label) {
|
||||||
|
return Array.from(document.querySelectorAll('.lm-lora-menu-item')).find(
|
||||||
|
(item) => item.textContent === label
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoRA widget context menu save options', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers workflow embedding as a separate, opt-in action', async () => {
|
||||||
|
const { createContextMenu } = await import(EVENTS_MODULE);
|
||||||
|
const widget = { value: [], callback: vi.fn() };
|
||||||
|
|
||||||
|
createContextMenu(10, 10, 'lora-a', widget, null, vi.fn());
|
||||||
|
|
||||||
|
const plain = findMenuItem('Save Recipe');
|
||||||
|
const withWorkflow = findMenuItem('Save Recipe with Workflow');
|
||||||
|
expect(plain).toBeTruthy();
|
||||||
|
expect(withWorkflow).toBeTruthy();
|
||||||
|
|
||||||
|
plain.click();
|
||||||
|
expect(saveRecipeDirectly).toHaveBeenLastCalledWith();
|
||||||
|
|
||||||
|
// Re-open: the first click removed the menu.
|
||||||
|
createContextMenu(10, 10, 'lora-a', widget, null, vi.fn());
|
||||||
|
findMenuItem('Save Recipe with Workflow').click();
|
||||||
|
expect(saveRecipeDirectly).toHaveBeenLastCalledWith({ embedWorkflow: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { UTILS_MODULE, APP_MODULE, API_MODULE, BASE_PATH_MODULE } = vi.hoisted(() => ({
|
||||||
|
UTILS_MODULE: new URL('../../../web/comfyui/loras_widget_utils.js', import.meta.url).pathname,
|
||||||
|
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
||||||
|
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
||||||
|
BASE_PATH_MODULE: new URL('../../../web/comfyui/base_path.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const toastAdd = vi.fn();
|
||||||
|
const graphToPrompt = vi.fn();
|
||||||
|
|
||||||
|
vi.mock(APP_MODULE, () => ({
|
||||||
|
app: {
|
||||||
|
graphToPrompt,
|
||||||
|
extensionManager: { toast: { add: toastAdd } },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_MODULE, () => ({
|
||||||
|
api: { fetchApi: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(BASE_PATH_MODULE, () => ({
|
||||||
|
lmUrl: (path) => `/lm${path}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function runSave(options, responseBody) {
|
||||||
|
let captured = null;
|
||||||
|
globalThis.fetch = vi.fn(async (url, init) => {
|
||||||
|
captured = { url, init };
|
||||||
|
return { json: async () => responseBody };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { saveRecipeDirectly } = await import(UTILS_MODULE);
|
||||||
|
await saveRecipeDirectly(options);
|
||||||
|
return captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('saveRecipeDirectly', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
graphToPrompt.mockResolvedValue({
|
||||||
|
workflow: { nodes: [{ id: 1 }], last_node_id: 1 },
|
||||||
|
output: { 1: { class_type: 'KSampler' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.fetch;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts no workflow by default', async () => {
|
||||||
|
const captured = await runSave(undefined, { success: true, has_workflow: false });
|
||||||
|
|
||||||
|
expect(captured.init.body).toBe('{}');
|
||||||
|
expect(JSON.parse(captured.init.body)).not.toHaveProperty('workflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('embeds the UI-format graph when asked', async () => {
|
||||||
|
const captured = await runSave(
|
||||||
|
{ embedWorkflow: true },
|
||||||
|
{ success: true, has_workflow: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = JSON.parse(captured.init.body);
|
||||||
|
expect(body.workflow).toEqual({ nodes: [{ id: 1 }], last_node_id: 1 });
|
||||||
|
expect(captured.init.headers['Content-Type']).toBe('application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a skipped oversized workflow as a warning', async () => {
|
||||||
|
await runSave(
|
||||||
|
{ embedWorkflow: true },
|
||||||
|
{ success: true, has_workflow: false, workflow_skipped: 'too_large' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastToast = toastAdd.mock.calls.at(-1)[0];
|
||||||
|
expect(lastToast.severity).toBe('warn');
|
||||||
|
expect(lastToast.summary).toBe('Recipe Saved without Workflow');
|
||||||
|
expect(lastToast.detail).toContain('too large');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a successful embed distinctly from a plain save', async () => {
|
||||||
|
await runSave(
|
||||||
|
{ embedWorkflow: true },
|
||||||
|
{ success: true, has_workflow: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastToast = toastAdd.mock.calls.at(-1)[0];
|
||||||
|
expect(lastToast.summary).toBe('Recipe Saved with Workflow');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -208,6 +208,11 @@ class StubAnalysisService:
|
|||||||
self.remote_calls: List[Optional[str]] = []
|
self.remote_calls: List[Optional[str]] = []
|
||||||
self.local_calls: List[Optional[str]] = []
|
self.local_calls: List[Optional[str]] = []
|
||||||
self.local_ignore_recipe_metadata_calls: List[bool] = []
|
self.local_ignore_recipe_metadata_calls: List[bool] = []
|
||||||
|
self.widget_analysis_calls: List[Any] = []
|
||||||
|
self.widget_result = SimpleNamespace(
|
||||||
|
payload={"metadata": {"loras": ""}, "image_bytes": b"widget-image"},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
||||||
self._recipe_parser_factory: Any = None
|
self._recipe_parser_factory: Any = None
|
||||||
StubAnalysisService.instances.append(self)
|
StubAnalysisService.instances.append(self)
|
||||||
@@ -242,7 +247,8 @@ class StubAnalysisService:
|
|||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
async def analyze_widget_metadata(self, *, recipe_scanner) -> SimpleNamespace:
|
async def analyze_widget_metadata(self, *, recipe_scanner) -> SimpleNamespace:
|
||||||
return SimpleNamespace(payload={"metadata": {}, "image_bytes": b""}, status=200)
|
self.widget_analysis_calls.append(recipe_scanner)
|
||||||
|
return self.widget_result
|
||||||
|
|
||||||
|
|
||||||
class StubPersistenceService:
|
class StubPersistenceService:
|
||||||
@@ -252,6 +258,7 @@ class StubPersistenceService:
|
|||||||
|
|
||||||
def __init__(self, **_: Any) -> None:
|
def __init__(self, **_: Any) -> None:
|
||||||
self.save_calls: List[Dict[str, Any]] = []
|
self.save_calls: List[Dict[str, Any]] = []
|
||||||
|
self.widget_calls: List[Dict[str, Any]] = []
|
||||||
self.delete_calls: List[str] = []
|
self.delete_calls: List[str] = []
|
||||||
self.move_calls: List[Dict[str, str]] = []
|
self.move_calls: List[Dict[str, str]] = []
|
||||||
self.update_calls: List[Dict[str, Any]] = []
|
self.update_calls: List[Dict[str, Any]] = []
|
||||||
@@ -359,9 +366,24 @@ class StubPersistenceService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def save_recipe_from_widget(
|
async def save_recipe_from_widget(
|
||||||
self, *, recipe_scanner, metadata: Dict[str, Any], image_bytes: bytes
|
self,
|
||||||
|
*,
|
||||||
|
recipe_scanner,
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
image_bytes: bytes,
|
||||||
|
workflow: Any = None,
|
||||||
) -> SimpleNamespace: # pragma: no cover
|
) -> SimpleNamespace: # pragma: no cover
|
||||||
return SimpleNamespace(payload={"success": True}, status=200)
|
self.widget_calls.append(
|
||||||
|
{
|
||||||
|
"recipe_scanner": recipe_scanner,
|
||||||
|
"metadata": metadata,
|
||||||
|
"image_bytes": image_bytes,
|
||||||
|
"workflow": workflow,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return SimpleNamespace(
|
||||||
|
payload={"success": True, "has_workflow": workflow is not None}, status=200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StubSharingService:
|
class StubSharingService:
|
||||||
@@ -481,6 +503,33 @@ async def recipe_harness(
|
|||||||
StubSharingService.instances.clear()
|
StubSharingService.instances.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_from_widget_forwards_workflow_body(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
"""The opt-in workflow arrives through a real JSON body and is handed to
|
||||||
|
the persistence layer; the response reports whether it was embedded."""
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
workflow = {"nodes": [{"id": 1}], "last_node_id": 1}
|
||||||
|
|
||||||
|
response = await harness.client.post(
|
||||||
|
"/api/lm/recipes/save-from-widget", json={"workflow": workflow}
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["has_workflow"] is True
|
||||||
|
assert harness.persistence.widget_calls[0]["workflow"] == workflow
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_from_widget_without_body_still_saves(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
"""The long-standing body-less POST must keep working unchanged."""
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
response = await harness.client.post("/api/lm/recipes/save-from-widget")
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["has_workflow"] is False
|
||||||
|
assert harness.persistence.widget_calls[0]["workflow"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> None:
|
async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> None:
|
||||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
recipe_path = harness.tmp_dir / "recipes" / "demo.png"
|
recipe_path = harness.tmp_dir / "recipes" / "demo.png"
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Handler tests for the widget "Save Recipe" endpoint.
|
||||||
|
|
||||||
|
Covers the opt-in workflow body: the endpoint historically received no body at
|
||||||
|
all, so a missing, empty or malformed body must degrade to "no workflow"
|
||||||
|
rather than failing the save.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.routes.handlers.recipe_handlers import RecipeManagementHandler
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_ensure() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRequest:
|
||||||
|
"""Minimal request double exposing the optional-body contract."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
body: Any = None,
|
||||||
|
can_read_body: bool = True,
|
||||||
|
json_raises: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._body = body
|
||||||
|
self.can_read_body = can_read_body
|
||||||
|
self._json_raises = json_raises
|
||||||
|
|
||||||
|
async def json(self) -> Any:
|
||||||
|
if self._json_raises or self._body is None:
|
||||||
|
raise ValueError("no JSON body")
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class CapturingPersistence:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def save_recipe_from_widget(self, **kwargs: Any) -> SimpleNamespace:
|
||||||
|
self.calls.append(kwargs)
|
||||||
|
return SimpleNamespace(
|
||||||
|
payload={"success": True, "has_workflow": bool(kwargs.get("workflow"))},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_handler(persistence: CapturingPersistence) -> RecipeManagementHandler:
|
||||||
|
analysis_service = SimpleNamespace(
|
||||||
|
analyze_widget_metadata=lambda **kwargs: _analysis_result()
|
||||||
|
)
|
||||||
|
|
||||||
|
return RecipeManagementHandler(
|
||||||
|
ensure_dependencies_ready=_noop_ensure,
|
||||||
|
recipe_scanner_getter=lambda: object(),
|
||||||
|
logger=logging.getLogger(__name__),
|
||||||
|
persistence_service=persistence, # pyright: ignore[reportArgumentType]
|
||||||
|
analysis_service=analysis_service, # pyright: ignore[reportArgumentType]
|
||||||
|
downloader_factory=lambda: None,
|
||||||
|
civitai_client_getter=lambda: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _analysis_result() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
payload={"metadata": {"loras": ""}, "image_bytes": b"image"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_widget_save_forwards_workflow_from_json_body():
|
||||||
|
persistence = CapturingPersistence()
|
||||||
|
handler = _make_handler(persistence)
|
||||||
|
workflow = {"nodes": [{"id": 1}]}
|
||||||
|
|
||||||
|
response = await handler.save_recipe_from_widget(
|
||||||
|
FakeRequest(body={"workflow": workflow}) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert persistence.calls[0]["workflow"] == workflow
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_widget_save_without_body_passes_no_workflow():
|
||||||
|
persistence = CapturingPersistence()
|
||||||
|
handler = _make_handler(persistence)
|
||||||
|
|
||||||
|
response = await handler.save_recipe_from_widget(
|
||||||
|
FakeRequest(can_read_body=False) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert persistence.calls[0]["workflow"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_widget_save_tolerates_malformed_body():
|
||||||
|
persistence = CapturingPersistence()
|
||||||
|
handler = _make_handler(persistence)
|
||||||
|
|
||||||
|
await handler.save_recipe_from_widget(
|
||||||
|
FakeRequest(json_raises=True) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
await handler.save_recipe_from_widget(
|
||||||
|
FakeRequest(body=["not", "an", "object"]) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [call["workflow"] for call in persistence.calls] == [None, None]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_widget_save_reports_embedded_workflow_in_response():
|
||||||
|
persistence = CapturingPersistence()
|
||||||
|
handler = _make_handler(persistence)
|
||||||
|
|
||||||
|
response = await handler.save_recipe_from_widget(
|
||||||
|
FakeRequest(body={"workflow": {"nodes": []}}) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(response.text)["has_workflow"] is True
|
||||||
@@ -50,6 +50,13 @@ class DummyExifUtils:
|
|||||||
self.embedded_workflows.append((image_path, workflow))
|
self.embedded_workflows.append((image_path, workflow))
|
||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
|
def normalise_workflow(self, workflow):
|
||||||
|
if isinstance(workflow, str):
|
||||||
|
return workflow or None
|
||||||
|
if isinstance(workflow, (dict, list)):
|
||||||
|
return json.dumps(workflow)
|
||||||
|
return None
|
||||||
|
|
||||||
def extract_image_metadata(self, path):
|
def extract_image_metadata(self, path):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -981,6 +988,137 @@ async def test_save_recipe_from_widget_enriches_checkpoint_from_local_cache(tmp_
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_recipe_from_widget_embeds_opted_in_workflow(tmp_path):
|
||||||
|
"""Opt-in widget saves embed the live graph so the recipe can send its
|
||||||
|
workflow back to ComfyUI, mirroring imported recipes."""
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
self.added = []
|
||||||
|
|
||||||
|
async def get_local_lora(self, name): # pragma: no cover - no loras
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def add_recipe(self, recipe_data):
|
||||||
|
self.added.append(recipe_data)
|
||||||
|
|
||||||
|
image_buffer = BytesIO()
|
||||||
|
Image.new("RGB", (96, 48), color="navy").save(
|
||||||
|
image_buffer, format="PNG"
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=ExifUtils,
|
||||||
|
card_preview_width=64,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow = {"nodes": [{"id": 1}], "last_node_id": 1}
|
||||||
|
result = await service.save_recipe_from_widget(
|
||||||
|
recipe_scanner=scanner,
|
||||||
|
metadata={"loras": "", "prompt": "a calm scene"},
|
||||||
|
image_bytes=image_buffer.getvalue(),
|
||||||
|
workflow=workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["has_workflow"] is True
|
||||||
|
assert "workflow_skipped" not in result.payload
|
||||||
|
|
||||||
|
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||||
|
assert stored["has_workflow"] is True
|
||||||
|
assert ExifUtils._load_structured_metadata(result.payload["image_path"])[
|
||||||
|
"workflow"
|
||||||
|
] == json.dumps(workflow)
|
||||||
|
assert scanner.added[0]["has_workflow"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_recipe_from_widget_without_workflow_stays_unflagged(tmp_path):
|
||||||
|
"""The default action must keep saving a workflow-free preview."""
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
|
||||||
|
async def get_local_lora(self, name): # pragma: no cover - no loras
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def add_recipe(self, recipe_data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
image_buffer = BytesIO()
|
||||||
|
Image.new("RGB", (96, 48), color="navy").save(
|
||||||
|
image_buffer, format="PNG"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=ExifUtils,
|
||||||
|
card_preview_width=64,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.save_recipe_from_widget(
|
||||||
|
recipe_scanner=DummyScanner(tmp_path),
|
||||||
|
metadata={"loras": "", "prompt": "a calm scene"},
|
||||||
|
image_bytes=image_buffer.getvalue(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["has_workflow"] is False
|
||||||
|
assert ExifUtils._load_structured_metadata(result.payload["image_path"])[
|
||||||
|
"workflow"
|
||||||
|
] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_recipe_from_widget_skips_oversized_workflow(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""A pathological graph is dropped instead of inflating the preview."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.recipes.persistence_service.MAX_WORKFLOW_EMBED_BYTES", 32
|
||||||
|
)
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
self.added = []
|
||||||
|
|
||||||
|
async def get_local_lora(self, name): # pragma: no cover - no loras
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def add_recipe(self, recipe_data):
|
||||||
|
self.added.append(recipe_data)
|
||||||
|
|
||||||
|
image_buffer = BytesIO()
|
||||||
|
Image.new("RGB", (96, 48), color="navy").save(
|
||||||
|
image_buffer, format="PNG"
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=ExifUtils,
|
||||||
|
card_preview_width=64,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow = {"nodes": [{"id": index} for index in range(20)]}
|
||||||
|
result = await service.save_recipe_from_widget(
|
||||||
|
recipe_scanner=scanner,
|
||||||
|
metadata={"loras": "", "prompt": "a calm scene"},
|
||||||
|
image_bytes=image_buffer.getvalue(),
|
||||||
|
workflow=workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["workflow_skipped"] == "too_large"
|
||||||
|
assert result.payload["has_workflow"] is False
|
||||||
|
assert ExifUtils._load_structured_metadata(result.payload["image_path"])[
|
||||||
|
"workflow"
|
||||||
|
] is None
|
||||||
|
assert scanner.added[0]["has_workflow"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_move_recipe_updates_paths(tmp_path):
|
async def test_move_recipe_updates_paths(tmp_path):
|
||||||
exif_utils = DummyExifUtils()
|
exif_utils = DummyExifUtils()
|
||||||
|
|||||||
@@ -873,6 +873,19 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Save recipe with the current graph embedded. Kept opt-in rather than
|
||||||
|
// folded into "Save Recipe": the workflow dwarfs every other metadata field
|
||||||
|
// and can carry sensitive widget values, so it stays an explicit choice.
|
||||||
|
const saveWithWorkflowOption = createMenuItem(
|
||||||
|
'Save Recipe with Workflow',
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg>',
|
||||||
|
() => {
|
||||||
|
menu.remove();
|
||||||
|
document.removeEventListener('click', closeMenu);
|
||||||
|
saveRecipeDirectly({ embedWorkflow: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Move Up option with arrow up icon
|
// Move Up option with arrow up icon
|
||||||
const moveUpOption = createMenuItem(
|
const moveUpOption = createMenuItem(
|
||||||
'Move Up',
|
'Move Up',
|
||||||
@@ -982,6 +995,7 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
|
|||||||
menu.appendChild(copyTriggerWordsOption);
|
menu.appendChild(copyTriggerWordsOption);
|
||||||
menu.appendChild(separator2);
|
menu.appendChild(separator2);
|
||||||
menu.appendChild(saveOption);
|
menu.appendChild(saveOption);
|
||||||
|
menu.appendChild(saveWithWorkflowOption);
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
|
|
||||||
|
|||||||
@@ -460,15 +460,29 @@ export function syncClipStrengthIfCollapsed(loraData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Function to directly save the recipe without dialog
|
// Function to directly save the recipe without dialog
|
||||||
export async function saveRecipeDirectly() {
|
export async function saveRecipeDirectly({ embedWorkflow = false } = {}) {
|
||||||
try {
|
try {
|
||||||
const prompt = await app.graphToPrompt();
|
const prompt = await app.graphToPrompt();
|
||||||
console.log('Prompt:', prompt); // for debugging purposes
|
console.log('Prompt:', prompt); // for debugging purposes
|
||||||
|
|
||||||
|
// Embedding the graph is opt-in: it is by far the largest metadata field
|
||||||
|
// and its widget values can contain sensitive data (paths, API keys). The
|
||||||
|
// UI-format graph is sent rather than the API prompt so node layout and
|
||||||
|
// groups survive — that is what "Send Workflow to ComfyUI" restores.
|
||||||
|
const requestBody = {};
|
||||||
|
if (embedWorkflow) {
|
||||||
|
if (prompt && prompt.workflow) {
|
||||||
|
requestBody.workflow = prompt.workflow;
|
||||||
|
} else {
|
||||||
|
showToast('No workflow available to embed; saving the recipe without it', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show loading toast
|
// Show loading toast
|
||||||
if (app && app.extensionManager && app.extensionManager.toast) {
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||||
app.extensionManager.toast.add({
|
app.extensionManager.toast.add({
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
summary: 'Saving Recipe',
|
summary: embedWorkflow ? 'Saving Recipe with Workflow' : 'Saving Recipe',
|
||||||
detail: 'Please wait...',
|
detail: 'Please wait...',
|
||||||
life: 2000
|
life: 2000
|
||||||
});
|
});
|
||||||
@@ -476,7 +490,9 @@ export async function saveRecipeDirectly() {
|
|||||||
|
|
||||||
// Send the request to the backend API
|
// Send the request to the backend API
|
||||||
const response = await fetch(lmUrl('/api/lm/recipes/save-from-widget'), {
|
const response = await fetch(lmUrl('/api/lm/recipes/save-from-widget'), {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -484,12 +500,23 @@ export async function saveRecipeDirectly() {
|
|||||||
// Show result toast
|
// Show result toast
|
||||||
if (app && app.extensionManager && app.extensionManager.toast) {
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
app.extensionManager.toast.add({
|
let severity = 'success';
|
||||||
severity: 'success',
|
let summary = embedWorkflow ? 'Recipe Saved with Workflow' : 'Recipe Saved';
|
||||||
summary: 'Recipe Saved',
|
let detail = embedWorkflow
|
||||||
detail: 'Recipe has been saved successfully',
|
? 'Recipe and the current workflow have been saved'
|
||||||
life: 3000
|
: 'Recipe has been saved successfully';
|
||||||
});
|
|
||||||
|
if (embedWorkflow && result.workflow_skipped === 'too_large') {
|
||||||
|
severity = 'warn';
|
||||||
|
summary = 'Recipe Saved without Workflow';
|
||||||
|
detail = 'The workflow is too large to embed; the recipe was saved without it';
|
||||||
|
} else if (embedWorkflow && result.has_workflow !== true) {
|
||||||
|
severity = 'warn';
|
||||||
|
summary = 'Recipe Saved without Workflow';
|
||||||
|
detail = 'The workflow could not be embedded in the recipe image';
|
||||||
|
}
|
||||||
|
|
||||||
|
app.extensionManager.toast.add({ severity, summary, detail, life: 5000 });
|
||||||
} else {
|
} else {
|
||||||
app.extensionManager.toast.add({
|
app.extensionManager.toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
|
|||||||
Reference in New Issue
Block a user