diff --git a/README.md b/README.md index 1cc120f..53bffeb 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,13 @@ when you need different settings for each participant. age, body/body phrase, skin, hair, eyes, figure, and subject type. To save a Woman Slot, connect `Woman Slot.character_slot` to `Character Profile Save.character_slot`, set `source=character_slot`, enter a -name, then click `Save Profile Now`. It only writes a file when the button is -clicked; otherwise it just outputs profile JSON for direct wiring. Saved files -are written under `profiles/.json`; saved profile files are -ignored by git. The button is backed by the hidden `save_now` trigger and queues -the workflow once. +name, run the workflow once so the save node caches that exact profile, then +click `Save Profile Now`. The button writes the cached profile directly and does +not queue or regenerate the workflow, so it saves the character you just liked. +Otherwise the node just outputs profile JSON for direct wiring. Saved files are +written under `profiles/.json`; saved profile files are ignored by +git. The hidden `save_now` trigger remains for legacy/API use, but the visible +button does not use it. `SxCP Character Profile Load` has an `enabled` switch. When disabled, it returns an empty profile so connected prompt builders ignore it. When enabled, it loads diff --git a/__init__.py b/__init__.py index d2f5c4e..e117286 100644 --- a/__init__.py +++ b/__init__.py @@ -3,6 +3,13 @@ from __future__ import annotations import json import random +try: + from aiohttp import web + from server import PromptServer +except Exception: + web = None + PromptServer = None + try: from .loop_nodes import ANY_TYPE, LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS from .prompt_builder import ( @@ -49,6 +56,7 @@ try: generation_profile_choices, hardcore_detail_density_choices, load_character_profile_json, + save_character_profile_payload, seed_mode_choices, subcategory_choices, ) @@ -100,6 +108,7 @@ except ImportError: generation_profile_choices, hardcore_detail_density_choices, load_character_profile_json, + save_character_profile_payload, seed_mode_choices, subcategory_choices, ) @@ -107,6 +116,20 @@ except ImportError: from krea_formatter import format_krea2_prompt +if PromptServer is not None and web is not None: + @PromptServer.instance.routes.post("/sxcp/profile/save_cached") + async def sxcp_save_cached_profile(request): + try: + payload = await request.json() + result = save_character_profile_payload( + profile_name=str(payload.get("profile_name") or ""), + profile_json=payload.get("profile_json") or "", + ) + return web.json_response(result) + except Exception as exc: + return web.json_response({"error": str(exc)}, status=400) + + class SxCPPromptBuilder: @classmethod def INPUT_TYPES(cls): @@ -984,6 +1007,8 @@ class SxCPManSlot: class SxCPCharacterProfileSave: + OUTPUT_NODE = True + @classmethod def INPUT_TYPES(cls): return { @@ -1042,7 +1067,7 @@ class SxCPCharacterProfileSave: figure=figure, save_now=save_now, ) - return ( + result = ( profile["profile_json"], profile["descriptor"], profile["profile_name"], @@ -1050,6 +1075,16 @@ class SxCPCharacterProfileSave: profile["status"], profile["profile_json"], ) + return { + "ui": { + "profile_json": [profile["profile_json"]], + "descriptor": [profile["descriptor"]], + "profile_name": [profile["profile_name"]], + "saved_path": [profile["saved_path"]], + "status": [profile["status"]], + }, + "result": result, + } class SxCPCharacterProfileLoad: diff --git a/prompt_builder.py b/prompt_builder.py index 8d02fd4..2c9227c 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -3027,6 +3027,23 @@ def build_character_profile_json( } +def save_character_profile_payload(profile_name: str = "", profile_json: str | dict[str, Any] | None = "") -> dict[str, str]: + raw_profile = _load_json_object(profile_json, "profile_json") + if not raw_profile: + raise ValueError("No cached character profile is available to save.") + profile = _normalize_character_profile(raw_profile, profile_name or str(raw_profile.get("profile_name") or "")) + PROFILE_DIR.mkdir(parents=True, exist_ok=True) + path = _profile_path(profile["profile_name"]) + path.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return { + "profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True), + "profile_name": profile["profile_name"], + "descriptor": profile["descriptor"], + "saved_path": str(path), + "status": "saved", + } + + def _empty_profile_result(status: str = "empty") -> dict[str, str]: return { "profile_json": "", diff --git a/web/profile_buttons.js b/web/profile_buttons.js index f5ece80..e290c10 100644 --- a/web/profile_buttons.js +++ b/web/profile_buttons.js @@ -1,6 +1,8 @@ import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; const EXTENSION = "ethanfel.prompt_builder.profile_buttons"; +const profileCache = new Map(); function widget(node, name) { return node.widgets?.find((w) => w.name === name); @@ -20,6 +22,96 @@ function resizeNode(node) { app.graph?.setDirtyCanvas(true, true); } +function nodeKey(nodeOrId) { + return String(typeof nodeOrId === "object" ? nodeOrId?.id : nodeOrId); +} + +function isProfileSaveNode(node) { + return node?.comfyClass === "SxCPCharacterProfileSave" || node?.type === "SxCPCharacterProfileSave"; +} + +function isProfileLoadNode(node) { + return node?.comfyClass === "SxCPCharacterProfileLoad" || node?.type === "SxCPCharacterProfileLoad"; +} + +function firstOutput(output, key) { + const value = output?.[key]; + return Array.isArray(value) ? value[0] : value || ""; +} + +function cacheStatus(cache) { + if (!cache?.profile_json) return "no cached profile"; + const name = cache.profile_name || "unnamed"; + const descriptor = cache.descriptor ? ` - ${cache.descriptor}` : ""; + const text = `cached: ${name}${descriptor}`; + return text.length > 120 ? `${text.slice(0, 117)}...` : text; +} + +function updateCacheWidget(node) { + const cache = profileCache.get(nodeKey(node)) || node._sxcpProfileCache; + if (node._sxcpCachedProfileWidget) node._sxcpCachedProfileWidget.value = cacheStatus(cache); + node.setDirtyCanvas?.(true, true); +} + +function setProfileCache(node, cache) { + if (!node || !cache?.profile_json) return; + node._sxcpProfileCache = cache; + profileCache.set(nodeKey(node), cache); + updateCacheWidget(node); +} + +function getNodeById(id) { + return app.graph?.getNodeById?.(Number(id)) || app.graph?._nodes_by_id?.[id] || app.graph?._nodes_by_id?.[Number(id)]; +} + +function addProfileChoice(profileName) { + if (!profileName) return; + for (const node of app.graph?._nodes || []) { + if (!isProfileLoadNode(node)) continue; + const profileWidget = widget(node, "profile_name"); + const values = profileWidget?.options?.values; + if (Array.isArray(values) && !values.includes(profileName)) values.push(profileName); + } +} + +async function saveCachedProfile(node) { + const cache = profileCache.get(nodeKey(node)) || node._sxcpProfileCache; + if (!cache?.profile_json) { + alert("No cached profile yet. Run the workflow once with this save node connected, then press Save Profile Now."); + return; + } + const profileName = (widget(node, "profile_name")?.value || cache.profile_name || "").trim(); + if (!profileName) { + alert("Fill profile_name before saving."); + return; + } + + try { + const response = await api.fetchApi("/sxcp/profile/save_cached", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + profile_name: profileName, + profile_json: cache.profile_json, + }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data?.error || response.statusText); + setProfileCache(node, { + profile_json: data.profile_json || cache.profile_json, + descriptor: data.descriptor || cache.descriptor || "", + profile_name: data.profile_name || profileName, + saved_path: data.saved_path || "", + status: data.status || "saved", + }); + addProfileChoice(data.profile_name || profileName); + alert(`Saved profile "${data.profile_name || profileName}".`); + } catch (err) { + console.error(`[${EXTENSION}] save cached profile failed`, err); + alert(`Save failed: ${err}`); + } +} + async function queueOnce(node, triggerName) { const trigger = widget(node, triggerName); if (!trigger) { @@ -46,9 +138,14 @@ async function queueOnce(node, triggerName) { function setupSaveNode(node) { hideWidget(widget(node, "save_now")); - if (!node._sxcpSaveButton) { - node._sxcpSaveButton = node.addWidget("button", "Save Profile Now", null, () => queueOnce(node, "save_now")); + if (!node._sxcpCachedProfileWidget) { + node._sxcpCachedProfileWidget = node.addWidget("text", "cached_profile", "no cached profile", () => {}); + node._sxcpCachedProfileWidget.serialize = false; } + if (!node._sxcpSaveButton) { + node._sxcpSaveButton = node.addWidget("button", "Save Profile Now", null, () => saveCachedProfile(node)); + } + updateCacheWidget(node); resizeNode(node); } @@ -88,6 +185,23 @@ function setupLoadNode(node) { app.registerExtension({ name: EXTENSION, + async setup() { + api.addEventListener("executed", ({detail}) => { + const node = getNodeById(detail?.node); + if (!isProfileSaveNode(node)) return; + const output = detail?.output || {}; + const profileJson = firstOutput(output, "profile_json"); + if (!profileJson) return; + setProfileCache(node, { + profile_json: profileJson, + descriptor: firstOutput(output, "descriptor"), + profile_name: firstOutput(output, "profile_name"), + saved_path: firstOutput(output, "saved_path"), + status: firstOutput(output, "status"), + }); + }); + }, + async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== "SxCPCharacterProfileSave" && nodeData.name !== "SxCPCharacterProfileLoad") return;