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:
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,7 @@ node_list = [
|
||||
"util_video_node",
|
||||
"util_image_node",
|
||||
"util_data_node",
|
||||
"builder_node",
|
||||
]
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
@@ -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)",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user