From e841e9b76be592117a7f6c2d4e42d9a354b16dd8 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 23 Feb 2026 15:47:22 +0100 Subject: [PATCH] Add JSONLoaderDynamic node with JS frontend for auto-discovered outputs Dynamic node reads JSON keys and exposes them as outputs automatically via 32 AnyType slots managed by a JS extension (show/hide/rename). Includes /json_manager/get_keys API route, bool-safe type handling, and workflow save/reload support. Co-Authored-By: Claude Opus 4.6 --- __init__.py | 4 +- json_loader.py | 81 +++++++++++++++++++++++ tests/test_json_loader.py | 90 +++++++++++++++++++++++++- web/json_dynamic.js | 133 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 web/json_dynamic.js diff --git a/__init__.py b/__init__.py index 012f698..43198c8 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,5 @@ from .json_loader import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS -__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] +WEB_DIRECTORY = "./web" + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] diff --git a/json_loader.py b/json_loader.py index ecc9487..91475ff 100644 --- a/json_loader.py +++ b/json_loader.py @@ -6,6 +6,22 @@ from typing import Any logger = logging.getLogger(__name__) KEY_BATCH_DATA = "batch_data" +MAX_DYNAMIC_OUTPUTS = 32 + + +class AnyType(str): + """Universal connector type that matches any ComfyUI type.""" + def __ne__(self, __value: object) -> bool: + return False + +any_type = AnyType("*") + + +try: + from server import PromptServer + from aiohttp import web +except ImportError: + PromptServer = None def to_float(val: Any) -> float: @@ -49,6 +65,69 @@ def read_json_data(json_path: str) -> dict[str, Any]: return {} return data +# --- API Route --- +if PromptServer is not None: + @PromptServer.instance.routes.get("/json_manager/get_keys") + async def get_keys_route(request): + json_path = request.query.get("path", "") + try: + seq = int(request.query.get("sequence_number", "1")) + except (ValueError, TypeError): + seq = 1 + data = read_json_data(json_path) + target = get_batch_item(data, seq) + keys = list(target.keys()) if isinstance(target, dict) else [] + return web.json_response({"keys": keys}) + + +# ========================================== +# 0. DYNAMIC NODE +# ========================================== + +class JSONLoaderDynamic: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "json_path": ("STRING", {"default": "", "multiline": False}), + "sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}), + }, + "optional": { + "output_keys": ("STRING", {"default": ""}), + }, + } + + RETURN_TYPES = tuple(any_type for _ in range(MAX_DYNAMIC_OUTPUTS)) + RETURN_NAMES = tuple(f"output_{i}" for i in range(MAX_DYNAMIC_OUTPUTS)) + FUNCTION = "load_dynamic" + CATEGORY = "utils/json" + OUTPUT_NODE = False + + def load_dynamic(self, json_path, sequence_number, output_keys=""): + data = read_json_data(json_path) + target = get_batch_item(data, sequence_number) + + keys = [k.strip() for k in output_keys.split(",") if k.strip()] if output_keys else [] + + results = [] + for key in keys: + val = target.get(key, "") + if isinstance(val, bool): + results.append(str(val).lower()) + elif isinstance(val, int): + results.append(val) + elif isinstance(val, float): + results.append(val) + else: + results.append(str(val)) + + # Pad to MAX_DYNAMIC_OUTPUTS + while len(results) < MAX_DYNAMIC_OUTPUTS: + results.append("") + + return tuple(results) + + # ========================================== # 1. STANDARD NODES (Single File) # ========================================== @@ -270,6 +349,7 @@ class JSONLoaderCustom6: # --- Mappings --- NODE_CLASS_MAPPINGS = { + "JSONLoaderDynamic": JSONLoaderDynamic, "JSONLoaderLoRA": JSONLoaderLoRA, "JSONLoaderStandard": JSONLoaderStandard, "JSONLoaderVACE": JSONLoaderVACE, @@ -282,6 +362,7 @@ NODE_CLASS_MAPPINGS = { } NODE_DISPLAY_NAME_MAPPINGS = { + "JSONLoaderDynamic": "JSON Loader (Dynamic)", "JSONLoaderLoRA": "JSON Loader (LoRAs Only)", "JSONLoaderStandard": "JSON Loader (Standard/I2V)", "JSONLoaderVACE": "JSON Loader (VACE Full)", diff --git a/tests/test_json_loader.py b/tests/test_json_loader.py index 2a02690..2729e4d 100644 --- a/tests/test_json_loader.py +++ b/tests/test_json_loader.py @@ -3,7 +3,10 @@ import os import pytest -from json_loader import to_float, to_int, get_batch_item, read_json_data +from json_loader import ( + to_float, to_int, get_batch_item, read_json_data, + JSONLoaderDynamic, MAX_DYNAMIC_OUTPUTS, +) class TestToFloat: @@ -75,3 +78,88 @@ class TestReadJsonData: p = tmp_path / "ok.json" p.write_text(json.dumps({"key": "val"})) assert read_json_data(str(p)) == {"key": "val"} + + +class TestJSONLoaderDynamic: + def _make_json(self, tmp_path, data): + p = tmp_path / "test.json" + p.write_text(json.dumps(data)) + return str(p) + + def test_known_keys(self, tmp_path): + path = self._make_json(tmp_path, {"name": "alice", "age": 30, "score": 9.5}) + loader = JSONLoaderDynamic() + result = loader.load_dynamic(path, 1, output_keys="name,age,score") + assert result[0] == "alice" + assert result[1] == 30 + assert result[2] == 9.5 + + def test_empty_output_keys(self, tmp_path): + path = self._make_json(tmp_path, {"name": "alice"}) + loader = JSONLoaderDynamic() + result = loader.load_dynamic(path, 1, output_keys="") + assert len(result) == MAX_DYNAMIC_OUTPUTS + assert all(v == "" for v in result) + + def test_pads_to_max(self, tmp_path): + path = self._make_json(tmp_path, {"a": "1", "b": "2"}) + loader = JSONLoaderDynamic() + result = loader.load_dynamic(path, 1, output_keys="a,b") + assert len(result) == MAX_DYNAMIC_OUTPUTS + assert result[0] == "1" + assert result[1] == "2" + assert all(v == "" for v in result[2:]) + + def test_type_preservation_int(self, tmp_path): + path = self._make_json(tmp_path, {"count": 42}) + loader = JSONLoaderDynamic() + result = loader.load_dynamic(path, 1, output_keys="count") + assert result[0] == 42 + assert isinstance(result[0], int) + + def test_type_preservation_float(self, tmp_path): + path = self._make_json(tmp_path, {"rate": 3.14}) + loader = JSONLoaderDynamic() + result = loader.load_dynamic(path, 1, output_keys="rate") + assert result[0] == 3.14 + assert isinstance(result[0], float) + + def test_type_preservation_str(self, tmp_path): + path = self._make_json(tmp_path, {"label": "hello"}) + loader = JSONLoaderDynamic() + result = loader.load_dynamic(path, 1, output_keys="label") + assert result[0] == "hello" + assert isinstance(result[0], str) + + def test_bool_becomes_string(self, tmp_path): + path = self._make_json(tmp_path, {"flag": True, "off": False}) + loader = JSONLoaderDynamic() + result = loader.load_dynamic(path, 1, output_keys="flag,off") + assert result[0] == "true" + assert result[1] == "false" + assert isinstance(result[0], str) + + def test_missing_key_returns_empty_string(self, tmp_path): + path = self._make_json(tmp_path, {"a": "1"}) + loader = JSONLoaderDynamic() + result = loader.load_dynamic(path, 1, output_keys="a,nonexistent") + assert result[0] == "1" + assert result[1] == "" + + def test_missing_file_returns_all_empty(self, tmp_path): + loader = JSONLoaderDynamic() + result = loader.load_dynamic(str(tmp_path / "nope.json"), 1, output_keys="a,b") + assert len(result) == MAX_DYNAMIC_OUTPUTS + assert result[0] == "" + assert result[1] == "" + + def test_batch_data(self, tmp_path): + path = self._make_json(tmp_path, { + "batch_data": [ + {"sequence_number": 1, "x": "first"}, + {"sequence_number": 2, "x": "second"}, + ] + }) + loader = JSONLoaderDynamic() + result = loader.load_dynamic(path, 2, output_keys="x") + assert result[0] == "second" diff --git a/web/json_dynamic.js b/web/json_dynamic.js new file mode 100644 index 0000000..af6773f --- /dev/null +++ b/web/json_dynamic.js @@ -0,0 +1,133 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +app.registerExtension({ + name: "json.manager.dynamic", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name !== "JSONLoaderDynamic") return; + + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + origOnNodeCreated?.apply(this, arguments); + + // Hide the output_keys widget (managed internally by JS) + const okWidget = this.widgets?.find(w => w.name === "output_keys"); + if (okWidget) { + okWidget.type = "hidden"; + okWidget.computeSize = () => [0, -4]; + } + + // Hide all 32 outputs initially + this._dynamicOutputCount = 0; + for (let i = 0; i < this.outputs.length; i++) { + this.outputs[i]._visible = false; + } + + // Add Refresh button + this.addWidget("button", "Refresh Outputs", null, () => { + this.refreshDynamicOutputs(); + }); + + // Store original outputs array for show/hide + this._allOutputs = [...this.outputs]; + // Start with no visible outputs + this.outputs.length = 0; + }; + + nodeType.prototype.refreshDynamicOutputs = async function () { + const pathWidget = this.widgets?.find(w => w.name === "json_path"); + const seqWidget = this.widgets?.find(w => w.name === "sequence_number"); + if (!pathWidget?.value) return; + + try { + const resp = await api.fetchApi( + `/json_manager/get_keys?path=${encodeURIComponent(pathWidget.value)}&sequence_number=${seqWidget?.value || 1}` + ); + const { keys } = await resp.json(); + + // Update output_keys widget for Python to read + const okWidget = this.widgets?.find(w => w.name === "output_keys"); + if (okWidget) okWidget.value = keys.join(","); + + // Restore full outputs array to manipulate + if (this._allOutputs) { + this.outputs = this._allOutputs; + } + + // Disconnect any links on outputs that will be hidden + for (let i = keys.length; i < 32; i++) { + if (this.outputs[i]?.links?.length) { + for (const linkId of [...this.outputs[i].links]) { + this.graph?.removeLink(linkId); + } + } + } + + // Rename visible outputs to key names + for (let i = 0; i < 32; i++) { + if (i < keys.length) { + this.outputs[i].name = keys[i]; + this.outputs[i]._visible = true; + } else { + this.outputs[i].name = `output_${i}`; + this.outputs[i]._visible = false; + } + } + + // Truncate outputs array to only show active ones + this._dynamicOutputCount = keys.length; + this._allOutputs = [...this.outputs]; + this.outputs.length = keys.length; + + this.setSize(this.computeSize()); + app.graph.setDirtyCanvas(true, true); + } catch (e) { + console.error("[JSONLoaderDynamic] Refresh failed:", e); + } + }; + + // Override configure to restore state on workflow load + const origOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + origOnConfigure?.apply(this, arguments); + + const okWidget = this.widgets?.find(w => w.name === "output_keys"); + if (okWidget) { + okWidget.type = "hidden"; + okWidget.computeSize = () => [0, -4]; + } + + const keys = okWidget?.value + ? okWidget.value.split(",").filter(k => k.trim()) + : []; + + // Ensure we have the full 32 outputs stored + this._allOutputs = [...this.outputs]; + while (this._allOutputs.length < 32) { + this._allOutputs.push({ + name: `output_${this._allOutputs.length}`, + type: "*", + links: null, + _visible: false, + }); + } + + // Rename and set visibility + for (let i = 0; i < 32; i++) { + if (i < keys.length) { + this._allOutputs[i].name = keys[i].trim(); + this._allOutputs[i]._visible = true; + } else { + this._allOutputs[i].name = `output_${i}`; + this._allOutputs[i]._visible = false; + } + } + + this._dynamicOutputCount = keys.length; + this.outputs = this._allOutputs.slice(0, keys.length); + + this.setSize(this.computeSize()); + }; + }, +});