Files
gokayfem-ComfyUI-fal-API/scripts/build_registry.py
T
Gökay Aydoğan 8a47f0598b feat: v2.5.0 — async execution, typed builders, featured tier, registry freshness (#80)
- Async node execution: on ComfyUI with native async support (detected
  via comfy_execution.utils, added in the same commit as async nodes),
  all dynamic nodes and Fal Any Endpoint run as coroutines — independent
  graph branches execute fal calls concurrently with no Submit/Collect
  required. Uploads/downloads/preflight run off-loop; older ComfyUI
  versions keep byte-identical sync behavior. Live-verified: two
  concurrent generations in 2.5s total.
- Typed builder nodes (FAL/Utils/Builders): 8 chainable builders
  (LoRA, embedding, ControlNet, IP-Adapter, reference image/element,
  multi-prompt shot, key-value, JSON merge) replacing JSON-by-hand for
  the 467 object-typed inputs across the catalog; shapes validated
  against live OpenAPI schemas.
- Discovery: FAL/Featured tier (data/featured_models.json, 26 flagship
  endpoints with display-name overrides), 434 models flagged as
  superseded within their family in node help, thumbnails in the
  endpoint picker.
- Registry freshness: startup delta check against the live catalog
  (logs how many models are newer than the snapshot), sidebar Registry
  section with one-click refresh (atomic registry write; restart note).
- Docs: README 1,946 → 327 lines; model tables moved to MODELS.md
  (generator retargeted; weekly refresh workflow now regenerates it);
  CONTRIBUTING.md redirects hand-written-node PRs to the registry and
  featured-list workflow.

Review fixes: spend-guard preflight moved off the event loop in the
async path; registry writes atomically via temp+rename; freshness
daemon gated off in tests; non-finite numbers rejected in FalKeyValue;
sidebar poll budget aligned with the server timeout.
2026-07-02 20:37:15 +03:00

647 lines
23 KiB
Python

#!/usr/bin/env python3
"""Build a compact registry of fal.ai model endpoints.
Distills the fal.ai model catalog plus per-endpoint OpenAPI schemas into a
single registry JSON (``data/fal_registry.json``) that a node factory can use
to auto-generate ComfyUI nodes.
Stdlib only. Usage:
python scripts/build_registry.py \
--out data/fal_registry.json \
--since-days 0 \
--catalog-cache /path/to/fal_models_all.json \
--schemas-cache /path/to/fal_schemas_recent.json
"""
import argparse
import json
import logging
import os
import time
import urllib.error
import urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta, timezone
CATALOG_URL = "https://fal.ai/api/models?page={page}&total=100"
SCHEMA_URL = "https://fal.ai/api/openapi/queue/openapi.json?endpoint_id={endpoint_id}"
USER_AGENT = "ComfyUI-fal-API-registry-builder/1.0"
FETCH_ATTEMPTS = 3
BACKOFF_BASE_SECONDS = 1.5
MAX_INPUT_PROPERTIES = 40
MAX_DESCRIPTION_CHARS = 500
MULTILINE_NAMES = frozenset({"prompt", "negative_prompt", "text", "script", "dialogue"})
MULTILINE_DESCRIPTION_THRESHOLD = 120
SKIPPED_PROPERTY_NAMES = frozenset({"sync_mode"})
FILE_OUTPUT_PROPS = frozenset({"model_glb", "model_mesh", "model_url", "model_urls", "mesh"})
logger = logging.getLogger("build_registry")
# ---------------------------------------------------------------------------
# Fetching
# ---------------------------------------------------------------------------
def fetch_json(url):
"""Fetch a URL and parse JSON, with retries and backoff.
Returns the parsed document, or None for a 404 (skip-and-log).
Raises on persistent non-404 failure.
"""
last_error = None
for attempt in range(FETCH_ATTEMPTS):
try:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
if error.code == 404:
logger.warning("404 for %s, skipping", url)
return None
last_error = error
except (urllib.error.URLError, TimeoutError, ValueError) as error:
last_error = error
time.sleep(BACKOFF_BASE_SECONDS * (2 ** attempt))
raise RuntimeError(f"Failed to fetch {url} after {FETCH_ATTEMPTS} attempts: {last_error}")
def extract_catalog_items(payload):
"""Normalize a catalog API response page into a list of items."""
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in ("items", "models", "data", "results"):
value = payload.get(key)
if isinstance(value, list):
return value
return []
def fetch_catalog():
"""Fetch all catalog pages until an empty page is returned."""
items = []
page = 1
while True:
payload = fetch_json(CATALOG_URL.format(page=page))
page_items = extract_catalog_items(payload)
if not page_items:
break
items = items + page_items
logger.info("Fetched catalog page %d (%d items)", page, len(page_items))
page += 1
return items
def fetch_schemas(endpoint_ids, max_workers):
"""Fetch OpenAPI docs for endpoint ids concurrently. Returns id -> doc."""
schemas = {}
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(fetch_json, SCHEMA_URL.format(endpoint_id=endpoint_id)): endpoint_id
for endpoint_id in endpoint_ids
}
for future in as_completed(futures):
endpoint_id = futures[future]
try:
doc = future.result()
except RuntimeError as error:
logger.warning("Schema fetch failed for %s: %s", endpoint_id, error)
continue
if doc is not None:
schemas = {**schemas, endpoint_id: doc}
return schemas
# ---------------------------------------------------------------------------
# Catalog filtering
# ---------------------------------------------------------------------------
def parse_published_at(item):
"""Parse the model's publication timestamp, or None."""
raw = item.get("publishedAt") or item.get("date") or ""
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
def filter_catalog(catalog, since):
"""Keep live, public models (optionally within a publish window; since=None keeps all).
Returns (kept_items, skip_reason_counter).
"""
kept = []
skipped = Counter()
seen_ids = set()
for item in catalog:
endpoint_id = item.get("id") or ""
if not endpoint_id or endpoint_id in seen_ids:
skipped["duplicate_or_missing_id"] += 1
continue
seen_ids.add(endpoint_id)
if item.get("status") != "public":
skipped["not_public"] += 1
continue
if item.get("deprecated"):
skipped["deprecated"] += 1
continue
if item.get("removed"):
skipped["removed"] += 1
continue
if since is not None:
published = parse_published_at(item)
if published is None or published < since:
skipped["outside_window"] += 1
continue
kept = kept + [item]
return kept, skipped
# ---------------------------------------------------------------------------
# Schema resolution helpers
# ---------------------------------------------------------------------------
def resolve_ref(schema, components):
"""Resolve a local $ref against components.schemas, one level."""
ref = schema.get("$ref", "")
if not ref.startswith("#/components/schemas/"):
return schema
name = ref.rsplit("/", 1)[-1]
resolved = components.get(name)
if not isinstance(resolved, dict):
return schema
siblings = {key: value for key, value in schema.items() if key != "$ref"}
return {**resolved, **siblings}
def non_null_branches(branches, components):
"""Resolve and drop null branches from an anyOf/oneOf list."""
resolved = [resolve_ref(branch, components) for branch in branches if isinstance(branch, dict)]
return [branch for branch in resolved if branch.get("type") != "null"]
def merge_all_of(schema, components):
"""Merge an allOf list (one level), with sibling keys taking precedence."""
merged = {}
for branch in schema.get("allOf", []):
if isinstance(branch, dict):
merged = {**merged, **resolve_ref(branch, components)}
siblings = {key: value for key, value in schema.items() if key != "allOf"}
return {**merged, **siblings}
def is_custom_size_pair(branches):
"""Detect the image_size pattern: [enum-of-presets, width/height object]."""
enum_branch = next((b for b in branches if b.get("enum")), None)
object_branch = next(
(
b
for b in branches
if b.get("type") == "object" or "properties" in b
),
None,
)
if enum_branch is None or object_branch is None:
return None
properties = object_branch.get("properties", {})
if "width" in properties and "height" in properties:
return enum_branch
return None
def normalize_schema(schema, components):
"""Resolve $ref / allOf / anyOf / oneOf one level.
Returns (resolved_schema, has_custom_size, custom_size_enum_values).
"""
if not isinstance(schema, dict):
return {}, False, None
resolved = resolve_ref(schema, components)
if "allOf" in resolved:
resolved = merge_all_of(resolved, components)
branches_key = "anyOf" if "anyOf" in resolved else ("oneOf" if "oneOf" in resolved else None)
if branches_key is None:
return resolved, False, None
branches = non_null_branches(resolved[branches_key], components)
siblings = {key: value for key, value in resolved.items() if key != branches_key}
if not branches:
return siblings, False, None
custom_enum_branch = is_custom_size_pair(branches)
if custom_enum_branch is not None:
values = list(custom_enum_branch.get("enum", [])) + ["custom_size"]
return {**custom_enum_branch, **siblings}, True, values
enum_branch = next((branch for branch in branches if branch.get("enum")), None)
chosen = enum_branch if enum_branch is not None else branches[0]
return {**chosen, **siblings}, False, None
# ---------------------------------------------------------------------------
# Input distillation
# ---------------------------------------------------------------------------
def detect_media_kind(name, schema, is_list):
"""Heuristic media kind from a property name (string-typed props only)."""
lowered = name.lower()
description = str(schema.get("description", "")).lower()
if "image_url" in lowered or "mask_url" in lowered:
return "image"
if lowered.endswith("_image"):
return "image"
if "video_url" in lowered:
return "video"
if "audio_url" in lowered or "voice_url" in lowered:
return "audio"
if "_url" in lowered or lowered == "url" or schema.get("format") == "uri":
for kind in ("image", "video", "audio"):
if kind in description:
return kind
return "file"
del is_list # signature symmetry; list-ness does not change the kind
return None
def trim_text(value, limit=MAX_DESCRIPTION_CHARS):
"""Trim a description/title string."""
return str(value or "").strip()[:limit]
def scalar_type_of(schema):
"""Map an OpenAPI scalar type to a registry type."""
type_name = schema.get("type")
if schema.get("enum"):
return "enum"
if type_name in ("integer", "number", "boolean", "string"):
return type_name
if type_name == "object" or "properties" in schema:
return "json"
return "json"
def distill_property(name, raw_schema, required_names, components):
"""Distill one input property into a registry input record, or None."""
if name in SKIPPED_PROPERTY_NAMES or name.startswith("_"):
return None
schema, has_custom_size, custom_enum = normalize_schema(raw_schema, components)
is_list = False
if schema.get("type") == "array":
is_list = True
items, _, _ = normalize_schema(schema.get("items", {}), components)
item_type = scalar_type_of(items)
if item_type == "json":
type_name = "json"
is_list = False # rendered as a single JSON field
else:
type_name = item_type
item_schema = items
else:
type_name = scalar_type_of(schema)
item_schema = schema
enum_values = None
if has_custom_size:
type_name = "enum"
enum_values = custom_enum
elif type_name == "enum":
enum_values = list(item_schema.get("enum", []))
minimum = schema.get("minimum", schema.get("exclusiveMinimum"))
maximum = schema.get("maximum", schema.get("exclusiveMaximum"))
if type_name not in ("integer", "number"):
minimum = None
maximum = None
default = schema.get("default", raw_schema.get("default") if isinstance(raw_schema, dict) else None)
if type_name == "json" and default is not None and not isinstance(default, str):
default = json.dumps(default, ensure_ascii=False, sort_keys=True)
# Some upstream schemas declare enum members and the default with mismatched
# types (e.g. enum ["1","2","4","8"] with default 4). Normalize the default
# onto the literal enum member it string-matches so widgets get a valid value.
if enum_values and default is not None and default not in enum_values:
match = next((v for v in enum_values if str(v) == str(default)), None)
if match is not None:
default = match
description = trim_text(schema.get("description") or schema.get("title"))
media_kind = None
if type_name == "string" or (is_list and type_name == "string"):
media_kind = detect_media_kind(name, schema, is_list)
multiline = name in MULTILINE_NAMES or (
type_name == "string"
and not enum_values
and len(description) > MULTILINE_DESCRIPTION_THRESHOLD
)
record = {
"name": name,
"type": type_name,
"required": name in required_names,
"default": default,
"enum": enum_values,
"min": minimum,
"max": maximum,
"description": description,
"media_kind": media_kind,
"is_list": is_list,
"multiline": multiline,
}
if has_custom_size:
record = {**record, "has_custom_size": True}
return record
def ordered_property_names(schema):
"""Property names, preferring fal's declared ordering."""
properties = schema.get("properties", {})
declared = schema.get("x-fal-order-properties")
if isinstance(declared, list):
ordered = [name for name in declared if name in properties]
remainder = [name for name in properties if name not in ordered]
return ordered + remainder
return list(properties)
def distill_inputs(schema, components, endpoint_id):
"""Distill an Input schema's properties into registry input records."""
properties = schema.get("properties", {})
required_names = set(schema.get("required", []))
names = ordered_property_names(schema)
if len(names) > MAX_INPUT_PROPERTIES:
required_first = [n for n in names if n in required_names]
optional = [n for n in names if n not in required_names]
budget = max(MAX_INPUT_PROPERTIES - len(required_first), 0)
names = required_first + optional[:budget]
logger.info(
"%s: input schema has %d properties, capped to %d",
endpoint_id,
len(properties),
len(names),
)
inputs = []
for name in names:
record = distill_property(name, properties.get(name, {}), required_names, components)
if record is not None:
inputs = inputs + [record]
return inputs
# ---------------------------------------------------------------------------
# Schema selection
# ---------------------------------------------------------------------------
def ref_name(schema):
"""Extract the local component name from a {'$ref': ...} node."""
ref = schema.get("$ref", "") if isinstance(schema, dict) else ""
return ref.rsplit("/", 1)[-1] if ref.startswith("#/components/schemas/") else None
def input_ref_from_paths(doc):
"""Name of the schema referenced by the app POST requestBody."""
for operations in doc.get("paths", {}).values():
post = operations.get("post") if isinstance(operations, dict) else None
if not isinstance(post, dict):
continue
content = post.get("requestBody", {}).get("content", {})
schema = content.get("application/json", {}).get("schema", {})
name = ref_name(schema)
if name:
return name
return None
def output_ref_from_paths(doc):
"""Name of the schema referenced by result GET responses."""
for operations in doc.get("paths", {}).values():
get = operations.get("get") if isinstance(operations, dict) else None
if not isinstance(get, dict):
continue
for response in get.get("responses", {}).values():
content = response.get("content", {}) if isinstance(response, dict) else {}
schema = content.get("application/json", {}).get("schema", {})
name = ref_name(schema)
if name and name.endswith("Output"):
return name
return None
def select_schema(doc, endpoint_id, suffix, path_lookup):
"""Select the app Input/Output schema from components.schemas."""
components = doc.get("components", {}).get("schemas", {})
referenced = path_lookup(doc)
if referenced and referenced in components:
return components[referenced]
candidates = [name for name in components if name.endswith(suffix)]
if not candidates:
return None
normalized_endpoint = "".join(ch for ch in endpoint_id.lower() if ch.isalnum())
matching = [
name
for name in candidates
if "".join(ch for ch in name.lower() if ch.isalnum()).replace(suffix.lower(), "")
in normalized_endpoint
]
pool = matching or candidates
return components[max(pool, key=len)]
# ---------------------------------------------------------------------------
# Output kind detection
# ---------------------------------------------------------------------------
def detect_output(schema, components):
"""Classify an Output schema. Returns (output_kind, output_props)."""
if schema is None:
return "json", []
properties = schema.get("properties", {})
prop_names = list(properties)
lowered = {name.lower() for name in prop_names}
def prop_is_array(name):
resolved, _, _ = normalize_schema(properties.get(name, {}), components)
return resolved.get("type") == "array"
if "images" in lowered and prop_is_array("images"):
return "images", prop_names
if "image" in lowered:
return "image", prop_names
if "video" in lowered or "videos" in lowered:
return "video", prop_names
if "audio" in lowered or "audios" in lowered:
return "audio", prop_names
if lowered & FILE_OUTPUT_PROPS:
return "file", prop_names
if prop_names:
all_stringlike = True
for name in prop_names:
resolved, _, _ = normalize_schema(properties.get(name, {}), components)
if scalar_type_of(resolved) != "string":
all_stringlike = False
break
if all_stringlike:
return "text", prop_names
return "json", prop_names
# ---------------------------------------------------------------------------
# Record assembly
# ---------------------------------------------------------------------------
def build_record(item, doc):
"""Build a single registry record from a catalog item + OpenAPI doc."""
endpoint_id = item["id"]
components = doc.get("components", {}).get("schemas", {})
input_schema = select_schema(doc, endpoint_id, "Input", input_ref_from_paths)
if input_schema is None:
logger.warning("%s: no Input schema found, skipping", endpoint_id)
return None
output_schema = select_schema(doc, endpoint_id, "Output", output_ref_from_paths)
output_kind, output_props = detect_output(output_schema, components)
published = parse_published_at(item)
pricing = str(item.get("pricingInfoOverride") or "").replace("**", "").strip()
return {
"endpoint_id": endpoint_id,
"title": str(item.get("title") or "").strip(),
"category": str(item.get("category") or "").strip(),
"lab": str(item.get("modelLab") or "").strip(),
"family": str(item.get("modelFamily") or "").strip(),
"description": trim_text(item.get("shortDescription")),
"pricing": pricing,
"published_at": published.isoformat() if published else "",
"thumbnail": str(item.get("thumbnailUrl") or "").strip(),
"inputs": distill_inputs(input_schema, components, endpoint_id),
"output_kind": output_kind,
"output_props": output_props,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def load_json_file(path):
"""Load a JSON cache file."""
try:
with open(path, encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError) as error:
raise RuntimeError(f"Failed to load cache file {path}: {error}") from error
def parse_args():
parser = argparse.ArgumentParser(description="Build the fal.ai model registry JSON.")
parser.add_argument("--out", default="data/fal_registry.json", help="Output registry path")
parser.add_argument(
"--since-days",
type=int,
default=0,
help="Rolling publish window in days; 0 (default) = all live models",
)
parser.add_argument("--catalog-cache", default=None, help="Path to cached catalog JSON")
parser.add_argument("--schemas-cache", default=None, help="Path to cached endpoint_id->OpenAPI JSON")
parser.add_argument("--max-workers", type=int, default=16, help="Concurrent schema fetches")
return parser.parse_args()
def log_summary(records, skipped):
"""Log counts by category / output kind and skip reasons."""
category_counts = Counter(record["category"] for record in records)
kind_counts = Counter(record["output_kind"] for record in records)
logger.info("Models by category:")
for category, count in category_counts.most_common():
logger.info(" %-28s %d", category or "(none)", count)
logger.info("Models by output_kind:")
for kind, count in kind_counts.most_common():
logger.info(" %-10s %d", kind, count)
logger.info("Skipped: %s", dict(skipped) or "none")
def main():
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
args = parse_args()
now = datetime.now(timezone.utc)
since = now - timedelta(days=args.since_days) if args.since_days > 0 else None
catalog = (
load_json_file(args.catalog_cache) if args.catalog_cache else fetch_catalog()
)
logger.info("Catalog: %d items", len(catalog))
kept, skipped = filter_catalog(catalog, since)
logger.info("After filtering: %d live public models in window", len(kept))
if args.schemas_cache:
schemas = load_json_file(args.schemas_cache)
else:
schemas = fetch_schemas([item["id"] for item in kept], args.max_workers)
logger.info("Schemas available: %d", len(schemas))
records = []
for item in kept:
doc = schemas.get(item["id"])
if doc is None:
skipped["no_schema"] += 1
logger.warning("%s: no schema available, skipping", item["id"])
continue
record = build_record(item, doc)
if record is None:
skipped["no_input_schema"] += 1
continue
records = records + [record]
records = sorted(records, key=lambda record: record["endpoint_id"])
# NOTE: no wall-clock fields (generated_at etc.) — the committed registry
# must be content-deterministic so the weekly refresh workflow only opens a
# PR when the model set actually changes.
registry = {
"version": 1,
"window_days": args.since_days,
"model_count": len(records),
"models": records,
}
# atomic write: the live sidebar refresh runs this inside a running
# ComfyUI — a crash mid-write must not corrupt the tracked registry
tmp_out = args.out + ".tmp"
with open(tmp_out, "w", encoding="utf-8") as handle:
json.dump(
registry,
handle,
indent=None,
separators=(",", ":"),
sort_keys=True,
ensure_ascii=False,
)
handle.write("\n")
os.replace(tmp_out, args.out)
log_summary(records, skipped)
logger.info("Wrote %d models to %s", len(records), args.out)
if __name__ == "__main__":
main()