- Fix NameError: pass state to _render_vace_settings (tab_batch_ng.py) - Fix non-atomic sync_to_db: use BEGIN IMMEDIATE transaction with rollback - Fix create_secondary() missing db/current_project/db_enabled fields - Fix URL encoding: percent-encode project/file names in API URLs - Fix import_json_file crash on re-import: upsert instead of insert - Fix dual DB instances: share single ProjectDB between UI and API routes - Also fixes top_level metadata never being updated on existing data_files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
262 lines
10 KiB
Python
262 lines
10 KiB
Python
import json
|
|
import logging
|
|
import urllib.parse
|
|
import urllib.request
|
|
import urllib.error
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
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 _fetch_json(url: str) -> dict:
|
|
"""Fetch JSON from a URL using stdlib urllib."""
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=5) as resp:
|
|
return json.loads(resp.read())
|
|
except (urllib.error.URLError, json.JSONDecodeError, OSError) as e:
|
|
logger.warning(f"Failed to fetch {url}: {e}")
|
|
return {}
|
|
|
|
|
|
def _fetch_data(manager_url: str, project: str, file: str, seq: int) -> dict:
|
|
"""Fetch sequence data from the NiceGUI REST API."""
|
|
p = urllib.parse.quote(project, safe='')
|
|
f = urllib.parse.quote(file, safe='')
|
|
url = f"{manager_url.rstrip('/')}/api/projects/{p}/files/{f}/data?seq={seq}"
|
|
return _fetch_json(url)
|
|
|
|
|
|
def _fetch_keys(manager_url: str, project: str, file: str, seq: int) -> dict:
|
|
"""Fetch keys/types from the NiceGUI REST API."""
|
|
p = urllib.parse.quote(project, safe='')
|
|
f = urllib.parse.quote(file, safe='')
|
|
url = f"{manager_url.rstrip('/')}/api/projects/{p}/files/{f}/keys?seq={seq}"
|
|
return _fetch_json(url)
|
|
|
|
|
|
# --- ComfyUI-side proxy endpoints (for frontend JS) ---
|
|
if PromptServer is not None:
|
|
@PromptServer.instance.routes.get("/json_manager/list_projects")
|
|
async def list_projects_proxy(request):
|
|
manager_url = request.query.get("url", "http://localhost:8080")
|
|
url = f"{manager_url.rstrip('/')}/api/projects"
|
|
data = _fetch_json(url)
|
|
return web.json_response(data)
|
|
|
|
@PromptServer.instance.routes.get("/json_manager/list_project_files")
|
|
async def list_project_files_proxy(request):
|
|
manager_url = request.query.get("url", "http://localhost:8080")
|
|
project = urllib.parse.quote(request.query.get("project", ""), safe='')
|
|
url = f"{manager_url.rstrip('/')}/api/projects/{project}/files"
|
|
data = _fetch_json(url)
|
|
return web.json_response(data)
|
|
|
|
@PromptServer.instance.routes.get("/json_manager/list_project_sequences")
|
|
async def list_project_sequences_proxy(request):
|
|
manager_url = request.query.get("url", "http://localhost:8080")
|
|
project = urllib.parse.quote(request.query.get("project", ""), safe='')
|
|
file_name = urllib.parse.quote(request.query.get("file", ""), safe='')
|
|
url = f"{manager_url.rstrip('/')}/api/projects/{project}/files/{file_name}/sequences"
|
|
data = _fetch_json(url)
|
|
return web.json_response(data)
|
|
|
|
@PromptServer.instance.routes.get("/json_manager/get_project_keys")
|
|
async def get_project_keys_proxy(request):
|
|
manager_url = request.query.get("url", "http://localhost:8080")
|
|
project = request.query.get("project", "")
|
|
file_name = request.query.get("file", "")
|
|
try:
|
|
seq = int(request.query.get("seq", "1"))
|
|
except (ValueError, TypeError):
|
|
seq = 1
|
|
data = _fetch_keys(manager_url, project, file_name, seq)
|
|
return web.json_response(data)
|
|
|
|
|
|
|
|
# ==========================================
|
|
# 0. DYNAMIC NODE (Project-based)
|
|
# ==========================================
|
|
|
|
class ProjectLoaderDynamic:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
|
|
"project_name": ("STRING", {"default": "", "multiline": False}),
|
|
"file_name": ("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/project"
|
|
OUTPUT_NODE = False
|
|
|
|
def load_dynamic(self, manager_url, project_name, file_name, sequence_number,
|
|
output_keys="", output_types=""):
|
|
data = _fetch_data(manager_url, project_name, file_name, sequence_number)
|
|
|
|
keys = [k.strip() for k in output_keys.split(",") if k.strip()] if output_keys else []
|
|
|
|
results = []
|
|
for key in keys:
|
|
val = data.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))
|
|
|
|
while len(results) < MAX_DYNAMIC_OUTPUTS:
|
|
results.append("")
|
|
|
|
return tuple(results)
|
|
|
|
|
|
# ==========================================
|
|
# 1. STANDARD NODE (Project-based I2V)
|
|
# ==========================================
|
|
|
|
class ProjectLoaderStandard:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {"required": {
|
|
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
|
|
"project_name": ("STRING", {"default": "", "multiline": False}),
|
|
"file_name": ("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_standard"
|
|
CATEGORY = "utils/json/project"
|
|
|
|
def load_standard(self, manager_url, project_name, file_name, sequence_number):
|
|
data = _fetch_data(manager_url, project_name, file_name, sequence_number)
|
|
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", ""))
|
|
)
|
|
|
|
|
|
# ==========================================
|
|
# 2. VACE NODE (Project-based)
|
|
# ==========================================
|
|
|
|
class ProjectLoaderVACE:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {"required": {
|
|
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
|
|
"project_name": ("STRING", {"default": "", "multiline": False}),
|
|
"file_name": ("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_vace"
|
|
CATEGORY = "utils/json/project"
|
|
|
|
def load_vace(self, manager_url, project_name, file_name, sequence_number):
|
|
data = _fetch_data(manager_url, project_name, file_name, sequence_number)
|
|
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", ""))
|
|
)
|
|
|
|
|
|
# ==========================================
|
|
# 3. LoRA NODE (Project-based)
|
|
# ==========================================
|
|
|
|
class ProjectLoaderLoRA:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {"required": {
|
|
"manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}),
|
|
"project_name": ("STRING", {"default": "", "multiline": False}),
|
|
"file_name": ("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_loras"
|
|
CATEGORY = "utils/json/project"
|
|
|
|
def load_loras(self, manager_url, project_name, file_name, sequence_number):
|
|
data = _fetch_data(manager_url, project_name, file_name, sequence_number)
|
|
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", ""))
|
|
)
|
|
|
|
|
|
# --- Mappings ---
|
|
PROJECT_NODE_CLASS_MAPPINGS = {
|
|
"ProjectLoaderDynamic": ProjectLoaderDynamic,
|
|
"ProjectLoaderStandard": ProjectLoaderStandard,
|
|
"ProjectLoaderVACE": ProjectLoaderVACE,
|
|
"ProjectLoaderLoRA": ProjectLoaderLoRA,
|
|
}
|
|
|
|
PROJECT_NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"ProjectLoaderDynamic": "Project Loader (Dynamic)",
|
|
"ProjectLoaderStandard": "Project Loader (Standard/I2V)",
|
|
"ProjectLoaderVACE": "Project Loader (VACE Full)",
|
|
"ProjectLoaderLoRA": "Project Loader (LoRAs)",
|
|
}
|