- Deferred output cleanup (_configured flag + queueMicrotask) to prevent breaking links when other nodes (e.g. Kijai Set/Get) resolve outputs during graph loading - file_not_found error handling in refresh to keep existing outputs intact - Fallback widget sync in onConfigure when widget values are empty but serialized outputs exist Applied to both json_dynamic.js and project_dynamic.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
387 lines
16 KiB
Python
387 lines
16 KiB
Python
import json
|
|
import os
|
|
import logging
|
|
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:
|
|
try:
|
|
return float(val)
|
|
except (ValueError, TypeError):
|
|
return 0.0
|
|
|
|
def to_int(val: Any) -> int:
|
|
try:
|
|
return int(float(val))
|
|
except (ValueError, TypeError):
|
|
return 0
|
|
|
|
def get_batch_item(data: dict[str, Any], sequence_number: int) -> dict[str, Any]:
|
|
"""Resolve batch item by sequence_number field, falling back to array index."""
|
|
if KEY_BATCH_DATA in data and isinstance(data[KEY_BATCH_DATA], list) and len(data[KEY_BATCH_DATA]) > 0:
|
|
# Search by sequence_number field first
|
|
for item in data[KEY_BATCH_DATA]:
|
|
if int(item.get("sequence_number", 0)) == sequence_number:
|
|
return item
|
|
# Fallback to array index
|
|
idx = max(0, min(sequence_number - 1, len(data[KEY_BATCH_DATA]) - 1))
|
|
logger.warning(f"No item with sequence_number={sequence_number}, falling back to index {idx}")
|
|
return data[KEY_BATCH_DATA][idx]
|
|
return data
|
|
|
|
# --- Shared Helper ---
|
|
def read_json_data(json_path: str) -> dict[str, Any]:
|
|
if not os.path.exists(json_path):
|
|
logger.warning(f"File not found at {json_path}")
|
|
return {}
|
|
try:
|
|
with open(json_path, 'r') as f:
|
|
data = json.load(f)
|
|
except (json.JSONDecodeError, IOError) as e:
|
|
logger.warning(f"Error reading {json_path}: {e}")
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
logger.warning(f"Expected dict from {json_path}, got {type(data).__name__}")
|
|
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)
|
|
if not data:
|
|
return web.json_response({"keys": [], "types": [], "error": "file_not_found"})
|
|
target = get_batch_item(data, seq)
|
|
keys = []
|
|
types = []
|
|
if isinstance(target, dict):
|
|
for k, v in target.items():
|
|
keys.append(k)
|
|
if isinstance(v, bool):
|
|
types.append("STRING")
|
|
elif isinstance(v, int):
|
|
types.append("INT")
|
|
elif isinstance(v, float):
|
|
types.append("FLOAT")
|
|
else:
|
|
types.append("STRING")
|
|
return web.json_response({"keys": keys, "types": types})
|
|
|
|
|
|
# ==========================================
|
|
# 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": ""}),
|
|
"output_types": ("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="", output_types=""):
|
|
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)
|
|
# ==========================================
|
|
|
|
class JSONLoaderLoRA:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False})}}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("lora_1_high", "lora_1_low", "lora_2_high", "lora_2_low", "lora_3_high", "lora_3_low")
|
|
FUNCTION = "load_loras"
|
|
CATEGORY = "utils/json"
|
|
|
|
def load_loras(self, json_path):
|
|
data = read_json_data(json_path)
|
|
return (
|
|
str(data.get("lora 1 high", "")), str(data.get("lora 1 low", "")),
|
|
str(data.get("lora 2 high", "")), str(data.get("lora 2 low", "")),
|
|
str(data.get("lora 3 high", "")), str(data.get("lora 3 low", ""))
|
|
)
|
|
|
|
class JSONLoaderStandard:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False})}}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "FLOAT", "INT", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("general_prompt", "general_negative", "current_prompt", "negative", "camera", "flf", "seed", "video_file_path", "reference_image_path", "flf_image_path")
|
|
FUNCTION = "load_standard"
|
|
CATEGORY = "utils/json"
|
|
|
|
def load_standard(self, json_path):
|
|
data = read_json_data(json_path)
|
|
return (
|
|
str(data.get("general_prompt", "")), str(data.get("general_negative", "")),
|
|
str(data.get("current_prompt", "")), str(data.get("negative", "")),
|
|
str(data.get("camera", "")), to_float(data.get("flf", 0.0)),
|
|
to_int(data.get("seed", 0)), str(data.get("video file path", "")),
|
|
str(data.get("reference image path", "")), str(data.get("flf image path", ""))
|
|
)
|
|
|
|
class JSONLoaderVACE:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False})}}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "FLOAT", "INT", "INT", "INT", "INT", "STRING", "INT", "INT", "STRING", "STRING")
|
|
RETURN_NAMES = ("general_prompt", "general_negative", "current_prompt", "negative", "camera", "flf", "seed", "frame_to_skip", "input_a_frames", "input_b_frames", "reference_path", "reference_switch", "vace_schedule", "video_file_path", "reference_image_path")
|
|
FUNCTION = "load_vace"
|
|
CATEGORY = "utils/json"
|
|
|
|
def load_vace(self, json_path):
|
|
data = read_json_data(json_path)
|
|
return (
|
|
str(data.get("general_prompt", "")), str(data.get("general_negative", "")),
|
|
str(data.get("current_prompt", "")), str(data.get("negative", "")),
|
|
str(data.get("camera", "")), to_float(data.get("flf", 0.0)),
|
|
to_int(data.get("seed", 0)),
|
|
to_int(data.get("frame_to_skip", 81)), to_int(data.get("input_a_frames", 16)),
|
|
to_int(data.get("input_b_frames", 16)), str(data.get("reference path", "")),
|
|
to_int(data.get("reference switch", 1)), to_int(data.get("vace schedule", 1)),
|
|
str(data.get("video file path", "")), str(data.get("reference image path", ""))
|
|
)
|
|
|
|
# ==========================================
|
|
# 2. BATCH NODES
|
|
# ==========================================
|
|
|
|
class JSONLoaderBatchLoRA:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False}), "sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999})}}
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("lora_1_high", "lora_1_low", "lora_2_high", "lora_2_low", "lora_3_high", "lora_3_low")
|
|
FUNCTION = "load_batch_loras"
|
|
CATEGORY = "utils/json"
|
|
|
|
def load_batch_loras(self, json_path, sequence_number):
|
|
data = read_json_data(json_path)
|
|
target_data = get_batch_item(data, sequence_number)
|
|
return (
|
|
str(target_data.get("lora 1 high", "")), str(target_data.get("lora 1 low", "")),
|
|
str(target_data.get("lora 2 high", "")), str(target_data.get("lora 2 low", "")),
|
|
str(target_data.get("lora 3 high", "")), str(target_data.get("lora 3 low", ""))
|
|
)
|
|
|
|
class JSONLoaderBatchI2V:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False}), "sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999})}}
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "FLOAT", "INT", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("general_prompt", "general_negative", "current_prompt", "negative", "camera", "flf", "seed", "video_file_path", "reference_image_path", "flf_image_path")
|
|
FUNCTION = "load_batch_i2v"
|
|
CATEGORY = "utils/json"
|
|
|
|
def load_batch_i2v(self, json_path, sequence_number):
|
|
data = read_json_data(json_path)
|
|
target_data = get_batch_item(data, sequence_number)
|
|
|
|
return (
|
|
str(target_data.get("general_prompt", "")), str(target_data.get("general_negative", "")),
|
|
str(target_data.get("current_prompt", "")), str(target_data.get("negative", "")),
|
|
str(target_data.get("camera", "")), to_float(target_data.get("flf", 0.0)),
|
|
to_int(target_data.get("seed", 0)), str(target_data.get("video file path", "")),
|
|
str(target_data.get("reference image path", "")), str(target_data.get("flf image path", ""))
|
|
)
|
|
|
|
class JSONLoaderBatchVACE:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {"required": {"json_path": ("STRING", {"default": "", "multiline": False}), "sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999})}}
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "FLOAT", "INT", "INT", "INT", "INT", "STRING", "INT", "INT", "STRING", "STRING")
|
|
RETURN_NAMES = ("general_prompt", "general_negative", "current_prompt", "negative", "camera", "flf", "seed", "frame_to_skip", "input_a_frames", "input_b_frames", "reference_path", "reference_switch", "vace_schedule", "video_file_path", "reference_image_path")
|
|
FUNCTION = "load_batch_vace"
|
|
CATEGORY = "utils/json"
|
|
|
|
def load_batch_vace(self, json_path, sequence_number):
|
|
data = read_json_data(json_path)
|
|
target_data = get_batch_item(data, sequence_number)
|
|
|
|
return (
|
|
str(target_data.get("general_prompt", "")), str(target_data.get("general_negative", "")),
|
|
str(target_data.get("current_prompt", "")), str(target_data.get("negative", "")),
|
|
str(target_data.get("camera", "")), to_float(target_data.get("flf", 0.0)),
|
|
to_int(target_data.get("seed", 0)), to_int(target_data.get("frame_to_skip", 81)),
|
|
to_int(target_data.get("input_a_frames", 16)), to_int(target_data.get("input_b_frames", 16)),
|
|
str(target_data.get("reference path", "")), to_int(target_data.get("reference switch", 1)),
|
|
to_int(target_data.get("vace schedule", 1)), str(target_data.get("video file path", "")),
|
|
str(target_data.get("reference image path", ""))
|
|
)
|
|
|
|
# ==========================================
|
|
# 3. UNIVERSAL CUSTOM NODES (1, 3, 6 Slots)
|
|
# ==========================================
|
|
|
|
class JSONLoaderCustom1:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"json_path": ("STRING", {"default": "", "multiline": False}),
|
|
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
|
},
|
|
"optional": { "key_1": ("STRING", {"default": "", "multiline": False}) }
|
|
}
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("val_1",)
|
|
FUNCTION = "load_custom"
|
|
CATEGORY = "utils/json"
|
|
|
|
def load_custom(self, json_path, sequence_number, key_1=""):
|
|
data = read_json_data(json_path)
|
|
target_data = get_batch_item(data, sequence_number)
|
|
return (str(target_data.get(key_1, "")),)
|
|
|
|
class JSONLoaderCustom3:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"json_path": ("STRING", {"default": "", "multiline": False}),
|
|
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
|
},
|
|
"optional": {
|
|
"key_1": ("STRING", {"default": "", "multiline": False}),
|
|
"key_2": ("STRING", {"default": "", "multiline": False}),
|
|
"key_3": ("STRING", {"default": "", "multiline": False})
|
|
}
|
|
}
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("val_1", "val_2", "val_3")
|
|
FUNCTION = "load_custom"
|
|
CATEGORY = "utils/json"
|
|
|
|
def load_custom(self, json_path, sequence_number, key_1="", key_2="", key_3=""):
|
|
data = read_json_data(json_path)
|
|
target_data = get_batch_item(data, sequence_number)
|
|
return (
|
|
str(target_data.get(key_1, "")),
|
|
str(target_data.get(key_2, "")),
|
|
str(target_data.get(key_3, ""))
|
|
)
|
|
|
|
class JSONLoaderCustom6:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"json_path": ("STRING", {"default": "", "multiline": False}),
|
|
"sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
|
},
|
|
"optional": {
|
|
"key_1": ("STRING", {"default": "", "multiline": False}),
|
|
"key_2": ("STRING", {"default": "", "multiline": False}),
|
|
"key_3": ("STRING", {"default": "", "multiline": False}),
|
|
"key_4": ("STRING", {"default": "", "multiline": False}),
|
|
"key_5": ("STRING", {"default": "", "multiline": False}),
|
|
"key_6": ("STRING", {"default": "", "multiline": False})
|
|
}
|
|
}
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("val_1", "val_2", "val_3", "val_4", "val_5", "val_6")
|
|
FUNCTION = "load_custom"
|
|
CATEGORY = "utils/json"
|
|
|
|
def load_custom(self, json_path, sequence_number, key_1="", key_2="", key_3="", key_4="", key_5="", key_6=""):
|
|
data = read_json_data(json_path)
|
|
target_data = get_batch_item(data, sequence_number)
|
|
return (
|
|
str(target_data.get(key_1, "")), str(target_data.get(key_2, "")),
|
|
str(target_data.get(key_3, "")), str(target_data.get(key_4, "")),
|
|
str(target_data.get(key_5, "")), str(target_data.get(key_6, ""))
|
|
)
|
|
|
|
# --- Mappings ---
|
|
NODE_CLASS_MAPPINGS = {
|
|
"JSONLoaderDynamic": JSONLoaderDynamic,
|
|
"JSONLoaderLoRA": JSONLoaderLoRA,
|
|
"JSONLoaderStandard": JSONLoaderStandard,
|
|
"JSONLoaderVACE": JSONLoaderVACE,
|
|
"JSONLoaderBatchLoRA": JSONLoaderBatchLoRA,
|
|
"JSONLoaderBatchI2V": JSONLoaderBatchI2V,
|
|
"JSONLoaderBatchVACE": JSONLoaderBatchVACE,
|
|
"JSONLoaderCustom1": JSONLoaderCustom1,
|
|
"JSONLoaderCustom3": JSONLoaderCustom3,
|
|
"JSONLoaderCustom6": JSONLoaderCustom6
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"JSONLoaderDynamic": "JSON Loader (Dynamic)",
|
|
"JSONLoaderLoRA": "JSON Loader (LoRAs Only)",
|
|
"JSONLoaderStandard": "JSON Loader (Standard/I2V)",
|
|
"JSONLoaderVACE": "JSON Loader (VACE Full)",
|
|
"JSONLoaderBatchLoRA": "JSON Batch Loader (LoRAs)",
|
|
"JSONLoaderBatchI2V": "JSON Batch Loader (I2V)",
|
|
"JSONLoaderBatchVACE": "JSON Batch Loader (VACE)",
|
|
"JSONLoaderCustom1": "JSON Loader (Custom 1)",
|
|
"JSONLoaderCustom3": "JSON Loader (Custom 3)",
|
|
"JSONLoaderCustom6": "JSON Loader (Custom 6)"
|
|
}
|