Auto-generated nodes for the entire live fal catalog (1,391 models → 1,482 total nodes) built at startup from a committed registry, plus a full rewrite of the core layer. All 87 v1 node keys, inputs and outputs are preserved — existing workflows load and run unchanged. New: - data/fal_registry.json + scripts/build_registry.py: distills fal's catalog and per-endpoint OpenAPI schemas into a deterministic registry - nodes/dynamic/: schema→node factory (typed widgets, tooltips, pricing in node help, IMAGE/VIDEO/AUDIO auto-upload, native VIDEO/AUDIO outputs, seed -1=random, force_rerun), plus a generic Fal Any Endpoint node for arbitrary endpoints - nodes/utils/: rewritten core — FalApiError surfacing fal's real error payloads, queue progress logs, ComfyUI cancel support, retries and timeouts everywhere, parallel uploads/downloads, structured logging - tests/ (45 tests incl. a legacy-key snapshot lock), CI lint+test workflow, weekly registry-refresh workflow that opens a PR on changes - README regenerated from the registry (scripts/build_readme.py) Fixed: - GPTImage2 / GPTImage2Edit were defined but never registered - Topaz video upscale sent a nonexistent API field (desired_increase) - SeedVR video upscaler had its error handling commented out + wrong model label; ProRes enum value mismatch translated at argument level - 10x `is ""` comparisons, bare excepts, missing HTTP timeouts, temp-file leaks, fps=0 crash in LoadVideoURL, duplicate enum entry - opencv-python & friends missing from pyproject (Registry installs) - API failures now raise visible errors instead of silently returning blank images or "Error:" strings that downstream nodes tried to load BREAKING (behavioral only): failed API calls raise instead of returning blank/black images or error strings. Node signatures are unchanged.
79 lines
3.1 KiB
Python
79 lines
3.1 KiB
Python
"""Validate the committed model registry — pure JSON, no heavy imports."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
REGISTRY = Path(__file__).resolve().parents[1] / "data" / "fal_registry.json"
|
|
|
|
VALID_INPUT_TYPES = {"string", "integer", "number", "boolean", "enum", "object", "array", "json"}
|
|
VALID_OUTPUT_KINDS = {"images", "image", "video", "audio", "text", "file", "json"}
|
|
VALID_MEDIA_KINDS = {None, "image", "video", "audio", "file"}
|
|
|
|
|
|
def _registry():
|
|
return json.loads(REGISTRY.read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_top_level_shape():
|
|
reg = _registry()
|
|
assert reg["version"] == 1
|
|
assert reg["model_count"] == len(reg["models"])
|
|
assert reg["model_count"] > 500
|
|
|
|
|
|
def test_models_well_formed():
|
|
reg = _registry()
|
|
seen_ids = set()
|
|
for model in reg["models"]:
|
|
eid = model["endpoint_id"]
|
|
assert eid and "/" in eid, f"bad endpoint_id: {eid!r}"
|
|
assert eid not in seen_ids, f"duplicate endpoint_id: {eid}"
|
|
seen_ids.add(eid)
|
|
assert model["title"], f"{eid}: missing title"
|
|
assert model["category"], f"{eid}: missing category"
|
|
assert model["output_kind"] in VALID_OUTPUT_KINDS, f"{eid}: {model['output_kind']}"
|
|
assert isinstance(model["inputs"], list)
|
|
|
|
|
|
def test_inputs_well_formed():
|
|
reg = _registry()
|
|
for model in reg["models"]:
|
|
eid = model["endpoint_id"]
|
|
names = set()
|
|
for inp in model["inputs"]:
|
|
name = inp["name"]
|
|
assert name not in names, f"{eid}: duplicate input {name}"
|
|
names.add(name)
|
|
assert inp["type"] in VALID_INPUT_TYPES, f"{eid}.{name}: {inp['type']}"
|
|
assert inp.get("media_kind") in VALID_MEDIA_KINDS, f"{eid}.{name}"
|
|
if inp["type"] == "enum":
|
|
assert inp.get("enum"), f"{eid}.{name}: enum without values"
|
|
|
|
|
|
def test_enum_defaults_are_members_or_custom_size():
|
|
reg = _registry()
|
|
for model in reg["models"]:
|
|
for inp in model["inputs"]:
|
|
if inp["type"] == "enum" and inp.get("default") is not None:
|
|
if inp["default"] in inp["enum"]:
|
|
continue
|
|
# two legitimate non-member shapes exist in the wild:
|
|
# 1. has_custom_size enums defaulting to an explicit
|
|
# {width, height} object (mapped to the custom_size preset)
|
|
# 2. multi-select enums (is_list) defaulting to a list of
|
|
# members (mapped to a comma-separated string widget)
|
|
if inp.get("has_custom_size") and isinstance(inp["default"], dict):
|
|
continue
|
|
if inp.get("is_list") and isinstance(inp["default"], list):
|
|
assert all(v in inp["enum"] for v in inp["default"]), (
|
|
f"{model['endpoint_id']}.{inp['name']}: list default "
|
|
f"contains non-members"
|
|
)
|
|
continue
|
|
raise AssertionError(
|
|
f"{model['endpoint_id']}.{inp['name']}: default "
|
|
f"{inp['default']!r} not in enum"
|
|
)
|