Add persistent any text preview node
This commit is contained in:
@@ -17,6 +17,7 @@ The node is registered as:
|
|||||||
- `prompt_builder / SxCP For Loop End`
|
- `prompt_builder / SxCP For Loop End`
|
||||||
- `prompt_builder / SxCP Loop Append`
|
- `prompt_builder / SxCP Loop Append`
|
||||||
- `prompt_builder / SxCP Accumulator`
|
- `prompt_builder / SxCP Accumulator`
|
||||||
|
- `prompt_builder / util / SxCP Preview Any As Text`
|
||||||
- `prompt_builder / SxCP Category Preset`
|
- `prompt_builder / SxCP Category Preset`
|
||||||
- `prompt_builder / SxCP Cast Control`
|
- `prompt_builder / SxCP Cast Control`
|
||||||
- `prompt_builder / SxCP Cast Bias`
|
- `prompt_builder / SxCP Cast Bias`
|
||||||
@@ -170,6 +171,13 @@ Its outputs are:
|
|||||||
ComfyUI image batches require matching dimensions. For mixed image formats, use
|
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`.
|
`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
|
## Character Profiles
|
||||||
|
|
||||||
`SxCP Woman Slot` and `SxCP Man Slot` are the scalable per-participant control
|
`SxCP Woman Slot` and `SxCP Man Slot` are the scalable per-participant control
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"filename_prefix": "Filename prefix for saved accumulator images.",
|
||||||
"clear_after_save": "Clear the accumulator store after a successful batch save.",
|
"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.",
|
"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": "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.",
|
"index_base": "one_based means index 1 selects input_1. zero_based means index 0 selects input_1.",
|
||||||
|
|||||||
+102
@@ -50,6 +50,7 @@ ACCUMULATOR_PREVIEW_DELETE_ACTIONS = ["none", "delete_entry_id", "delete_index",
|
|||||||
INDEX_SWITCH_MODES = ["pick_input", "route_output"]
|
INDEX_SWITCH_MODES = ["pick_input", "route_output"]
|
||||||
INDEX_SWITCH_BASES = ["one_based", "zero_based"]
|
INDEX_SWITCH_BASES = ["one_based", "zero_based"]
|
||||||
INDEX_SWITCH_MISSING_BEHAVIORS = ["fallback", "none", "clamp", "wrap"]
|
INDEX_SWITCH_MISSING_BEHAVIORS = ["fallback", "none", "clamp", "wrap"]
|
||||||
|
PREVIEW_TEXT_FORMATS = ["auto", "json", "repr", "str"]
|
||||||
|
|
||||||
_ACCUMULATOR_STORES: dict[str, list[dict[str, Any]]] = {}
|
_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
|
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:
|
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]
|
images = [entry.get("image") for entry in store if entry.get("image") is not None]
|
||||||
shapes = []
|
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:
|
class SxCPForLoopEnd:
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
@@ -1320,6 +1420,7 @@ LOOP_NODE_CLASS_MAPPINGS = {
|
|||||||
"SxCPIndexSwitch": SxCPIndexSwitch,
|
"SxCPIndexSwitch": SxCPIndexSwitch,
|
||||||
"SxCPAccumulator": SxCPAccumulator,
|
"SxCPAccumulator": SxCPAccumulator,
|
||||||
"SxCPAccumulatorPreview": SxCPAccumulatorPreview,
|
"SxCPAccumulatorPreview": SxCPAccumulatorPreview,
|
||||||
|
"SxCPPreviewAnyAsText": SxCPPreviewAnyAsText,
|
||||||
"SxCPLoopIntAdd": SxCPLoopIntAdd,
|
"SxCPLoopIntAdd": SxCPLoopIntAdd,
|
||||||
"SxCPLoopLessThan": SxCPLoopLessThan,
|
"SxCPLoopLessThan": SxCPLoopLessThan,
|
||||||
"SxCPLoopLessThanOrEqual": SxCPLoopLessThanOrEqual,
|
"SxCPLoopLessThanOrEqual": SxCPLoopLessThanOrEqual,
|
||||||
@@ -1334,6 +1435,7 @@ LOOP_NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"SxCPIndexSwitch": "SxCP Index Switch",
|
"SxCPIndexSwitch": "SxCP Index Switch",
|
||||||
"SxCPAccumulator": "SxCP Accumulator",
|
"SxCPAccumulator": "SxCP Accumulator",
|
||||||
"SxCPAccumulatorPreview": "SxCP Accumulator Preview",
|
"SxCPAccumulatorPreview": "SxCP Accumulator Preview",
|
||||||
|
"SxCPPreviewAnyAsText": "SxCP Preview Any As Text",
|
||||||
"SxCPLoopIntAdd": "SxCP Loop Int Add",
|
"SxCPLoopIntAdd": "SxCP Loop Int Add",
|
||||||
"SxCPLoopLessThan": "SxCP Loop Less Than",
|
"SxCPLoopLessThan": "SxCP Loop Less Than",
|
||||||
"SxCPLoopLessThanOrEqual": "SxCP Loop Less Than Or Equal",
|
"SxCPLoopLessThanOrEqual": "SxCP Loop Less Than Or Equal",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user