Save cached character profiles without rerun

This commit is contained in:
2026-06-24 20:52:20 +02:00
parent 4172797b43
commit fba1825496
4 changed files with 176 additions and 8 deletions
+7 -5
View File
@@ -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/<profile_name>.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/<profile_name>.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
+36 -1
View File
@@ -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:
+17
View File
@@ -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": "",
+116 -2
View File
@@ -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;