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.
This commit is contained in:
Gökay Aydoğan
2026-07-02 20:37:15 +03:00
committed by GitHub
parent 31a2c0a35e
commit 8a47f0598b
21 changed files with 3508 additions and 1901 deletions
+2
View File
@@ -23,6 +23,8 @@ jobs:
python-version: "3.11"
- name: Rebuild registry
run: python scripts/build_registry.py --out data/fal_registry.json
- name: Regenerate MODELS.md
run: python scripts/build_readme.py
- name: Summarize changes
id: diff
run: |
+93
View File
@@ -0,0 +1,93 @@
# Contributing to ComfyUI-fal-API
Thanks for helping! Before you write anything, read this — it will probably save you the PR entirely.
## A new fal model does NOT need code
Historically, adding a model to this pack meant hand-writing a node class. **That is no longer how it works.** Every live public model on fal gets a node automatically, generated at ComfyUI startup from the committed snapshot at `data/fal_registry.json`. No node class, no mapping entry, no code.
The snapshot stays fresh two ways:
- A **weekly GitHub Action** (`.github/workflows/registry-refresh.yml`) rebuilds the registry and opens a PR.
- Anyone can run it locally: `python scripts/build_registry.py --out data/fal_registry.json` (then `python scripts/build_readme.py` to regenerate [MODELS.md](MODELS.md)).
To check whether a model is already covered:
```bash
grep '"endpoint_id": "fal-ai/your/endpoint"' data/fal_registry.json
# or browse MODELS.md, or search the node browser in ComfyUI
```
If a model is live on [fal.ai/models](https://fal.ai/models) but missing from the snapshot, rerun `scripts/build_registry.py` — if it's *still* missing, open an issue with the endpoint id. And if you need a model **right now**, the **Fal Any Endpoint (fal)** node calls any endpoint by id without any registry entry at all.
So: **please don't open a PR that adds a node class for a new model.** It will be redundant the moment the registry refreshes.
## Want a model promoted or renamed? Edit `data/featured_models.json`
When a model deserves curation — a spot in the **FAL/Featured** menu tier or a friendlier display name — add its endpoint to `data/featured_models.json` (featured tier + display-name override). That's the whole change: one JSON entry, not a new node class.
## When a hand-written node IS justified
A curated node earns its place only when the generated node genuinely can't express the UX:
- **Multi-endpoint orchestration** — one node fanning out to several endpoints (e.g. Combined Video Generation).
- **Special input ergonomics** — first/last-frame image pairing, unified T2V/I2V dispatch, LoRA slots with per-slot scales.
If you're writing one, the rules are non-negotiable:
1. **Import only from the `.fal_utils` facade** (`from .fal_utils import ApiHandler, FalConfig, ImageUtils, ResultProcessor, ...`) — never reach into `nodes/utils/` internals or call `fal_client` directly. The facade gives you the result cache, spend guard, session ledger, and error handling for free.
2. **Raise errors — no silent fallbacks.** Never return blank images or `"Error: ..."` strings; let `ApiHandler` surface fal's actual error message.
3. **Tooltips on every input.** Users should never have to guess a parameter.
4. **Never change existing node keys, input names, or output signatures.** Existing user workflows reference them forever. `tests/legacy_node_keys.json` is the snapshot of keys that must never be removed or renamed, and `tests/test_mappings.py` fails the suite if one disappears. New inputs must be optional with backward-compatible defaults.
5. **Add tests** alongside the existing ones in `tests/`.
## Dev setup
```bash
pip install -r requirements.txt
python -m pytest tests # the suite MUST pass
ruff check . # lint, same as CI
```
CI runs both on every PR (Python 3.10 and 3.12). The most important test to understand is the **compatibility snapshot**: `tests/test_mappings.py` asserts that every node key recorded in `tests/legacy_node_keys.json` still registers. If your change makes it fail, the fix is to restore the key — not to edit the snapshot.
## Architecture map
```
scripts/build_registry.py queries fal's platform APIs → writes the snapshot
data/fal_registry.json committed model catalog (~1,391 models)
data/featured_models.json curation: featured tier + display-name overrides
scripts/build_readme.py renders MODELS.md from the snapshot
nodes/dynamic/ the auto-generated node machinery
registry_loader.py reads the snapshot, applies [dynamic_nodes] config;
never raises — failures degrade to curated-only
factory.py builds one node class per model, in memory
schema_to_inputs.py registry input specs → ComfyUI INPUT_TYPES (+ tooltips)
arguments.py widget/socket values → API arguments (uploads media)
outputs.py API result → IMAGE / VIDEO / AUDIO / result_json
any_endpoint.py the generic "call any endpoint by id" node
nodes/*.py curated hand-written nodes (image, video, llm, vlm,
trainer, upscaler, util_*)
nodes/fal_utils.py import facade — node modules import ONLY from here
nodes/utils/ the implementations behind the facade: api, config,
pricing, result_cache, ledger, billing (spend guard),
job_store, media, archive, errors, logger
nodes/platform_node.py platform nodes (Submit/Collect, costs, request ids)
nodes/inbox_node.py durable job inbox
nodes/billing_node.py account balance
nodes/server_routes.py HTTP endpoints backing the frontend extension
web/ ComfyUI frontend: cost badges, fal sidebar,
endpoint autocomplete
tests/ pytest suite, incl. the legacy_node_keys.json snapshot
```
## PR checklist
- [ ] Not a hand-written node for a single new model (registry covers it — see above)
- [ ] `python -m pytest tests` passes locally
- [ ] `ruff check .` is clean
- [ ] No existing node keys, inputs, or outputs changed
- [ ] New curated node (if truly justified): uses `.fal_utils`, raises errors, has tooltips and tests
- [ ] No secrets, no `config.ini`, no generated artifacts in the diff
+1613
View File
File diff suppressed because it is too large Load Diff
+99 -1856
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@ node_list = [
"util_video_node",
"util_image_node",
"util_data_node",
"builder_node",
]
NODE_CLASS_MAPPINGS = {}
+31
View File
@@ -0,0 +1,31 @@
{
"version": 1,
"featured": [
{"endpoint_id": "fal-ai/kling-video/o3/pro/text-to-video", "display_name": null},
{"endpoint_id": "fal-ai/kling-video/o3/pro/image-to-video", "display_name": null},
{"endpoint_id": "fal-ai/veo3.1", "display_name": "Veo 3.1 Text to Video (fal)"},
{"endpoint_id": "fal-ai/veo3.1/image-to-video", "display_name": "Veo 3.1 Image to Video (fal)"},
{"endpoint_id": "fal-ai/wan/v2.7/text-to-video", "display_name": "Wan 2.7 Text to Video (fal)"},
{"endpoint_id": "fal-ai/wan/v2.7/image-to-video", "display_name": "Wan 2.7 Image to Video (fal)"},
{"endpoint_id": "bytedance/seedance-2.0/text-to-video", "display_name": "Seedance 2.0 Text to Video (fal)"},
{"endpoint_id": "bytedance/seedance-2.0/image-to-video", "display_name": "Seedance 2.0 Image to Video (fal)"},
{"endpoint_id": "fal-ai/sora-2/text-to-video/pro", "display_name": "Sora 2 Pro Text to Video (fal)"},
{"endpoint_id": "fal-ai/sora-2/image-to-video/pro", "display_name": "Sora 2 Pro Image to Video (fal)"},
{"endpoint_id": "fal-ai/minimax/hailuo-2.3/pro/image-to-video", "display_name": null},
{"endpoint_id": "fal-ai/flux-2-max", "display_name": null},
{"endpoint_id": "fal-ai/flux-2-max/edit", "display_name": "Flux 2 Max Edit (fal)"},
{"endpoint_id": "fal-ai/nano-banana-2", "display_name": null},
{"endpoint_id": "fal-ai/nano-banana-2/edit", "display_name": "Nano Banana 2 Edit (fal)"},
{"endpoint_id": "openai/gpt-image-2", "display_name": "GPT Image 2 (fal)"},
{"endpoint_id": "openai/gpt-image-2/edit", "display_name": "GPT Image 2 Edit (fal)"},
{"endpoint_id": "fal-ai/bytedance/seedream/v4.5/text-to-image", "display_name": "Seedream 4.5 Text to Image (fal)"},
{"endpoint_id": "fal-ai/bytedance/seedream/v4.5/edit", "display_name": "Seedream 4.5 Edit (fal)"},
{"endpoint_id": "fal-ai/recraft/v4.1/pro/text-to-image", "display_name": null},
{"endpoint_id": "ideogram/v4", "display_name": null},
{"endpoint_id": "fal-ai/elevenlabs/tts/eleven-v3", "display_name": "ElevenLabs TTS Eleven v3 (fal)"},
{"endpoint_id": "fal-ai/elevenlabs/speech-to-text/scribe-v2", "display_name": "ElevenLabs Scribe v2 (fal)"},
{"endpoint_id": "fal-ai/hunyuan-3d/v3.1/pro/image-to-3d", "display_name": null},
{"endpoint_id": "fal-ai/topaz/upscale/image", "display_name": "Topaz Image Upscale (fal)"},
{"endpoint_id": "fal-ai/topaz/upscale/video", "display_name": null}
]
}
+743
View File
@@ -0,0 +1,743 @@
"""Chainable typed builder nodes for JSON inputs on auto-generated fal nodes.
Auto-generated endpoint nodes render complex object/array inputs (registry
type "json") as raw JSON string widgets. The builders here emit exactly the
JSON those fields expect, and each accepts an optional ``chain`` input so N
builders can be daisy-chained to produce an N-element array (or a merged
object for ``FalKeyValue``).
Shapes were validated against the live OpenAPI schemas
(https://fal.ai/api/openapi/queue/openapi.json?endpoint_id=<id>):
- ``LoraWeight`` {path, scale[, weight_name]} fal-ai/flux-lora,
fal-ai/wan/v2.2-a14b/text-to-video/lora (126 "loras" inputs in registry)
- ``Embedding`` {path, tokens[]} fal-ai/fast-lightning-sdxl
- ``ControlNet`` {path, control_image_url, conditioning_scale,
start_percentage, end_percentage[, variant]} fal-ai/flux-general
- ``IPAdapter`` {path, image_encoder_path, image_url, scale
[, weight_name]} fal-ai/flux-general
- ``ElementInput`` {frontal_image_url, reference_image_urls[]}
fal-ai/kling-image/o1, fal-ai/kling-image/o3/*
- ``KlingV3MultiPromptElement`` {prompt, duration("1".."15")}
fal-ai/kling-video/o3/*/image-to-video
"""
from __future__ import annotations
import json
import math
from typing import Any
from .fal_utils import FalApiError, ImageUtils, logger
_CATEGORY = "FAL/Utils/Builders"
_CHAIN_TOOLTIP = (
"Optional: wire the json output of another builder of the same kind here "
"to append this entry after its entries (chain N builders for N items)."
)
def _parse_chain(node_name: str, chain: str, container: type) -> Any:
"""Parse a prior chain string into ``container`` (list or dict).
An empty/blank chain yields a fresh empty container. Anything that is not
valid JSON of the right container type raises a clear FalApiError.
"""
text = (chain or "").strip()
if not text:
return container()
try:
parsed = json.loads(text)
except ValueError as err:
logger.error("%s: invalid chain JSON: %s", node_name, err)
raise FalApiError(node_name, f"'chain' is not valid JSON: {err}") from err
if not isinstance(parsed, container):
wanted = "array" if container is list else "object"
if isinstance(parsed, dict):
got = "object"
elif isinstance(parsed, list):
got = "array"
else:
got = type(parsed).__name__
raise FalApiError(
node_name,
f"'chain' must be a JSON {wanted} (got {got}). "
f"Only chain {node_name}-compatible builders together.",
)
return parsed
def _append_entry(node_name: str, chain: str, entry: dict[str, Any]) -> str:
"""New JSON array string: entries from ``chain`` plus ``entry`` (no mutation)."""
prior = _parse_chain(node_name, chain, list)
return json.dumps([*prior, entry])
def _require(node_name: str, field: str, value: str) -> str:
"""Strip a required string field, raising when it is blank."""
text = (value or "").strip()
if not text:
raise FalApiError(node_name, f"'{field}' is required and cannot be empty")
return text
def _resolve_image_url(node_name: str, field: str, image: Any, url: str, required: bool) -> str:
"""A connected IMAGE wins (uploaded via fal storage); else the URL string."""
if image is not None:
return ImageUtils.upload_image(image)
text = (url or "").strip()
if not text and required:
raise FalApiError(
node_name,
f"Connect an image or fill '{field}': the schema requires an image URL",
)
return text
class FalLoRAConfig:
"""Append one LoraWeight ({path, scale}) entry to a JSON array."""
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("json",)
FUNCTION = "build"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Build a `loras` JSON array entry ({path, scale}) without hand-writing "
"JSON. Chain several to stack LoRAs. Wire the json output into the "
"`loras` field of 126+ fal nodes (fal-ai/flux-lora, "
"fal-ai/wan/v2.2-a14b/text-to-video/lora, fal-ai/qwen-image, ...)."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"path": (
"STRING",
{
"default": "",
"tooltip": (
"URL or Hugging Face id of the LoRA weights, e.g. "
"https://.../lora.safetensors. Feeds the `loras` field of "
"fal-ai/flux-lora, fal-ai/wan/v2.2-a14b/text-to-video/lora, "
"fal-ai/chrono-edit-lora and 120+ more."
),
},
),
"scale": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 4.0,
"step": 0.01,
"tooltip": "LoRA strength merged into the base model (LoraWeight.scale, 0-4).",
},
),
},
"optional": {
"weight_name": (
"STRING",
{
"default": "",
"tooltip": (
"Optional safetensors file name when `path` is a Hugging Face "
"repo with several files (e.g. Wan/Qwen LoRA endpoints). "
"Leave empty otherwise."
),
},
),
"chain": ("STRING", {"forceInput": True, "tooltip": _CHAIN_TOOLTIP}),
},
}
def build(self, path: str, scale: float, weight_name: str = "", chain: str = "") -> tuple[str]:
entry: dict[str, Any] = {
"path": _require("FalLoRAConfig", "path", path),
"scale": float(scale),
}
if (weight_name or "").strip():
entry = {**entry, "weight_name": weight_name.strip()}
return (_append_entry("FalLoRAConfig", chain, entry),)
class FalEmbeddingConfig:
"""Append one Embedding ({path, tokens}) entry to a JSON array."""
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("json",)
FUNCTION = "build"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Build an `embeddings` JSON array entry ({path, tokens}) for SD/SDXL "
"endpoints such as fal-ai/fast-lightning-sdxl, fal-ai/dreamshaper and "
"fal-ai/fast-fooocus-sdxl. Chain several to load multiple embeddings."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"path": (
"STRING",
{
"default": "",
"tooltip": (
"URL or path to the textual-inversion embedding weights, e.g. "
"https://civitai.com/api/download/models/135931. Feeds the "
"`embeddings` field of fal-ai/fast-lightning-sdxl, "
"fal-ai/dreamshaper, fal-ai/fast-fooocus-sdxl."
),
},
),
},
"optional": {
"tokens": (
"STRING",
{
"default": "<s0>, <s1>",
"tooltip": (
"Comma-separated trigger tokens for the embedding "
"(Embedding.tokens). Leave empty to use the endpoint default."
),
},
),
"chain": ("STRING", {"forceInput": True, "tooltip": _CHAIN_TOOLTIP}),
},
}
def build(self, path: str, tokens: str = "<s0>, <s1>", chain: str = "") -> tuple[str]:
entry: dict[str, Any] = {"path": _require("FalEmbeddingConfig", "path", path)}
token_list = [part.strip() for part in (tokens or "").split(",") if part.strip()]
if token_list:
entry = {**entry, "tokens": token_list}
return (_append_entry("FalEmbeddingConfig", chain, entry),)
class FalControlNetConfig:
"""Append one ControlNet conditioning entry to a JSON array."""
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("json",)
FUNCTION = "build"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Build a `controlnets` JSON array entry ({path, control_image_url, "
"conditioning_scale, start/end_percentage}) for fal-ai/flux-general and "
"its variants (image-to-image, inpainting, differential-diffusion). "
"Connect an IMAGE (auto-uploaded) or paste a control image URL."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"path": (
"STRING",
{
"default": "",
"tooltip": (
"URL or Hugging Face path to the ControlNet weights. Feeds the "
"`controlnets` field of fal-ai/flux-general, "
"fal-ai/flux-general/image-to-image, fal-ai/flux-general/inpainting."
),
},
),
"conditioning_scale": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 2.0,
"step": 0.01,
"tooltip": "Strength of the ControlNet guidance (ControlNet.conditioning_scale).",
},
),
"start_percentage": (
"FLOAT",
{
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Fraction of total timesteps at which the ControlNet starts applying (0-1).",
},
),
"end_percentage": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Fraction of total timesteps at which the ControlNet stops applying (0-1).",
},
),
},
"optional": {
"control_image": (
"IMAGE",
{
"tooltip": (
"Control image (canny/depth/pose map, ...). Uploaded to fal "
"storage and sent as `control_image_url`. Overrides the URL widget."
),
},
),
"control_image_url": (
"STRING",
{
"default": "",
"tooltip": (
"Direct URL for the control image; used when no IMAGE is connected. "
"The schema requires one of the two."
),
},
),
"variant": (
"STRING",
{
"default": "",
"tooltip": "Optional variant when `path` is a Hugging Face repo key. Leave empty otherwise.",
},
),
"chain": ("STRING", {"forceInput": True, "tooltip": _CHAIN_TOOLTIP}),
},
}
def build(
self,
path: str,
conditioning_scale: float,
start_percentage: float,
end_percentage: float,
control_image: Any = None,
control_image_url: str = "",
variant: str = "",
chain: str = "",
) -> tuple[str]:
node = "FalControlNetConfig"
entry: dict[str, Any] = {
"path": _require(node, "path", path),
"control_image_url": _resolve_image_url(
node, "control_image_url", control_image, control_image_url, required=True
),
"conditioning_scale": float(conditioning_scale),
"start_percentage": float(start_percentage),
"end_percentage": float(end_percentage),
}
if (variant or "").strip():
entry = {**entry, "variant": variant.strip()}
return (_append_entry(node, chain, entry),)
class FalIPAdapterConfig:
"""Append one IP-Adapter entry to a JSON array."""
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("json",)
FUNCTION = "build"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Build an `ip_adapters` JSON array entry ({path, image_encoder_path, "
"image_url, scale}) for fal-ai/flux-general and its variants. Connect "
"an IMAGE (auto-uploaded) or paste a reference image URL. For the older "
"fal-ai/lora `ip_adapter` field (different keys) use FalKeyValue."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"path": (
"STRING",
{
"default": "",
"tooltip": (
"Hugging Face path to the IP-Adapter weights. Feeds the "
"`ip_adapters` field of fal-ai/flux-general, "
"fal-ai/flux-general/image-to-image, fal-ai/flux-general/rf-inversion."
),
},
),
"image_encoder_path": (
"STRING",
{
"default": "openai/clip-vit-large-patch14",
"tooltip": "Path to the image encoder for the IP-Adapter (IPAdapter.image_encoder_path).",
},
),
"scale": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 4.0,
"step": 0.01,
"tooltip": "Strength of the IP-Adapter conditioning (IPAdapter.scale).",
},
),
},
"optional": {
"image": (
"IMAGE",
{
"tooltip": (
"Reference image for the IP-Adapter conditioning. Uploaded to fal "
"storage and sent as `image_url`. Overrides the URL widget."
),
},
),
"image_url": (
"STRING",
{
"default": "",
"tooltip": (
"Direct URL for the reference image; used when no IMAGE is connected. "
"The schema requires one of the two."
),
},
),
"weight_name": (
"STRING",
{
"default": "",
"tooltip": (
"Optional safetensors file name containing the IP-Adapter weights "
"(IPAdapter.weight_name). Leave empty otherwise."
),
},
),
"chain": ("STRING", {"forceInput": True, "tooltip": _CHAIN_TOOLTIP}),
},
}
def build(
self,
path: str,
image_encoder_path: str,
scale: float,
image: Any = None,
image_url: str = "",
weight_name: str = "",
chain: str = "",
) -> tuple[str]:
node = "FalIPAdapterConfig"
entry: dict[str, Any] = {
"path": _require(node, "path", path),
"image_encoder_path": _require(node, "image_encoder_path", image_encoder_path),
"image_url": _resolve_image_url(node, "image_url", image, image_url, required=True),
"scale": float(scale),
}
if (weight_name or "").strip():
entry = {**entry, "weight_name": weight_name.strip()}
return (_append_entry(node, chain, entry),)
class FalReferenceImage:
"""Append one Kling ElementInput (reference character/object) to a JSON array."""
_MAX_REFERENCE_IMAGES = 3
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("json",)
FUNCTION = "build"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Build an `elements` JSON array entry ({frontal_image_url, "
"reference_image_urls}) for Kling Omni image endpoints "
"(fal-ai/kling-image/o1, fal-ai/kling-image/o3/text-to-image, "
"fal-ai/kling-image/o3/image-to-image). Images are auto-uploaded. "
"Chain one builder per character/object element."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"frontal_image": (
"IMAGE",
{
"tooltip": (
"Frontal view of the character/object. Uploaded to fal storage and "
"sent as `frontal_image_url` inside the `elements` field of "
"fal-ai/kling-image/o1 and fal-ai/kling-image/o3 endpoints."
),
},
),
},
"optional": {
"reference_images": (
"IMAGE",
{
"tooltip": (
"Optional batch of up to 3 additional views from different angles "
"(sent as `reference_image_urls`)."
),
},
),
"chain": ("STRING", {"forceInput": True, "tooltip": _CHAIN_TOOLTIP}),
},
}
def build(self, frontal_image: Any, reference_images: Any = None, chain: str = "") -> tuple[str]:
node = "FalReferenceImage"
entry: dict[str, Any] = {"frontal_image_url": ImageUtils.upload_image(frontal_image)}
if reference_images is not None:
urls = ImageUtils.prepare_images(reference_images)
if len(urls) > self._MAX_REFERENCE_IMAGES:
raise FalApiError(
node,
f"'reference_images' supports at most {self._MAX_REFERENCE_IMAGES} "
f"images per element (got {len(urls)})",
)
if urls:
entry = {**entry, "reference_image_urls": urls}
return (_append_entry(node, chain, entry),)
class FalMultiPromptShot:
"""Append one Kling multi-prompt shot ({prompt, duration}) to a JSON array."""
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("json",)
FUNCTION = "build"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Build a `multi_prompt` JSON array entry ({prompt, duration}) for Kling "
"O3 video endpoints (fal-ai/kling-video/o3/standard/image-to-video, "
"fal-ai/kling-video/o3/pro/text-to-video, .../4k variants). Chain one "
"builder per shot to script a multi-shot video."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"prompt": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": (
"The prompt for this shot. Feeds the `multi_prompt` field of "
"fal-ai/kling-video/o3 image-to-video / text-to-video / "
"reference-to-video endpoints."
),
},
),
"duration": (
"INT",
{
"default": 5,
"min": 1,
"max": 15,
"tooltip": "Duration of this shot in seconds (1-15, sent as a string per the schema).",
},
),
},
"optional": {
"chain": ("STRING", {"forceInput": True, "tooltip": _CHAIN_TOOLTIP}),
},
}
def build(self, prompt: str, duration: int, chain: str = "") -> tuple[str]:
node = "FalMultiPromptShot"
entry = {
"prompt": _require(node, "prompt", prompt),
"duration": str(int(duration)),
}
return (_append_entry(node, chain, entry),)
def _typed_value(node: str, value: str, value_type: str) -> Any:
"""Coerce the FalKeyValue string widget into the selected JSON type."""
if value_type == "string":
return value
text = value.strip()
if value_type == "number":
try:
number = float(text)
except ValueError as err:
raise FalApiError(node, f"'value' is not a number: {text!r}") from err
if not math.isfinite(number):
raise FalApiError(node, f"'value' must be a finite number, got: {text!r}")
return int(number) if number.is_integer() else number
if value_type == "boolean":
lowered = text.lower()
if lowered in ("true", "1", "yes"):
return True
if lowered in ("false", "0", "no"):
return False
raise FalApiError(node, f"'value' is not a boolean (use true/false): {text!r}")
# value_type == "json": nested arrays/objects/null, e.g. from another builder
try:
return json.loads(text)
except ValueError as err:
raise FalApiError(node, f"'value' is not valid JSON: {err}") from err
class FalKeyValue:
"""Merge one typed key/value pair into a JSON object (chainable)."""
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("json",)
FUNCTION = "build"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Generic escape hatch: build a JSON OBJECT one typed key at a time. "
"Chain several to fill object fields like `audio_setting` / "
"`voice_setting` (fal-ai/minimax-music/v2, fal-ai/minimax/speech-02-hd) "
"or `validation` (fal-ai/ltx23-trainer-v2). Set value_type to `json` to "
"nest arrays/objects, including outputs of the array builders."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"key": (
"STRING",
{
"default": "",
"tooltip": (
"Object key to set, e.g. sample_rate for `audio_setting` on "
"fal-ai/minimax-music/v2 or speed for `voice_setting` on "
"fal-ai/minimax/speech-02-hd."
),
},
),
"value": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Value for the key, interpreted according to value_type.",
},
),
"value_type": (
["string", "number", "boolean", "json"],
{
"default": "string",
"tooltip": (
"How to encode the value: string as-is, number/boolean parsed, "
"json for nested objects/arrays (e.g. a builder output)."
),
},
),
},
"optional": {
"chain": (
"STRING",
{
"forceInput": True,
"tooltip": (
"Optional: wire another FalKeyValue json output here to merge this "
"key into that object (later keys win)."
),
},
),
},
}
def build(self, key: str, value: str, value_type: str, chain: str = "") -> tuple[str]:
node = "FalKeyValue"
prior = _parse_chain(node, chain, dict)
merged = {**prior, _require(node, "key", key): _typed_value(node, value, value_type)}
return (json.dumps(merged),)
class FalJSONMerge:
"""Merge two builder outputs: arrays concatenate, objects merge (b wins)."""
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("json",)
FUNCTION = "merge"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Merge two JSON strings: two arrays concatenate (a then b), two objects "
"merge with b overriding a. Useful to combine separately built chains "
"before wiring them into one json field (e.g. two `loras` chains, or "
"FalKeyValue objects for `audio_setting` / `validation`)."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"a": (
"STRING",
{
"forceInput": True,
"tooltip": "First JSON array or object (a builder json output). Empty is allowed.",
},
),
"b": (
"STRING",
{
"forceInput": True,
"tooltip": (
"Second JSON array or object. Must be the same container type as "
"'a'; object keys in 'b' override 'a'."
),
},
),
},
}
@staticmethod
def _parse(side: str, text: str) -> Any:
stripped = (text or "").strip()
if not stripped:
return None
try:
parsed = json.loads(stripped)
except ValueError as err:
raise FalApiError("FalJSONMerge", f"'{side}' is not valid JSON: {err}") from err
if not isinstance(parsed, (list, dict)):
raise FalApiError(
"FalJSONMerge",
f"'{side}' must be a JSON array or object, got {type(parsed).__name__}",
)
return parsed
def merge(self, a: str, b: str) -> tuple[str]:
parsed_a = self._parse("a", a)
parsed_b = self._parse("b", b)
if parsed_a is None and parsed_b is None:
raise FalApiError("FalJSONMerge", "Both 'a' and 'b' are empty; nothing to merge")
if parsed_a is None or parsed_b is None:
return (json.dumps(parsed_b if parsed_a is None else parsed_a),)
if isinstance(parsed_a, list) and isinstance(parsed_b, list):
return (json.dumps([*parsed_a, *parsed_b]),)
if isinstance(parsed_a, dict) and isinstance(parsed_b, dict):
return (json.dumps({**parsed_a, **parsed_b}),)
raise FalApiError(
"FalJSONMerge",
"'a' and 'b' must both be arrays or both be objects "
f"(got {type(parsed_a).__name__} and {type(parsed_b).__name__})",
)
NODE_CLASS_MAPPINGS = {
"FalLoRAConfig_fal": FalLoRAConfig,
"FalEmbeddingConfig_fal": FalEmbeddingConfig,
"FalControlNetConfig_fal": FalControlNetConfig,
"FalIPAdapterConfig_fal": FalIPAdapterConfig,
"FalReferenceImage_fal": FalReferenceImage,
"FalMultiPromptShot_fal": FalMultiPromptShot,
"FalKeyValue_fal": FalKeyValue,
"FalJSONMerge_fal": FalJSONMerge,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FalLoRAConfig_fal": "LoRA Config (fal)",
"FalEmbeddingConfig_fal": "Embedding Config (fal)",
"FalControlNetConfig_fal": "ControlNet Config (fal)",
"FalIPAdapterConfig_fal": "IP-Adapter Config (fal)",
"FalReferenceImage_fal": "Reference Image Element (fal)",
"FalMultiPromptShot_fal": "Multi-Prompt Shot (fal)",
"FalKeyValue_fal": "Key/Value JSON (fal)",
"FalJSONMerge_fal": "JSON Merge (fal)",
}
+48 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import json
from typing import Any
@@ -13,13 +14,20 @@ from ..fal_utils import (
ResultProcessor,
logger,
)
from .factory import stable_hash
from .factory import _ASYNC_CAPABLE, stable_hash
from .outputs import find_url
ANY_ENDPOINT_KEY = "FalAnyEndpoint_fal"
ANY_ENDPOINT_DISPLAY_NAME = "Fal Any Endpoint (fal)"
def _validated_endpoint(endpoint_id: str) -> str:
endpoint = (endpoint_id or "").strip()
if not endpoint:
raise FalApiError("(any endpoint)", "endpoint_id is required")
return endpoint
def _parse_arguments_json(endpoint_id: str, arguments_json: str) -> dict[str, Any]:
text = (arguments_json or "").strip()
if not text:
@@ -194,7 +202,7 @@ class FalAnyEndpoint:
return float("nan")
return stable_hash(kwargs)
def run(
def _run_sync(
self,
endpoint_id: str,
arguments_json: str = "{}",
@@ -205,9 +213,7 @@ class FalAnyEndpoint:
seed: int = -1,
force_rerun: bool = False,
) -> tuple[Any, Any, Any, str]:
endpoint = (endpoint_id or "").strip()
if not endpoint:
raise FalApiError("(any endpoint)", "endpoint_id is required")
endpoint = _validated_endpoint(endpoint_id)
arguments = build_overlay_arguments(
endpoint, arguments_json, image, image_2, video, audio, seed
@@ -218,3 +224,40 @@ class FalAnyEndpoint:
)
return extract_flexible_outputs(result)
async def _run_async(
self,
endpoint_id: str,
arguments_json: str = "{}",
image: Any = None,
image_2: Any = None,
video: Any = None,
audio: Any = None,
seed: int = -1,
force_rerun: bool = False,
) -> tuple[Any, Any, Any, str]:
endpoint = _validated_endpoint(endpoint_id)
# Media uploads (build_overlay_arguments) and result downloads
# (extract_flexible_outputs) are blocking HTTP, so both run in worker
# threads; the fal call awaits on the loop so other branches proceed.
arguments = await asyncio.to_thread(
build_overlay_arguments,
endpoint,
arguments_json,
image,
image_2,
video,
audio,
seed,
)
result = await ApiHandler.submit_and_get_result_async(
endpoint, arguments, skip_cache=bool(force_rerun)
)
return await asyncio.to_thread(extract_flexible_outputs, result)
# On async-capable ComfyUI the executor awaits the coroutine, running
# other graph branches concurrently; older ComfyUI gets the sync path.
run = _run_async if _ASYNC_CAPABLE else _run_sync
+41 -1
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import hashlib
import importlib.util
import inspect
import re
from functools import cache
@@ -16,6 +18,25 @@ from .schema_to_inputs import build_input_types
NODE_KEY_PREFIX = "FalAPI_"
def _detect_async_capable() -> bool:
"""True when the host ComfyUI awaits coroutine node FUNCTIONs.
``comfy_execution/utils.py`` was introduced by the exact commit that added
async node support (Comfy-Org/ComfyUI commit 2b653e8c18, PR #8830,
2025-07-10) and has not been touched since, so its presence is a precise
import-time proxy for ``_async_map_node_over_list`` existing in the
executor. Must never raise outside ComfyUI: a missing ``comfy_execution``
package (tests, older ComfyUI) simply selects the sync path.
"""
try:
return importlib.util.find_spec("comfy_execution.utils") is not None
except Exception:
return False
_ASYNC_CAPABLE = _detect_async_capable()
def node_key(model: dict[str, Any]) -> str:
return NODE_KEY_PREFIX + model["endpoint_id"].replace("/", "-")
@@ -77,6 +98,15 @@ def _call_api(endpoint_id: str, arguments: dict[str, Any], skip_cache: bool) ->
return submit(endpoint_id, arguments)
async def _call_api_async(
endpoint_id: str, arguments: dict[str, Any], skip_cache: bool
) -> Any:
submit = ApiHandler.submit_and_get_result_async
if _accepts_skip_cache(submit):
return await submit(endpoint_id, arguments, skip_cache=skip_cache)
return await submit(endpoint_id, arguments)
def _class_name(model: dict[str, Any]) -> str:
return re.sub(r"[^0-9A-Za-z_]", "_", node_key(model))
@@ -109,6 +139,16 @@ def build_node_class(model: dict[str, Any]) -> type:
result = _call_api(endpoint_id, arguments, bool(kwargs.get("force_rerun")))
return process_result(model, result)
async def run_async(self: Any, **kwargs: Any) -> tuple:
# build_arguments uploads media and process_result downloads results —
# blocking HTTP — so both run in worker threads; only the fal call
# itself awaits on the loop, letting other graph branches proceed.
arguments = await asyncio.to_thread(build_arguments, model, kwargs)
result = await _call_api_async(
endpoint_id, arguments, bool(kwargs.get("force_rerun"))
)
return await asyncio.to_thread(process_result, model, result)
attrs = {
"INPUT_TYPES": classmethod(input_types),
"IS_CHANGED": classmethod(is_changed),
@@ -117,7 +157,7 @@ def build_node_class(model: dict[str, Any]) -> type:
"FUNCTION": "run",
"CATEGORY": f"FAL/Models/{category}",
"DESCRIPTION": _description(model),
"run": run,
"run": run_async if _ASYNC_CAPABLE else run,
"_FAL_ENDPOINT_ID": endpoint_id,
}
return type(_class_name(model), (object,), attrs)
+130 -6
View File
@@ -16,6 +16,7 @@ from .factory import build_display_name, build_node_class, node_key
_REGISTRY_FILENAME = "fal_registry.json"
_FIXTURE_FILENAME = "_fixture_registry.json"
_FEATURED_FILENAME = "featured_models.json"
Mappings = tuple[dict[str, type], dict[str, str]]
@@ -28,6 +29,11 @@ def _registry_path() -> Path:
return package_dir / _FIXTURE_FILENAME
def _featured_path() -> Path:
package_dir = Path(__file__).resolve().parent
return package_dir.parents[1] / "data" / _FEATURED_FILENAME
def _truthy(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
@@ -61,6 +67,76 @@ def _read_models() -> list[dict[str, Any]]:
return []
def _read_featured() -> dict[str, str | None]:
"""Curated featured tier: {endpoint_id: display_name_override_or_None}.
Empty dict when the tier is disabled, the file is missing, or unreadable.
"""
if not _truthy(_get_setting("dynamic_nodes", "featured_tier", True)):
logger.info("Featured fal node tier disabled via config")
return {}
path = _featured_path()
try:
with open(path, encoding="utf-8") as handle:
document = json.load(handle)
entries = document.get("featured", [])
if not isinstance(entries, list):
raise ValueError("'featured' is not a list")
featured: dict[str, str | None] = {}
for entry in entries:
if not isinstance(entry, dict) or not entry.get("endpoint_id"):
continue
override = entry.get("display_name")
featured = {
**featured,
str(entry["endpoint_id"]): str(override) if override else None,
}
return featured
except Exception as err:
logger.debug("No featured fal models applied (%s): %s", path, err)
return {}
def _superseded_map(models: list[dict[str, Any]]) -> dict[str, tuple[str, str]]:
"""{endpoint_id: (newest_endpoint_id, newest_published_date)} per family.
Conservative: models are grouped by (family, category) only when the
registry declares a non-empty ``family`` (no fuzzy title matching), and a
model is flagged only when its group has >1 member and its published_at is
strictly older than the group's newest.
"""
groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
for model in models:
family = str(model.get("family") or "").strip()
if not family or not model.get("endpoint_id"):
continue
group_key = (family, str(model.get("category") or ""))
groups = {**groups, group_key: groups.get(group_key, []) + [model]}
superseded: dict[str, tuple[str, str]] = {}
for members in groups.values():
if len(members) < 2:
continue
newest = max(members, key=lambda m: str(m.get("published_at") or ""))
newest_date = str(newest.get("published_at") or "")
if not newest_date:
continue
for model in members:
if str(model.get("published_at") or "") < newest_date:
superseded = {
**superseded,
str(model["endpoint_id"]): (str(newest["endpoint_id"]), newest_date[:10]),
}
return superseded
def _apply_superseded_note(node_class: type, newest_id: str, newest_date: str) -> None:
"""Prefix the class DESCRIPTION with a newer-release warning."""
note = f"Superseded: a newer release exists in this family: {newest_id} ({newest_date})"
existing = str(getattr(node_class, "DESCRIPTION", "") or "")
node_class.DESCRIPTION = f"{note}\n\n{existing}".rstrip()
def _unique_display_name(name: str, used: set[str]) -> str:
if name not in used:
return name
@@ -71,12 +147,18 @@ def _unique_display_name(name: str, used: set[str]) -> str:
def _build_model_mappings(
models: list[dict[str, Any]], categories: set[str]
) -> tuple[dict[str, type], dict[str, str], int]:
models: list[dict[str, Any]],
categories: set[str],
featured: dict[str, str | None] | None = None,
superseded: dict[str, tuple[str, str]] | None = None,
) -> tuple[dict[str, type], dict[str, str], int, int]:
classes: dict[str, type] = {}
display: dict[str, str] = {}
used_names: set[str] = {ANY_ENDPOINT_DISPLAY_NAME}
featured = featured or {}
superseded = superseded or {}
skipped = 0
flagged = 0
for model in models:
try:
@@ -88,7 +170,19 @@ def _build_model_mappings(
logger.debug("Duplicate dynamic node key skipped: %s", key)
continue
node_class = build_node_class(model)
name = _unique_display_name(build_display_name(model), used_names)
endpoint_id = str(model.get("endpoint_id") or "")
preferred = build_display_name(model)
if endpoint_id in featured:
category = str(model.get("category") or "other")
node_class.CATEGORY = f"FAL/Featured/{category}"
preferred = featured[endpoint_id] or preferred
if endpoint_id in superseded:
newest_id, newest_date = superseded[endpoint_id]
_apply_superseded_note(node_class, newest_id, newest_date)
flagged += 1
name = _unique_display_name(preferred, used_names)
classes = {**classes, key: node_class}
display = {**display, key: name}
used_names.add(name)
@@ -100,7 +194,26 @@ def _build_model_mappings(
err,
)
return classes, display, skipped
return classes, display, skipped, flagged
def _log_missing_featured(featured: dict[str, str | None], models: list[dict[str, Any]]) -> int:
"""Debug-log featured ids absent from the registry; returns how many matched."""
registry_ids = {str(m.get("endpoint_id") or "") for m in models}
missing = [endpoint_id for endpoint_id in featured if endpoint_id not in registry_ids]
for endpoint_id in missing:
logger.debug("Featured model not in registry, skipped: %s", endpoint_id)
return len(featured) - len(missing)
def _schedule_freshness_check() -> None:
"""Kick off the delayed registry freshness check; never raises."""
try:
from ..utils.freshness import schedule_startup_check
schedule_startup_check()
except Exception as err:
logger.debug("Could not schedule registry freshness check: %s", err)
def load_dynamic_mappings() -> Mappings:
@@ -112,14 +225,25 @@ def load_dynamic_mappings() -> Mappings:
categories = _category_filter()
models = _read_models()
classes, display, skipped = _build_model_mappings(models, categories)
featured = _read_featured()
featured_count = _log_missing_featured(featured, models)
superseded = _superseded_map(models)
classes, display, skipped, flagged = _build_model_mappings(
models, categories, featured=featured, superseded=superseded
)
all_classes = {ANY_ENDPOINT_KEY: FalAnyEndpoint, **classes}
all_display = {ANY_ENDPOINT_KEY: ANY_ENDPOINT_DISPLAY_NAME, **display}
logger.info(
"Registered %d dynamic fal nodes (skipped %d)", len(all_classes), skipped
"Registered %d dynamic fal nodes (skipped %d, featured %d, "
"%d flagged as superseded within their family)",
len(all_classes),
skipped,
featured_count,
flagged,
)
_schedule_freshness_check()
return all_classes, all_display
except Exception as err:
logger.error("Dynamic fal node loading failed entirely: %s", err)
+127
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import os
import threading
import time
from typing import Any, Callable
from .utils.billing import BillingUtils
@@ -188,11 +189,122 @@ def _search_models(
"title": model.get("title") or model["endpoint_id"],
"category": model.get("category"),
"label": (info or {}).get("label"),
"thumbnail": model.get("thumbnail") or None,
}
for model, info in hits[:capped]
]
# -- registry freshness + refresh -----------------------------------------------
_RESTART_NOTE = "Restart ComfyUI after the refresh finishes: new nodes register at import time."
_REFRESH_TIMEOUT_S = 1800
_refresh_lock = threading.Lock()
_refresh_state: dict[str, Any] = {
"running": False,
"started_at": None,
"finished_at": None,
"ok": None,
"message": "Registry refresh has not been started.",
}
def _repo_root() -> str:
nodes_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.dirname(nodes_dir)
def _registry_status() -> dict[str, Any]:
"""Cached diff of the live fal catalog vs. the local registry (may fetch)."""
from .utils.freshness import check_for_new_models
return check_for_new_models(timeout_s=20)
def _refresh_status() -> dict[str, Any]:
"""Snapshot of the background registry-refresh state."""
with _refresh_lock:
return {**_refresh_state, "restart_note": _RESTART_NOTE}
def _run_refresh_subprocess() -> tuple[bool, str]:
"""Run scripts/build_registry.py; returns (ok, message)."""
import subprocess
import sys
root = _repo_root()
command = [
sys.executable,
os.path.join(root, "scripts", "build_registry.py"),
"--out",
os.path.join("data", "fal_registry.json"),
]
completed = subprocess.run(
command, cwd=root, capture_output=True, text=True, timeout=_REFRESH_TIMEOUT_S
)
if completed.returncode != 0:
tail = (completed.stderr or completed.stdout or "").strip()[-500:]
return False, f"build_registry.py exited with {completed.returncode}: {tail}"
return True, f"Registry refreshed. {_RESTART_NOTE}"
def _finish_refresh(ok: bool, message: str) -> None:
global _refresh_state
with _refresh_lock:
_refresh_state = {
**_refresh_state,
"running": False,
"finished_at": time.time(),
"ok": ok,
"message": message,
}
def _refresh_worker(runner: Callable[[], tuple[bool, str]]) -> None:
"""Run the refresh and record the outcome. Never raises."""
try:
ok, message = runner()
except Exception as exc:
logger.warning("server_routes: registry refresh failed: %s", exc)
ok, message = False, f"Registry refresh failed: {exc}"
_finish_refresh(ok, message)
logger.info("server_routes: registry refresh finished (ok=%s): %s", ok, message)
def _start_refresh(
runner: Callable[[], tuple[bool, str]] | None = None,
spawn: Callable[[Callable[[], None]], None] | None = None,
) -> dict[str, Any]:
"""Start a background registry rebuild; no-op when one is already running.
``runner``/``spawn`` are injectable for tests (stub subprocess / run inline).
"""
global _refresh_state
with _refresh_lock:
if _refresh_state["running"]:
return {"started": False, **_refresh_state, "restart_note": _RESTART_NOTE}
_refresh_state = {
**_refresh_state,
"running": True,
"started_at": time.time(),
"finished_at": None,
"ok": None,
"message": "Registry refresh running — rebuilding data/fal_registry.json...",
}
active_runner = runner or _run_refresh_subprocess
def work() -> None:
_refresh_worker(active_runner)
if spawn is not None:
spawn(work)
else:
threading.Thread(target=work, name="fal-registry-refresh", daemon=True).start()
return {"started": True, **_refresh_status()}
def _cancel(endpoint_id: str, request_id: str) -> dict[str, Any]:
"""Best-effort cancel of a queued fal request via fal_client. Never raises."""
endpoint = (endpoint_id or "").strip()
@@ -280,6 +392,18 @@ async def models_route(request: Any) -> Any:
)
async def registry_status_route(request: Any) -> Any:
return _guarded(_registry_status, "/fal_api/registry_status")
async def registry_refresh_start_route(request: Any) -> Any:
return _guarded(_start_refresh, "/fal_api/registry_refresh")
async def registry_refresh_status_route(request: Any) -> Any:
return _guarded(_refresh_status, "/fal_api/registry_refresh")
async def cancel_route(request: Any) -> Any:
try:
body = await request.json()
@@ -299,6 +423,9 @@ ROUTES: tuple[tuple[str, str, Callable[..., Any]], ...] = (
("GET", "/fal_api/jobs", jobs_route),
("GET", "/fal_api/balance", balance_route),
("GET", "/fal_api/models", models_route),
("GET", "/fal_api/registry_status", registry_status_route),
("GET", "/fal_api/registry_refresh", registry_refresh_status_route),
("POST", "/fal_api/registry_refresh", registry_refresh_start_route),
("POST", "/fal_api/cancel", cancel_route),
)
+94 -6
View File
@@ -189,6 +189,34 @@ def _remember_result_urls(endpoint: str, request_id: str | None, result: Any) ->
except Exception as exc:
logger.debug("[%s] remember_urls failed: %s", endpoint, exc)
def _finalize_live_call(endpoint: str, request_id: str | None, started: float) -> None:
"""Log the finished call and record it in the session ledger."""
duration_s = time.monotonic() - started
logger.info(
"[%s] call finished in %.1fs (request_id=%s)",
endpoint,
duration_s,
request_id,
)
_record_ledger_entry(endpoint, request_id, duration_s)
async def _close_async_client(client: Any) -> None:
"""Best-effort close of a per-call AsyncClient's underlying httpx client.
fal_client.AsyncClient lazily caches an httpx.AsyncClient per instance
(bound to the current event loop); we create one AsyncClient per call, so
close it here to avoid leaking connections. Resolving ``_client`` does no
network I/O; any failure is swallowed — cleanup must never mask a result
or an error from the call itself.
"""
try:
httpx_client = await client._client
await httpx_client.aclose()
except Exception as exc:
logger.debug("async fal client close failed: %s", exc)
def _raise_generation_error(model_name: str, error: Exception | str) -> NoReturn:
"""Normalize an exception or error string into a raised FalApiError."""
if isinstance(error, BaseException):
@@ -252,14 +280,74 @@ class ApiHandler:
raise
raise_fal_error(endpoint, exc)
finally:
duration_s = time.monotonic() - started
logger.info(
"[%s] call finished in %.1fs (request_id=%s)",
_finalize_live_call(endpoint, request_id_ref[0], started)
_store_result_in_cache(endpoint, arguments, result, request_id_ref[0])
_remember_result_urls(endpoint, request_id_ref[0], result)
return result
@staticmethod
async def submit_and_get_result_async(
endpoint: str,
arguments: dict[str, Any],
skip_cache: bool = False,
) -> Any:
"""Async twin of ``submit_and_get_result`` for async-capable ComfyUI.
Same semantics — spend-guard preflight, persistent result cache,
queue-progress logging, interruption via the queue callback, ledger
recording and cache/provenance bookkeeping — but awaits the fal call
on the event loop so the executor can run other graph branches
concurrently. The AsyncClient is created per call because its cached
httpx client is bound to the current event loop (ComfyUI runs each
prompt in a fresh loop via ``asyncio.run``).
"""
# Cache first: a hit costs nothing, so it must not be blocked by the
# spend guard (which only gates live, billable calls).
if not skip_cache:
cached = ResultCache().get(endpoint, arguments)
if cached is not None:
return cached
# off-loop: preflight may make a blocking balance HTTP call
await asyncio.to_thread(_spend_guard_preflight, endpoint)
from fal_client import AsyncClient
# Validate the key via get_client() first so a missing/placeholder key
# raises the actionable config error instead of a raw auth failure.
FalConfig().get_client()
client = AsyncClient(key=FalConfig().get_key())
callback = _make_queue_callback(endpoint)
request_id_ref: list[str | None] = [None]
def on_enqueue(request_id: str) -> None:
request_id_ref[0] = request_id
# The queue callback checks interruption on every update while the job
# runs; this covers a cancel that landed before submission (and stays
# outside the try so it cannot record a ledger entry for a job that
# was never submitted).
_check_interruption()
started = time.monotonic()
try:
result = await client.subscribe(
endpoint,
duration_s,
request_id_ref[0],
arguments=arguments,
with_logs=True,
on_enqueue=on_enqueue,
on_queue_update=callback,
)
_record_ledger_entry(endpoint, request_id_ref[0], duration_s)
except FalApiError:
raise
except Exception as exc:
if _is_interruption(exc):
raise
raise_fal_error(endpoint, exc)
finally:
_finalize_live_call(endpoint, request_id_ref[0], started)
await _close_async_client(client)
_store_result_in_cache(endpoint, arguments, result, request_id_ref[0])
_remember_result_urls(endpoint, request_id_ref[0], result)
+217
View File
@@ -0,0 +1,217 @@
"""Checks the live fal.ai catalog for models missing from the local registry.
``check_for_new_models`` diffs the public catalog against the committed
``data/fal_registry.json`` and caches the result module-level (1h TTL) so the
sidebar and the startup check share one fetch. ``schedule_startup_check``
spawns a delayed daemon thread that logs a single INFO line when the local
registry is behind. Nothing in here may break node loading: the startup path
never raises.
"""
from __future__ import annotations
import json
import os
import threading
import time
from typing import Any
from .logger import logger
CATALOG_URL = "https://fal.ai/api/models?page={page}&total={total}"
_USER_AGENT = "ComfyUI-fal-API-freshness/1.0"
_PAGE_SIZE = 100
_MAX_PAGES = 25
_MAX_NEW_LISTED = 25
_CACHE_TTL_S = 3600.0
_STARTUP_DELAY_S = 10.0
_DEFAULT_TIMEOUT_S = 20.0
_lock = threading.Lock()
_cached_result: dict[str, Any] | None = None
_startup_scheduled = False
def _registry_path() -> str:
"""Path to data/fal_registry.json at the repo root."""
utils_dir = os.path.dirname(os.path.abspath(__file__))
repo_root = os.path.dirname(os.path.dirname(utils_dir))
return os.path.join(repo_root, "data", "fal_registry.json")
def _registry_endpoint_ids() -> set[str]:
"""Endpoint ids present in the committed registry; empty set on failure."""
try:
with open(_registry_path(), encoding="utf-8") as handle:
registry = json.load(handle)
models = registry.get("models")
if not isinstance(models, list):
raise ValueError("'models' is not a list")
return {
str(model["endpoint_id"])
for model in models
if isinstance(model, dict) and model.get("endpoint_id")
}
except Exception as err:
logger.debug("freshness: could not read local registry: %s", err)
return set()
def _extract_items(payload: Any) -> list[dict[str, Any]]:
"""Normalize one catalog API page into a list of item dicts."""
if isinstance(payload, list):
raw = payload
elif isinstance(payload, dict):
raw = next(
(
payload[key]
for key in ("items", "models", "data", "results")
if isinstance(payload.get(key), list)
),
[],
)
else:
raw = []
return [item for item in raw if isinstance(item, dict)]
def _fetch_catalog(timeout_s: float) -> list[dict[str, Any]]:
"""Fetch catalog pages until an empty page (hard cap _MAX_PAGES).
Raises RuntimeError when the very first page cannot be fetched; a failure
on a later page returns the partial catalog (better a lower bound than
nothing).
"""
import requests
items: list[dict[str, Any]] = []
for page in range(1, _MAX_PAGES + 1):
url = CATALOG_URL.format(page=page, total=_PAGE_SIZE)
try:
response = requests.get(url, headers={"User-Agent": _USER_AGENT}, timeout=timeout_s)
response.raise_for_status()
page_items = _extract_items(response.json())
except Exception as err:
if page == 1:
raise RuntimeError(f"fal catalog fetch failed: {err}") from err
logger.debug("freshness: catalog page %d failed (%s); using partial catalog", page, err)
break
if not page_items:
break
items = items + page_items
return items
def _is_live_public(item: dict[str, Any]) -> bool:
return bool(
item.get("id")
and item.get("status") == "public"
and not item.get("deprecated")
and not item.get("removed")
)
def _new_model_entry(item: dict[str, Any]) -> dict[str, Any]:
return {
"endpoint_id": str(item.get("id") or ""),
"title": str(item.get("title") or "").strip(),
"category": str(item.get("category") or "").strip(),
"published_at": str(item.get("publishedAt") or item.get("date") or "").strip(),
}
def check_for_new_models(timeout_s: float = _DEFAULT_TIMEOUT_S) -> dict[str, Any]:
"""Diff the live fal catalog against the local registry (cached, 1h TTL).
Returns ``{"new_count", "new_models" (newest first, max 25), "checked_at"}``.
Raises RuntimeError when the catalog cannot be reached at all; failed runs
are never cached.
"""
global _cached_result
with _lock:
if (
_cached_result is not None
and time.time() - float(_cached_result.get("checked_at", 0)) < _CACHE_TTL_S
):
return _cached_result
known_ids = _registry_endpoint_ids()
catalog = _fetch_catalog(timeout_s)
live = [item for item in catalog if _is_live_public(item)]
seen: set[str] = set()
fresh: list[dict[str, Any]] = []
for item in live:
endpoint_id = str(item["id"])
if endpoint_id in known_ids or endpoint_id in seen:
continue
seen.add(endpoint_id)
fresh = fresh + [_new_model_entry(item)]
fresh.sort(key=lambda entry: entry["published_at"], reverse=True)
result = {
"new_count": len(fresh),
"new_models": fresh[:_MAX_NEW_LISTED],
"checked_at": time.time(),
}
with _lock:
_cached_result = result
return result
def _startup_check_enabled() -> bool:
if os.environ.get("FAL_DISABLE_STARTUP_CHECK"):
return False
try:
from .config import FalConfig
value = FalConfig().get_setting("registry", "startup_check", True)
except Exception as err:
logger.debug("freshness: could not read startup_check setting: %s", err)
return True
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return bool(value)
def _startup_worker() -> None:
"""Delayed freshness check; logs one INFO line, never raises."""
try:
time.sleep(_STARTUP_DELAY_S)
result = check_for_new_models()
new_count = result.get("new_count", 0)
if new_count:
logger.info(
"fal catalog: %d models newer than the local registry — "
"see the fal sidebar or run scripts/build_registry.py",
new_count,
)
else:
logger.debug("fal catalog: local registry is up to date")
except Exception as err:
logger.debug("fal registry freshness check failed: %s", err)
def schedule_startup_check() -> bool:
"""Spawn the delayed startup freshness thread once. Never raises.
Returns True when a thread was started (enabled and not yet scheduled).
"""
global _startup_scheduled
try:
with _lock:
if _startup_scheduled:
return False
_startup_scheduled = True
if not _startup_check_enabled():
logger.debug("freshness: startup check disabled via config")
return False
thread = threading.Thread(
target=_startup_worker, name="fal-registry-freshness", daemon=True
)
thread.start()
return True
except Exception as err:
logger.debug("freshness: could not schedule startup check: %s", err)
return False
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "fal-api"
description = "Custom nodes for using fal API with auto-generated full-catalog coverage of fal.ai models. Video generation with Kling, Runway, Luma. Image generation with Flux. LLMs and VLMs OpenAI, Claude, Llama and Gemini."
version = "2.4.1"
version = "2.5.0"
license = {file = "LICENSE"}
requires-python = ">=3.9"
dependencies = [
+39 -19
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
"""Regenerate the auto-generated model list section of README.md.
"""Regenerate the auto-generated model catalog in MODELS.md.
Reads data/fal_registry.json and rewrites ONLY the section between
`<!-- BEGIN GENERATED MODEL LIST -->` and `<!-- END GENERATED MODEL LIST -->`
in README.md. Everything outside the markers is left untouched, and running
the script twice in a row produces no diff.
in MODELS.md. Everything outside the markers is left untouched, and running
the script twice in a row produces no diff. If MODELS.md does not exist yet,
it is created with a standard header around the markers.
Usage:
python scripts/build_readme.py
@@ -19,11 +20,24 @@ from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
REGISTRY_PATH = REPO_ROOT / "data" / "fal_registry.json"
README_PATH = REPO_ROOT / "README.md"
MODELS_PATH = REPO_ROOT / "MODELS.md"
BEGIN_MARKER = "<!-- BEGIN GENERATED MODEL LIST -->"
END_MARKER = "<!-- END GENERATED MODEL LIST -->"
MODELS_TEMPLATE = f"""# fal Model Catalog — auto-generated
Every auto-generated model node in [ComfyUI-fal-API](README.md), grouped by
category (largest first). Click a category to expand it.
Do not edit this file by hand — refresh `data/fal_registry.json` with
`python scripts/build_registry.py`, then regenerate this catalog with
`python scripts/build_readme.py`.
{BEGIN_MARKER}
{END_MARKER}
"""
MODEL_URL_TEMPLATE = "https://fal.ai/models/{endpoint_id}"
@@ -103,33 +117,39 @@ def render_generated_section(registry: dict[str, Any]) -> str:
return "\n\n".join([summary, *blocks])
def replace_between_markers(readme: str, generated: str) -> str:
begin = readme.find(BEGIN_MARKER)
end = readme.find(END_MARKER)
def replace_between_markers(document: str, generated: str) -> str:
begin = document.find(BEGIN_MARKER)
end = document.find(END_MARKER)
if begin == -1 or end == -1 or end < begin:
raise SystemExit(
f"README.md must contain '{BEGIN_MARKER}' followed by '{END_MARKER}'"
f"MODELS.md must contain '{BEGIN_MARKER}' followed by '{END_MARKER}'"
)
head = readme[: begin + len(BEGIN_MARKER)]
tail = readme[end:]
head = document[: begin + len(BEGIN_MARKER)]
tail = document[end:]
return f"{head}\n\n{generated}\n\n{tail}"
def read_models_document(path: Path) -> str:
if not path.is_file():
return MODELS_TEMPLATE
try:
return path.read_text(encoding="utf-8")
except OSError as err:
raise SystemExit(f"Failed to read {path}: {err}") from err
def main() -> int:
registry = load_registry(REGISTRY_PATH)
try:
readme = README_PATH.read_text(encoding="utf-8")
except OSError as err:
raise SystemExit(f"Failed to read {README_PATH}: {err}") from err
document = read_models_document(MODELS_PATH)
updated = replace_between_markers(readme, render_generated_section(registry))
if updated == readme:
print(f"README.md already up to date ({registry.get('model_count')} models)")
updated = replace_between_markers(document, render_generated_section(registry))
if MODELS_PATH.is_file() and updated == document:
print(f"MODELS.md already up to date ({registry.get('model_count')} models)")
return 0
README_PATH.write_text(updated, encoding="utf-8")
MODELS_PATH.write_text(updated, encoding="utf-8")
print(
f"README.md model list regenerated: {registry.get('model_count')} models, "
f"MODELS.md model catalog regenerated: {registry.get('model_count')} models, "
f"{len(group_by_category(registry['models']))} categories"
)
return 0
+7 -1
View File
@@ -17,6 +17,7 @@ Stdlib only. Usage:
import argparse
import json
import logging
import os
import time
import urllib.error
import urllib.request
@@ -621,7 +622,10 @@ def main():
"models": records,
}
with open(args.out, "w", encoding="utf-8") as handle:
# 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,
@@ -632,6 +636,8 @@ def main():
)
handle.write("\n")
os.replace(tmp_out, args.out)
log_summary(records, skipped)
logger.info("Wrote %d models to %s", len(records), args.out)
+3
View File
@@ -14,6 +14,9 @@ import pytest
ROOT = Path(__file__).resolve().parents[1]
PKG = "ComfyUI_fal_API"
# never let the freshness daemon make live network calls during tests
os.environ.setdefault("FAL_DISABLE_STARTUP_CHECK", "1")
# keep the persistent result cache out of the user's real cache dir during tests
os.environ.setdefault(
"COMFYUI_FAL_API_CACHE_DB",
+1 -1
View File
@@ -111,7 +111,7 @@ def test_extract_frames_from_real_video(pack, tmp_path):
def test_all_util_nodes_have_tooltips(pack):
util_keys = [k for k, c in pack.NODE_CLASS_MAPPINGS.items() if c.CATEGORY.startswith("FAL/Utils")]
assert len(util_keys) == 20
assert len(util_keys) == 28 # 20 utility nodes + 8 typed builders
for key in util_keys:
input_types = pack.NODE_CLASS_MAPPINGS[key].INPUT_TYPES()
for bucket in ("required", "optional"):
+28 -2
View File
@@ -39,22 +39,48 @@ function findTarget(canvas, value) {
return null;
}
function resultThumb(model) {
if (!model?.thumbnail || typeof model.thumbnail !== "string") return null;
try {
const img = document.createElement("img");
img.className = "fal-suggest-thumb";
img.src = model.thumbnail;
img.loading = "lazy";
img.decoding = "async";
img.alt = "";
img.addEventListener("error", () => {
img.style.display = "none";
});
return img;
} catch (error) {
console.debug("[fal] suggestion thumbnail failed", error);
return null;
}
}
function resultRow(model, apply) {
const row = document.createElement("div");
row.className = "fal-suggest-item";
const thumb = resultThumb(model);
if (thumb) row.append(thumb);
const text = document.createElement("div");
text.className = "fal-suggest-text";
const title = document.createElement("span");
title.className = "fal-suggest-title";
title.textContent = model.title || model.endpoint_id;
const endpoint = document.createElement("span");
endpoint.className = "fal-suggest-endpoint";
endpoint.textContent = model.endpoint_id;
row.append(title, endpoint);
text.append(title, endpoint);
if (model.label) {
const price = document.createElement("span");
price.className = "fal-suggest-price";
price.textContent = model.label;
row.append(price);
text.append(price);
}
row.append(text);
row.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopPropagation();
+80 -2
View File
@@ -165,12 +165,29 @@
.fal-suggest-item {
display: flex;
flex-direction: column;
gap: 1px;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 6px 10px;
cursor: pointer;
}
.fal-suggest-thumb {
flex: none;
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
}
.fal-suggest-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.fal-suggest-item:hover {
background: rgba(167, 139, 250, 0.15);
}
@@ -188,3 +205,64 @@
font-size: 10px;
color: #c4b5fd;
}
/* Registry freshness section in the sidebar panel. */
.fal-registry {
display: flex;
flex-direction: column;
gap: 4px;
}
.fal-registry-news {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border: 1px solid rgba(167, 139, 250, 0.25);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
}
.fal-registry-count {
font-weight: 600;
color: #c4b5fd;
}
.fal-registry-model {
font-size: 11px;
opacity: 0.8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fal-registry-refresh {
margin-top: 4px;
padding: 4px 10px;
border: 1px solid rgba(167, 139, 250, 0.5);
border-radius: 6px;
background: transparent;
color: #ece9fd;
font-size: 11px;
cursor: pointer;
}
.fal-registry-refresh:hover:not(:disabled) {
background: rgba(167, 139, 250, 0.15);
}
.fal-registry-refresh:disabled {
opacity: 0.55;
cursor: default;
}
.fal-registry-done {
font-size: 11px;
color: #86efac;
}
.fal-registry-error {
font-size: 11px;
color: #fca5a5;
}
+110 -1
View File
@@ -4,6 +4,9 @@ import { formatUsd, getJson, humanAge, postJson, shortEndpoint } from "./fal_api
const REFRESH_MS = 3000;
const JOB_LIMIT = 50;
const REGISTRY_TITLE_LIMIT = 5;
const REGISTRY_POLL_MS = 3000;
const REGISTRY_POLL_MAX = 600;
let refreshTimer = null;
let panelRoot = null;
@@ -87,16 +90,118 @@ function buildPanel() {
const jobsHeader = element("div", "fal-jobs-header", "Jobs");
const jobs = element("div", "fal-jobs");
root.append(stats, jobsHeader, jobs);
const registryHeader = element("div", "fal-jobs-header", "Registry");
const registry = element("div", "fal-registry");
registry.append(element("div", "fal-muted", "checking for new models…"));
root.append(stats, jobsHeader, jobs, registryHeader, registry);
return {
root,
sessionValue: session.lastChild,
balanceValue: balance.lastChild,
jobsHeader,
jobs,
registryHeader,
registry,
};
}
// -- Registry freshness section -------------------------------------------------
function registryDone(view, ok, message) {
const note = element(
"div",
ok ? "fal-registry-done" : "fal-registry-error",
ok ? "done — restart ComfyUI to load new nodes" : message || "refresh failed"
);
view.registry.append(note);
}
async function pollRefresh(view, button) {
for (let attempt = 0; attempt < REGISTRY_POLL_MAX; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, REGISTRY_POLL_MS));
if (!view.registry.isConnected) return;
let status = null;
try {
status = await getJson("/registry_refresh");
} catch (error) {
console.debug("[fal] registry refresh poll failed", error);
continue;
}
if (status && status.running === false && status.finished_at) {
registryDone(view, status.ok === true, status.message);
return;
}
}
if (button) button.textContent = "Still running \u2014 check back later";
}
async function startRegistryRefresh(view, button) {
try {
button.disabled = true;
button.textContent = "Refreshing…";
const result = await postJson("/registry_refresh", {});
if (!result?.started && result?.running !== true) {
registryDone(view, false, result?.message || "could not start refresh");
return;
}
await pollRefresh(view, button);
} catch (error) {
console.debug("[fal] registry refresh failed", error);
registryDone(view, false, "refresh request failed");
}
}
function renderRegistry(view, status) {
try {
const count = Number(status?.new_count) || 0;
if (count <= 0) {
view.registry.replaceChildren(element("div", "fal-muted", "Registry is up to date."));
return;
}
const box = element("div", "fal-registry-news");
box.append(
element("div", "fal-registry-count", `${count} new model${count === 1 ? "" : "s"} on fal`)
);
const models = Array.isArray(status?.new_models) ? status.new_models : [];
for (const model of models.slice(0, REGISTRY_TITLE_LIMIT)) {
const title = model?.title || model?.endpoint_id || "";
if (!title) continue;
const row = element("div", "fal-registry-model", title);
if (model?.endpoint_id) row.title = model.endpoint_id;
box.append(row);
}
if (count > REGISTRY_TITLE_LIMIT) {
box.append(element("div", "fal-muted", `…and ${count - REGISTRY_TITLE_LIMIT} more`));
}
const button = element("button", "fal-registry-refresh", "Refresh registry");
button.addEventListener("click", () => {
startRegistryRefresh(view, button).catch((error) =>
console.debug("[fal] registry refresh flow failed", error)
);
});
box.append(button);
view.registry.replaceChildren(box);
} catch (error) {
console.debug("[fal] registry render failed", error);
}
}
async function loadRegistrySection(view) {
try {
const status = await getJson("/registry_status");
renderRegistry(view, status);
} catch (error) {
console.debug("[fal] registry status failed", error);
try {
view.registry.replaceChildren(element("div", "fal-muted", "Registry status unavailable."));
} catch (renderError) {
console.debug("[fal] registry fallback render failed", renderError);
}
}
}
function renderSession(target, data) {
const total = formatUsd(data?.total_usd) ?? "$0";
const calls = data?.calls ?? 0;
@@ -156,6 +261,10 @@ export function mountPanel(container) {
panelRoot = view.root;
container.replaceChildren(view.root);
startRefreshLoop(view);
// Fetched once per panel open (server-side result is cached for an hour).
loadRegistrySection(view).catch((error) =>
console.debug("[fal] registry section load failed", error)
);
}
function mountFloatingFallback() {