Save cached character profiles without rerun
This commit is contained in:
@@ -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
|
age, body/body phrase, skin, hair, eyes, figure, and subject type. To save a
|
||||||
Woman Slot, connect `Woman Slot.character_slot` to
|
Woman Slot, connect `Woman Slot.character_slot` to
|
||||||
`Character Profile Save.character_slot`, set `source=character_slot`, enter a
|
`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
|
name, run the workflow once so the save node caches that exact profile, then
|
||||||
clicked; otherwise it just outputs profile JSON for direct wiring. Saved files
|
click `Save Profile Now`. The button writes the cached profile directly and does
|
||||||
are written under `profiles/<profile_name>.json`; saved profile files are
|
not queue or regenerate the workflow, so it saves the character you just liked.
|
||||||
ignored by git. The button is backed by the hidden `save_now` trigger and queues
|
Otherwise the node just outputs profile JSON for direct wiring. Saved files are
|
||||||
the workflow once.
|
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
|
`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
|
an empty profile so connected prompt builders ignore it. When enabled, it loads
|
||||||
|
|||||||
+36
-1
@@ -3,6 +3,13 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
|
|
||||||
|
try:
|
||||||
|
from aiohttp import web
|
||||||
|
from server import PromptServer
|
||||||
|
except Exception:
|
||||||
|
web = None
|
||||||
|
PromptServer = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .loop_nodes import ANY_TYPE, LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS
|
from .loop_nodes import ANY_TYPE, LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS
|
||||||
from .prompt_builder import (
|
from .prompt_builder import (
|
||||||
@@ -49,6 +56,7 @@ try:
|
|||||||
generation_profile_choices,
|
generation_profile_choices,
|
||||||
hardcore_detail_density_choices,
|
hardcore_detail_density_choices,
|
||||||
load_character_profile_json,
|
load_character_profile_json,
|
||||||
|
save_character_profile_payload,
|
||||||
seed_mode_choices,
|
seed_mode_choices,
|
||||||
subcategory_choices,
|
subcategory_choices,
|
||||||
)
|
)
|
||||||
@@ -100,6 +108,7 @@ except ImportError:
|
|||||||
generation_profile_choices,
|
generation_profile_choices,
|
||||||
hardcore_detail_density_choices,
|
hardcore_detail_density_choices,
|
||||||
load_character_profile_json,
|
load_character_profile_json,
|
||||||
|
save_character_profile_payload,
|
||||||
seed_mode_choices,
|
seed_mode_choices,
|
||||||
subcategory_choices,
|
subcategory_choices,
|
||||||
)
|
)
|
||||||
@@ -107,6 +116,20 @@ except ImportError:
|
|||||||
from krea_formatter import format_krea2_prompt
|
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:
|
class SxCPPromptBuilder:
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
@@ -984,6 +1007,8 @@ class SxCPManSlot:
|
|||||||
|
|
||||||
|
|
||||||
class SxCPCharacterProfileSave:
|
class SxCPCharacterProfileSave:
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
return {
|
||||||
@@ -1042,7 +1067,7 @@ class SxCPCharacterProfileSave:
|
|||||||
figure=figure,
|
figure=figure,
|
||||||
save_now=save_now,
|
save_now=save_now,
|
||||||
)
|
)
|
||||||
return (
|
result = (
|
||||||
profile["profile_json"],
|
profile["profile_json"],
|
||||||
profile["descriptor"],
|
profile["descriptor"],
|
||||||
profile["profile_name"],
|
profile["profile_name"],
|
||||||
@@ -1050,6 +1075,16 @@ class SxCPCharacterProfileSave:
|
|||||||
profile["status"],
|
profile["status"],
|
||||||
profile["profile_json"],
|
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:
|
class SxCPCharacterProfileLoad:
|
||||||
|
|||||||
@@ -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]:
|
def _empty_profile_result(status: str = "empty") -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"profile_json": "",
|
"profile_json": "",
|
||||||
|
|||||||
+116
-2
@@ -1,6 +1,8 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
const EXTENSION = "ethanfel.prompt_builder.profile_buttons";
|
const EXTENSION = "ethanfel.prompt_builder.profile_buttons";
|
||||||
|
const profileCache = new Map();
|
||||||
|
|
||||||
function widget(node, name) {
|
function widget(node, name) {
|
||||||
return node.widgets?.find((w) => w.name === name);
|
return node.widgets?.find((w) => w.name === name);
|
||||||
@@ -20,6 +22,96 @@ function resizeNode(node) {
|
|||||||
app.graph?.setDirtyCanvas(true, true);
|
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) {
|
async function queueOnce(node, triggerName) {
|
||||||
const trigger = widget(node, triggerName);
|
const trigger = widget(node, triggerName);
|
||||||
if (!trigger) {
|
if (!trigger) {
|
||||||
@@ -46,9 +138,14 @@ async function queueOnce(node, triggerName) {
|
|||||||
|
|
||||||
function setupSaveNode(node) {
|
function setupSaveNode(node) {
|
||||||
hideWidget(widget(node, "save_now"));
|
hideWidget(widget(node, "save_now"));
|
||||||
if (!node._sxcpSaveButton) {
|
if (!node._sxcpCachedProfileWidget) {
|
||||||
node._sxcpSaveButton = node.addWidget("button", "Save Profile Now", null, () => queueOnce(node, "save_now"));
|
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);
|
resizeNode(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +185,23 @@ function setupLoadNode(node) {
|
|||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: EXTENSION,
|
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) {
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
if (nodeData.name !== "SxCPCharacterProfileSave" && nodeData.name !== "SxCPCharacterProfileLoad") return;
|
if (nodeData.name !== "SxCPCharacterProfileSave" && nodeData.name !== "SxCPCharacterProfileLoad") return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user