Add persistent any text preview node

This commit is contained in:
2026-06-26 09:32:11 +02:00
parent 49c6ee77a6
commit a3371afe0c
4 changed files with 159 additions and 0 deletions
+8
View File
@@ -17,6 +17,7 @@ The node is registered as:
- `prompt_builder / SxCP For Loop End`
- `prompt_builder / SxCP Loop Append`
- `prompt_builder / SxCP Accumulator`
- `prompt_builder / util / SxCP Preview Any As Text`
- `prompt_builder / SxCP Category Preset`
- `prompt_builder / SxCP Cast Control`
- `prompt_builder / SxCP Cast Bias`
@@ -170,6 +171,13 @@ Its outputs are:
ComfyUI image batches require matching dimensions. For mixed image formats, use
`image_list` or the grouped `image_batch_1..4` outputs instead of `image_batch`.
`SxCP Preview Any As Text` is a persistent text preview for arbitrary values.
Connect any output to `value`; the node renders strings directly and formats
dict/list/tensor-like values as readable text. After execution, its
`preview_text` widget is updated by the frontend and is serialized in the
workflow. Save the workflow after a run and the preview text will still be
there after reload.
## Character Profiles
`SxCP Woman Slot` and `SxCP Man Slot` are the scalable per-participant control
+3
View File
@@ -153,6 +153,9 @@ COMMON_INPUT_TOOLTIPS = {
"save_path": "Folder to save the accumulator batch. Relative paths are inside ComfyUI output; absolute paths are used directly.",
"filename_prefix": "Filename prefix for saved accumulator images.",
"clear_after_save": "Clear the accumulator store after a successful batch save.",
"preview_text": "Serialized persistent text preview. It is updated after execution and saved with the workflow.",
"preview_format": "How to convert an arbitrary input to preview text.",
"max_chars": "Maximum stored preview characters. 0 disables truncation.",
"mode": "Switch direction: pick_input selects one input to value, route_output sends route_value to one output.",
"index": "Index used by SxCP Index Switch. For Loop Start outputs one_based indexes by default.",
"index_base": "one_based means index 1 selects input_1. zero_based means index 0 selects input_1.",
+102
View File
@@ -50,6 +50,7 @@ ACCUMULATOR_PREVIEW_DELETE_ACTIONS = ["none", "delete_entry_id", "delete_index",
INDEX_SWITCH_MODES = ["pick_input", "route_output"]
INDEX_SWITCH_BASES = ["one_based", "zero_based"]
INDEX_SWITCH_MISSING_BEHAVIORS = ["fallback", "none", "clamp", "wrap"]
PREVIEW_TEXT_FORMATS = ["auto", "json", "repr", "str"]
_ACCUMULATOR_STORES: dict[str, list[dict[str, Any]]] = {}
@@ -248,6 +249,66 @@ def _attach_preview_images(entries: list[dict[str, Any]], images: list[dict[str,
entry["preview_image"] = image
def _jsonable_preview_value(value: Any, depth: int = 4, max_items: int = 80) -> Any:
if depth < 0:
return "..."
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
items = list(value.items())
output = {
str(key): _jsonable_preview_value(item, depth - 1, max_items)
for key, item in items[:max_items]
}
if len(items) > max_items:
output["..."] = f"{len(items) - max_items} more"
return output
if isinstance(value, (list, tuple, set)):
items = list(value)
output = [_jsonable_preview_value(item, depth - 1, max_items) for item in items[:max_items]]
if len(items) > max_items:
output.append(f"... {len(items) - max_items} more")
return output
shape = getattr(value, "shape", None)
if shape is not None:
try:
return {
"type": type(value).__name__,
"shape": [int(part) for part in shape],
"dtype": str(getattr(value, "dtype", "")),
"device": str(getattr(value, "device", "")),
}
except Exception:
pass
return str(value)
def _truncate_preview_text(text: str, max_chars: int) -> str:
max_chars = max(0, int(max_chars or 0))
if max_chars <= 0 or len(text) <= max_chars:
return text
omitted = len(text) - max_chars
return f"{text[:max_chars]}\n... truncated {omitted} characters"
def _any_to_preview_text(value: Any, preview_format: str, max_chars: int) -> str:
preview_format = preview_format if preview_format in PREVIEW_TEXT_FORMATS else "auto"
if preview_format == "str":
text = str(value)
elif preview_format == "repr":
text = repr(value)
elif preview_format == "json":
text = json.dumps(_jsonable_preview_value(value), ensure_ascii=True, indent=2, sort_keys=True)
elif isinstance(value, str):
text = value
else:
try:
text = json.dumps(_jsonable_preview_value(value), ensure_ascii=True, indent=2, sort_keys=True)
except Exception:
text = str(value)
return _truncate_preview_text(text, max_chars)
def _accumulator_status(key: str, store: list[dict[str, Any]]) -> str:
images = [entry.get("image") for entry in store if entry.get("image") is not None]
shapes = []
@@ -1194,6 +1255,45 @@ class SxCPAccumulatorPreview:
}
class SxCPPreviewAnyAsText:
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"preview_text": ("STRING", {"default": "", "multiline": True}),
"preview_format": (PREVIEW_TEXT_FORMATS, {"default": "auto"}),
"max_chars": ("INT", {"default": 20000, "min": 0, "max": 200000, "step": 1000}),
},
"optional": {
"value": (ANY_TYPE, {"forceInput": True}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "preview"
CATEGORY = "prompt_builder/util"
@classmethod
def IS_CHANGED(cls, *args, **kwargs):
return random.random()
def preview(self, preview_text, preview_format, max_chars, **kwargs):
if "value" not in kwargs:
text = _truncate_preview_text(str(preview_text or ""), int(max_chars))
else:
value = kwargs.get("value")
text = _any_to_preview_text(value, str(preview_format or "auto"), int(max_chars))
return {
"ui": {
"preview_text": [text],
},
"result": (text,),
}
class SxCPForLoopEnd:
@classmethod
def INPUT_TYPES(cls):
@@ -1320,6 +1420,7 @@ LOOP_NODE_CLASS_MAPPINGS = {
"SxCPIndexSwitch": SxCPIndexSwitch,
"SxCPAccumulator": SxCPAccumulator,
"SxCPAccumulatorPreview": SxCPAccumulatorPreview,
"SxCPPreviewAnyAsText": SxCPPreviewAnyAsText,
"SxCPLoopIntAdd": SxCPLoopIntAdd,
"SxCPLoopLessThan": SxCPLoopLessThan,
"SxCPLoopLessThanOrEqual": SxCPLoopLessThanOrEqual,
@@ -1334,6 +1435,7 @@ LOOP_NODE_DISPLAY_NAME_MAPPINGS = {
"SxCPIndexSwitch": "SxCP Index Switch",
"SxCPAccumulator": "SxCP Accumulator",
"SxCPAccumulatorPreview": "SxCP Accumulator Preview",
"SxCPPreviewAnyAsText": "SxCP Preview Any As Text",
"SxCPLoopIntAdd": "SxCP Loop Int Add",
"SxCPLoopLessThan": "SxCP Loop Less Than",
"SxCPLoopLessThanOrEqual": "SxCP Loop Less Than Or Equal",
+46
View File
@@ -0,0 +1,46 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const EXTENSION = "ethanfel.prompt_builder.preview_any_text";
const NODE_NAME = "SxCPPreviewAnyAsText";
function isPreviewTextNode(node) {
return node?.comfyClass === NODE_NAME || node?.type === NODE_NAME;
}
function getNodeById(id) {
return app.graph?.getNodeById?.(Number(id)) || app.graph?._nodes_by_id?.[id] || app.graph?._nodes_by_id?.[Number(id)];
}
function widget(node, name) {
return node.widgets?.find((item) => item.name === name);
}
function outputPreviewText(output) {
const raw = output?.preview_text;
if (Array.isArray(raw)) return raw[0] ?? "";
return raw ?? "";
}
function setPreviewWidget(node, text) {
const preview = widget(node, "preview_text");
if (!preview) return;
preview.value = text;
if (preview.inputEl) preview.inputEl.value = text;
preview.callback?.(text, app.canvas, node);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
}
app.registerExtension({
name: EXTENSION,
async setup() {
api.addEventListener("executed", ({detail}) => {
const node = getNodeById(detail?.display_node ?? detail?.node);
if (!isPreviewTextNode(node)) return;
const text = outputPreviewText(detail?.output || {});
setPreviewWidget(node, text);
});
},
});