diff --git a/README.md b/README.md index c3429d0..ecc1a10 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__init__.py b/__init__.py index 7b8e867..340146c 100644 --- a/__init__.py +++ b/__init__.py @@ -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.", diff --git a/loop_nodes.py b/loop_nodes.py index f4ccd73..6571b84 100644 --- a/loop_nodes.py +++ b/loop_nodes.py @@ -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", diff --git a/web/preview_any_text.js b/web/preview_any_text.js new file mode 100644 index 0000000..cfe84ed --- /dev/null +++ b/web/preview_any_text.js @@ -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); + }); + }, +});