Compare commits

1 Commits
Author SHA1 Message Date
Gokay Aydogan aba69be9a2 feat: v2.0.0 — full fal catalog coverage, dynamic nodes, core rewrite
Auto-generated nodes for the entire live fal catalog (1,391 models →
1,482 total nodes) built at startup from a committed registry, plus a
full rewrite of the core layer. All 87 v1 node keys, inputs and outputs
are preserved — existing workflows load and run unchanged.

New:
- data/fal_registry.json + scripts/build_registry.py: distills fal's
  catalog and per-endpoint OpenAPI schemas into a deterministic registry
- nodes/dynamic/: schema→node factory (typed widgets, tooltips, pricing
  in node help, IMAGE/VIDEO/AUDIO auto-upload, native VIDEO/AUDIO
  outputs, seed -1=random, force_rerun), plus a generic Fal Any Endpoint
  node for arbitrary endpoints
- nodes/utils/: rewritten core — FalApiError surfacing fal's real error
  payloads, queue progress logs, ComfyUI cancel support, retries and
  timeouts everywhere, parallel uploads/downloads, structured logging
- tests/ (45 tests incl. a legacy-key snapshot lock), CI lint+test
  workflow, weekly registry-refresh workflow that opens a PR on changes
- README regenerated from the registry (scripts/build_readme.py)

Fixed:
- GPTImage2 / GPTImage2Edit were defined but never registered
- Topaz video upscale sent a nonexistent API field (desired_increase)
- SeedVR video upscaler had its error handling commented out + wrong
  model label; ProRes enum value mismatch translated at argument level
- 10x `is ""` comparisons, bare excepts, missing HTTP timeouts,
  temp-file leaks, fps=0 crash in LoadVideoURL, duplicate enum entry
- opencv-python & friends missing from pyproject (Registry installs)
- API failures now raise visible errors instead of silently returning
  blank images or "Error:" strings that downstream nodes tried to load

BREAKING (behavioral only): failed API calls raise instead of returning
blank/black images or error strings. Node signatures are unchanged.
2026-07-02 12:42:06 +03:00
59 changed files with 1888 additions and 10628 deletions
-2
View File
@@ -23,8 +23,6 @@ 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: |
-1
View File
@@ -174,4 +174,3 @@ memory-bank/
.DS_Store
.claude/
Node-Docs/
output/
-93
View File
@@ -1,93 +0,0 @@
# 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
+1792 -101
View File
File diff suppressed because it is too large Load Diff
+1 -21
View File
@@ -8,15 +8,6 @@ node_list = [
"vlm_node",
"trainer_node",
"upscaler_node",
"platform_node",
"billing_node",
"inbox_node",
"util_dataset_node",
"util_media_in_node",
"util_video_node",
"util_image_node",
"util_data_node",
"builder_node",
]
NODE_CLASS_MAPPINGS = {}
@@ -48,15 +39,4 @@ except Exception as _dynamic_error: # never break static nodes
)
WEB_DIRECTORY = "./web"
try:
from .nodes import server_routes as _server_routes # noqa: F401 registers /fal_api routes
except Exception as _routes_error: # never break node loading over HTTP extras
import logging
logging.getLogger(__name__).warning(
"fal API server routes not registered: %s", _routes_error
)
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
-19
View File
@@ -1,21 +1,2 @@
[API]
FAL_KEY = <your_fal_api_key_here>
; --- Optional sections (defaults shown; uncomment to change) ---
; [dynamic_nodes]
; enabled = true
; categories = text-to-image,image-to-video
; [cache]
; Persistent result cache: identical fal calls are served from disk (free)
; across ComfyUI restarts. Bypass per node with force_rerun.
; enabled = true
; ttl_days = 7
; max_entries = 5000
; [spend_guard]
; Refuse to submit jobs once the session's estimated spend reaches the
; budget, or when the account balance falls below the floor. 0 = disabled.
; session_budget_usd = 0
; min_balance_usd = 0
-31
View File
@@ -1,31 +0,0 @@
{
"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}
]
}
-95
View File
@@ -1,95 +0,0 @@
"""Billing nodes: account balance reporting with spend-guard visibility."""
from __future__ import annotations
from typing import Any
from .fal_utils import BillingUtils, SpendGuard, logger
_CATEGORY = "FAL/Platform"
_UNAVAILABLE_HINT = (
"Hint: the balance API needs an API key with billing access "
"(Authorization: Key ...); check your key at https://fal.ai/dashboard/keys."
)
def _guard_line(settings: dict[str, float]) -> str:
"""One-line summary of the active spend-guard configuration."""
budget = settings.get("session_budget_usd") or 0.0
floor = settings.get("min_balance_usd") or 0.0
if budget <= 0 and floor <= 0:
return (
"Spend guard: disabled (set [spend_guard] session_budget_usd / "
"min_balance_usd in config.ini to enable)"
)
parts = []
if budget > 0:
parts.append(f"session budget ${budget:.2f}")
if floor > 0:
parts.append(f"min balance ${floor:.2f}")
return f"Spend guard: {', '.join(parts)}"
def _build_report(balance: float | None, settings: dict[str, float]) -> str:
"""Human-readable balance report. Never raises."""
if balance is not None:
lines = [f"fal account balance: ${balance:,.2f}"]
else:
lines = ["fal account balance: unavailable", _UNAVAILABLE_HINT]
return "\n".join([*lines, _guard_line(settings)])
class FalBalance:
"""Report the fal.ai account credit balance and active spend-guard limits."""
RETURN_TYPES = ("STRING", "FLOAT")
RETURN_NAMES = ("report", "balance_usd")
FUNCTION = "check"
CATEGORY = _CATEGORY
OUTPUT_NODE = True
DESCRIPTION = (
"Fetch your fal.ai account credit balance and show the active "
"spend-guard settings. Never fails: balance_usd is -1.0 when the "
"balance API is unavailable."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {},
"optional": {
"force_refresh": (
"BOOLEAN",
{
"default": False,
"tooltip": "Bypass the 60s balance cache and query fal again",
},
),
},
}
@classmethod
def IS_CHANGED(cls, **kwargs: Any) -> Any:
# The balance changes outside the graph; always re-run.
return float("nan")
def check(self, force_refresh: bool = False) -> tuple[str, float]:
try:
balance = BillingUtils.get_balance(force=bool(force_refresh))
settings = SpendGuard.settings()
report = _build_report(balance, settings)
except Exception as err: # This node must never fail the graph.
logger.warning("FalBalance: could not build balance report: %s", err)
balance = None
report = f"fal account balance: unavailable ({err})\n{_UNAVAILABLE_HINT}"
return (report, float(balance) if balance is not None else -1.0)
NODE_CLASS_MAPPINGS = {
"FalBalance_fal": FalBalance,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FalBalance_fal": "Fal Account Balance (fal)",
}
-743
View File
@@ -1,743 +0,0 @@
"""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)",
}
+1 -151
View File
@@ -216,7 +216,7 @@ def _test_fixture_behaviour(dyn, stub):
# --- end-to-end run() per output kind ---
flux_node = factory.build_node_class(flux)()
assert flux_node.run(**kwargs) == ("IMAGE_TENSOR", "https://x/i.png")
assert flux_node.run(**kwargs) == ("IMAGE_TENSOR",)
kling_node = factory.build_node_class(kling)()
out = kling_node.run(prompt="move", image_url="TENSOR", duration="5",
@@ -271,153 +271,6 @@ def _test_fixture_behaviour(dyn, stub):
print("[fixture] behaviour tests passed")
def _file_input(name, media_kind, required=False, is_list=False, type_="string"):
return {
"name": name, "type": type_, "required": required, "default": None,
"enum": None, "min": None, "max": None, "description": "",
"media_kind": media_kind, "is_list": is_list, "multiline": False,
"has_custom_size": False,
}
def _test_direct_url_passthrough(stub):
from importlib import import_module
arguments = import_module(f"{PKG}.dynamic.arguments")
factory = import_module(f"{PKG}.dynamic.factory")
schema = import_module(f"{PKG}.dynamic.schema_to_inputs")
models = _load_models(PACKAGE_DIR / "_fixture_registry.json")
by_id = {m["endpoint_id"]: m for m in models}
kling = by_id["fal-ai/kling-video/v2/master/image-to-video"]
tripo = by_id["tripo3d/tripo/v2.5/image-to-3d"]
flux = by_id["fal-ai/flux/dev"]
# --- twins present and placed: required media → start of optional ---
it = schema.build_input_types(kling)
assert list(it["optional"])[0] == "image_url_direct_url"
assert it["optional"]["image_url_direct_url"][0] == "STRING"
assert "image_url_direct_url" not in it["required"]
uit = schema.build_input_types(by_id["fal-ai/video-upscaler"])
assert list(uit["optional"])[0] == "video_url_direct_url"
# optional is_list media → twin immediately after its media input
tit = schema.build_input_types(tripo)
tkeys = list(tit["optional"])
assert tkeys[0] == "image_url_direct_url"
assert tkeys.index("image_urls_direct_url") == tkeys.index("image_urls") + 1
assert "Comma" in tit["optional"]["image_urls_direct_url"][1]["tooltip"]
# no twin for text-only models or media_kind "file"
fit = schema.build_input_types(flux)
assert not any(k.endswith("_direct_url") for k in {**fit["required"], **fit["optional"]})
file_model = {**kling, "inputs": [_file_input("doc_url", "file", required=True)]}
ffit = schema.build_input_types(file_model)
assert not any(k.endswith("_direct_url") for k in {**ffit["required"], **ffit["optional"]})
# collision guard: a literal *_direct_url input suppresses the generated twin
collide = {**kling, "inputs": [
_file_input("image_url", "image", required=True),
_file_input("image_url_direct_url", None),
]}
cit = schema.build_input_types(collide)
assert list(cit["optional"]).count("image_url_direct_url") == 1
# --- passthrough beats tensor upload; twin key never leaks ---
kargs = arguments.build_arguments(kling, {
"prompt": "move", "image_url": "TENSOR",
"image_url_direct_url": " https://cdn.fal.media/start.png ",
"duration": "5", "negative_prompt": "", "cfg_scale": 0.5,
})
assert kargs["image_url"] == "https://cdn.fal.media/start.png"
assert not any(k.endswith("_direct_url") for k in kargs)
# media input None or absent: URL still wins
for image_value in ({"image_url": None}, {}):
k2 = arguments.build_arguments(
kling,
{"prompt": "m", "image_url_direct_url": "https://cdn.fal.media/s.png", **image_value},
)
assert k2["image_url"] == "https://cdn.fal.media/s.png"
# blank twin falls back to the normal upload path
k3 = arguments.build_arguments(
kling, {"prompt": "m", "image_url": "TENSOR", "image_url_direct_url": " "}
)
assert k3["image_url"] == "https://stub.fal.media/image.png"
# non-http(s) raises
try:
arguments.build_arguments(kling, {"prompt": "x", "image_url_direct_url": "ftp://nope"})
raise AssertionError("expected FalApiError for non-http(s) direct URL")
except stub.FalApiError:
pass
# is_list twin: comma-separated string → list of URLs
targs = arguments.build_arguments(tripo, {
"image_url": "TENSOR", "texture": "HD", "seed": -1,
"image_urls_direct_url": "https://a/1.png, https://a/2.png ,https://a/3.png",
})
assert targs["image_urls"] == ["https://a/1.png", "https://a/2.png", "https://a/3.png"]
assert targs["image_url"] == "https://stub.fal.media/image.png"
assert not any(k.endswith("_direct_url") for k in targs)
try:
arguments.build_arguments(tripo, {"image_urls_direct_url": "https://a/1.png, nope"})
raise AssertionError("expected FalApiError for bad URL in list")
except stub.FalApiError:
pass
# collision model: the literal input passes through as a plain string argument
cargs = arguments.build_arguments(
collide, {"image_url": "TENSOR", "image_url_direct_url": "not-a-url"}
)
assert cargs["image_url"] == "https://stub.fal.media/image.png"
assert cargs["image_url_direct_url"] == "not-a-url"
# --- skip_cache plumbing: old signature tolerated, new one receives the flag ---
node = factory.build_node_class(flux)()
node.run(prompt="hi", force_rerun=True)
assert len(stub.ApiHandler.last_call) == 2 # legacy stub: called without skip_cache
def with_skip(endpoint, arguments, timeout=None, skip_cache=False):
stub.ApiHandler.last_call = (endpoint, arguments, skip_cache)
return _CANNED_RESULTS.get(endpoint, {"ok": True})
original = stub.ApiHandler.submit_and_get_result
stub.ApiHandler.submit_and_get_result = staticmethod(with_skip)
try:
node.run(prompt="hi", force_rerun=True)
assert stub.ApiHandler.last_call[2] is True
node.run(prompt="hi", force_rerun=False)
assert stub.ApiHandler.last_call[2] is False
finally:
stub.ApiHandler.submit_and_get_result = original
print("[fixture] direct-url passthrough tests passed")
def _twin_sweep(models, schema):
"""Real-registry sweep: build every INPUT_TYPES, count twin coverage."""
gained_nodes = 0
twin_count = 0
for model in models:
input_types = schema.build_input_types(model)
names = {inp["name"] for inp in model.get("inputs", [])}
overlap = set(input_types["required"]) & set(input_types["optional"])
assert not overlap, f"{model['endpoint_id']}: bucket overlap {overlap}"
twins = [
key for key in input_types["optional"]
if key.endswith("_direct_url") and key not in names
]
if twins:
gained_nodes += 1
twin_count += len(twins)
print(f"[real] direct-url twins: {twin_count} twin inputs across "
f"{gained_nodes}/{len(models)} nodes")
def _dump_samples(models, factory):
samples = [
("flux", "text-to-image"),
@@ -462,13 +315,10 @@ def main() -> int:
assert built == 5
_test_fixture_behaviour(dyn, stub)
_test_direct_url_passthrough(stub)
if REAL_REGISTRY.is_file():
schema = import_module(f"{PKG}.dynamic.schema_to_inputs")
real_models = _load_models(REAL_REGISTRY)
built, skipped = _check_registry(real_models, factory, outputs, "real")
_twin_sweep(real_models, schema)
classes, display = dyn.get_dynamic_mappings()
print(f"[real] loader registered {len(classes)} nodes "
f"(incl. any-endpoint), display names unique: "
+16 -89
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import json
from typing import Any
@@ -14,20 +13,13 @@ from ..fal_utils import (
ResultProcessor,
logger,
)
from .factory import _ASYNC_CAPABLE, stable_hash
from .factory import 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:
@@ -60,25 +52,6 @@ def _media_overlay(
return overlay
def build_overlay_arguments(
endpoint_id: str,
arguments_json: str,
image: Any = None,
image_2: Any = None,
video: Any = None,
audio: Any = None,
seed: int = -1,
) -> dict[str, Any]:
"""Merge free-form JSON arguments with uploaded media inputs and seed.
Connected media inputs win over matching keys in the JSON
(image_url, image_urls, video_url, audio_url, seed).
"""
parsed = _parse_arguments_json(endpoint_id, arguments_json)
overlay = _media_overlay(image, image_2, video, audio, seed)
return {**parsed, **overlay}
def _extract_images(result: dict[str, Any]) -> Any | None:
try:
images = result.get("images")
@@ -111,20 +84,6 @@ def _extract_audio(result: dict[str, Any]) -> Any | None:
return None
def extract_flexible_outputs(result: dict[str, Any]) -> tuple[Any, Any, Any, str]:
"""Opportunistically extract (images, video, audio, raw json) from a result.
Each media slot is None when the result has no matching content; the raw
result is always available as a JSON string in the last slot.
"""
return (
_extract_images(result),
_extract_video(result),
_extract_audio(result),
json.dumps(result, default=str),
)
class FalAnyEndpoint:
"""Call any fal.ai endpoint with raw JSON arguments plus optional media inputs."""
@@ -202,7 +161,7 @@ class FalAnyEndpoint:
return float("nan")
return stable_hash(kwargs)
def _run_sync(
def run(
self,
endpoint_id: str,
arguments_json: str = "{}",
@@ -213,51 +172,19 @@ class FalAnyEndpoint:
seed: int = -1,
force_rerun: bool = False,
) -> tuple[Any, Any, Any, str]:
endpoint = _validated_endpoint(endpoint_id)
endpoint = (endpoint_id or "").strip()
if not endpoint:
raise FalApiError("(any endpoint)", "endpoint_id is required")
arguments = build_overlay_arguments(
endpoint, arguments_json, image, image_2, video, audio, seed
parsed = _parse_arguments_json(endpoint, arguments_json)
overlay = _media_overlay(image, image_2, video, audio, seed)
arguments = {**parsed, **overlay}
result = ApiHandler.submit_and_get_result(endpoint, arguments)
return (
_extract_images(result),
_extract_video(result),
_extract_audio(result),
json.dumps(result, default=str),
)
result = ApiHandler.submit_and_get_result(
endpoint, arguments, skip_cache=bool(force_rerun)
)
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
+1 -34
View File
@@ -6,33 +6,10 @@ import json
from typing import Any
from ..fal_utils import FalApiError, ImageUtils, MediaUtils
from .schema_to_inputs import DIRECT_URL_KINDS, DIRECT_URL_SUFFIX
_DEFAULT_DIMENSION = 1024
def _direct_url_value(inp: dict[str, Any], kwargs: dict[str, Any]) -> str:
"""The stripped '<name>_direct_url' kwarg for a media input, or ''."""
if inp.get("media_kind") not in DIRECT_URL_KINDS:
return ""
raw = kwargs.get(inp["name"] + DIRECT_URL_SUFFIX)
return str(raw).strip() if isinstance(raw, str) else ""
def _direct_url_argument(endpoint: str, inp: dict[str, Any], text: str) -> Any:
"""Validate a passthrough URL string; is_list inputs accept comma-separated URLs."""
twin_name = inp["name"] + DIRECT_URL_SUFFIX
error = FalApiError(endpoint, f"'{twin_name}' must be an http(s) URL")
if not inp.get("is_list"):
if not text.startswith(("http://", "https://")):
raise error
return text
parts = [part.strip() for part in text.split(",") if part.strip()]
if not parts or any(not part.startswith(("http://", "https://")) for part in parts):
raise error
return parts
def _upload_image(inp: dict[str, Any], value: Any) -> Any:
if inp.get("is_list"):
return ImageUtils.prepare_images(value)
@@ -122,19 +99,9 @@ def build_arguments(model: dict[str, Any], kwargs: dict[str, Any]) -> dict[str,
"""Build the fal API argument dict from node kwargs. Never mutates inputs."""
endpoint = model["endpoint_id"]
arguments: dict[str, Any] = {}
inputs = model.get("inputs", [])
input_names = {inp["name"] for inp in inputs}
for inp in inputs:
for inp in model.get("inputs", []):
name = inp["name"]
# URL passthrough wins over the media input (which may be None or connected);
# skip when the twin name is a real model input (no twin was generated then)
if name + DIRECT_URL_SUFFIX not in input_names:
direct_url = _direct_url_value(inp, kwargs)
if direct_url:
resolved = _direct_url_argument(endpoint, inp, direct_url)
arguments = {**arguments, name: resolved}
continue
if name not in kwargs:
continue
value = kwargs[name]
+2 -65
View File
@@ -2,12 +2,8 @@
from __future__ import annotations
import asyncio
import hashlib
import importlib.util
import inspect
import re
from functools import cache
from typing import Any
from ..fal_utils import ApiHandler
@@ -18,25 +14,6 @@ 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,36 +54,6 @@ def stable_hash(kwargs: dict[str, Any]) -> str:
@cache
def _accepts_skip_cache(func: Any) -> bool:
"""Whether ``submit_and_get_result`` supports the skip_cache keyword.
Cached per callable so the check runs once, and re-evaluated automatically
when tests stub out ApiHandler. On uninspectable callables fall back to
False: calling without skip_cache works with both old and new signatures.
"""
try:
return "skip_cache" in inspect.signature(func).parameters
except (TypeError, ValueError):
return False
def _call_api(endpoint_id: str, arguments: dict[str, Any], skip_cache: bool) -> Any:
submit = ApiHandler.submit_and_get_result
if _accepts_skip_cache(submit):
return submit(endpoint_id, arguments, skip_cache=skip_cache)
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))
@@ -136,19 +83,9 @@ def build_node_class(model: dict[str, Any]) -> type:
def run(self: Any, **kwargs: Any) -> tuple:
arguments = build_arguments(model, kwargs)
result = _call_api(endpoint_id, arguments, bool(kwargs.get("force_rerun")))
result = ApiHandler.submit_and_get_result(endpoint_id, arguments)
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),
@@ -157,7 +94,7 @@ def build_node_class(model: dict[str, Any]) -> type:
"FUNCTION": "run",
"CATEGORY": f"FAL/Models/{category}",
"DESCRIPTION": _description(model),
"run": run_async if _ASYNC_CAPABLE else run,
"run": run,
"_FAL_ENDPOINT_ID": endpoint_id,
}
return type(_class_name(model), (object,), attrs)
+4 -24
View File
@@ -9,8 +9,8 @@ from typing import Any
from ..fal_utils import FalApiError, MediaUtils, ResultProcessor
RETURN_SPECS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
"images": (("IMAGE", "STRING"), ("images", "image_urls")),
"image": (("IMAGE", "STRING"), ("image", "image_url")),
"images": (("IMAGE",), ("images",)),
"image": (("IMAGE",), ("images",)),
"video": (("VIDEO", "STRING"), ("video", "video_url")),
"audio": (("AUDIO", "STRING"), ("audio", "audio_url")),
"text": (("STRING",), ("text",)),
@@ -75,26 +75,6 @@ def _media_url(model: dict[str, Any], result: dict[str, Any], primary: str) -> s
return url
def _image_urls_csv(result: dict[str, Any]) -> str:
"""CDN URLs of an {"images": [...]} result, comma-joined (twin-input format)."""
images = result.get("images")
if not isinstance(images, (list, tuple)):
return ""
urls = [url for url in (find_url(item) for item in images) if url]
return ",".join(urls)
def _process_images(result: dict[str, Any]) -> tuple[Any, ...]:
tensor = ResultProcessor.process_image_result(result)[0]
return (tensor, _image_urls_csv(result))
def _process_image(result: dict[str, Any]) -> tuple[Any, ...]:
tensor = ResultProcessor.process_single_image_result(result)[0]
url = find_url(result.get("image"))
return (tensor, url or "")
def _process_video(model: dict[str, Any], result: dict[str, Any]) -> tuple[Any, ...]:
url = _media_url(model, result, "video")
return (MediaUtils.video_from_url(url), url)
@@ -130,9 +110,9 @@ def process_result(model: dict[str, Any], result: dict[str, Any]) -> tuple[Any,
"""Convert a raw fal API result dict into the node's return tuple."""
kind = model.get("output_kind", "json")
if kind == "images":
return _process_images(result)
return ResultProcessor.process_image_result(result)
if kind == "image":
return _process_image(result)
return ResultProcessor.process_single_image_result(result)
if kind == "video":
return _process_video(model, result)
if kind == "audio":
+6 -130
View File
@@ -16,7 +16,6 @@ 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]]
@@ -29,11 +28,6 @@ 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")
@@ -67,76 +61,6 @@ 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
@@ -147,18 +71,12 @@ def _unique_display_name(name: str, used: set[str]) -> str:
def _build_model_mappings(
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]:
models: list[dict[str, Any]], categories: set[str]
) -> tuple[dict[str, type], dict[str, str], 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:
@@ -170,19 +88,7 @@ def _build_model_mappings(
logger.debug("Duplicate dynamic node key skipped: %s", key)
continue
node_class = build_node_class(model)
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)
name = _unique_display_name(build_display_name(model), used_names)
classes = {**classes, key: node_class}
display = {**display, key: name}
used_names.add(name)
@@ -194,26 +100,7 @@ def _build_model_mappings(
err,
)
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)
return classes, display, skipped
def load_dynamic_mappings() -> Mappings:
@@ -225,25 +112,14 @@ def load_dynamic_mappings() -> Mappings:
categories = _category_filter()
models = _read_models()
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
)
classes, display, skipped = _build_model_mappings(models, categories)
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, featured %d, "
"%d flagged as superseded within their family)",
len(all_classes),
skipped,
featured_count,
flagged,
"Registered %d dynamic fal nodes (skipped %d)", len(all_classes), skipped
)
_schedule_freshness_check()
return all_classes, all_display
except Exception as err:
logger.error("Dynamic fal node loading failed entirely: %s", err)
+1 -43
View File
@@ -28,11 +28,6 @@ WIDTH_HEIGHT_OPTS = {"default": 1024, "min": 64, "max": 14142, "step": 8}
_MEDIA_TYPES = {"image": "IMAGE", "video": "VIDEO", "audio": "AUDIO"}
# media kinds that get a "<name>_direct_url" passthrough companion input
# ("file" is excluded: it is already a plain STRING URL widget)
DIRECT_URL_SUFFIX = "_direct_url"
DIRECT_URL_KINDS = ("image", "video", "audio")
def _clamp(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value))
@@ -140,31 +135,6 @@ def _media_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
return ("STRING", {"default": "", "tooltip": tooltip})
def _direct_url_spec(name: str, media_kind: str, is_list: bool) -> tuple[Any, ...]:
tooltip = (
f"fal/CDN URL passthrough for '{name}': when set, this URL is sent directly "
f"and the {media_kind} input is ignored — no download/re-upload. "
"Chain fal nodes' *_url outputs here."
)
if is_list:
tooltip += " Comma-separate multiple URLs."
return ("STRING", {"default": "", "tooltip": tooltip})
def _direct_url_twin(
inp: dict[str, Any], existing_names: set[str]
) -> tuple[str, tuple[Any, ...]] | None:
"""The optional passthrough companion for a media input, or None."""
media_kind = inp.get("media_kind")
if media_kind not in DIRECT_URL_KINDS:
return None
twin_name = inp["name"] + DIRECT_URL_SUFFIX
if twin_name in existing_names:
return None
spec = _direct_url_spec(inp["name"], media_kind, bool(inp.get("is_list")))
return (twin_name, spec)
def _input_spec(inp: dict[str, Any]) -> tuple[Any, ...]:
if inp.get("media_kind"):
return _media_spec(inp)
@@ -186,14 +156,9 @@ def build_input_types(model: dict[str, Any]) -> dict[str, Any]:
"""Build a ComfyUI INPUT_TYPES dict from a registry model entry."""
required: dict[str, Any] = {}
optional: dict[str, Any] = {}
# twins of *required* media inputs go at the start of the optional bucket
leading_optional: dict[str, Any] = {}
custom_size_input: dict[str, Any] | None = None
inputs = model.get("inputs", [])
existing_names = {inp["name"] for inp in inputs}
for inp in inputs:
for inp in model.get("inputs", []):
name = inp["name"]
if name == "seed":
optional[name] = SEED_SPEC
@@ -201,17 +166,10 @@ def build_input_types(model: dict[str, Any]) -> dict[str, Any]:
if inp.get("has_custom_size") and custom_size_input is None:
custom_size_input = inp
spec = _input_spec(inp)
twin = _direct_url_twin(inp, existing_names)
if inp.get("required"):
required[name] = spec
if twin is not None:
leading_optional[twin[0]] = twin[1]
else:
optional[name] = spec
if twin is not None:
optional[twin[0]] = twin[1]
optional = {**leading_optional, **optional}
if custom_size_input is not None:
for dimension in ("width", "height"):
-14
View File
@@ -9,34 +9,20 @@ The implementations now live in the ``nodes/utils`` package.
from .utils import (
ApiHandler,
ArchiveUtils,
BillingUtils,
FalApiError,
FalConfig,
ImageUtils,
JobStore,
MediaUtils,
PricingUtils,
ResultCache,
ResultProcessor,
SessionLedger,
SpendGuard,
logger,
)
__all__ = [
"ApiHandler",
"ArchiveUtils",
"BillingUtils",
"FalApiError",
"FalConfig",
"ImageUtils",
"JobStore",
"MediaUtils",
"PricingUtils",
"ResultCache",
"ResultProcessor",
"SessionLedger",
"SpendGuard",
"logger",
]
-97
View File
@@ -1,97 +0,0 @@
"""Fal Job Inbox: list async fal jobs recorded across ComfyUI sessions."""
from __future__ import annotations
from typing import Any
from .fal_utils import JobStore, logger
_CATEGORY = "FAL/Platform"
_STATUS_CHOICES = ("all", "submitted", "collected")
class FalJobInbox:
"""List async fal jobs from the persistent store; jobs survive restarts."""
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("report", "latest_request_id", "latest_endpoint")
FUNCTION = "inbox"
CATEGORY = _CATEGORY
OUTPUT_NODE = True
DESCRIPTION = (
"Inbox of async fal jobs recorded by Fal Submit. The store is "
"persistent, so jobs queued in a previous session survive a ComfyUI "
"restart: submit tonight, restart, then wire latest_request_id and "
"latest_endpoint into Fal Result by Request ID to collect tomorrow "
"without re-paying. Never fails the graph."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {},
"optional": {
"status_filter": (
list(_STATUS_CHOICES),
{
"default": "all",
"tooltip": (
"Which jobs to list: 'submitted' shows jobs still "
"waiting to be collected (including ones queued "
"before a restart), 'collected' shows finished ones."
),
},
),
"limit": (
"INT",
{
"default": 20,
"min": 1,
"max": 200,
"tooltip": "Maximum number of jobs to list, newest first",
},
),
},
}
@classmethod
def IS_CHANGED(cls, **kwargs: Any) -> Any:
# The job store mutates outside the graph; always re-run.
return float("nan")
@staticmethod
def _filtered_report(store: JobStore, status: str, limit: int) -> str:
"""Plain listing of jobs with one status (report() covers 'all')."""
entries = store.entries(limit=limit, status=status)
lines = [f"Fal job inbox ({status}): {len(entries)} shown, newest first"]
lines.extend(
f" {entry.get('endpoint') or '(unknown endpoint)'} "
f"req={entry.get('request_id') or '-'}"
for entry in entries
)
return "\n".join(lines)
def inbox(self, status_filter: str = "all", limit: int = 20) -> tuple[str, str, str]:
try:
store = JobStore()
if status_filter in _STATUS_CHOICES[1:]:
report = self._filtered_report(store, status_filter, int(limit))
else:
report = store.report(limit=int(limit))
latest = next(iter(store.pending(limit=1)), None)
latest_request_id = str(latest.get("request_id") or "") if latest else ""
latest_endpoint = str(latest.get("endpoint") or "") if latest else ""
return (report, latest_request_id, latest_endpoint)
except Exception as exc: # This node must never fail the graph.
logger.warning("FalJobInbox: could not read job store: %s", exc)
return ("Fal job inbox: report unavailable", "", "")
NODE_CLASS_MAPPINGS = {
"FalJobInbox_fal": FalJobInbox,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FalJobInbox_fal": "Fal Job Inbox (fal)",
}
-622
View File
@@ -1,622 +0,0 @@
"""Platform nodes: async submit/collect, result recovery, cost tools, media saving."""
from __future__ import annotations
import json
import os
import shutil
import time
from typing import Any
from urllib.parse import urlparse
from .dynamic.any_endpoint import build_overlay_arguments, extract_flexible_outputs
from .fal_utils import (
ApiHandler,
FalApiError,
MediaUtils,
PricingUtils,
ResultCache,
SessionLedger,
logger,
)
_CATEGORY = "FAL/Platform"
# Custom ComfyUI type carried between FalSubmit and FalCollect.
# Shape: {"endpoint_id": str, "request_id": str}
FAL_HANDLE_TYPE = "FAL_HANDLE"
_FLEXIBLE_RETURN_TYPES = ("IMAGE", "VIDEO", "AUDIO", "STRING")
_FLEXIBLE_RETURN_NAMES = ("images", "video", "audio", "result_json")
_MAX_SEED = 2**31 - 1
_SAVE_COUNTER_LIMIT = 100_000
def _collect_result(
node_name: str, endpoint_id: str, request_id: str, record_cost: bool = True
) -> tuple[Any, Any, Any, str]:
"""Fetch a queued result by id and extract flexible outputs from it.
``record_cost=False`` marks a pure recovery of a past request — the fetch
is logged for traceability but adds no new spend to the session ledger.
"""
endpoint = (endpoint_id or "").strip()
request = (request_id or "").strip()
if not endpoint or not request:
raise FalApiError(node_name, "Both endpoint_id and request_id are required")
result = ApiHandler.result_from_request_id(endpoint, request, record_cost=record_cost)
return extract_flexible_outputs(result)
class FalSubmit:
"""Queue a fal.ai job without waiting; pair with Fal Collect for the result."""
RETURN_TYPES = (FAL_HANDLE_TYPE, "STRING")
RETURN_NAMES = ("handle", "request_id")
FUNCTION = "submit"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Submit a job to any fal.ai endpoint and return immediately with a "
"handle. Wire several Submits into Collects to run generations in "
"parallel instead of one at a time."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"endpoint_id": (
"STRING",
{
"default": "fal-ai/kling-video/v3/pro/image-to-video",
"tooltip": (
"fal endpoint id, e.g. fal-ai/kling-video/v3/pro/image-to-video. "
"Submitting queues the job instantly, so multiple Fal Submit nodes "
"fan out in parallel; wire each handle into a Fal Collect node to "
"wait for its result."
),
},
),
"arguments_json": (
"STRING",
{
"default": "{}",
"multiline": True,
"tooltip": (
"JSON object of API arguments. Connected media inputs "
"and seed override matching keys here."
),
},
),
},
"optional": {
"image": ("IMAGE", {"tooltip": "Uploaded and sent as image_url"}),
"image_2": (
"IMAGE",
{
"tooltip": (
"Second image; when set together with 'image', both are "
"also sent as image_urls [url1, url2]"
)
},
),
"video": ("VIDEO", {"tooltip": "Uploaded and sent as video_url"}),
"audio": ("AUDIO", {"tooltip": "Uploaded and sent as audio_url"}),
"seed": (
"INT",
{
"default": -1,
"min": -1,
"max": _MAX_SEED,
"control_after_generate": True,
"tooltip": "-1 = omit seed; any other value is sent to the API",
},
),
},
}
def submit(
self,
endpoint_id: str,
arguments_json: str = "{}",
image: Any = None,
image_2: Any = None,
video: Any = None,
audio: Any = None,
seed: int = -1,
) -> tuple[dict[str, str], str]:
endpoint = (endpoint_id or "").strip()
if not endpoint:
raise FalApiError("FalSubmit", "endpoint_id is required")
arguments = build_overlay_arguments(
endpoint, arguments_json, image, image_2, video, audio, seed
)
request_id = ApiHandler.submit_only(endpoint, arguments)
logger.info("[%s] submitted request %s", endpoint, request_id)
handle = {"endpoint_id": endpoint, "request_id": request_id}
return (handle, request_id)
class FalCollect:
"""Wait for a queued fal.ai job (from Fal Submit) and extract its outputs."""
RETURN_TYPES = _FLEXIBLE_RETURN_TYPES
RETURN_NAMES = _FLEXIBLE_RETURN_NAMES
FUNCTION = "collect"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Block until a job submitted with Fal Submit finishes, then extract "
"outputs opportunistically. The raw result is always available as JSON."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"handle": (
FAL_HANDLE_TYPE,
{"tooltip": "Handle from a Fal Submit node"},
),
},
}
def collect(self, handle: Any) -> tuple[Any, Any, Any, str]:
if not isinstance(handle, dict) or not handle.get("endpoint_id") or not handle.get("request_id"):
raise FalApiError(
"FalCollect",
"Expected a FAL_HANDLE dict with 'endpoint_id' and 'request_id' "
"keys; connect the handle output of a Fal Submit node.",
)
return _collect_result("FalCollect", handle["endpoint_id"], handle["request_id"])
class FalResultByRequestId:
"""Fetch any past fal.ai generation by its request id, without re-paying."""
RETURN_TYPES = _FLEXIBLE_RETURN_TYPES
RETURN_NAMES = _FLEXIBLE_RETURN_NAMES
FUNCTION = "fetch"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Recover a past generation by request id. Results are fetched from "
"fal's queue by id, so nothing is re-generated or re-billed."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"endpoint_id": (
"STRING",
{
"default": "",
"tooltip": "fal endpoint id the request was originally submitted to",
},
),
"request_id": (
"STRING",
{
"default": "",
"tooltip": (
"Recover any past generation from your logs, the session cost "
"report, or the fal.ai dashboard without re-paying; the result "
"is fetched from fal's queue by this id."
),
},
),
},
}
def fetch(self, endpoint_id: str, request_id: str) -> tuple[Any, Any, Any, str]:
return _collect_result(
"FalResultByRequestId", endpoint_id, request_id, record_cost=False
)
class FalCostEstimator:
"""Estimate the cost of running an endpoint N times, from registry pricing."""
RETURN_TYPES = ("STRING", "FLOAT")
RETURN_NAMES = ("report", "total_usd")
FUNCTION = "estimate"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Estimate USD cost for running a fal endpoint a number of times. "
"Never fails: unknown endpoints produce a 'pricing unknown' report."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"endpoint_id": (
"STRING",
{
"default": "fal-ai/flux/dev",
"tooltip": (
"fal endpoint id to estimate. See the model list in the "
"README for available endpoint ids."
),
},
),
"runs": (
"INT",
{
"default": 1,
"min": 1,
"max": 10000,
"tooltip": "Number of runs to estimate the total cost for",
},
),
},
}
def estimate(self, endpoint_id: str, runs: int = 1) -> tuple[str, float]:
endpoint = (endpoint_id or "").strip()
try:
est = PricingUtils.estimate(endpoint, runs=int(runs))
report = PricingUtils.format_report(est)
total = est.get("total")
except Exception as err: # This node must never fail the graph.
logger.warning("FalCostEstimator: could not estimate %s: %s", endpoint, err)
report = f"{endpoint or '(no endpoint)'}: pricing unknown ({err})"
total = None
return (report, float(total) if total is not None else 0.0)
class FalSessionCosts:
"""Report every fal API call made this ComfyUI session and its total cost."""
RETURN_TYPES = ("STRING", "FLOAT")
RETURN_NAMES = ("report", "total_usd")
FUNCTION = "report"
CATEGORY = _CATEGORY
OUTPUT_NODE = True
DESCRIPTION = (
"Report all fal API calls recorded this session (endpoints, request "
"ids, estimated costs) and the running total in USD."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {},
"optional": {
"reset": (
"BOOLEAN",
{
"default": False,
"tooltip": "Clear the ledger after reporting",
},
),
"trigger": (
"STRING",
{
"forceInput": True,
"tooltip": (
"Connect any upstream text output (e.g. result_json) to "
"force this node to run after your generations finish"
),
},
),
},
}
@classmethod
def IS_CHANGED(cls, **kwargs: Any) -> Any:
# The ledger mutates outside the graph; always re-run.
return float("nan")
def report(self, reset: bool = False, trigger: str = "") -> tuple[str, float]:
del trigger # Only used to order execution in the graph.
ledger = SessionLedger()
report_text = ledger.report()
total = ledger.total_cost()
if reset:
ledger.reset()
return (report_text, float(total))
def _output_directory() -> str:
"""ComfyUI's output directory, or ./output when running outside ComfyUI."""
try:
import folder_paths
except ImportError:
return os.path.abspath(os.path.join(os.getcwd(), "output"))
return folder_paths.get_output_directory()
def _suffix_from_url(url: str) -> str:
suffix = os.path.splitext(urlparse(url).path)[1]
return suffix if suffix else ".bin"
def _resolve_save_directory(filename_prefix: str) -> tuple[str, str]:
"""Split the prefix into a confined save directory and a basename.
The resolved directory must stay inside the ComfyUI output directory —
a shared workflow must not be able to write outside it via '..' segments.
"""
prefix = (filename_prefix or "").strip().strip("/") or "fal/media"
subdir, basename = os.path.split(prefix)
basename = basename or "media"
output_root = os.path.realpath(_output_directory())
directory = os.path.realpath(os.path.join(output_root, subdir))
if directory != output_root and not directory.startswith(output_root + os.sep):
raise FalApiError(
"FalSaveMediaURL",
f"filename_prefix escapes the output directory: {filename_prefix!r}",
)
return directory, basename
def _claim_unique_destination(directory: str, basename: str, suffix: str) -> str:
"""Atomically claim the first free '<basename>_00001<suffix>' path.
O_CREAT|O_EXCL closes the check-then-act race between concurrent saves.
"""
for counter in range(1, _SAVE_COUNTER_LIMIT):
candidate = os.path.join(directory, f"{basename}_{counter:05d}{suffix}")
try:
os.close(os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
return candidate
except FileExistsError:
continue
raise FalApiError(
"FalSaveMediaURL",
f"Could not find a free filename for '{basename}{suffix}' in {directory}",
)
_PROVENANCE_VERSION = 1
_PNG_PROVENANCE_KEY = "fal_provenance"
_SIDECAR_SUFFIX = ".fal.json"
def _build_provenance(target_url: str) -> dict[str, Any]:
"""Assemble the provenance receipt for a saved URL; lookup misses are None."""
endpoint_id: str | None = None
request_id: str | None = None
try:
found = ResultCache().find_request_by_url(target_url)
except Exception as err:
logger.warning("FalSaveMediaURL: provenance lookup failed for %s: %s", target_url, err)
found = None
if found:
endpoint_id = found.get("endpoint_id")
request_id = found.get("request_id")
return {
"version": _PROVENANCE_VERSION,
"endpoint_id": endpoint_id,
"request_id": request_id,
"source_url": target_url,
"saved_at": time.time(),
}
def _write_provenance_sidecar(saved_path: str, provenance: dict[str, Any]) -> None:
"""Write '<saved_path>.fal.json' next to the file. Best-effort, never raises."""
sidecar_path = saved_path + _SIDECAR_SUFFIX
try:
with open(sidecar_path, "w", encoding="utf-8") as handle:
json.dump(provenance, handle, indent=2)
except Exception as err:
logger.warning("FalSaveMediaURL: could not write sidecar %s: %s", sidecar_path, err)
def _embed_png_provenance(saved_path: str, provenance: dict[str, Any]) -> None:
"""Embed provenance as a PNG text chunk (PNG files only). Never raises."""
if not saved_path.lower().endswith(".png"):
return
try:
from PIL import Image
from PIL.PngImagePlugin import PngInfo
info = PngInfo()
info.add_text(_PNG_PROVENANCE_KEY, json.dumps(provenance))
with Image.open(saved_path) as image:
image.load() # read fully before overwriting the same path
# carry over the source PNG's existing text chunks and color
# profile — a fresh PngInfo would otherwise strip them on re-save
for key, value in (getattr(image, "text", {}) or {}).items():
if key != _PNG_PROVENANCE_KEY and isinstance(value, str):
info.add_text(key, value)
save_kwargs: dict[str, Any] = {"pnginfo": info}
icc_profile = image.info.get("icc_profile")
if icc_profile:
save_kwargs["icc_profile"] = icc_profile
image.save(saved_path, **save_kwargs)
except Exception as err:
logger.warning(
"FalSaveMediaURL: could not embed PNG provenance in %s: %s", saved_path, err
)
def _read_provenance_sidecar(path: str) -> dict[str, Any] | None:
"""Load '<path>.fal.json' if present and valid; None otherwise."""
sidecar_path = path + _SIDECAR_SUFFIX
if not os.path.isfile(sidecar_path):
return None
try:
with open(sidecar_path, encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else None
except Exception as err:
logger.warning("FalProvenanceFromFile: unreadable sidecar %s: %s", sidecar_path, err)
return None
def _read_png_provenance(path: str) -> dict[str, Any] | None:
"""Read the 'fal_provenance' PNG text chunk (PNG files only); None otherwise."""
if not path.lower().endswith(".png"):
return None
try:
from PIL import Image
with Image.open(path) as image:
raw = getattr(image, "text", {}).get(_PNG_PROVENANCE_KEY)
if not raw:
return None
data = json.loads(raw)
return data if isinstance(data, dict) else None
except Exception as err:
logger.warning(
"FalProvenanceFromFile: could not read PNG provenance from %s: %s", path, err
)
return None
class FalSaveMediaURL:
"""Download a media URL and save it into the ComfyUI output directory."""
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("path", "request_id")
FUNCTION = "save"
CATEGORY = _CATEGORY
OUTPUT_NODE = True
DESCRIPTION = (
"Download a result URL (video, audio, file, ...) and store it under "
"the ComfyUI output directory with a unique, never-overwriting name. "
"Every save also writes a provenance receipt (a .fal.json sidecar, "
"plus an embedded text chunk for PNGs) so the generation can be "
"recovered later for free via 'Fal Provenance from File'."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"url": (
"STRING",
{
"forceInput": True,
"tooltip": "http(s) URL of the media to download and save",
},
),
"filename_prefix": (
"STRING",
{
"default": "fal/media",
"tooltip": "Relative to the ComfyUI output directory; subfolders allowed",
},
),
},
}
def save(self, url: str, filename_prefix: str = "fal/media") -> tuple[str, str]:
target_url = (url or "").strip()
if not target_url.startswith(("http://", "https://")):
raise FalApiError(
"FalSaveMediaURL",
f"Expected an http(s) URL to save, got: {target_url!r}",
)
directory, basename = _resolve_save_directory(filename_prefix)
suffix = _suffix_from_url(target_url)
temp_path = MediaUtils.download_url_to_temp(target_url, suffix)
try:
os.makedirs(directory, exist_ok=True)
destination = _claim_unique_destination(directory, basename, suffix)
shutil.move(temp_path, destination)
except FalApiError:
raise
except Exception as err:
logger.error("FalSaveMediaURL: failed to save %s: %s", target_url, err)
raise FalApiError(
"FalSaveMediaURL", f"Failed to save {target_url}: {err}"
) from err
finally:
if os.path.exists(temp_path):
try:
os.unlink(temp_path)
except OSError:
pass
saved = os.path.abspath(destination)
logger.info("FalSaveMediaURL: saved %s -> %s", target_url, saved)
# Provenance receipt: best-effort, never fails the save itself.
provenance = _build_provenance(target_url)
_write_provenance_sidecar(saved, provenance)
_embed_png_provenance(saved, provenance)
request_id = str(provenance.get("request_id") or "")
return (saved, request_id)
class FalProvenanceFromFile:
"""Read the fal provenance receipt of a previously saved output file."""
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("endpoint_id", "request_id", "provenance_json")
FUNCTION = "read"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Recover where a saved file came from: reads the .fal.json sidecar "
"written by 'Fal Save Media from URL' (or the provenance chunk "
"embedded in PNGs). Wire endpoint_id and request_id into 'Fal Result "
"by Request ID' to re-materialize the generation for free."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"file_path": (
"STRING",
{
"default": "",
"tooltip": (
"Absolute path of a previously saved output — reads the "
".fal.json sidecar or embedded PNG chunk."
),
},
),
},
}
def read(self, file_path: str) -> tuple[str, str, str]:
path = os.path.expanduser((file_path or "").strip())
if not path:
raise FalApiError("FalProvenanceFromFile", "file_path is required")
if not os.path.isfile(path):
raise FalApiError("FalProvenanceFromFile", f"File not found: {path}")
provenance = _read_provenance_sidecar(path)
if provenance is None:
provenance = _read_png_provenance(path)
if provenance is None:
raise FalApiError(
"FalProvenanceFromFile",
f"No fal provenance found for {path}: expected a "
f"'{os.path.basename(path)}{_SIDECAR_SUFFIX}' sidecar next to it, or a "
f"'{_PNG_PROVENANCE_KEY}' text chunk inside a PNG. Only files saved by "
"'Fal Save Media from URL' carry a provenance receipt.",
)
endpoint_id = str(provenance.get("endpoint_id") or "")
request_id = str(provenance.get("request_id") or "")
return (endpoint_id, request_id, json.dumps(provenance))
NODE_CLASS_MAPPINGS = {
"FalSubmit_fal": FalSubmit,
"FalCollect_fal": FalCollect,
"FalResultByRequestId_fal": FalResultByRequestId,
"FalCostEstimator_fal": FalCostEstimator,
"FalSessionCosts_fal": FalSessionCosts,
"FalSaveMediaURL_fal": FalSaveMediaURL,
"FalProvenanceFromFile_fal": FalProvenanceFromFile,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FalSubmit_fal": "Fal Submit (async) (fal)",
"FalCollect_fal": "Fal Collect (async result) (fal)",
"FalResultByRequestId_fal": "Fal Result by Request ID (fal)",
"FalCostEstimator_fal": "Fal Cost Estimator (fal)",
"FalSessionCosts_fal": "Fal Session Costs (fal)",
"FalSaveMediaURL_fal": "Fal Save Media from URL (fal)",
"FalProvenanceFromFile_fal": "Fal Provenance from File (fal)",
}
-459
View File
@@ -1,459 +0,0 @@
"""HTTP routes exposing fal pricing, session costs, jobs and balance to the ComfyUI frontend.
Registered on ComfyUI's PromptServer under ``/fal_api/*``. The module must
import cleanly without ComfyUI (headless/tests): ``register()`` is a no-op when
``server`` is unavailable, and every handler is a thin wrapper over a pure
function so the payload logic is unit-testable without aiohttp.
"""
from __future__ import annotations
import json
import os
import threading
import time
from typing import Any, Callable
from .utils.billing import BillingUtils
from .utils.ledger import SessionLedger
from .utils.logger import logger
from .utils.pricing import PricingUtils
_SESSION_TAIL = 20
_DEFAULT_JOB_LIMIT = 50
_DEFAULT_SEARCH_LIMIT = 25
_MAX_SEARCH_LIMIT = 100
_cache_lock = threading.Lock()
# [(model, pricing_info|None), ...] in registry order; None until first build.
_catalog_cache: list[tuple[dict[str, Any], dict[str, Any] | None]] | None = None
_pricing_map_cache: dict[str, dict[str, Any]] | None = None
# -- registry catalog (lazy, built once) ---------------------------------------
def _registry_path() -> str:
"""Path to data/fal_registry.json at the repo root."""
nodes_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(os.path.dirname(nodes_dir), "data", "fal_registry.json")
def _read_models() -> list[dict[str, Any]]:
"""Read registry models; empty list on any 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 [m for m in models if isinstance(m, dict) and m.get("endpoint_id")]
except Exception as exc:
logger.warning("server_routes: could not load fal registry: %s", exc)
return []
def _format_amount(value: float) -> str:
"""Format a dollar amount compactly (up to 4 decimals, no trailing zeros)."""
text = f"{value:,.4f}".rstrip("0").rstrip(".")
return text or "0"
def _pricing_info(parsed: dict[str, Any]) -> dict[str, Any] | None:
"""Turn a PricingUtils.parse result into {"label", "per_run"}, or None.
per_run known -> "≈$X/run"; only per_unit -> "$X per <unit>"; else None.
"""
per_run = parsed.get("per_run")
if isinstance(per_run, (int, float)):
return {"label": f"≈${_format_amount(float(per_run))}/run", "per_run": float(per_run)}
per_unit = parsed.get("per_unit")
unit = parsed.get("unit")
if isinstance(per_unit, (int, float)) and unit:
return {"label": f"${_format_amount(float(per_unit))} per {unit}", "per_run": None}
return None
def _node_key_for(model: dict[str, Any]) -> str:
"""Dynamic node class key for a registry model (same as factory.node_key)."""
try:
from .dynamic.factory import node_key
return node_key(model)
except Exception: # stripped env without the dynamic package's deps
return "FalAPI_" + str(model.get("endpoint_id", "")).replace("/", "-")
def _catalog() -> list[tuple[dict[str, Any], dict[str, Any] | None]]:
"""Registry models paired with parsed pricing info, cached after first build."""
global _catalog_cache
if _catalog_cache is not None:
return _catalog_cache
with _cache_lock:
if _catalog_cache is not None:
return _catalog_cache
_catalog_cache = [
(model, _pricing_info(PricingUtils.parse(str(model.get("pricing") or ""))))
for model in _read_models()
]
return _catalog_cache
# -- pure payload builders (unit-tested directly) -------------------------------
def _pricing_map() -> dict[str, dict[str, Any]]:
"""{node_class_key: {"label", "per_run"}} for every priced dynamic node."""
global _pricing_map_cache
if _pricing_map_cache is not None:
return _pricing_map_cache
mapping = {
_node_key_for(model): info for model, info in _catalog() if info is not None
}
with _cache_lock:
_pricing_map_cache = mapping
return mapping
def _pricing_single(endpoint_id: str) -> dict[str, Any]:
"""Live pricing label for one endpoint; {"label": None} when unknown."""
endpoint = (endpoint_id or "").strip()
if not endpoint:
return {"label": None, "per_run": None}
estimate = PricingUtils.estimate(endpoint)
per_run = estimate.get("per_run")
if isinstance(per_run, (int, float)):
return {"label": f"≈${_format_amount(float(per_run))}/run", "per_run": float(per_run)}
unit_note = estimate.get("unit_note") or ""
if unit_note:
return {"label": unit_note, "per_run": None}
return {"label": None, "per_run": None}
def _session() -> dict[str, Any]:
"""Session ledger totals plus the last few call entries."""
ledger = SessionLedger()
entries = ledger.entries()
return {
"total_usd": ledger.total_cost(),
"calls": len(entries),
"entries": entries[-_SESSION_TAIL:],
}
def _jobs(limit: int = _DEFAULT_JOB_LIMIT) -> dict[str, Any]:
"""Persistent async-job inbox; degrades to empty when the store is missing."""
try:
from .utils.job_store import JobStore
store = JobStore()
return {"jobs": store.entries(limit=limit), "counts": store.counts()}
except Exception as exc:
logger.debug("server_routes: job store unavailable: %s", exc)
return {"jobs": [], "counts": {}}
def _balance() -> dict[str, Any]:
"""Account credit balance (60s-cached inside BillingUtils)."""
return {"balance_usd": BillingUtils.get_balance()}
def _search_models(
q: str = "",
category: str = "",
max_price: float | None = None,
limit: int = _DEFAULT_SEARCH_LIMIT,
) -> list[dict[str, Any]]:
"""Search the registry; newest first, filtered by text/category/per-run price."""
needle = (q or "").strip().lower()
wanted_category = (category or "").strip()
capped = max(1, min(int(limit), _MAX_SEARCH_LIMIT))
def matches(model: dict[str, Any], info: dict[str, Any] | None) -> bool:
haystack = f"{model.get('endpoint_id', '')} {model.get('title', '')}".lower()
if needle and needle not in haystack:
return False
if wanted_category and model.get("category") != wanted_category:
return False
if max_price is not None:
per_run = (info or {}).get("per_run")
if not isinstance(per_run, (int, float)) or per_run > max_price:
return False
return True
hits = [(model, info) for model, info in _catalog() if matches(model, info)]
hits.sort(key=lambda pair: str(pair[0].get("published_at") or ""), reverse=True)
return [
{
"endpoint_id": model["endpoint_id"],
"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()
request = (request_id or "").strip()
if not endpoint or not request:
return {"ok": False, "error": "endpoint_id and request_id are required"}
try:
from .utils.config import FalConfig
FalConfig().get_client().cancel(endpoint, request)
logger.info("server_routes: cancelled %s request %s", endpoint, request)
return {"ok": True}
except Exception as exc:
logger.debug("server_routes: cancel %s/%s failed: %s", endpoint, request, exc)
return {"ok": False, "error": str(exc)}
# -- aiohttp glue ----------------------------------------------------------------
def _json_response(payload: Any, status: int = 200) -> Any:
"""aiohttp JSON response; the raw payload when aiohttp is unavailable (tests)."""
try:
from aiohttp import web
except ImportError:
return payload
return web.json_response(payload, status=status)
def _guarded(build: Callable[[], Any], route: str) -> Any:
"""Run a payload builder; any exception becomes a 500 {"error": ...} JSON."""
try:
return _json_response(build())
except Exception as exc:
logger.warning("server_routes: %s failed: %s", route, exc)
return _json_response({"error": str(exc)}, status=500)
def _query_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _query_float(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
async def pricing_map_route(request: Any) -> Any:
return _guarded(_pricing_map, "/fal_api/pricing_map")
async def pricing_route(request: Any) -> Any:
endpoint_id = request.query.get("endpoint_id", "")
return _guarded(lambda: _pricing_single(endpoint_id), "/fal_api/pricing")
async def session_route(request: Any) -> Any:
return _guarded(_session, "/fal_api/session")
async def jobs_route(request: Any) -> Any:
limit = _query_int(request.query.get("limit"), _DEFAULT_JOB_LIMIT)
limit = max(1, min(limit, _MAX_SEARCH_LIMIT))
return _guarded(lambda: _jobs(limit=limit), "/fal_api/jobs")
async def balance_route(request: Any) -> Any:
return _guarded(_balance, "/fal_api/balance")
async def models_route(request: Any) -> Any:
query = request.query
q = query.get("q", "")
category = query.get("category", "")
max_price = _query_float(query.get("max_price"))
limit = _query_int(query.get("limit"), _DEFAULT_SEARCH_LIMIT)
return _guarded(
lambda: _search_models(q=q, category=category, max_price=max_price, limit=limit),
"/fal_api/models",
)
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()
except Exception:
body = {}
payload = body if isinstance(body, dict) else {}
return _guarded(
lambda: _cancel(payload.get("endpoint_id", ""), payload.get("request_id", "")),
"/fal_api/cancel",
)
ROUTES: tuple[tuple[str, str, Callable[..., Any]], ...] = (
("GET", "/fal_api/pricing_map", pricing_map_route),
("GET", "/fal_api/pricing", pricing_route),
("GET", "/fal_api/session", session_route),
("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),
)
def register() -> bool:
"""Attach the /fal_api routes to ComfyUI's PromptServer. Never raises.
Returns False (with a debug log) when running headless without ComfyUI.
"""
try:
from server import PromptServer
except ImportError:
logger.debug("server_routes: ComfyUI server not available; routes not registered")
return False
try:
instance = getattr(PromptServer, "instance", None)
if instance is None:
logger.debug("server_routes: PromptServer has no instance yet; skipping")
return False
routes = instance.routes
for method, path, handler in ROUTES:
adder = routes.get if method == "GET" else routes.post
adder(path)(handler)
logger.info("server_routes: registered %d /fal_api routes", len(ROUTES))
return True
except Exception as exc:
logger.warning("server_routes: could not register /fal_api routes: %s", exc)
return False
register()
+36 -5
View File
@@ -1,15 +1,46 @@
from .fal_utils import ApiHandler, ArchiveUtils, FalConfig
import os
import tempfile
import zipfile
import torch
from PIL import Image
from .fal_utils import ApiHandler, FalConfig, ImageUtils
# Initialize FalConfig
fal_config = FalConfig()
def create_zip_from_images(images):
"""Create a zip file from a list of images and upload it (returns the URL)."""
"""Create a zip file from a list of images."""
try:
zip_path = ArchiveUtils.zip_images(images)
# Upload the zip through the shared utility (raises on failure)
return ArchiveUtils.upload_zip(zip_path)
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as temp_zip:
with zipfile.ZipFile(temp_zip, "w") as zf:
for idx, img_tensor in enumerate(images):
# Convert tensor to PIL Image
if isinstance(img_tensor, torch.Tensor):
# Convert to numpy and scale to 0-255 range
img_np = (img_tensor.cpu().numpy() * 255).astype("uint8")
# Handle different tensor formats
if img_np.shape[0] == 3: # If in format (C, H, W)
img_np = img_np.transpose(1, 2, 0)
img = Image.fromarray(img_np)
else:
img = img_tensor
# Save image to temporary file
with tempfile.NamedTemporaryFile(
suffix=".png", delete=False
) as temp_img:
img.save(temp_img, format="PNG")
temp_img_path = temp_img.name
# Add to zip file
zf.write(temp_img_path, f"image_{idx}.png")
os.unlink(temp_img_path)
# Upload the zip through the shared utility (raises on failure)
return ImageUtils.upload_file(temp_zip.name)
except Exception as e:
return ApiHandler.handle_text_generation_error(
"flux-lora-fast-training", f"Failed to create zip file: {str(e)}"
-259
View File
@@ -1,259 +0,0 @@
"""Data utility nodes: JSON path extraction, prompt line cycling, text templating."""
from __future__ import annotations
import json
import re
from typing import Any
from .fal_utils import FalApiError, logger
_CATEGORY = "FAL/Utils/Data"
_MAX_INDEX = 2**31 - 1
_MISSING = object()
# a path segment is an optional key name followed by zero or more [N] indices
_PATH_SEGMENT = re.compile(r"^([^\[\]]*)((?:\[\d+\])*)$")
_BRACKET_INDEX = re.compile(r"\[(\d+)\]")
def _tokenize_path(path: str) -> list[Any]:
"""Split a dot/bracket path into str keys and int indices. No eval."""
tokens: list[Any] = []
for part in path.split("."):
segment = part.strip()
if not segment:
continue
match = _PATH_SEGMENT.match(segment)
if match is None:
# contract: anything that can't resolve returns the default,
# a malformed segment included — it can never match a key anyway
logger.debug("FalJSONExtract: unparseable path segment %r in %r", segment, path)
return None
name, brackets = match.group(1), match.group(2)
if name:
tokens.append(name)
tokens.extend(int(index) for index in _BRACKET_INDEX.findall(brackets))
return tokens
def _value_to_bool(value: Any) -> bool:
"""Truthiness with JSON-string awareness: "false"/"0"/"no"/"" are False."""
if isinstance(value, str):
return value.strip().lower() not in ("", "false", "0", "no", "none", "null")
return bool(value)
def _walk_path(value: Any, tokens: list[Any]) -> Any:
"""Follow tokens through nested dicts/lists; return _MISSING when absent."""
current = value
for token in tokens:
index = token if isinstance(token, int) else None
if index is None and isinstance(current, list) and str(token).isdigit():
index = int(token) # bare integer segment indexing an array
if index is not None:
if isinstance(current, list) and 0 <= index < len(current):
current = current[index]
else:
return _MISSING
elif isinstance(current, dict) and token in current:
current = current[token]
else:
return _MISSING
return current
def _value_to_text(value: Any) -> str:
"""Strings pass through; everything else is re-serialized as JSON."""
if isinstance(value, str):
return value
return json.dumps(value)
def _value_to_number(value: Any) -> float:
"""Coerce a value to float; anything non-numeric becomes 0.0."""
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return 0.0
return 0.0
class FalJSONExtract:
"""Pull a value out of a JSON result by dot/bracket path."""
RETURN_TYPES = ("STRING", "FLOAT", "BOOLEAN")
RETURN_NAMES = ("text", "number", "boolean")
FUNCTION = "extract"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Extract a value from JSON text by path (e.g. video.url, "
"images[0].url). Returns it as text, number, and boolean so it can "
"wire straight into other nodes. Missing paths return the default "
"instead of failing the graph."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"json_text": (
"STRING",
{
"forceInput": True,
"multiline": True,
"tooltip": (
"Wire result_json from a Fal Any Endpoint / Fal Collect "
"node here to pick values out of the raw API result."
),
},
),
"path": (
"STRING",
{
"default": "video.url",
"tooltip": (
"Dot/bracket path into the JSON, e.g. video.url, "
"images[0].url, data.items[2].name. Bare integers "
"also index arrays (images.0.url)."
),
},
),
"default": (
"STRING",
{
"default": "",
"tooltip": "Returned as text when the path is missing (not an error)",
},
),
},
}
def extract(self, json_text: str, path: str = "video.url", default: str = "") -> tuple[str, float, bool]:
try:
payload = json.loads(json_text)
except Exception as exc:
logger.error("FalJSONExtract: invalid JSON input: %s", exc)
raise FalApiError("FalJSONExtract", f"Input is not valid JSON: {exc}") from exc
tokens = _tokenize_path(path or "")
value = _walk_path(payload, tokens) if tokens is not None else _MISSING
if value is _MISSING:
logger.debug("FalJSONExtract: path %r missing, returning default", path)
value = default
return (_value_to_text(value), _value_to_number(value), _value_to_bool(value))
class FalPromptLines:
"""Cycle through a multiline prompt list, one line per run."""
RETURN_TYPES = ("STRING", "INT", "INT")
RETURN_NAMES = ("line", "index", "total")
FUNCTION = "pick"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Pick one line from a multiline text by index. The index wraps "
"around (modulo the number of lines), so with control_after_generate "
"set to increment it cycles through your prompt list forever."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"text": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Prompt list, one prompt per line",
},
),
"index": (
"INT",
{
"default": 0,
"min": 0,
"max": _MAX_INDEX,
"control_after_generate": True,
"tooltip": (
"Which line to pick (wraps around). Set the control to "
"'increment' to iterate through your prompt list run by run."
),
},
),
"skip_blank": (
"BOOLEAN",
{"default": True, "tooltip": "Ignore empty/whitespace-only lines"},
),
},
}
def pick(self, text: str, index: int = 0, skip_blank: bool = True) -> tuple[str, int, int]:
lines = (text or "").splitlines()
if skip_blank:
lines = [line for line in lines if line.strip()]
total = len(lines)
if total == 0:
return ("", 0, 0)
effective = int(index) % total
return (lines[effective], effective, total)
class FalTextTemplate:
"""Fill a text template's {a}..{d} placeholders from string inputs."""
RETURN_TYPES = ("STRING",)
FUNCTION = "render"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Substitute {a}, {b}, {c}, {d} placeholders in a template with the "
"connected string inputs — quick prompt assembly without string "
"concatenation chains. Missing inputs become empty text."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"template": (
"STRING",
{
"default": "a photo of {a}, {b} style",
"multiline": True,
"tooltip": "Template text; {a} {b} {c} {d} are replaced with the inputs below",
},
),
},
"optional": {
"a": ("STRING", {"default": "", "tooltip": "Value for {a}"}),
"b": ("STRING", {"default": "", "tooltip": "Value for {b}"}),
"c": ("STRING", {"default": "", "tooltip": "Value for {c}"}),
"d": ("STRING", {"default": "", "tooltip": "Value for {d}"}),
},
}
def render(self, template: str, a: str = "", b: str = "", c: str = "", d: str = "") -> tuple[str]:
result = template or ""
for key, value in (("a", a), ("b", b), ("c", c), ("d", d)):
result = result.replace("{" + key + "}", value or "")
return (result,)
NODE_CLASS_MAPPINGS = {
"FalJSONExtract_fal": FalJSONExtract,
"FalPromptLines_fal": FalPromptLines,
"FalTextTemplate_fal": FalTextTemplate,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FalJSONExtract_fal": "JSON Extract (fal)",
"FalPromptLines_fal": "Prompt Lines (fal)",
"FalTextTemplate_fal": "Text Template (fal)",
}
-409
View File
@@ -1,409 +0,0 @@
"""Dataset preparation utility nodes (zip building, frame extraction, captioning)."""
from __future__ import annotations
import os
import tempfile
import zipfile
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from .fal_utils import (
ApiHandler,
ArchiveUtils,
FalApiError,
FalConfig,
ImageUtils,
MediaUtils,
logger,
)
# Initialize FalConfig
fal_config = FalConfig()
_CATEGORY = "FAL/Utils/Dataset"
_ARCHIVE_MODEL = "archive"
_VISION_ENDPOINT = "openrouter/router/vision"
_MAX_CAPTION_WORKERS = 8
_STREAM_CHUNK_SIZE = 1 << 20 # 1 MiB
def _safe_unlink(path: str | None) -> None:
"""Delete a temp file, ignoring errors."""
if path is None:
return
try:
os.unlink(path)
except OSError:
pass
def _split_caption_lines(captions: str) -> list[str] | None:
"""Split a multiline caption field into one caption per line (None if empty)."""
if not captions or not captions.strip():
return None
return captions.splitlines()
def _video_to_local_path(video: Any) -> tuple[str, bool]:
"""Resolve a VIDEO input to a local file path. Returns (path, is_temp)."""
source = video.get_stream_source() if hasattr(video, "get_stream_source") else video
if isinstance(source, str):
if source.startswith(("http://", "https://")):
return MediaUtils.download_url_to_temp(source, ".mp4"), True
if not os.path.isfile(source):
raise FalApiError(
_ARCHIVE_MODEL,
f"Video file not found: {source}. Connect a valid VIDEO input.",
)
return source, False
if hasattr(source, "read"):
temp_path: str | None = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_file:
temp_path = temp_file.name
while True:
chunk = source.read(_STREAM_CHUNK_SIZE)
if not chunk:
break
temp_file.write(chunk)
return temp_path, True
except Exception as exc:
_safe_unlink(temp_path)
raise FalApiError(
_ARCHIVE_MODEL, f"Failed to buffer video stream to disk: {exc}"
) from exc
raise FalApiError(
_ARCHIVE_MODEL,
"Unsupported VIDEO input: could not resolve a local file, URL, or stream from it.",
)
def _extract_frames_to_zip(video_path: str, every_nth: int, max_frames: int) -> str:
"""Decode a video with cv2, sample every Nth frame as PNG into a zip, return the zip path."""
try:
import cv2
except ImportError as exc:
raise FalApiError(
_ARCHIVE_MODEL,
"opencv-python is required to extract video frames. "
"Install it with 'pip install opencv-python'.",
) from exc
capture = cv2.VideoCapture(video_path)
if not capture.isOpened():
raise FalApiError(
_ARCHIVE_MODEL,
f"Could not open video for decoding: {video_path}. "
"Check that the input is a valid video file.",
)
zip_path: str | None = None
try:
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as temp_zip:
zip_path = temp_zip.name
saved = 0
index = 0
with zipfile.ZipFile(zip_path, "w") as zip_file:
while saved < max_frames:
ok, frame = capture.read()
if not ok:
break
if index % every_nth == 0:
encoded, buffer = cv2.imencode(".png", frame)
if not encoded:
raise FalApiError(
_ARCHIVE_MODEL, f"Failed to encode frame {index} as PNG."
)
zip_file.writestr(f"frame_{saved:05d}.png", buffer.tobytes())
saved += 1
index += 1
if saved == 0:
raise FalApiError(
_ARCHIVE_MODEL,
"No frames could be decoded from the video. "
"Check the input video and the every_nth setting.",
)
logger.info("Extracted %d frame(s) from %s", saved, video_path)
return zip_path
except Exception:
_safe_unlink(zip_path)
raise
finally:
capture.release()
class FalImagesToZipURL:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": (
"IMAGE",
{
"tooltip": "Images to package as a training dataset zip (image_0.png, image_1.png, ...).",
},
),
},
"optional": {
"captions": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Optional captions, one per line (blank lines allowed). "
"Line count must match the image batch size, or leave empty for no captions. "
"Written as image_0.txt, image_1.txt, ... next to each image.",
},
),
"name_prefix": (
"STRING",
{
"default": "image",
"tooltip": "File name prefix inside the zip (e.g. 'image' -> image_0.png).",
},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("zip_url",)
FUNCTION = "create_zip_url"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Zips an IMAGE batch (with optional per-image captions) and uploads it to fal.ai. "
"Feed the URL directly into the LoRA trainer nodes' images_data_url input."
)
def create_zip_url(self, images, captions="", name_prefix="image"):
caption_lines = _split_caption_lines(captions)
zip_path = ArchiveUtils.zip_images(
images, captions=caption_lines, name_prefix=name_prefix
)
return (ArchiveUtils.upload_zip(zip_path),)
class FalFolderToZipURL:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder_path": (
"STRING",
{
"default": "",
"tooltip": "Path to a local folder whose files will be zipped and uploaded.",
},
),
"recursive": (
"BOOLEAN",
{
"default": False,
"tooltip": "Also include files from subfolders (hidden entries are always skipped).",
},
),
"extensions": (
"STRING",
{
"default": ".png,.jpg,.jpeg,.webp,.txt",
"tooltip": "Comma-separated list of file extensions to include. Empty includes all files.",
},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("zip_url",)
FUNCTION = "create_zip_url"
CATEGORY = _CATEGORY
DESCRIPTION = "Zips a local folder and uploads it to fal.ai, returning the zip URL."
def create_zip_url(self, folder_path, recursive=False, extensions=".png,.jpg,.jpeg,.webp,.txt"):
extension_list = [part.strip() for part in extensions.split(",") if part.strip()]
zip_path = ArchiveUtils.zip_folder(
folder_path,
include_extensions=extension_list or None,
recursive=recursive,
)
return (ArchiveUtils.upload_zip(zip_path),)
class FalVideoToFrameDatasetZip:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video": (
"VIDEO",
{
"tooltip": "Video to sample frames from for a training dataset.",
},
),
"every_nth": (
"INT",
{
"default": 10,
"min": 1,
"max": 10000,
"step": 1,
"tooltip": "Keep one frame out of every N decoded frames.",
},
),
"max_frames": (
"INT",
{
"default": 200,
"min": 1,
"max": 2000,
"step": 1,
"tooltip": "Stop after this many frames have been saved.",
},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("zip_url",)
FUNCTION = "create_zip_url"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Samples frames from a video (every Nth frame, up to max_frames), zips them as PNGs, "
"uploads the zip to fal.ai, and returns the URL."
)
def create_zip_url(self, video, every_nth=10, max_frames=200):
local_path, is_temp = _video_to_local_path(video)
try:
zip_path = _extract_frames_to_zip(local_path, every_nth, max_frames)
finally:
if is_temp:
_safe_unlink(local_path)
return (ArchiveUtils.upload_zip(zip_path),)
class FalBatchCaption:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": (
"IMAGE",
{
"tooltip": "Images to caption, one caption per frame.",
},
),
"prompt": (
"STRING",
{
"default": "Describe this image for LoRA training in one dense sentence.",
"multiline": True,
"tooltip": "Instruction sent to the vision model for each image.",
},
),
"model": (
[
"google/gemini-2.5-flash",
"anthropic/claude-sonnet-4.5",
"openai/gpt-4o",
"custom",
],
{
"default": "google/gemini-2.5-flash",
"tooltip": "Vision model to use. Select 'custom' to type any OpenRouter model id "
"in custom_model_name.",
},
),
},
"optional": {
"custom_model_name": (
"STRING",
{
"default": "",
"tooltip": "OpenRouter model id used when model is set to 'custom'.",
},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("captions",)
FUNCTION = "caption_images"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Captions each image in a batch with a fal VLM (concurrently, order preserved) and returns "
"one caption per line — wire straight into FalImagesToZipURL's captions input."
)
def caption_images(
self,
images,
prompt="Describe this image for LoRA training in one dense sentence.",
model="google/gemini-2.5-flash",
custom_model_name="",
):
if model == "custom":
if not custom_model_name or not custom_model_name.strip():
raise FalApiError(
_VISION_ENDPOINT,
"custom_model_name is required when model is set to 'custom'.",
)
model = custom_model_name.strip()
image_urls = ImageUtils.prepare_images(images)
if not image_urls:
raise FalApiError(
_VISION_ENDPOINT, "No images provided to caption. Connect an IMAGE batch."
)
def caption_one(image_url: str) -> str:
arguments = {
"model": model,
"prompt": prompt,
"image_urls": [image_url],
"stream": False,
}
result = ApiHandler.submit_and_get_result(_VISION_ENDPOINT, arguments)
# Captions are joined by newline, so flatten any multiline output.
return str(result["output"]).replace("\r", " ").replace("\n", " ").strip()
with ThreadPoolExecutor(max_workers=_MAX_CAPTION_WORKERS) as executor:
futures = [executor.submit(caption_one, url) for url in image_urls]
captions: list[str] = []
failure_count = 0
for index, future in enumerate(futures):
try:
captions = [*captions, future.result()]
except Exception as exc:
# a user Cancel raised inside a worker must stop the node,
# not silently become an empty caption
if exc.__class__.__name__ == "InterruptProcessingException":
raise
logger.warning("Caption for image %d failed: %s", index, exc)
captions = [*captions, ""]
failure_count += 1
if failure_count == len(image_urls):
raise FalApiError(
_VISION_ENDPOINT,
f"All {len(image_urls)} caption request(s) failed. "
"Check the model id, your fal API key, and the queue logs above.",
)
return ("\n".join(captions),)
# Node class mappings
NODE_CLASS_MAPPINGS = {
"FalImagesToZipURL_fal": FalImagesToZipURL,
"FalFolderToZipURL_fal": FalFolderToZipURL,
"FalVideoToFrameDatasetZip_fal": FalVideoToFrameDatasetZip,
"FalBatchCaption_fal": FalBatchCaption,
}
# Node display name mappings
NODE_DISPLAY_NAME_MAPPINGS = {
"FalImagesToZipURL_fal": "Images → Training ZIP URL (fal)",
"FalFolderToZipURL_fal": "Folder → ZIP URL (fal)",
"FalVideoToFrameDatasetZip_fal": "Video → Frame Dataset ZIP URL (fal)",
"FalBatchCaption_fal": "Batch Caption Images (fal VLM)",
}
-423
View File
@@ -1,423 +0,0 @@
"""Image utility nodes: labeled grids, preset resizing, and base64 conversion."""
from __future__ import annotations
import base64
import io
import math
import re
from typing import Any
import numpy as np
import torch
from PIL import Image, ImageDraw, ImageFont
from .fal_utils import FalApiError, ImageUtils, logger
_CATEGORY = "FAL/Utils/Image"
# fal image_size preset -> (width, height)
_PRESET_SIZES = {
"square_hd": (1024, 1024),
"square": (512, 512),
"portrait_4_3": (768, 1024),
"portrait_16_9": (576, 1024),
"landscape_4_3": (1024, 768),
"landscape_16_9": (1024, 576),
}
_CUSTOM_PRESET = "custom"
_LANCZOS = getattr(Image, "Resampling", Image).LANCZOS
_GRID_BG = (24, 24, 24)
_LABEL_BG = (16, 16, 16)
_LABEL_FG = (235, 235, 235)
_LABEL_MARGIN = 4
_ELLIPSIS = "..."
_B64_WHITESPACE = re.compile(r"\s+")
_MIME_BY_FORMAT = {"png": "image/png", "jpeg": "image/jpeg", "webp": "image/webp"}
def _pils_to_tensor(pils: list[Image.Image]) -> torch.Tensor:
"""Stack same-sized PIL images into a float32 (B, H, W, 3) IMAGE tensor."""
arrays = [np.array(pil.convert("RGB")).astype(np.float32) / 255.0 for pil in pils]
return torch.from_numpy(np.stack(arrays, axis=0))
def _image_input_to_pils(images: Any) -> list[Image.Image]:
"""Convert an IMAGE input (batch tensor or list of tensors) to PIL images."""
if isinstance(images, torch.Tensor) and images.ndim == 4:
items: list[Any] = [images[i] for i in range(images.shape[0])]
elif isinstance(images, (list, tuple)):
items = list(images)
else:
items = [images]
if not items:
raise FalApiError("FalImageGrid", "IMAGE input contained no images")
return [ImageUtils.tensor_to_pil(item) for item in items]
def _letterbox(pil: Image.Image, width: int, height: int, fill: tuple[int, int, int]) -> Image.Image:
"""Fit an image inside (width, height) preserving aspect, padded with fill."""
scale = min(width / pil.width, height / pil.height)
new_size = (max(1, round(pil.width * scale)), max(1, round(pil.height * scale)))
resized = pil.convert("RGB").resize(new_size, _LANCZOS)
canvas = Image.new("RGB", (width, height), fill)
offset = ((width - new_size[0]) // 2, (height - new_size[1]) // 2)
canvas.paste(resized, offset)
return canvas
def _truncate_label(draw: ImageDraw.ImageDraw, text: str, font: Any, max_width: int) -> str:
"""Truncate text with an ellipsis so it fits within max_width pixels."""
if draw.textlength(text, font=font) <= max_width:
return text
for end in range(len(text) - 1, 0, -1):
candidate = text[:end].rstrip() + _ELLIPSIS
if draw.textlength(candidate, font=font) <= max_width:
return candidate
return _ELLIPSIS
def _draw_label(
canvas: Image.Image, text: str, x: int, y: int, cell_width: int, label_height: int
) -> None:
"""Draw one centered label line on its dark strip below a cell."""
draw = ImageDraw.Draw(canvas)
draw.rectangle((x, y, x + cell_width - 1, y + label_height - 1), fill=_LABEL_BG)
if not text:
return
font = ImageFont.load_default()
fitted = _truncate_label(draw, text, font, cell_width - 2 * _LABEL_MARGIN)
text_width = draw.textlength(fitted, font=font)
bbox = font.getbbox(fitted)
text_height = bbox[3] - bbox[1]
text_x = x + max(_LABEL_MARGIN, (cell_width - text_width) // 2)
text_y = y + max(0, (label_height - text_height) // 2) - bbox[1]
draw.text((text_x, text_y), fitted, font=font, fill=_LABEL_FG)
def _grid_shape(count: int, columns: int) -> tuple[int, int]:
"""Resolve (columns, rows) for a grid; columns == 0 means auto square-ish."""
cols = columns if columns > 0 else math.ceil(math.sqrt(count))
cols = max(1, min(cols, count))
return cols, math.ceil(count / cols)
def _compose_grid(
pils: list[Image.Image], labels: list[str], columns: int, padding: int, label_height: int
) -> Image.Image:
"""Lay out letterboxed cells (plus optional label strips) on a dark canvas."""
cell_w = max(pil.width for pil in pils)
cell_h = max(pil.height for pil in pils)
strip_h = label_height if labels else 0
cols, rows = _grid_shape(len(pils), columns)
total_w = cols * cell_w + (cols + 1) * padding
total_h = rows * (cell_h + strip_h) + (rows + 1) * padding
canvas = Image.new("RGB", (total_w, total_h), _GRID_BG)
for i, pil in enumerate(pils):
col, row = i % cols, i // cols
x = padding + col * (cell_w + padding)
y = padding + row * (cell_h + strip_h + padding)
canvas.paste(_letterbox(pil, cell_w, cell_h, _GRID_BG), (x, y))
if strip_h:
text = labels[i] if i < len(labels) else ""
_draw_label(canvas, text, x, y + cell_h, cell_w, strip_h)
return canvas
class FalImageGrid:
"""Compose an image batch into a single labeled contact-sheet grid."""
RETURN_TYPES = ("IMAGE",)
FUNCTION = "compose"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Arrange a batch of images into one grid image with optional text "
"labels under each cell. Mixed sizes are letterboxed into uniform "
"cells on a dark background — handy for comparing seeds, prompts, "
"or models side by side."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"images": ("IMAGE", {"tooltip": "Batch of images to arrange into a grid"}),
},
"optional": {
"labels": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": (
"One label per line, matched to images in batch order. "
"Leave empty for no label strips."
),
},
),
"columns": (
"INT",
{
"default": 0,
"min": 0,
"max": 64,
"tooltip": "Number of grid columns; 0 = auto (roughly square)",
},
),
"cell_padding": (
"INT",
{
"default": 8,
"min": 0,
"max": 64,
"tooltip": "Pixels of dark padding around each cell",
},
),
"label_height": (
"INT",
{
"default": 28,
"min": 12,
"max": 128,
"tooltip": "Height in pixels of the label strip under each cell",
},
),
},
}
def compose(
self,
images: Any,
labels: str = "",
columns: int = 0,
cell_padding: int = 8,
label_height: int = 28,
) -> tuple[torch.Tensor]:
pils = _image_input_to_pils(images)
label_lines = [line.strip() for line in labels.splitlines()] if labels.strip() else []
try:
grid = _compose_grid(pils, label_lines, int(columns), int(cell_padding), int(label_height))
except FalApiError:
raise
except Exception as exc:
logger.error("FalImageGrid: failed to compose grid: %s", exc)
raise FalApiError("FalImageGrid", f"Failed to compose image grid: {exc}") from exc
logger.debug("FalImageGrid: composed %d cells into %dx%d", len(pils), grid.width, grid.height)
return (_pils_to_tensor([grid]),)
def _resize_one(pil: Image.Image, width: int, height: int, mode: str) -> Image.Image:
"""Resize a single PIL image to (width, height) using the given mode."""
source = pil.convert("RGB")
if mode == "stretch":
return source.resize((width, height), _LANCZOS)
if mode == "contain_pad":
return _letterbox(source, width, height, (0, 0, 0))
# cover_crop: scale to fully cover the target, then center-crop
scale = max(width / source.width, height / source.height)
scaled = source.resize(
(max(width, round(source.width * scale)), max(height, round(source.height * scale))),
_LANCZOS,
)
left = (scaled.width - width) // 2
top = (scaled.height - height) // 2
return scaled.crop((left, top, left + width, top + height))
class FalResizeToPreset:
"""Resize images to an exact fal image_size preset (or custom dimensions)."""
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("image", "width", "height")
FUNCTION = "resize"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Resize images to the exact pixel dimensions of a fal image_size "
"preset (square_hd, portrait_16_9, ...) or custom width/height. "
"Choose cover (crop), contain (letterbox), or stretch. Batch-safe."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"image": ("IMAGE", {"tooltip": "Image (or batch) to resize"}),
"preset": (
[*_PRESET_SIZES.keys(), _CUSTOM_PRESET],
{
"default": "square_hd",
"tooltip": (
"fal image_size preset: square_hd=1024x1024, square=512x512, "
"portrait_4_3=768x1024, portrait_16_9=576x1024, "
"landscape_4_3=1024x768, landscape_16_9=1024x576. "
"'custom' uses the width/height inputs."
),
},
),
"width": (
"INT",
{
"default": 1024,
"min": 8,
"max": 14142,
"step": 8,
"tooltip": "Target width in pixels (used when preset is 'custom')",
},
),
"height": (
"INT",
{
"default": 1024,
"min": 8,
"max": 14142,
"step": 8,
"tooltip": "Target height in pixels (used when preset is 'custom')",
},
),
"mode": (
["cover_crop", "contain_pad", "stretch"],
{
"default": "cover_crop",
"tooltip": (
"cover_crop: fill the frame and center-crop the overflow; "
"contain_pad: fit inside and letterbox with black bars; "
"stretch: ignore aspect ratio"
),
},
),
},
}
def resize(
self,
image: Any,
preset: str = "square_hd",
width: int = 1024,
height: int = 1024,
mode: str = "cover_crop",
) -> tuple[torch.Tensor, int, int]:
if preset == _CUSTOM_PRESET:
target_w, target_h = int(width), int(height)
elif preset in _PRESET_SIZES:
target_w, target_h = _PRESET_SIZES[preset]
else:
raise FalApiError("FalResizeToPreset", f"Unknown preset: {preset!r}")
if target_w < 1 or target_h < 1:
raise FalApiError("FalResizeToPreset", f"Invalid target size: {target_w}x{target_h}")
pils = _image_input_to_pils(image)
try:
resized = [_resize_one(pil, target_w, target_h, mode) for pil in pils]
except Exception as exc:
logger.error("FalResizeToPreset: resize failed: %s", exc)
raise FalApiError("FalResizeToPreset", f"Failed to resize image: {exc}") from exc
return (_pils_to_tensor(resized), target_w, target_h)
class FalImageToBase64:
"""Encode an image as a base64 string (optionally a data: URI)."""
RETURN_TYPES = ("STRING",)
FUNCTION = "encode"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Encode the first image of a batch as base64 text, optionally "
"wrapped in a data: URI — useful for APIs that accept inline "
"base64 images instead of URLs."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"image": ("IMAGE", {"tooltip": "Image to encode (first of the batch is used)"}),
"format": (
["png", "jpeg", "webp"],
{"default": "png", "tooltip": "Encoding format; png and webp are lossless-capable"},
),
"data_uri": (
"BOOLEAN",
{
"default": True,
"tooltip": "Prefix with 'data:image/...;base64,' (most APIs expect this)",
},
),
},
}
def encode(self, image: Any, format: str = "png", data_uri: bool = True) -> tuple[str]:
fmt = (format or "png").lower()
if fmt not in _MIME_BY_FORMAT:
raise FalApiError("FalImageToBase64", f"Unsupported format: {format!r}")
pil = ImageUtils.tensor_to_pil(image).convert("RGB")
try:
buffer = io.BytesIO()
save_kwargs = {"lossless": True} if fmt == "webp" else {}
pil.save(buffer, format=fmt.upper(), **save_kwargs)
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
except Exception as exc:
logger.error("FalImageToBase64: encoding failed: %s", exc)
raise FalApiError("FalImageToBase64", f"Failed to encode image as {fmt}: {exc}") from exc
if data_uri:
return (f"data:{_MIME_BY_FORMAT[fmt]};base64,{encoded}",)
return (encoded,)
class FalBase64ToImage:
"""Decode a base64 string (raw or data: URI) into an IMAGE tensor."""
RETURN_TYPES = ("IMAGE",)
FUNCTION = "decode"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Decode base64 image data — either a raw base64 string or a full "
"'data:image/...;base64,...' URI — into a ComfyUI IMAGE."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"data": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Raw base64 image data, or a data:image/...;base64,... URI",
},
),
},
}
def decode(self, data: str) -> tuple[torch.Tensor]:
payload = (data or "").strip()
if payload.startswith("data:"):
_, _, payload = payload.partition(",")
payload = _B64_WHITESPACE.sub("", payload)
if not payload:
raise FalApiError("FalBase64ToImage", "No base64 data provided")
try:
raw = base64.b64decode(payload)
pil = Image.open(io.BytesIO(raw)).convert("RGB")
except Exception as exc:
logger.error("FalBase64ToImage: decoding failed: %s", exc)
raise FalApiError("FalBase64ToImage", f"Failed to decode base64 image: {exc}") from exc
return (_pils_to_tensor([pil]),)
NODE_CLASS_MAPPINGS = {
"FalImageGrid_fal": FalImageGrid,
"FalResizeToPreset_fal": FalResizeToPreset,
"FalImageToBase64_fal": FalImageToBase64,
"FalBase64ToImage_fal": FalBase64ToImage,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FalImageGrid_fal": "Image Grid with Labels (fal)",
"FalResizeToPreset_fal": "Resize to fal Preset (fal)",
"FalImageToBase64_fal": "Image → Base64 (fal)",
"FalBase64ToImage_fal": "Base64 → Image (fal)",
}
-404
View File
@@ -1,404 +0,0 @@
"""Utility loader nodes: bring images/audio/folders into ComfyUI from URLs and disk.
ComfyUI IMAGE convention: float32 tensors in [0, 1] with shape (B, H, W, C).
"""
from __future__ import annotations
import glob
import io
import os
from typing import Any
import numpy as np
import requests
import torch
from PIL import Image
from .fal_utils import FalApiError, MediaUtils, logger
_CATEGORY = "FAL/Utils/Load"
_DOWNLOAD_TIMEOUT = (10, 180)
_DEFAULT_FOLDER_PATTERN = "*.png,*.jpg,*.jpeg,*.webp"
def _split_csv(value: str) -> list[str]:
"""Split a comma-separated string into stripped, non-empty parts."""
return [part.strip() for part in (value or "").split(",") if part.strip()]
def _validate_http_url(node_name: str, url: str) -> str:
"""Validate that a URL is a non-empty http(s) URL and return it stripped."""
stripped = (url or "").strip()
if not stripped:
raise FalApiError(
node_name, "'url' is empty. Provide an http(s) URL to a media file."
)
if not stripped.startswith(("http://", "https://")):
raise FalApiError(
node_name,
f"Invalid URL '{stripped}'. Only http(s) URLs are supported.",
)
return stripped
def _download_pil_image(node_name: str, url: str) -> Image.Image:
"""Download a URL and decode it as an RGB PIL image."""
try:
response = requests.get(url, timeout=_DOWNLOAD_TIMEOUT)
response.raise_for_status()
return Image.open(io.BytesIO(response.content)).convert("RGB")
except FalApiError:
raise
except Exception as exc:
logger.error("%s: failed to download image %s: %s", node_name, url, exc)
raise FalApiError(
node_name,
f"Failed to download or decode image from '{url}': {exc}",
) from exc
def _images_to_batch_tensor(
node_name: str, images: list[Image.Image], labels: list[str]
) -> torch.Tensor:
"""Stack RGB PIL images into a float32 (B, H, W, C) tensor in [0, 1].
Images whose size differs from the first image are resized to match
(with a warning) so the batch stays valid.
"""
first_size = images[0].size # (W, H)
arrays: list[np.ndarray] = []
for img, label in zip(images, labels):
if img.size != first_size:
logger.warning(
"%s: '%s' is %sx%s; resizing to %sx%s to match the first image",
node_name,
label,
img.size[0],
img.size[1],
first_size[0],
first_size[1],
)
img = img.resize(first_size, Image.LANCZOS)
arrays.append(np.array(img).astype(np.float32) / 255.0)
return torch.from_numpy(np.stack(arrays, axis=0))
class FalLoadImageURL:
"""Load one or more images from http(s) URLs into an IMAGE batch."""
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "load"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Download an image from an http(s) URL into a ComfyUI IMAGE tensor. "
"Accepts a comma-separated list of URLs to build a batch; images with "
"differing sizes are resized to match the first."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"url": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": (
"http(s) URL of the image to load. A comma-separated "
"list of URLs produces a batched IMAGE; mismatched "
"sizes are resized to the first image's size. URLs "
"containing literal commas are not supported in list mode."
),
},
),
},
}
def load(self, url: str) -> tuple[torch.Tensor]:
node_name = "FalLoadImageURL"
urls = [_validate_http_url(node_name, part) for part in _split_csv(url)]
if not urls:
raise FalApiError(
node_name,
"'url' is empty. Provide an http(s) URL (or a comma-separated "
"list of URLs) to image file(s).",
)
images = [_download_pil_image(node_name, u) for u in urls]
return (_images_to_batch_tensor(node_name, images, urls),)
class FalLoadAudioURL:
"""Load audio from an http(s) URL into a ComfyUI AUDIO output."""
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "load"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Download and decode an audio file from an http(s) URL into a native "
"ComfyUI AUDIO output (waveform + sample rate)."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"url": (
"STRING",
{
"default": "",
"tooltip": (
"http(s) URL of the audio file to download and "
"decode (e.g. the audio_url output of a fal node)."
),
},
),
},
}
def load(self, url: str) -> tuple[dict[str, Any]]:
node_name = "FalLoadAudioURL"
validated = _validate_http_url(node_name, url)
try:
return (MediaUtils.audio_from_url(validated),)
except FalApiError:
raise
except Exception as exc:
logger.error("%s: failed to load audio %s: %s", node_name, validated, exc)
raise FalApiError(
node_name,
f"Failed to load audio from '{validated}': {exc}",
) from exc
def _resolve_folder(node_name: str, folder_path: str) -> str:
"""Expand and validate a folder path, returning its absolute form."""
expanded = os.path.expanduser((folder_path or "").strip())
if not expanded:
raise FalApiError(
node_name, "'folder_path' is empty. Provide a path to a folder."
)
if not os.path.isdir(expanded):
raise FalApiError(
node_name,
f"Folder not found: '{expanded}'. Provide an existing folder path.",
)
return os.path.abspath(expanded)
def _glob_folder_files(folder: str, patterns: list[str]) -> list[str]:
"""Glob a folder with each pattern, deduplicated, unordered."""
matched: set[str] = set()
for pattern in patterns:
for path in glob.glob(os.path.join(folder, pattern)):
if os.path.isfile(path):
matched.add(os.path.abspath(path))
return list(matched)
def _sort_files(files: list[str], sort: str) -> list[str]:
"""Sort file paths deterministically by name or modification time."""
if sort == "modified":
return sorted(files, key=lambda path: (os.path.getmtime(path), path))
return sorted(files)
class FalLoadImageFolder:
"""Load a folder of images from disk into a single IMAGE batch."""
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("images", "count")
FUNCTION = "load"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Load every image matching the pattern(s) in a local folder into one "
"IMAGE batch. Mixed sizes are resized to the first image's dimensions."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"folder_path": (
"STRING",
{
"default": "",
"tooltip": (
"Path to a local folder of images. '~' expands to "
"your home directory."
),
},
),
"pattern": (
"STRING",
{
"default": _DEFAULT_FOLDER_PATTERN,
"tooltip": (
"Comma-separated glob pattern(s) selecting which "
"files to load, e.g. '*.png,*.jpg'."
),
},
),
"max_images": (
"INT",
{
"default": 100,
"min": 1,
"max": 1000,
"step": 1,
"tooltip": "Maximum number of images to load from the folder.",
},
),
"sort": (
["name", "modified"],
{
"default": "name",
"tooltip": (
"Order in which files are loaded: alphabetical by "
"'name' or oldest-first by 'modified' time."
),
},
),
},
}
def load(
self, folder_path: str, pattern: str, max_images: int, sort: str
) -> tuple[torch.Tensor, int]:
node_name = "FalLoadImageFolder"
folder = _resolve_folder(node_name, folder_path)
patterns = _split_csv(pattern) or _split_csv(_DEFAULT_FOLDER_PATTERN)
files = _sort_files(_glob_folder_files(folder, patterns), sort)[:max_images]
if not files:
raise FalApiError(
node_name,
f"No files matching '{', '.join(patterns)}' found in '{folder}'. "
"Adjust 'pattern' or point 'folder_path' at a folder with images.",
)
images = [self._open_image(node_name, path) for path in files]
batch = _images_to_batch_tensor(node_name, images, files)
return (batch, len(files))
@staticmethod
def _open_image(node_name: str, path: str) -> Image.Image:
"""Open a local image file as RGB, normalizing failures."""
try:
with Image.open(path) as img:
return img.convert("RGB")
except Exception as exc:
logger.error("%s: failed to open image %s: %s", node_name, path, exc)
raise FalApiError(
node_name,
f"Failed to open image '{path}': {exc}. Remove or exclude the "
"file via 'pattern' and retry.",
) from exc
def _normalize_extensions(extensions: str) -> list[str] | None:
"""Parse a comma-separated extension filter; empty means no filter."""
parts = [part.lstrip("*").lower() for part in _split_csv(extensions)]
normalized = [part if part.startswith(".") else f".{part}" for part in parts]
cleaned = [part for part in normalized if part != "."]
return cleaned or None
class FalUploadFolderAsZip:
"""Zip a local folder and upload the archive to fal.ai, returning its URL."""
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("zip_url",)
FUNCTION = "upload"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Zip a local folder (optionally recursive / filtered by extension) and "
"upload the archive to fal.ai storage, returning the ZIP's URL — handy "
"for endpoints that take a training-data archive."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"folder_path": (
"STRING",
{
"default": "",
"tooltip": (
"Path to the local folder to zip and upload. '~' "
"expands to your home directory."
),
},
),
"recursive": (
"BOOLEAN",
{
"default": False,
"tooltip": "Include files from subfolders in the ZIP.",
},
),
"extensions": (
"STRING",
{
"default": "",
"tooltip": (
"Comma-separated file extensions to include, e.g. "
"'.png,.jpg'. Leave empty to include all files."
),
},
),
},
}
def upload(
self, folder_path: str, recursive: bool, extensions: str
) -> tuple[str]:
node_name = "FalUploadFolderAsZip"
folder = _resolve_folder(node_name, folder_path)
archive_utils = self._load_archive_utils(node_name)
include_extensions = _normalize_extensions(extensions)
try:
zip_path = archive_utils.zip_folder(
folder, include_extensions=include_extensions, recursive=recursive
)
return (archive_utils.upload_zip(zip_path),)
except FalApiError:
raise
except Exception as exc:
logger.error("%s: failed to zip/upload %s: %s", node_name, folder, exc)
raise FalApiError(
node_name,
f"Failed to zip and upload folder '{folder}': {exc}",
) from exc
@staticmethod
def _load_archive_utils(node_name: str) -> Any:
"""Lazily import ArchiveUtils, degrading with an actionable error."""
try:
from .fal_utils import ArchiveUtils
return ArchiveUtils
except ImportError as exc:
raise FalApiError(
node_name,
"Archive utilities are unavailable in this install "
f"({exc}). Update/reinstall ComfyUI-fal-API so that "
"nodes/utils/archive.py is present.",
) from exc
NODE_CLASS_MAPPINGS = {
"FalLoadImageURL_fal": FalLoadImageURL,
"FalLoadAudioURL_fal": FalLoadAudioURL,
"FalLoadImageFolder_fal": FalLoadImageFolder,
"FalUploadFolderAsZip_fal": FalUploadFolderAsZip,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FalLoadImageURL_fal": "Load Image from URL (fal)",
"FalLoadAudioURL_fal": "Load Audio from URL (fal)",
"FalLoadImageFolder_fal": "Load Image Folder (fal)",
"FalUploadFolderAsZip_fal": "Upload Folder as ZIP URL (fal)",
}
-800
View File
@@ -1,800 +0,0 @@
"""Local video utility nodes: frame extraction, trim, concat, mux, audio extraction.
These nodes run entirely locally (cv2/PyAV) — no fal.ai API calls — and are
meant to glue video-generation workflows together (e.g. grab the last frame of
a clip and feed it into an image-to-video node to extend the video).
"""
from __future__ import annotations
import os
import tempfile
from fractions import Fraction
from typing import Any
import numpy as np
import torch
from .fal_utils import FalApiError, MediaUtils, logger
_CATEGORY = "FAL/Utils/Video"
_CHUNK_SIZE = 1 << 20 # 1 MiB
_AV_TIME_BASE = 1_000_000 # PyAV container.seek() offset units (microseconds)
_AAC_FRAME_SIZE = 1024 # samples per AAC frame
_TIME_EPS = 1e-6
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _safe_unlink(path: str | None) -> None:
"""Delete a temp file, ignoring errors."""
if path is None:
return
try:
os.unlink(path)
except OSError:
pass
def _import_cv2(node_name: str) -> Any:
"""Import cv2 lazily with a clear error when missing."""
try:
import cv2
return cv2
except ImportError as exc:
raise FalApiError(
node_name, "OpenCV is required for this node — pip install opencv-python"
) from exc
def _import_av(node_name: str) -> Any:
"""Import PyAV lazily with a clear error when missing."""
try:
import av
return av
except ImportError as exc:
raise FalApiError(node_name, "PyAV is required for this node — pip install av") from exc
def _resolve_video_from_file() -> type | None:
"""Locate ComfyUI's VideoFromFile class across API layouts."""
try:
from comfy_api.input_impl import VideoFromFile
return VideoFromFile
except ImportError:
pass
try:
from comfy_api.latest import input_impl
return getattr(input_impl, "VideoFromFile", None)
except ImportError:
return None
def _wrap_local_video(path: str, node_name: str) -> Any:
"""Wrap a local video file as a ComfyUI VIDEO object."""
video_cls = _resolve_video_from_file()
if video_cls is None:
raise FalApiError(
node_name,
"comfy_api VideoFromFile is unavailable; update ComfyUI to a version "
"that provides comfy_api to use VIDEO outputs.",
)
return video_cls(path)
def _new_temp_path(suffix: str) -> str:
"""Create an empty named temp file and return its path."""
with tempfile.NamedTemporaryFile(suffix=suffix, prefix="fal_util_video_", delete=False) as temp_file:
return temp_file.name
def _spool_stream_to_temp(source: Any, node_name: str) -> str:
"""Write a readable stream to a temp .mp4 file and return its path."""
temp_path: str | None = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp4", prefix="fal_util_video_", delete=False) as temp_file:
temp_path = temp_file.name
while True:
chunk = source.read(_CHUNK_SIZE)
if not chunk:
break
temp_file.write(chunk)
return temp_path
except Exception as exc:
_safe_unlink(temp_path)
raise FalApiError(node_name, f"Failed to read video stream: {exc}") from exc
def _video_input_to_path(video: Any, node_name: str) -> tuple[str, bool]:
"""Resolve a VIDEO input (or path/URL string) to a local file path.
Returns (path, cleanup_needed). cleanup_needed is True when the path is a
temp file created here that the caller must delete when done.
"""
if video is None:
raise FalApiError(node_name, "No video input provided")
source = video.get_stream_source() if hasattr(video, "get_stream_source") else video
if isinstance(source, str):
if source.startswith(("http://", "https://")):
return MediaUtils.download_url_to_temp(source, ".mp4"), True
if os.path.isfile(source):
return source, False
raise FalApiError(node_name, f"Video path does not exist: {source}")
if hasattr(source, "read"):
return _spool_stream_to_temp(source, node_name), True
raise FalApiError(
node_name,
f"Unsupported video input of type {type(video).__name__}; expected a "
"VIDEO object, a local file path, or an http(s) URL string.",
)
def _add_stream_from_template(output: Any, template: Any) -> Any:
"""Add an output stream copying the template's codec parameters."""
if hasattr(output, "add_stream_from_template"):
return output.add_stream_from_template(template)
return output.add_stream(template=template)
def _stream_duration_seconds(container: Any, stream: Any) -> float:
"""Best-effort duration (seconds) of a stream, falling back to container."""
if stream.duration is not None and stream.time_base is not None:
return float(stream.duration * stream.time_base)
if container.duration is not None:
return float(container.duration) / _AV_TIME_BASE
return 0.0
def _encode_audio_array(
av: Any, output: Any, stream: Any, layout: str, sample_rate: int, samples: np.ndarray, start_index: int
) -> int:
"""Encode a planar float32 (C, T) array as AAC frames; returns next sample index."""
total = samples.shape[1]
for offset in range(0, total, _AAC_FRAME_SIZE):
chunk = np.ascontiguousarray(samples[:, offset : offset + _AAC_FRAME_SIZE])
frame = av.AudioFrame.from_ndarray(chunk, format="fltp", layout=layout)
frame.sample_rate = sample_rate
frame.pts = start_index + offset
for packet in stream.encode(frame):
output.mux(packet)
return start_index + total
def _pad_or_truncate(samples: np.ndarray, needed: int) -> np.ndarray:
"""Pad a planar (C, T) array with silence, or truncate, to exactly `needed` samples."""
if samples.shape[1] >= needed:
return samples[:, :needed]
pad = np.zeros((samples.shape[0], needed - samples.shape[1]), dtype=np.float32)
return np.concatenate([samples, pad], axis=1)
def _normalize_audio_frame(array: np.ndarray, channels: int) -> np.ndarray:
"""Normalize a PyAV audio frame array to float32 with shape (C, N)."""
if np.issubdtype(array.dtype, np.integer):
info = np.iinfo(array.dtype)
scale = float(max(abs(info.min), info.max))
array = array.astype(np.float32) / scale
else:
array = array.astype(np.float32)
if array.ndim == 1:
array = array[np.newaxis, :]
if array.shape[0] == 1 and channels > 1:
# Packed/interleaved format: (1, N * C) -> (C, N)
array = array.reshape(-1, channels).T
return array
def _waveform_to_planar(audio: Any, node_name: str) -> tuple[np.ndarray, int]:
"""Convert a ComfyUI AUDIO dict to (planar float32 (C, T) with C in {1, 2}, sample_rate)."""
try:
waveform = audio["waveform"]
sample_rate = int(audio["sample_rate"])
except (KeyError, TypeError) as exc:
raise FalApiError(
node_name, "Expected an AUDIO dict with 'waveform' and 'sample_rate'"
) from exc
if not isinstance(waveform, torch.Tensor):
raise FalApiError(node_name, "AUDIO 'waveform' must be a torch tensor")
tensor = waveform.detach().cpu().to(torch.float32)
if tensor.ndim == 3:
tensor = tensor[0] # (B, C, T) -> (C, T)
if tensor.ndim == 1:
tensor = tensor.unsqueeze(0)
if tensor.ndim != 2:
raise FalApiError(node_name, f"AUDIO waveform has unsupported shape {tuple(waveform.shape)}")
array = tensor.clamp(-1.0, 1.0).numpy()
if array.shape[0] > 2:
logger.warning("%s: waveform has %d channels; keeping the first two", node_name, array.shape[0])
array = array[:2]
return np.ascontiguousarray(array), sample_rate
# ---------------------------------------------------------------------------
# FalExtractFrames
# ---------------------------------------------------------------------------
def _bgr_frames_to_tensor(frames: list[np.ndarray], cv2: Any) -> torch.Tensor:
"""Convert BGR uint8 frames to a (N, H, W, 3) float32 RGB tensor in 0-1."""
rgb = [cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) for frame in frames]
stacked = np.stack(rgb).astype(np.float32) / 255.0
return torch.from_numpy(stacked)
def _frame_via_seek(cap: Any, cv2: Any, index: int) -> np.ndarray | None:
"""Seek to a frame index and read it; returns None when the seek misbehaves."""
cap.set(cv2.CAP_PROP_POS_FRAMES, float(index))
ok, frame = cap.read()
return frame if ok and frame is not None else None
def _scan_frames(cap: Any, cv2: Any, stop_after: int | None = None) -> tuple[np.ndarray | None, int]:
"""Sequentially decode from frame 0; returns (last frame seen, frames read).
Stops after reading `stop_after + 1` frames when `stop_after` is given.
"""
cap.set(cv2.CAP_PROP_POS_FRAMES, 0.0)
last: np.ndarray | None = None
count = 0
while True:
ok, frame = cap.read()
if not ok or frame is None:
break
last = frame
count += 1
if stop_after is not None and count > stop_after:
break
return last, count
class FalExtractFrames:
"""Extract frames from a video as IMAGE outputs (local decode, no API call)."""
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("frames", "frame_count")
FUNCTION = "extract_frames"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Decode a video locally and extract frames. Mode 'last' grabs the final "
"frame — feed it into an image-to-video node to extend/continue a video."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"video": ("VIDEO", {"tooltip": "Video to decode. Tip: use mode 'last' to grab the final frame and feed it into an image-to-video node to extend the video."}),
"mode": (["first", "last", "nth", "every_nth"], {"default": "last", "tooltip": "first/last: single frame. nth: the n-th frame (1-based). every_nth: every n-th frame as a batch, capped at max_frames."}),
"n": ("INT", {"default": 1, "min": 1, "max": 1_000_000, "tooltip": "Frame index (1-based) for mode 'nth'; step size for 'every_nth'. Ignored otherwise."}),
"max_frames": ("INT", {"default": 64, "min": 1, "max": 1024, "tooltip": "Maximum number of frames returned by mode 'every_nth'; ignored for other modes."}),
},
}
def extract_frames(self, video: Any, mode: str, n: int, max_frames: int) -> tuple[torch.Tensor, int]:
node_name = "FalExtractFrames"
cv2 = _import_cv2(node_name)
path, cleanup = _video_input_to_path(video, node_name)
cap = None
try:
cap = cv2.VideoCapture(path)
if not cap.isOpened():
raise FalApiError(node_name, f"OpenCV could not open video: {path}")
reported = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
if mode == "every_nth":
frames, frame_count = self._extract_every_nth(cap, cv2, n, max_frames, reported)
else:
frame, frame_count = self._extract_single(cap, cv2, mode, n, reported, node_name)
frames = [frame]
if not frames or frames[0] is None:
raise FalApiError(node_name, f"No frames could be decoded from {path}")
return (_bgr_frames_to_tensor(frames, cv2), frame_count)
except FalApiError:
raise
except Exception as exc:
logger.error("FalExtractFrames failed: %s", exc)
raise FalApiError(node_name, f"Frame extraction failed: {exc}") from exc
finally:
if cap is not None:
cap.release()
if cleanup:
_safe_unlink(path)
@staticmethod
def _extract_every_nth(
cap: Any, cv2: Any, step: int, max_frames: int, reported: int
) -> tuple[list[np.ndarray], int]:
"""Sequentially collect every `step`-th frame, capped at max_frames."""
cap.set(cv2.CAP_PROP_POS_FRAMES, 0.0)
frames: list[np.ndarray] = []
count = 0
reached_eof = False
while True:
ok, frame = cap.read()
if not ok or frame is None:
reached_eof = True
break
if count % step == 0 and len(frames) < max_frames:
frames.append(frame)
count += 1
if len(frames) >= max_frames and reported > 0:
break # early stop: reported count stands in for the true total
frame_count = count if reached_eof or reported <= 0 else reported
return frames, frame_count
@staticmethod
def _extract_single(
cap: Any, cv2: Any, mode: str, n: int, reported: int, node_name: str
) -> tuple[np.ndarray | None, int]:
"""Extract a single frame for modes first/last/nth."""
if mode == "first":
target = 0
elif mode == "nth":
target = n - 1
if reported > 0 and target >= reported:
logger.warning("%s: frame %d beyond end (%d frames); using last frame", node_name, n, reported)
target = reported - 1
elif mode == "last":
target = max(reported - 1, 0)
else:
raise FalApiError(node_name, f"Unknown mode: {mode}")
frame: np.ndarray | None = None
if reported > 0:
# Fast path: direct seek (some codecs mis-seek; fall back below).
frame = _frame_via_seek(cap, cv2, target)
if frame is not None:
return frame, reported
# Sequential fallback: decode from the start.
if mode == "last":
frame, count = _scan_frames(cap, cv2)
return frame, count
frame, read = _scan_frames(cap, cv2, stop_after=target)
if read > target:
# Reached the target; total count comes from metadata or a full scan.
frame_count = reported if reported > 0 else _scan_frames(cap, cv2)[1]
return frame, frame_count
# Hit EOF early: `frame` is the last decodable frame, `read` the true count.
return frame, read
# ---------------------------------------------------------------------------
# FalTrimVideo
# ---------------------------------------------------------------------------
def _find_seek_start(av: Any, container: Any, anchor: Any, start: float) -> float:
"""Seek near `start` and return the timestamp of the first packet (keyframe snap)."""
if start <= 0:
return 0.0
container.seek(int(start * _AV_TIME_BASE), backward=True, any_frame=False)
actual_start = start
for packet in container.demux(anchor):
if packet.pts is None:
continue
actual_start = float(packet.pts * packet.time_base)
break
container.seek(int(start * _AV_TIME_BASE), backward=True, any_frame=False)
return actual_start
def _remux_trim(av: Any, in_path: str, out_path: str, start: float, end: float | None, node_name: str) -> None:
"""Copy packets between timestamps into a new mp4 without re-encoding."""
with av.open(in_path) as container, av.open(out_path, mode="w") as output:
video_in = container.streams.video[0] if container.streams.video else None
audio_in = container.streams.audio[0] if container.streams.audio else None
selected = [stream for stream in (video_in, audio_in) if stream is not None]
if not selected:
raise FalApiError(node_name, "Input has no video or audio streams")
out_streams = {stream.index: _add_stream_from_template(output, stream) for stream in selected}
anchor = video_in if video_in is not None else audio_in
actual_start = _find_seek_start(av, container, anchor, start)
if end is not None and end <= actual_start + _TIME_EPS:
raise FalApiError(
node_name,
f"Trim range is empty: start snapped to keyframe at {actual_start:.3f}s, end is {end:.3f}s",
)
offsets: dict[int, int] = {}
done = dict.fromkeys(out_streams, False)
kept = 0
for packet in container.demux(selected):
if packet.pts is None:
continue
index = packet.stream.index
if done.get(index, True):
continue
time = float(packet.pts * packet.time_base)
if time < actual_start - _TIME_EPS:
continue
if end is not None and time >= end - _TIME_EPS:
done[index] = True
if all(done.values()):
break
continue
if index not in offsets:
offsets[index] = packet.dts if packet.dts is not None else packet.pts
offset = offsets[index]
packet.pts -= offset
if packet.dts is not None:
packet.dts -= offset
packet.stream = out_streams[index]
output.mux(packet)
kept += 1
if kept == 0:
raise FalApiError(node_name, f"Trim produced no packets (start {start:.3f}s may be past the end)")
class FalTrimVideo:
"""Trim a video to [start, end] seconds by remuxing (no re-encode)."""
RETURN_TYPES = ("VIDEO", "STRING")
RETURN_NAMES = ("video", "path")
FUNCTION = "trim_video"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Trim a video without re-encoding by copying packets between timestamps. "
"Fast and lossless, but the start cut snaps to the nearest earlier keyframe."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"video": ("VIDEO", {"tooltip": "Video to trim (video + audio tracks are kept)."}),
"start_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100_000.0, "step": 0.1, "tooltip": "Trim start in seconds. Cuts snap to the nearest earlier keyframe (no re-encode)."}),
"end_seconds": ("FLOAT", {"default": 5.0, "min": 0.0, "max": 100_000.0, "step": 0.1, "tooltip": "Trim end in seconds; 0 = to the end of the video."}),
},
}
def trim_video(self, video: Any, start_seconds: float, end_seconds: float) -> tuple[Any, str]:
node_name = "FalTrimVideo"
av = _import_av(node_name)
end = end_seconds if end_seconds > 0 else None
if end is not None and end <= start_seconds:
raise FalApiError(node_name, f"end_seconds ({end:.3f}) must be greater than start_seconds ({start_seconds:.3f})")
path, cleanup = _video_input_to_path(video, node_name)
out_path = _new_temp_path(".mp4")
try:
_remux_trim(av, path, out_path, start_seconds, end, node_name)
# NOTE: out_path is deliberately kept — VideoFromFile reads it lazily.
return (_wrap_local_video(out_path, node_name), out_path)
except FalApiError:
_safe_unlink(out_path)
raise
except Exception as exc:
_safe_unlink(out_path)
logger.error("FalTrimVideo failed: %s", exc)
raise FalApiError(node_name, f"Trim failed: {exc}") from exc
finally:
if cleanup:
_safe_unlink(path)
# ---------------------------------------------------------------------------
# FalConcatVideos
# ---------------------------------------------------------------------------
def _probe_video(av: Any, path: str, node_name: str) -> dict[str, Any]:
"""Probe a clip for resolution, fps, audio presence, and audio rate."""
with av.open(path) as container:
if not container.streams.video:
raise FalApiError(node_name, f"Input has no video stream: {path}")
stream = container.streams.video[0]
fps = stream.average_rate or stream.guessed_rate
if not fps or fps <= 0:
fps = Fraction(30, 1)
audio_rate = None
if container.streams.audio:
audio_rate = int(container.streams.audio[0].rate or 44100)
return {
"width": max(2, stream.width - stream.width % 2),
"height": max(2, stream.height - stream.height % 2),
"fps": Fraction(fps),
"audio_rate": audio_rate,
}
def _encode_video_frame(output: Any, stream: Any, frame: Any, width: int, height: int, time_base: Fraction, index: int) -> None:
"""Scale/convert one decoded frame and encode it at the given frame index."""
scaled = frame.reformat(width=width, height=height, format="yuv420p")
scaled.pts = index
scaled.time_base = time_base
for packet in stream.encode(scaled):
output.mux(packet)
def _append_clip_video(
av: Any, output: Any, stream: Any, path: str, width: int, height: int, fps: Fraction, start_index: int, node_name: str
) -> int:
"""Decode a clip, resample to target fps/size, encode; returns frames emitted."""
time_base = Fraction(1, 1) / fps
step = 1.0 / float(fps)
emitted = 0
with av.open(path) as container:
next_time = 0.0
last = None
for frame in container.decode(container.streams.video[0]):
time = frame.time if frame.time is not None else next_time
while last is not None and time > next_time + _TIME_EPS:
_encode_video_frame(output, stream, last, width, height, time_base, start_index + emitted)
emitted += 1
next_time += step
last = frame
if last is not None:
_encode_video_frame(output, stream, last, width, height, time_base, start_index + emitted)
emitted += 1
if emitted == 0:
raise FalApiError(node_name, f"No video frames decoded from {path}")
return emitted
def _clip_audio_samples(av: Any, path: str, rate: int, needed: int) -> np.ndarray:
"""Decode+resample a clip's audio to stereo float32 (2, needed); silence when absent."""
with av.open(path) as container:
if not container.streams.audio:
return np.zeros((2, needed), dtype=np.float32)
resampler = av.AudioResampler(format="fltp", layout="stereo", rate=rate)
chunks: list[np.ndarray] = []
for frame in container.decode(container.streams.audio[0]):
frame.pts = None # let the resampler track timestamps itself
chunks.extend(out.to_ndarray() for out in resampler.resample(frame))
chunks.extend(out.to_ndarray() for out in resampler.resample(None))
if not chunks:
return np.zeros((2, needed), dtype=np.float32)
samples = np.concatenate(chunks, axis=1).astype(np.float32)
return _pad_or_truncate(samples, needed)
class FalConcatVideos:
"""Concatenate 2-4 videos by re-encoding to the first clip's resolution and fps."""
RETURN_TYPES = ("VIDEO", "STRING")
RETURN_NAMES = ("video", "path")
FUNCTION = "concat_videos"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Concatenate up to 4 videos. All clips are re-encoded (h264 crf 18) and scaled to "
"video_1's resolution and fps, so mismatched codecs/sizes are fine. Audio: the output "
"gets a stereo AAC track when any input has audio; inputs without audio contribute silence."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"video_1": ("VIDEO", {"tooltip": "First clip; its resolution and fps define the output format."}),
"video_2": ("VIDEO", {"tooltip": "Second clip, scaled to match video_1."}),
},
"optional": {
"video_3": ("VIDEO", {"tooltip": "Optional third clip."}),
"video_4": ("VIDEO", {"tooltip": "Optional fourth clip."}),
},
}
def concat_videos(
self, video_1: Any, video_2: Any, video_3: Any = None, video_4: Any = None
) -> tuple[Any, str]:
node_name = "FalConcatVideos"
av = _import_av(node_name)
inputs = [video for video in (video_1, video_2, video_3, video_4) if video is not None]
resolved: list[tuple[str, bool]] = []
out_path = _new_temp_path(".mp4")
try:
resolved = [_video_input_to_path(video, node_name) for video in inputs]
paths = [path for path, _ in resolved]
probes = [_probe_video(av, path, node_name) for path in paths]
target = probes[0]
width, height, fps = target["width"], target["height"], target["fps"]
audio_rates = [probe["audio_rate"] for probe in probes if probe["audio_rate"]]
audio_rate = audio_rates[0] if audio_rates else None
with av.open(out_path, mode="w") as output:
video_out = output.add_stream("libx264", rate=fps, options={"crf": "18", "preset": "veryfast"})
video_out.width = width
video_out.height = height
video_out.pix_fmt = "yuv420p"
audio_out = None
if audio_rate is not None:
audio_out = output.add_stream("aac", rate=audio_rate)
audio_out.layout = "stereo"
frame_index = 0
sample_index = 0
for path in paths:
emitted = _append_clip_video(av, output, video_out, path, width, height, fps, frame_index, node_name)
frame_index += emitted
if audio_out is not None:
needed = round(emitted / float(fps) * audio_rate)
samples = _clip_audio_samples(av, path, audio_rate, needed)
sample_index = _encode_audio_array(av, output, audio_out, "stereo", audio_rate, samples, sample_index)
for packet in video_out.encode(None):
output.mux(packet)
if audio_out is not None:
for packet in audio_out.encode(None):
output.mux(packet)
# NOTE: out_path is deliberately kept — VideoFromFile reads it lazily.
return (_wrap_local_video(out_path, node_name), out_path)
except FalApiError:
_safe_unlink(out_path)
raise
except Exception as exc:
_safe_unlink(out_path)
logger.error("FalConcatVideos failed: %s", exc)
raise FalApiError(node_name, f"Concat failed: {exc}") from exc
finally:
for path, cleanup in resolved:
if cleanup:
_safe_unlink(path)
# ---------------------------------------------------------------------------
# FalMuxAudioVideo
# ---------------------------------------------------------------------------
class FalMuxAudioVideo:
"""Mux an AUDIO waveform onto a video, replacing any existing audio track."""
RETURN_TYPES = ("VIDEO", "STRING")
RETURN_NAMES = ("video", "path")
FUNCTION = "mux_audio_video"
CATEGORY = _CATEGORY
DESCRIPTION = (
"Attach an AUDIO input to a video as an AAC track, replacing any existing audio. "
"Video packets are copied without re-encoding."
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"video": ("VIDEO", {"tooltip": "Video track; copied without re-encoding. Existing audio is replaced."}),
"audio": ("AUDIO", {"tooltip": "Audio to attach, encoded as AAC at its own sample rate."}),
"duration_policy": (["video", "shortest"], {"default": "video", "tooltip": "video: keep full video; audio is padded with silence or truncated to match. shortest: cut the output at whichever track ends first."}),
},
}
def mux_audio_video(self, video: Any, audio: Any, duration_policy: str) -> tuple[Any, str]:
node_name = "FalMuxAudioVideo"
av = _import_av(node_name)
samples, sample_rate = _waveform_to_planar(audio, node_name)
layout = "mono" if samples.shape[0] == 1 else "stereo"
audio_duration = samples.shape[1] / float(sample_rate)
path, cleanup = _video_input_to_path(video, node_name)
out_path = _new_temp_path(".mp4")
try:
with av.open(path) as container:
if not container.streams.video:
raise FalApiError(node_name, "Input has no video stream")
video_in = container.streams.video[0]
video_duration = _stream_duration_seconds(container, video_in)
if video_duration <= 0:
raise FalApiError(node_name, "Could not determine the video duration")
target = video_duration if duration_policy == "video" else min(video_duration, audio_duration)
with av.open(out_path, mode="w") as output:
video_out = _add_stream_from_template(output, video_in)
audio_out = output.add_stream("aac", rate=sample_rate)
audio_out.layout = layout
for packet in container.demux(video_in):
if packet.dts is None:
continue
if duration_policy == "shortest" and float(packet.dts * packet.time_base) >= target - _TIME_EPS:
break
packet.stream = video_out
output.mux(packet)
needed = round(target * sample_rate)
_encode_audio_array(
av, output, audio_out, layout, sample_rate, _pad_or_truncate(samples, needed), 0
)
for packet in audio_out.encode(None):
output.mux(packet)
# NOTE: out_path is deliberately kept — VideoFromFile reads it lazily.
return (_wrap_local_video(out_path, node_name), out_path)
except FalApiError:
_safe_unlink(out_path)
raise
except Exception as exc:
_safe_unlink(out_path)
logger.error("FalMuxAudioVideo failed: %s", exc)
raise FalApiError(node_name, f"Mux failed: {exc}") from exc
finally:
if cleanup:
_safe_unlink(path)
# ---------------------------------------------------------------------------
# FalVideoToAudio
# ---------------------------------------------------------------------------
class FalVideoToAudio:
"""Extract the audio track of a video as a ComfyUI AUDIO output."""
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "video_to_audio"
CATEGORY = _CATEGORY
DESCRIPTION = "Extract a video's audio track as an AUDIO output at its original sample rate."
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"video": ("VIDEO", {"tooltip": "Video whose audio track should be extracted."}),
},
}
def video_to_audio(self, video: Any) -> tuple[dict[str, Any]]:
node_name = "FalVideoToAudio"
av = _import_av(node_name)
path, cleanup = _video_input_to_path(video, node_name)
try:
with av.open(path) as container:
if not container.streams.audio:
raise FalApiError(node_name, "video has no audio track")
stream = container.streams.audio[0]
sample_rate = int(stream.rate or 44100)
channels = int(getattr(stream, "channels", 1) or 1)
chunks = [
_normalize_audio_frame(frame.to_ndarray(), channels)
for frame in container.decode(stream)
]
if not chunks:
raise FalApiError(node_name, "No audio frames could be decoded")
waveform = torch.from_numpy(np.concatenate(chunks, axis=1)).unsqueeze(0)
return ({"waveform": waveform, "sample_rate": sample_rate},)
except FalApiError:
raise
except Exception as exc:
logger.error("FalVideoToAudio failed: %s", exc)
raise FalApiError(node_name, f"Audio extraction failed: {exc}") from exc
finally:
if cleanup:
_safe_unlink(path)
NODE_CLASS_MAPPINGS = {
"FalExtractFrames_fal": FalExtractFrames,
"FalTrimVideo_fal": FalTrimVideo,
"FalConcatVideos_fal": FalConcatVideos,
"FalMuxAudioVideo_fal": FalMuxAudioVideo,
"FalVideoToAudio_fal": FalVideoToAudio,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FalExtractFrames_fal": "Extract Frames (fal)",
"FalTrimVideo_fal": "Trim Video (fal)",
"FalConcatVideos_fal": "Concat Videos (fal)",
"FalMuxAudioVideo_fal": "Mux Audio + Video (fal)",
"FalVideoToAudio_fal": "Video → Audio (fal)",
}
-13
View File
@@ -1,32 +1,19 @@
"""Core utilities for the ComfyUI-fal-API node pack."""
from .api import ApiHandler
from .archive import ArchiveUtils
from .billing import BillingUtils, SpendGuard
from .config import FalConfig
from .errors import FalApiError, extract_error_message, raise_fal_error
from .images import ImageUtils, ResultProcessor
from .job_store import JobStore
from .ledger import SessionLedger
from .logger import logger
from .media import MediaUtils
from .pricing import PricingUtils
from .result_cache import ResultCache
__all__ = [
"ApiHandler",
"ArchiveUtils",
"BillingUtils",
"FalApiError",
"FalConfig",
"ImageUtils",
"JobStore",
"MediaUtils",
"PricingUtils",
"ResultCache",
"ResultProcessor",
"SessionLedger",
"SpendGuard",
"extract_error_message",
"logger",
"raise_fal_error",
+4 -279
View File
@@ -4,18 +4,11 @@ from __future__ import annotations
import asyncio
import concurrent.futures
import time
from typing import Any, Callable, NoReturn
from .config import FalConfig
from .errors import FalApiError, extract_error_message, raise_fal_error
from .job_store import JobStore
from .ledger import SessionLedger
from .logger import logger
from .pricing import PricingUtils
from .result_cache import ResultCache
_RECOVERY_POLL_INTERVAL_S = 0.5
_MAX_QUEUE_LOG_LINES = 10_000
@@ -121,102 +114,6 @@ def _partition_results(
return successes, failures
def _record_ledger_entry(
endpoint: str,
request_id: str | None,
duration_s: float,
est_cost_override: float | None = None,
free: bool = False,
) -> None:
"""Record one fal call in the session ledger.
``free=True`` marks a recovery/replay that spent no new money (est_cost
None). Best-effort bookkeeping: any pricing or ledger error is swallowed
so it can never break generation.
"""
if free:
est_cost = est_cost_override
else:
try:
est_cost = PricingUtils.estimate(endpoint, 1)["total"]
except Exception as exc:
logger.debug("[%s] cost estimation failed: %s", endpoint, exc)
est_cost = None
try:
SessionLedger().record(endpoint, request_id, duration_s, est_cost)
except Exception as exc:
logger.debug("[%s] ledger record failed: %s", endpoint, exc)
def _spend_guard_preflight(endpoint: str) -> None:
"""Enforce the spend budget before submitting a paid call.
A no-op when the billing module is absent. A FalApiError raised by
SpendGuard (over budget) propagates to the caller.
"""
try:
from .billing import SpendGuard
except ImportError:
return
SpendGuard.preflight(endpoint)
def _store_result_in_cache(
endpoint: str,
arguments: dict[str, Any],
result: Any,
request_id: str | None,
) -> None:
"""Persist a successful live result in the persistent cache.
Best-effort bookkeeping: only dict results are cached and any cache
error is swallowed so it can never break generation.
"""
if not isinstance(result, dict):
return
try:
ResultCache().put(endpoint, arguments, result, request_id)
except Exception as exc:
logger.debug("[%s] result cache store failed: %s", endpoint, exc)
def _remember_result_urls(endpoint: str, request_id: str | None, result: Any) -> None:
"""Best-effort provenance bookkeeping: map result URLs to their request."""
if not request_id:
return
try:
ResultCache().remember_urls(endpoint, request_id, result)
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):
@@ -234,43 +131,21 @@ class ApiHandler:
endpoint: str,
arguments: dict[str, Any],
timeout: float | None = None,
skip_cache: bool = False,
) -> Any:
"""Submit a job via client.subscribe and return the final result.
Checks the spend budget first, then the persistent result cache: an
identical previous call returns its stored result immediately (no
charge, no ledger entry). Pass ``skip_cache=True`` to force a live
call (e.g. force_rerun). Logs queue position and in-progress log
lines, and checks for ComfyUI interruption on every queue update.
``timeout`` is reserved for future use (fal_client 1.0 subscribe does
not accept one).
Logs queue position and in-progress log lines, and checks for ComfyUI
interruption on every queue update. ``timeout`` is reserved for future
use (fal_client 1.0 subscribe does not accept one).
"""
del timeout # Reserved; not supported by fal_client 1.0 subscribe.
# 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
_spend_guard_preflight(endpoint)
client = FalConfig().get_client()
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
started = time.monotonic()
try:
result = client.subscribe(
return client.subscribe(
endpoint,
arguments=arguments,
with_logs=True,
on_enqueue=on_enqueue,
on_queue_update=callback,
)
except FalApiError:
@@ -279,156 +154,6 @@ class ApiHandler:
if _is_interruption(exc):
raise
raise_fal_error(endpoint, exc)
finally:
_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,
arguments=arguments,
with_logs=True,
on_enqueue=on_enqueue,
on_queue_update=callback,
)
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)
return result
@staticmethod
def submit_only(endpoint: str, arguments: dict[str, Any]) -> str:
"""Submit a job without waiting and return its request id.
Checks the spend budget first (async fan-out must respect it too).
Does not record to the session ledger — the collect side
(``result_from_request_id``) records the call.
"""
_spend_guard_preflight(endpoint)
client = FalConfig().get_client()
try:
handle = client.submit(endpoint, arguments=arguments)
except FalApiError:
raise
except Exception as exc:
if _is_interruption(exc):
raise
raise_fal_error(endpoint, exc)
logger.info("[%s] submitted async (request_id=%s)", endpoint, handle.request_id)
# Best-effort bookkeeping: the persistent job inbox lets this request
# be found and collected even after a ComfyUI restart.
try:
JobStore().record_submit(endpoint, handle.request_id)
except Exception as exc:
logger.debug("[%s] job store record_submit failed: %s", endpoint, exc)
return handle.request_id
@staticmethod
def result_from_request_id(
endpoint: str, request_id: str, record_cost: bool = True
) -> dict[str, Any]:
"""Wait for and fetch the result of a previously submitted request.
Reconstructs a queue handle from the request id, polls until the
request completes (honoring ComfyUI interruption), and returns the
result payload. A request that already completed returns immediately
without incurring new charges — the result-recovery path.
"""
label = f"{endpoint}#{request_id}"
client = FalConfig().get_client()
started = time.monotonic()
try:
handle = client.get_handle(endpoint, request_id)
for _status in handle.iter_events(
with_logs=False, interval=_RECOVERY_POLL_INTERVAL_S
):
_check_interruption()
result = handle.get()
except FalApiError:
raise
except Exception as exc:
if _is_interruption(exc):
raise
raise_fal_error(label, exc)
finally:
duration_s = time.monotonic() - started
logger.info(
"[%s] result recovery finished in %.1fs (request_id=%s)",
endpoint,
duration_s,
request_id,
)
# record_cost=False marks a pure recovery of an old request:
# log the fetch for traceability but count no new spend.
if record_cost:
_record_ledger_entry(endpoint, request_id, duration_s)
else:
_record_ledger_entry(endpoint, request_id, duration_s, est_cost_override=None, free=True)
# Best-effort bookkeeping: mark the job collected in the persistent
# inbox (inserting it if it was submitted in another session).
try:
JobStore().mark_collected(request_id)
except Exception as exc:
logger.debug("[%s] job store mark_collected failed: %s", endpoint, exc)
_remember_result_urls(endpoint, request_id, result)
return result
@staticmethod
def submit_multiple_and_get_results(
-223
View File
@@ -1,223 +0,0 @@
"""Zip archive helpers for dataset preparation (LoRA training uploads)."""
from __future__ import annotations
import io
import os
import tempfile
import zipfile
from typing import Any
import torch
from .config import FalConfig
from .errors import FalApiError
from .images import ImageUtils
from .logger import logger
_MODEL_NAME = "archive"
def _safe_unlink(path: str | None) -> None:
"""Delete a temp file, ignoring errors."""
if path is None:
return
try:
os.unlink(path)
except OSError:
pass
def _split_frames(images: Any) -> list[Any]:
"""Split an IMAGE input (batch tensor, list, or single image) into frames."""
if images is None:
return []
if isinstance(images, torch.Tensor):
if images.ndim == 4:
return [images[i] for i in range(images.shape[0])]
return [images]
if isinstance(images, (list, tuple)):
return list(images)
return [images]
def _normalize_extensions(extensions: Any) -> list[str] | None:
"""Normalize an extension filter to lowercase dot-prefixed suffixes."""
if not extensions:
return None
normalized = []
for ext in extensions:
cleaned = str(ext).strip().lower()
if not cleaned:
continue
normalized.append(cleaned if cleaned.startswith(".") else f".{cleaned}")
return normalized or None
def _matches_filter(file_name: str, extensions: list[str] | None) -> bool:
"""Whether a file passes the hidden-file and extension filters."""
if file_name.startswith("."):
return False
if extensions is None:
return True
return os.path.splitext(file_name)[1].lower() in extensions
def _collect_folder_files(
folder: str, extensions: list[str] | None, recursive: bool
) -> list[str]:
"""List matching files in a folder (sorted, hidden entries skipped)."""
if not recursive:
return [
os.path.join(folder, name)
for name in sorted(os.listdir(folder))
if os.path.isfile(os.path.join(folder, name))
and _matches_filter(name, extensions)
]
matches: list[str] = []
for root, dirs, files in os.walk(folder):
dirs[:] = sorted(d for d in dirs if not d.startswith("."))
for name in sorted(files):
if _matches_filter(name, extensions):
matches.append(os.path.join(root, name))
return matches
def _new_temp_zip_path() -> str:
"""Reserve a temp .zip path and return it."""
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as temp_zip:
return temp_zip.name
class ArchiveUtils:
"""Utility functions for building and uploading zip archives."""
@staticmethod
def zip_images(
images: Any,
captions: list[str] | None = None,
name_prefix: str = "image",
) -> str:
"""Zip an IMAGE batch as image_0.png, image_1.png, ... and return the local zip path.
``captions`` (optional, one per image, entries may be "") also writes
image_0.txt, image_1.txt, ... — the standard LoRA-training caption
layout. The caller is responsible for uploading/deleting the zip
(see ``upload_zip``).
"""
frames = _split_frames(images)
if not frames:
raise FalApiError(
_MODEL_NAME,
"No images provided to zip. Connect an IMAGE batch with at least one frame.",
)
if captions is not None and len(captions) != len(frames):
raise FalApiError(
_MODEL_NAME,
f"Caption count ({len(captions)}) does not match image count ({len(frames)}). "
"Provide exactly one caption per image (blank entries are allowed) or none at all.",
)
prefix = (name_prefix or "image").strip() or "image"
zip_path: str | None = None
try:
zip_path = _new_temp_zip_path()
with zipfile.ZipFile(zip_path, "w") as zip_file:
for index, frame in enumerate(frames):
pil_image = ImageUtils.tensor_to_pil(frame)
buffer = io.BytesIO()
pil_image.save(buffer, format="PNG")
zip_file.writestr(f"{prefix}_{index}.png", buffer.getvalue())
if captions is not None:
zip_file.writestr(f"{prefix}_{index}.txt", captions[index])
return zip_path
except FalApiError:
_safe_unlink(zip_path)
raise
except Exception as exc:
_safe_unlink(zip_path)
logger.error("Failed to create image zip: %s", exc)
raise FalApiError(
_MODEL_NAME, f"Failed to create image zip: {exc}"
) from exc
@staticmethod
def zip_folder(
folder_path: str,
include_extensions: list[str] | None = None,
recursive: bool = False,
) -> str:
"""Zip a folder's files and return the local zip path.
``include_extensions`` filters by suffix (e.g. [".png", ".txt"]); None
includes everything. Hidden files/directories are always skipped.
Non-recursive by default; arcnames are relative to the folder.
"""
if not folder_path or not isinstance(folder_path, str) or not folder_path.strip():
raise FalApiError(
_MODEL_NAME,
"folder_path is empty. Provide the path to a folder of dataset files.",
)
folder = os.path.abspath(os.path.expanduser(folder_path.strip()))
if not os.path.isdir(folder):
raise FalApiError(
_MODEL_NAME,
f"Folder not found: {folder}. Provide the path to an existing directory.",
)
extensions = _normalize_extensions(include_extensions)
files = _collect_folder_files(folder, extensions, recursive)
if not files:
suffix_hint = f" matching extensions {extensions}" if extensions else ""
raise FalApiError(
_MODEL_NAME,
f"No files{suffix_hint} found in {folder}. "
"Check the folder contents, the extension filter, and the recursive flag.",
)
# Folder zips get uploaded to fal's CDN: log loudly what is being read
# and cap runaway/hostile selections ([archive] section in config.ini).
total_bytes = sum(os.path.getsize(f) for f in files)
max_files = int(FalConfig().get_setting("archive", "max_files", 5000))
max_mb = float(FalConfig().get_setting("archive", "max_total_mb", 2048))
if len(files) > max_files or total_bytes > max_mb * 1024 * 1024:
raise FalApiError(
_MODEL_NAME,
f"Refusing to zip {len(files)} file(s) / {total_bytes / 1048576:.1f} MiB "
f"from {folder} — over the [archive] limits (max_files={max_files}, "
f"max_total_mb={max_mb:g}). Narrow the folder/extensions or raise the "
"limits in config.ini.",
)
logger.info(
"archive: zipping %d file(s) (%.1f MiB) from %s",
len(files),
total_bytes / 1048576,
folder,
)
zip_path: str | None = None
try:
zip_path = _new_temp_zip_path()
with zipfile.ZipFile(zip_path, "w") as zip_file:
for file_path in files:
zip_file.write(file_path, os.path.relpath(file_path, folder))
return zip_path
except Exception as exc:
_safe_unlink(zip_path)
logger.error("Failed to zip folder %s: %s", folder, exc)
raise FalApiError(
_MODEL_NAME, f"Failed to zip folder {folder}: {exc}"
) from exc
@staticmethod
def upload_zip(zip_path: str) -> str:
"""Upload a local zip to fal.ai and return its URL; the zip is always deleted."""
if not zip_path or not os.path.isfile(zip_path):
raise FalApiError(
_MODEL_NAME,
f"Zip file not found: {zip_path}. Build it with zip_images/zip_folder first.",
)
try:
return ImageUtils.upload_file(zip_path)
finally:
_safe_unlink(zip_path)
-273
View File
@@ -1,273 +0,0 @@
"""fal.ai platform billing: account balance, usage reconciliation, spend guard.
Uses the fal Platform APIs (base ``https://api.fal.ai/v1``):
- ``GET /account/billing?expand=credits`` — current credit balance.
- ``GET /models/requests/by-endpoint`` — per-request records (request_id,
endpoint_id; fal does not publish a per-request billed amount).
- ``GET /models/usage?expand=summary`` — aggregated billed cost per endpoint.
"""
from __future__ import annotations
import threading
import time
from datetime import datetime, timezone
from typing import Any
import requests
from .config import FalConfig
from .errors import FalApiError
from .ledger import SessionLedger
from .logger import logger
_API_BASE = "https://api.fal.ai/v1"
_REQUEST_TIMEOUT = (5, 15)
_BALANCE_CACHE_TTL_S = 60.0
_MAX_ENDPOINT_FILTERS = 50
_MAX_REQUEST_LIMIT = 100
_BILLING_DASHBOARD_URL = "https://fal.ai/dashboard/billing"
_balance_lock = threading.Lock()
# [cached balance (float|None), fetched_at unix time]; fetched_at 0 = no fetch yet.
_balance_cache: list[Any] = [None, 0.0]
_warn_lock = threading.Lock()
_warned_once: frozenset[str] = frozenset()
def _warn_once(topic: str, message: str) -> None:
"""Log ``message`` as WARNING the first time per topic, DEBUG afterwards."""
global _warned_once
with _warn_lock:
first_time = topic not in _warned_once
_warned_once = _warned_once | {topic}
if first_time:
logger.warning(message)
else:
logger.debug(message)
def _get_json(path: str, params: dict[str, Any], topic: str) -> Any | None:
"""GET a Platform API path; return parsed JSON or None on any failure."""
key = FalConfig().get_key()
if not key:
_warn_once(topic, f"Cannot call fal Platform API {path}: FAL_KEY is not configured")
return None
try:
response = requests.get(
f"{_API_BASE}{path}",
params=params,
headers={"Authorization": f"Key {key}"},
timeout=_REQUEST_TIMEOUT,
)
response.raise_for_status()
return response.json()
except Exception as exc:
_warn_once(topic, f"fal Platform API {path} unavailable: {exc}")
return None
def _fetch_balance() -> float | None:
"""Fetch the current credit balance in USD, or None on any failure."""
payload = _get_json("/account/billing", {"expand": "credits"}, topic="balance")
if not isinstance(payload, dict):
return None
credits = payload.get("credits")
balance = credits.get("current_balance") if isinstance(credits, dict) else None
if isinstance(balance, (int, float)):
return float(balance)
_warn_once("balance", "fal balance response had no credits.current_balance field")
return None
def _ledger_endpoints(entries: list[dict[str, Any]]) -> list[str]:
"""Unique endpoint ids from ledger entries, capped at the API filter limit."""
seen: list[str] = []
for entry in entries:
endpoint = entry.get("endpoint_id")
if isinstance(endpoint, str) and endpoint and endpoint not in seen:
seen.append(endpoint)
return seen[:_MAX_ENDPOINT_FILTERS]
def _earliest_timestamp_iso(entries: list[dict[str, Any]]) -> str | None:
"""ISO8601 UTC timestamp of the earliest ledger entry, or None."""
stamps = [e["timestamp"] for e in entries if isinstance(e.get("timestamp"), (int, float))]
if not stamps:
return None
earliest = datetime.fromtimestamp(min(stamps), tz=timezone.utc)
return earliest.strftime("%Y-%m-%dT%H:%M:%SZ")
def _billed_total_since(entries: list[dict[str, Any]]) -> float | None:
"""Aggregated billed cost for the ledger's endpoints since the session start.
Best effort via ``GET /models/usage?expand=summary``; the window covers the
whole workspace, so concurrent non-session calls may be included.
"""
endpoints = _ledger_endpoints(entries)
start = _earliest_timestamp_iso(entries)
if not endpoints or start is None:
return None
params = {
"expand": "summary",
"start": start,
"endpoint_id": ",".join(endpoints),
"bound_to_timeframe": "false",
}
payload = _get_json("/models/usage", params, topic="usage")
if not isinstance(payload, dict) or not isinstance(payload.get("summary"), list):
return None
costs = [
item.get("cost")
for item in payload["summary"]
if isinstance(item, dict) and isinstance(item.get("cost"), (int, float))
]
return float(sum(costs)) if costs else None
class BillingUtils:
"""Read-only access to fal account balance and billed usage. Never raises."""
@staticmethod
def get_balance(force: bool = False) -> float | None:
"""Current account credit balance in USD, or None on any failure.
Results (including failures) are cached for 60 seconds so callers such
as SpendGuard.preflight do not hammer the API; ``force=True`` bypasses.
"""
global _balance_cache
# the fetch happens under the lock so a cold-start burst of parallel
# callers (e.g. 8 caption workers hitting SpendGuard at once) collapses
# into a single API call instead of hammering /account/billing
with _balance_lock:
value, fetched_at = _balance_cache
if not force and fetched_at > 0 and time.time() - fetched_at < _BALANCE_CACHE_TTL_S:
return value
value = _fetch_balance()
_balance_cache = [value, time.time()]
return value
@staticmethod
def get_recent_usage(limit: int = 50) -> list[dict[str, Any]] | None:
"""Recent per-request records for this session's endpoints, or None.
Backed by ``GET /models/requests/by-endpoint`` filtered to the
endpoints recorded in the SessionLedger. fal's Platform APIs do not
expose a per-request billed amount (usage is aggregated), so ``amount``
is always None. Returns None when the ledger is empty or the API is
unavailable.
"""
try:
endpoints = _ledger_endpoints(SessionLedger().entries())
if not endpoints:
return None
params = {
"endpoint_id": ",".join(endpoints),
"limit": max(1, min(int(limit), _MAX_REQUEST_LIMIT)),
}
payload = _get_json("/models/requests/by-endpoint", params, topic="requests")
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return None
return [
{
"request_id": item.get("request_id"),
"endpoint": item.get("endpoint_id"),
"amount": None,
}
for item in payload["items"]
if isinstance(item, dict)
]
except Exception as exc:
logger.debug("get_recent_usage failed: %s", exc)
return None
@staticmethod
def reconcile_ledger() -> dict[str, Any]:
"""Best-effort reconciliation of the SessionLedger against fal's records.
Returns ``{"matched": n, "billed_total": float|None, "estimated_total": float}``
where ``matched`` counts ledger request_ids confirmed by the requests
API and ``billed_total`` is the aggregated billed cost (None when the
usage API is unavailable). Never raises.
"""
estimated_total = SessionLedger().total_cost()
result: dict[str, Any] = {
"matched": 0,
"billed_total": None,
"estimated_total": estimated_total,
}
try:
entries = SessionLedger().entries()
if not entries:
return result
ledger_ids = {e["request_id"] for e in entries if e.get("request_id")}
usage = BillingUtils.get_recent_usage(limit=_MAX_REQUEST_LIMIT) or []
matched = sum(1 for record in usage if record.get("request_id") in ledger_ids)
return {
**result,
"matched": matched,
"billed_total": _billed_total_since(entries),
}
except Exception as exc:
logger.debug("reconcile_ledger failed: %s", exc)
return result
def _setting_float(name: str) -> float:
"""Read a [spend_guard] float setting; 0.0 when unset or unparseable."""
try:
value = FalConfig().get_setting("spend_guard", name, 0)
if isinstance(value, bool):
return 0.0
return float(value)
except Exception as exc:
logger.debug("Invalid [spend_guard] %s value: %s", name, exc)
return 0.0
class SpendGuard:
"""Pre-call spend checks configured via config.ini [spend_guard]."""
@staticmethod
def settings() -> dict[str, float]:
"""Active spend-guard settings (0.0 means the check is disabled)."""
return {
"session_budget_usd": _setting_float("session_budget_usd"),
"min_balance_usd": _setting_float("min_balance_usd"),
}
@staticmethod
def preflight(endpoint: str) -> None:
"""Raise FalApiError if a configured spend limit blocks this call.
Frozen contract: called by ApiHandler before every fal request. Checks
are skipped when their setting is 0/unset; an unavailable balance API
never blocks. Raises nothing except FalApiError.
"""
budget = _setting_float("session_budget_usd")
if budget > 0:
spent = SessionLedger().total_cost()
if spent >= budget:
raise FalApiError(
"spend-guard",
f"Session budget ${budget:.2f} reached (spent ~${spent:.2f}). "
"Raise [spend_guard] session_budget_usd in config.ini or "
"reset the session ledger.",
)
floor = _setting_float("min_balance_usd")
if floor > 0:
balance = BillingUtils.get_balance()
if balance is None:
logger.debug(
"Spend guard: balance unavailable; allowing request to %s", endpoint
)
elif balance < floor:
raise FalApiError(
"spend-guard",
f"fal balance ${balance:.2f} is below your ${floor:.2f} floor "
f"— top up at {_BILLING_DASHBOARD_URL}",
)
-217
View File
@@ -1,217 +0,0 @@
"""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
+2 -62
View File
@@ -5,7 +5,6 @@ ComfyUI IMAGE convention: float32 tensors in [0, 1] with shape (B, H, W, C).
from __future__ import annotations
import hashlib
import io
import os
import tempfile
@@ -23,56 +22,6 @@ from .logger import logger
_DOWNLOAD_TIMEOUT = (10, 180)
_MAX_PARALLEL_TRANSFERS = 8
_HASH_CHUNK_SIZE = 1 << 20 # 1 MiB
def _hash_file(path: str) -> str | None:
"""Return the sha256 hex digest of a file's bytes, or None on any error."""
try:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(_HASH_CHUNK_SIZE), b""):
digest.update(chunk)
return digest.hexdigest()
except Exception as exc:
logger.debug("failed to hash %s for upload cache: %s", path, exc)
return None
def _upload_cache_lookup(file_path: Any) -> tuple[str | None, str | None]:
"""Hash a local file and consult the persistent upload cache.
Returns (content_hash, cached_url); both None when the file cannot be
hashed or caching is unavailable. Never raises.
"""
try:
if not isinstance(file_path, (str, os.PathLike)):
return None, None
content_hash = _hash_file(os.fspath(file_path))
if content_hash is None:
return None, None
from .result_cache import ResultCache
cached_url = ResultCache().get_upload(content_hash)
if cached_url:
logger.debug("upload cache hit (sha256=%s...)", content_hash[:12])
return content_hash, cached_url
except Exception as exc:
logger.debug("upload cache lookup failed: %s", exc)
return None, None
def _upload_cache_store(content_hash: str | None, url: str) -> None:
"""Remember a completed upload in the persistent cache. Never raises."""
if content_hash is None:
return
try:
from .result_cache import ResultCache
ResultCache().put_upload(content_hash, url)
except Exception as exc:
logger.debug("upload cache store failed: %s", exc)
def _safe_unlink(path: str) -> None:
@@ -160,24 +109,15 @@ class ImageUtils:
@staticmethod
def upload_file(file_path: Any) -> str:
"""Upload a local file to fal.ai and return its URL.
Identical file contents reuse the previously uploaded URL via the
persistent upload cache (keyed by sha256), skipping the transfer.
"""
content_hash, cached_url = _upload_cache_lookup(file_path)
if cached_url:
return cached_url
"""Upload a local file to fal.ai and return its URL."""
try:
client = FalConfig().get_client()
url = client.upload_file(file_path)
return client.upload_file(file_path)
except FalApiError:
raise
except Exception as exc:
logger.error("Failed to upload file %s: %s", file_path, exc)
raise_fal_error("file-upload", exc)
_upload_cache_store(content_hash, url)
return url
@staticmethod
def mask_to_image(mask: torch.Tensor) -> torch.Tensor:
-248
View File
@@ -1,248 +0,0 @@
"""Persistent inbox of async fal jobs so they survive ComfyUI restarts.
Every job queued through Fal Submit is recorded in a ``jobs`` table inside
the same sqlite database as the result cache ("submit tonight, collect
tomorrow"). When a result is later fetched by request id — even in a fresh
session — the job is marked collected.
Every public method is best-effort: any sqlite failure degrades to a no-op
or an empty result — bookkeeping must never break generation.
"""
from __future__ import annotations
import os
import sqlite3
import threading
import time
from typing import Any
from .logger import logger
from .result_cache import _default_db_path
_PRUNE_AFTER_DAYS = 30
_SECONDS_PER_DAY = 86400.0
_STATUS_SUBMITTED = "submitted"
_STATUS_COLLECTED = "collected"
_COLUMNS = ("request_id", "endpoint", "status", "submitted_at", "collected_at", "note")
_SCHEMA = """
CREATE TABLE IF NOT EXISTS jobs (
request_id TEXT PRIMARY KEY,
endpoint TEXT,
status TEXT,
submitted_at REAL,
collected_at REAL,
note TEXT
)
"""
def _humanize_age(seconds: float) -> str:
"""Compact age like '45s', '12m', '2h' or '3d'. Clamped at zero."""
seconds = max(0.0, seconds)
if seconds < 60:
return f"{int(seconds)}s"
if seconds < 3600:
return f"{int(seconds // 60)}m"
if seconds < _SECONDS_PER_DAY:
return f"{int(seconds // 3600)}h"
return f"{int(seconds // _SECONDS_PER_DAY)}d"
def _format_entry(entry: dict[str, Any], now: float) -> str:
"""One report line: ' ⏳ 2h ago fal-ai/kling-video/v3 req=abc123'."""
collected = entry.get("status") == _STATUS_COLLECTED
icon = "✅" if collected else "⏳"
reference = entry.get("collected_at") if collected else entry.get("submitted_at")
if not isinstance(reference, (int, float)):
reference = entry.get("submitted_at")
age = (
f"{_humanize_age(now - float(reference))} ago"
if isinstance(reference, (int, float))
else "age unknown"
)
endpoint = entry.get("endpoint") or "(unknown endpoint)"
return f" {icon} {age} {endpoint} req={entry.get('request_id') or '-'}"
class JobStore:
"""Thread-safe singleton over the persistent async-job inbox."""
_instance: JobStore | None = None
_instance_lock = threading.Lock()
def __new__(cls) -> JobStore:
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
instance = super().__new__(cls)
instance._initialize()
cls._instance = instance
return cls._instance
def _initialize(self) -> None:
self._lock = threading.Lock()
self._conn: sqlite3.Connection | None = None
self._connect_failed = False
self._db_path = _default_db_path()
# -- connection -----------------------------------------------------------
def _connection(self) -> sqlite3.Connection | None:
"""Open (once) and return the sqlite connection; None if unavailable.
Must be called with ``self._lock`` held. A corrupted or unwritable
database disables the job store for the session instead of raising.
"""
if self._conn is not None:
return self._conn
if self._connect_failed:
return None
conn: sqlite3.Connection | None = None
try:
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
conn = sqlite3.connect(self._db_path, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(_SCHEMA)
conn.commit()
self._conn = conn
return conn
except Exception as exc:
self._connect_failed = True
if conn is not None:
try:
conn.close()
except Exception:
pass
logger.debug(
"fal job store unavailable this session (%s): %s", self._db_path, exc
)
return None
# -- writes ---------------------------------------------------------------
def record_submit(self, endpoint: str, request_id: str, note: str = "") -> None:
"""Record a freshly queued job as 'submitted'. Never raises."""
try:
if not request_id:
return
with self._lock:
conn = self._connection()
if conn is None:
return
conn.execute(
"INSERT OR REPLACE INTO jobs "
"(request_id, endpoint, status, submitted_at, collected_at, note) "
"VALUES (?, ?, ?, ?, NULL, ?)",
(request_id, endpoint, _STATUS_SUBMITTED, time.time(), note),
)
conn.commit()
except Exception as exc:
logger.debug("job store record_submit failed: %s", exc)
self.prune(_PRUNE_AFTER_DAYS)
def mark_collected(self, request_id: str) -> None:
"""Mark a job 'collected'. Unknown ids are inserted silently (recovery
of jobs submitted in other sessions). Never raises."""
try:
if not request_id:
return
now = time.time()
with self._lock:
conn = self._connection()
if conn is None:
return
conn.execute(
"INSERT OR IGNORE INTO jobs "
"(request_id, endpoint, status, submitted_at, collected_at, note) "
"VALUES (?, '', ?, ?, ?, '')",
(request_id, _STATUS_COLLECTED, now, now),
)
conn.execute(
"UPDATE jobs SET status = ?, collected_at = ? WHERE request_id = ?",
(_STATUS_COLLECTED, now, request_id),
)
conn.commit()
except Exception as exc:
logger.debug("job store mark_collected failed: %s", exc)
def prune(self, older_than_days: float = _PRUNE_AFTER_DAYS) -> None:
"""Delete jobs submitted more than ``older_than_days`` ago. Never raises.
fal queue entries expire long before this window, so stale rows are
pure noise by then.
"""
try:
cutoff = time.time() - float(older_than_days) * _SECONDS_PER_DAY
with self._lock:
conn = self._connection()
if conn is None:
return
conn.execute("DELETE FROM jobs WHERE submitted_at < ?", (cutoff,))
conn.commit()
except Exception as exc:
logger.debug("job store prune failed: %s", exc)
# -- reads ----------------------------------------------------------------
def entries(self, limit: int = 50, status: str | None = None) -> list[dict[str, Any]]:
"""Return jobs newest first as dicts keyed by column name. Never raises."""
try:
query = f"SELECT {', '.join(_COLUMNS)} FROM jobs"
params: tuple[Any, ...] = ()
if status:
query += " WHERE status = ?"
params = (status,)
query += " ORDER BY submitted_at DESC LIMIT ?"
params = (*params, int(limit))
with self._lock:
conn = self._connection()
if conn is None:
return []
rows = conn.execute(query, params).fetchall()
return [dict(zip(_COLUMNS, row)) for row in rows]
except Exception as exc:
logger.debug("job store entries failed: %s", exc)
return []
def pending(self, limit: int = 50) -> list[dict[str, Any]]:
"""Jobs submitted but not yet collected, newest first. Never raises."""
return self.entries(limit=limit, status=_STATUS_SUBMITTED)
def counts(self) -> dict[str, int]:
"""Return {'submitted': n, 'collected': n}. Never raises."""
result = {_STATUS_SUBMITTED: 0, _STATUS_COLLECTED: 0}
try:
with self._lock:
conn = self._connection()
if conn is None:
return result
rows = conn.execute(
"SELECT status, COUNT(*) FROM jobs GROUP BY status"
).fetchall()
return {**result, **{str(status): int(count) for status, count in rows if status in result}}
except Exception as exc:
logger.debug("job store counts failed: %s", exc)
return result
def report(self, limit: int = 20) -> str:
"""Multi-line human summary of the async job inbox. Never raises."""
try:
counts = self.counts()
pending = counts.get(_STATUS_SUBMITTED, 0)
collected = counts.get(_STATUS_COLLECTED, 0)
lines = [
f"Fal job inbox: {pending} pending, {collected} collected "
"(async jobs survive ComfyUI restarts)"
]
now = time.time()
lines.extend(_format_entry(entry, now) for entry in self.entries(limit=limit))
if pending == 0 and collected == 0:
lines.append(" (empty — queue jobs with Fal Submit to fill the inbox)")
return "\n".join(lines)
except Exception as exc:
logger.debug("job store report failed: %s", exc)
return "Fal job inbox: report unavailable"
-126
View File
@@ -1,126 +0,0 @@
"""Thread-safe in-memory ledger of fal API calls made this ComfyUI session."""
from __future__ import annotations
import threading
import time
from typing import Any
from .logger import logger
_REPORT_TAIL = 20
def _format_cost(est_cost: Any) -> str:
"""Render an estimated cost, or a placeholder when pricing is unknown."""
if isinstance(est_cost, (int, float)):
return f"~${est_cost:,.4f}".rstrip("0").rstrip(".")
return "cost unknown"
def _format_entry(entry: dict[str, Any]) -> str:
"""One report line: ' #12 fal-ai/kling.../v3 12.4s ~$0.35 req=abc123'."""
duration = entry.get("duration_s")
duration_text = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "?s"
request_id = entry.get("request_id") or "-"
return (
f" #{entry.get('index', '?')} {entry.get('endpoint_id', 'unknown')} "
f"{duration_text} {_format_cost(entry.get('est_cost'))} req={request_id}"
)
class SessionLedger:
"""Singleton recording every fal call this session. Methods never raise."""
_instance: SessionLedger | None = None
_instance_lock = threading.Lock()
def __new__(cls) -> SessionLedger:
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
instance = super().__new__(cls)
instance._initialize()
cls._instance = instance
return cls._instance
def _initialize(self) -> None:
self._lock = threading.Lock()
self._entries: list[dict[str, Any]] = []
self._next_index = 1
def record(
self,
endpoint_id: str,
request_id: str | None,
duration_s: float,
est_cost: float | None,
) -> None:
"""Append one call record (indexed, timestamped). Never raises."""
try:
with self._lock:
entry = {
"index": self._next_index,
"timestamp": time.time(),
"endpoint_id": endpoint_id,
"request_id": request_id,
"duration_s": duration_s,
"est_cost": est_cost,
}
self._entries = [*self._entries, entry]
self._next_index += 1
except Exception as exc:
logger.debug("SessionLedger.record failed: %s", exc)
def entries(self) -> list[dict[str, Any]]:
"""Return a copy of all recorded entries."""
try:
with self._lock:
return [dict(entry) for entry in self._entries]
except Exception as exc:
logger.debug("SessionLedger.entries failed: %s", exc)
return []
def total_cost(self) -> float:
"""Sum of all known estimated costs (unknowns excluded)."""
try:
return sum(
entry["est_cost"]
for entry in self.entries()
if isinstance(entry.get("est_cost"), (int, float))
)
except Exception as exc:
logger.debug("SessionLedger.total_cost failed: %s", exc)
return 0.0
def unknown_cost_count(self) -> int:
"""Number of recorded calls with no pricing estimate."""
try:
return sum(1 for entry in self.entries() if entry.get("est_cost") is None)
except Exception as exc:
logger.debug("SessionLedger.unknown_cost_count failed: %s", exc)
return 0
def reset(self) -> None:
"""Clear all entries and restart indexing."""
try:
with self._lock:
self._entries = []
self._next_index = 1
except Exception as exc:
logger.debug("SessionLedger.reset failed: %s", exc)
def report(self) -> str:
"""Multi-line human summary of session usage. Never raises."""
try:
entries = self.entries()
lines = [
f"Session fal usage: {len(entries)} calls, "
f"~${self.total_cost():,.2f} estimated, "
f"{self.unknown_cost_count()} with unknown pricing"
]
lines.extend(_format_entry(entry) for entry in entries[-_REPORT_TAIL:])
return "\n".join(lines)
except Exception as exc:
logger.debug("SessionLedger.report failed: %s", exc)
return "Session fal usage: report unavailable"
+1 -7
View File
@@ -156,13 +156,7 @@ def _stream_to_temp_file(source: Any, suffix: str) -> str:
class MediaUtils:
"""Utility functions for video/audio download, conversion, and upload.
Local-file uploads (upload_video/upload_audio) route through
ImageUtils.upload_file, which consults the persistent upload cache
(sha256 of file bytes) so identical content is never uploaded twice.
http(s) URL inputs pass through untouched.
"""
"""Utility functions for video/audio download, conversion, and upload."""
@staticmethod
def download_url_to_temp(url: str, suffix: str) -> str:
-231
View File
@@ -1,231 +0,0 @@
"""Pricing parsing and cost estimation from the committed fal model registry."""
from __future__ import annotations
import json
import os
import re
import threading
from typing import Any
from .logger import logger
# Units that imply one billable output per run (single-output assumption).
_RUN_UNITS = frozenset({"image", "video", "generation", "request", "run"})
# Tokens that terminate a unit phrase ("$0.015 per megapixel in TURBO mode").
_UNIT_STOPWORDS = frozenset(
{
"a",
"along",
"an",
"and",
"are",
"at",
"each",
"for",
"if",
"in",
"is",
"on",
"or",
"per",
"plus",
"rounded",
"the",
"to",
"when",
"will",
"with",
"without",
}
)
_MONEY = r"([\d,]+(?:\.\d+)?)"
# "For $1.00, you can run this model (with approximately) 12 times"
_RUNS_RATIO_RE = re.compile(
rf"for\s+\${_MONEY},?\s+you\s+can\s+run\s+this\s+model\s+"
rf"(?:with\s+)?(?:approximately\s+)?{_MONEY}\s+times",
re.IGNORECASE,
)
# "$0.05 per second of video", "$0.025 per 1000 characters", "$0.08 per image"
_PER_UNIT_RE = re.compile(
rf"\$\s*{_MONEY}\s+per\s+(\w+(?:\s+\w+){{0,3}})",
re.IGNORECASE,
)
_registry_lock = threading.Lock()
_pricing_map: dict[str, str] | None = None
def _registry_path() -> str:
"""Return the path to data/fal_registry.json at the repo root."""
utils_dir = os.path.dirname(os.path.abspath(__file__))
nodes_dir = os.path.dirname(utils_dir)
repo_root = os.path.dirname(nodes_dir)
return os.path.join(repo_root, "data", "fal_registry.json")
def _load_pricing_map() -> dict[str, str]:
"""Lazily load the endpoint_id -> pricing-text map (cached module-wide)."""
global _pricing_map
if _pricing_map is not None:
return _pricing_map
with _registry_lock:
if _pricing_map is not None:
return _pricing_map
mapping: dict[str, str] = {}
try:
with open(_registry_path(), encoding="utf-8") as fh:
registry = json.load(fh)
for model in registry.get("models") or []:
endpoint_id = model.get("endpoint_id")
if endpoint_id:
mapping[endpoint_id] = str(model.get("pricing") or "")
except Exception as exc:
logger.warning("Could not load pricing registry: %s", exc)
_pricing_map = mapping
return _pricing_map
def _to_float(text: str) -> float:
"""Parse a dollar/count figure, tolerating thousands separators."""
return float(text.replace(",", ""))
def _clean_unit(phrase: str) -> str | None:
"""Trim a raw unit capture to the meaningful phrase, or None if empty."""
tokens = phrase.lower().split()
kept: list[str] = []
for position, token in enumerate(tokens):
stripped = token.strip(".,")
if stripped in _UNIT_STOPWORDS:
break
if stripped == "of":
has_object = (
bool(kept)
and position + 1 < len(tokens)
and tokens[position + 1].strip(".,") not in _UNIT_STOPWORDS
)
if not has_object:
break
kept.append(stripped)
cleaned = " ".join(kept)
return cleaned or None
def _head_noun(unit: str) -> str:
"""Return the singular head noun of a unit phrase.
"second of video" -> "second"; "generated image" -> "image";
"image generated" -> "image" (trailing participles are dropped).
"""
tokens = unit.split(" of ")[0].split()
while len(tokens) > 1 and tokens[-1].endswith("ed"):
tokens = tokens[:-1]
head = tokens[-1]
if head.endswith("s") and not head.endswith("ss"):
return head[:-1]
return head
def _format_amount(value: float) -> str:
"""Format a dollar amount compactly (up to 4 decimals, no trailing zeros)."""
text = f"{value:,.4f}".rstrip("0").rstrip(".")
return text or "0"
def _empty_parse(raw: str) -> dict[str, Any]:
return {"per_run": None, "per_unit": None, "unit": None, "raw": raw}
class PricingUtils:
"""Best-effort cost estimation from the registry's human pricing strings."""
@staticmethod
def parse(pricing_text: str) -> dict[str, Any]:
"""Parse a human pricing string into structured numbers.
Precedence for ``per_run``: the explicit "For $A, you can run this
model N times" ratio wins over a "$X per <unit>" price; the latter
only sets ``per_run`` for single-output units (image, video,
generation, request, run). Never raises; unmatched strings return
all-None fields with ``raw`` preserved.
"""
raw = pricing_text or ""
result = _empty_parse(raw)
try:
ratio = _RUNS_RATIO_RE.search(raw)
if ratio:
runs = _to_float(ratio.group(2))
if runs > 0:
result = {**result, "per_run": _to_float(ratio.group(1)) / runs}
per_unit = _PER_UNIT_RE.search(raw)
if per_unit:
unit = _clean_unit(per_unit.group(2))
if unit:
result = {
**result,
"per_unit": _to_float(per_unit.group(1)),
"unit": unit,
}
if result["per_run"] is None and _head_noun(unit) in _RUN_UNITS:
result = {**result, "per_run": result["per_unit"]}
except Exception as exc:
logger.debug("Failed to parse pricing text %r: %s", raw, exc)
return result
@staticmethod
def pricing_for(endpoint_id: str) -> dict[str, Any] | None:
"""Parsed pricing for an endpoint, or None if unknown/unpublished."""
pricing_text = _load_pricing_map().get(endpoint_id)
if not pricing_text:
return None
return PricingUtils.parse(pricing_text)
@staticmethod
def estimate(endpoint_id: str, runs: int = 1) -> dict[str, Any]:
"""Estimate the cost of ``runs`` runs of an endpoint."""
parsed = PricingUtils.pricing_for(endpoint_id) or _empty_parse("")
per_run = parsed["per_run"]
unit_note = ""
if parsed["per_unit"] is not None and parsed["unit"]:
unit_note = f"${_format_amount(parsed['per_unit'])} per {parsed['unit']}"
return {
"endpoint_id": endpoint_id,
"runs": runs,
"per_run": per_run,
"unit_note": unit_note,
"total": per_run * runs if per_run is not None else None,
"raw": parsed["raw"],
}
@staticmethod
def format_report(estimate: dict[str, Any]) -> str:
"""Render an estimate as a compact human string. Never raises."""
try:
endpoint_id = estimate.get("endpoint_id") or "unknown"
runs = estimate.get("runs") or 1
per_run = estimate.get("per_run")
total = estimate.get("total")
unit_note = estimate.get("unit_note") or ""
if per_run is not None:
report = f"{endpoint_id}: ~${_format_amount(per_run)}/run"
if runs != 1 and total is not None:
report += f" → ~${_format_amount(total)} for {runs} runs"
return report
if unit_note:
depends_on = (
"duration"
if re.search(r"\b(second|minute|hour)s?\b", unit_note)
else "output size"
)
return f"{endpoint_id}: {unit_note} (per-run total depends on {depends_on})"
return f"{endpoint_id}: pricing not published"
except Exception as exc:
logger.debug("format_report failed: %s", exc)
return "pricing not published"
-442
View File
@@ -1,442 +0,0 @@
"""Persistent two-layer cache so the same fal call is never paid for twice.
Layer 1 (``results``) caches full API results keyed by endpoint + canonical
arguments. Layer 2 (``uploads``) caches fal media URLs keyed by the sha256 of
the uploaded file's bytes, so re-uploading identical content reuses the same
URL (which in turn keeps the result-cache key stable).
Both layers live in one sqlite database that survives ComfyUI restarts.
Every public method is best-effort: any sqlite/config failure degrades to a
cache miss or a no-op — bookkeeping must never break generation.
"""
from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import threading
import time
from typing import Any
from .config import FalConfig
from .logger import logger
_DB_ENV_VAR = "COMFYUI_FAL_API_CACHE_DB"
_DB_SUBDIR = "comfyui-fal-api"
_DB_FILENAME = "cache.db"
_DEFAULT_ENABLED = True
_DEFAULT_TTL_DAYS = 7 # fal CDN URLs inside cached results can expire; keep modest.
_DEFAULT_MAX_ENTRIES = 5000
_SECONDS_PER_DAY = 86400.0
_SCHEMA = (
"""
CREATE TABLE IF NOT EXISTS results (
key TEXT PRIMARY KEY,
endpoint TEXT,
request_id TEXT,
result_json TEXT,
created REAL,
last_used REAL
)
""",
"""
CREATE TABLE IF NOT EXISTS uploads (
content_hash TEXT PRIMARY KEY,
url TEXT,
created REAL
)
""",
"""
CREATE TABLE IF NOT EXISTS request_urls (
url TEXT PRIMARY KEY,
endpoint TEXT,
request_id TEXT,
created REAL
)
""",
)
def _default_db_path() -> str:
"""Resolve the cache database path.
Precedence: COMFYUI_FAL_API_CACHE_DB env var, then the ComfyUI user
directory, then ~/.cache (when running outside ComfyUI).
"""
override = os.environ.get(_DB_ENV_VAR)
if override:
return override
try:
import folder_paths
base = folder_paths.get_user_directory()
except ImportError:
base = os.path.join(os.path.expanduser("~"), ".cache")
return os.path.join(base, _DB_SUBDIR, _DB_FILENAME)
def _format_amount(value: float) -> str:
"""Format a dollar amount compactly (up to 4 decimals, no trailing zeros)."""
text = f"{value:,.4f}".rstrip("0").rstrip(".")
return text or "0"
class ResultCache:
"""Thread-safe singleton over the persistent result/upload cache."""
_instance: ResultCache | None = None
_instance_lock = threading.Lock()
def __new__(cls) -> ResultCache:
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
instance = super().__new__(cls)
instance._initialize()
cls._instance = instance
return cls._instance
def _initialize(self) -> None:
self._lock = threading.Lock()
self._conn: sqlite3.Connection | None = None
self._connect_failed = False
self._db_path = _default_db_path()
self._hits = 0
self._misses = 0
# -- connection / config ------------------------------------------------
def _connection(self) -> sqlite3.Connection | None:
"""Open (once) and return the sqlite connection; None if unavailable.
Must be called with ``self._lock`` held. A corrupted or unwritable
database disables the cache for the session instead of raising.
"""
if self._conn is not None:
return self._conn
if self._connect_failed:
return None
conn: sqlite3.Connection | None = None
try:
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
conn = sqlite3.connect(self._db_path, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
for statement in _SCHEMA:
conn.execute(statement)
conn.commit()
self._conn = conn
return conn
except Exception as exc:
self._connect_failed = True
if conn is not None:
try:
conn.close()
except Exception:
pass
logger.debug(
"fal cache unavailable, caching disabled this session (%s): %s",
self._db_path,
exc,
)
return None
@staticmethod
def _enabled() -> bool:
try:
return bool(FalConfig().get_setting("cache", "enabled", _DEFAULT_ENABLED))
except Exception as exc:
logger.debug("cache 'enabled' setting read failed: %s", exc)
return _DEFAULT_ENABLED
@staticmethod
def _ttl_seconds() -> float:
try:
days = float(FalConfig().get_setting("cache", "ttl_days", _DEFAULT_TTL_DAYS))
except Exception as exc:
logger.debug("cache 'ttl_days' setting read failed: %s", exc)
days = float(_DEFAULT_TTL_DAYS)
return days * _SECONDS_PER_DAY
@staticmethod
def _max_entries() -> int:
try:
return int(float(FalConfig().get_setting("cache", "max_entries", _DEFAULT_MAX_ENTRIES)))
except Exception as exc:
logger.debug("cache 'max_entries' setting read failed: %s", exc)
return _DEFAULT_MAX_ENTRIES
# -- layer 1: result cache ----------------------------------------------
@staticmethod
def make_key(endpoint: str, arguments: dict[str, Any]) -> str:
"""Deterministic cache key: sha256 of endpoint + canonical arguments."""
canonical = json.dumps(arguments, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(f"{endpoint}\x00{canonical}".encode()).hexdigest()
def get(self, endpoint: str, arguments: dict[str, Any]) -> dict[str, Any] | None:
"""Return the cached result dict for this exact call, or None on miss."""
try:
if not self._enabled():
return None
result_json = self._fetch_live_result_json(self.make_key(endpoint, arguments))
if result_json is not None:
result = json.loads(result_json)
if isinstance(result, dict):
self._hits += 1
self._log_hit(endpoint)
return result
self._misses += 1
return None
except Exception as exc:
logger.debug("[%s] result cache get failed: %s", endpoint, exc)
return None
def _fetch_live_result_json(self, key: str) -> str | None:
"""Fetch a non-expired row's JSON, deleting expired rows on the way."""
now = time.time()
ttl_seconds = self._ttl_seconds()
with self._lock:
conn = self._connection()
if conn is None:
return None
row = conn.execute(
"SELECT result_json, created FROM results WHERE key = ?", (key,)
).fetchone()
if row is None:
return None
result_json, created = row
if ttl_seconds > 0 and now - float(created or 0.0) > ttl_seconds:
conn.execute("DELETE FROM results WHERE key = ?", (key,))
conn.commit()
return None
conn.execute("UPDATE results SET last_used = ? WHERE key = ?", (now, key))
conn.commit()
return str(result_json)
@staticmethod
def _log_hit(endpoint: str) -> None:
"""Log a cache hit, with the estimated cost saved when pricing is known."""
saved = ""
try:
from .pricing import PricingUtils
total = PricingUtils.estimate(endpoint, 1)["total"]
if isinstance(total, (int, float)):
saved = f" (saved ~${_format_amount(total)})"
except Exception:
saved = ""
logger.info("[%s] cache HIT%s — returning stored result, no charge", endpoint, saved)
def put(
self,
endpoint: str,
arguments: dict[str, Any],
result: dict[str, Any],
request_id: str | None = None,
) -> None:
"""Store a successful live result; prunes oldest rows beyond max_entries."""
try:
if not self._enabled() or not isinstance(result, dict):
return
key = self.make_key(endpoint, arguments)
result_json = json.dumps(result, default=str)
max_entries = self._max_entries()
now = time.time()
with self._lock:
conn = self._connection()
if conn is None:
return
conn.execute(
"INSERT OR REPLACE INTO results "
"(key, endpoint, request_id, result_json, created, last_used) "
"VALUES (?, ?, ?, ?, ?, ?)",
(key, endpoint, request_id, result_json, now, now),
)
if max_entries > 0:
conn.execute(
"DELETE FROM results WHERE key IN "
"(SELECT key FROM results ORDER BY last_used DESC LIMIT -1 OFFSET ?)",
(max_entries,),
)
conn.commit()
except Exception as exc:
logger.debug("[%s] result cache put failed: %s", endpoint, exc)
def invalidate_key(self, key: str) -> None:
"""Delete one result row by its cache key. Never raises."""
try:
with self._lock:
conn = self._connection()
if conn is None:
return
conn.execute("DELETE FROM results WHERE key = ?", (key,))
conn.commit()
except Exception as exc:
logger.debug("result cache invalidate failed: %s", exc)
def clear(self) -> None:
"""Delete all cached results and uploads. Never raises."""
try:
with self._lock:
conn = self._connection()
if conn is None:
return
conn.execute("DELETE FROM results")
conn.execute("DELETE FROM uploads")
conn.commit()
except Exception as exc:
logger.debug("result cache clear failed: %s", exc)
def stats(self) -> dict[str, Any]:
"""Return {entries, db_path, hits, misses}; hit/miss counts are per session."""
entries = 0
try:
with self._lock:
conn = self._connection()
if conn is not None:
row = conn.execute("SELECT COUNT(*) FROM results").fetchone()
entries = int(row[0]) if row else 0
except Exception as exc:
logger.debug("result cache stats failed: %s", exc)
return {
"entries": entries,
"db_path": self._db_path,
"hits": self._hits,
"misses": self._misses,
}
def remember_urls(self, endpoint: str, request_id: str, result: Any) -> None:
"""Record every media URL in a result → (endpoint, request_id).
Called on every successful fetch (live, async collect, recovery) so
provenance lookups work regardless of which path produced the result.
Best-effort: never raises.
"""
try:
if not request_id or not isinstance(result, dict):
return
urls: list[str] = []
def dig(value: Any) -> None:
if isinstance(value, dict):
candidate = value.get("url")
if isinstance(candidate, str) and candidate.startswith("http"):
urls.append(candidate)
for child in value.values():
dig(child)
elif isinstance(value, list):
for child in value:
dig(child)
dig(result)
if not urls:
return
now = time.time()
with self._lock:
conn = self._connection()
if conn is None:
return
conn.executemany(
"INSERT OR REPLACE INTO request_urls VALUES (?, ?, ?, ?)",
[(u, endpoint, request_id, now) for u in urls[:64]],
)
conn.commit()
except Exception as exc:
logger.debug("result cache remember_urls failed: %s", exc)
def find_request_by_url(self, url: str) -> dict[str, Any] | None:
"""Find the origin of a result URL: {"endpoint_id", "request_id"} or None.
Checks the explicit request_urls table first (covers async collect and
recovery paths), then falls back to scanning cached results for the
URL as a substring. Best-effort: any failure is a miss.
"""
try:
target = (url or "").strip()
if not target:
return None
with self._lock:
conn = self._connection()
if conn is not None:
row = conn.execute(
"SELECT endpoint, request_id FROM request_urls WHERE url = ?",
(target,),
).fetchone()
if row is not None:
return {"endpoint_id": row[0], "request_id": row[1]}
# LIKE treats %, _ (and our escape char) specially — escape them
# so URLs containing percent-encoding still match literally.
escaped = (
target.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
)
with self._lock:
conn = self._connection()
if conn is None:
return None
row = conn.execute(
"SELECT endpoint, request_id FROM results "
"WHERE request_id IS NOT NULL "
"AND result_json LIKE '%' || ? || '%' ESCAPE '\\' "
"ORDER BY last_used DESC LIMIT 1",
(escaped,),
).fetchone()
if row is None:
return None
endpoint, request_id = row
return {"endpoint_id": endpoint, "request_id": request_id}
except Exception as exc:
logger.debug("result cache url lookup failed: %s", exc)
return None
# -- layer 2: upload cache ----------------------------------------------
def get_upload(self, content_hash: str) -> str | None:
"""Return the cached fal URL for previously uploaded content, or None."""
try:
if not self._enabled():
return None
now = time.time()
ttl_seconds = self._ttl_seconds()
with self._lock:
conn = self._connection()
if conn is None:
return None
row = conn.execute(
"SELECT url, created FROM uploads WHERE content_hash = ?",
(content_hash,),
).fetchone()
if row is None:
return None
url, created = row
if ttl_seconds > 0 and now - float(created or 0.0) > ttl_seconds:
conn.execute(
"DELETE FROM uploads WHERE content_hash = ?", (content_hash,)
)
conn.commit()
return None
return str(url) if url else None
except Exception as exc:
logger.debug("upload cache get failed: %s", exc)
return None
def put_upload(self, content_hash: str, url: str) -> None:
"""Remember that this content hash uploaded to ``url``. Never raises."""
try:
if not self._enabled():
return
with self._lock:
conn = self._connection()
if conn is None:
return
conn.execute(
"INSERT OR REPLACE INTO uploads (content_hash, url, created) "
"VALUES (?, ?, ?)",
(content_hash, url, time.time()),
)
conn.commit()
except Exception as exc:
logger.debug("upload cache put failed: %s", exc)
+1 -2
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.5.0"
version = "2.0.0"
license = {file = "LICENSE"}
requires-python = ">=3.9"
dependencies = [
@@ -11,7 +11,6 @@ dependencies = [
"numpy",
"pillow",
"requests",
"av",
]
[project.urls]
-1
View File
@@ -4,4 +4,3 @@ opencv-python
numpy
pillow
requests
av
+19 -39
View File
@@ -1,11 +1,10 @@
#!/usr/bin/env python3
"""Regenerate the auto-generated model catalog in MODELS.md.
"""Regenerate the auto-generated model list section of README.md.
Reads data/fal_registry.json and rewrites ONLY the section between
`<!-- BEGIN GENERATED MODEL LIST -->` and `<!-- END GENERATED MODEL LIST -->`
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.
in README.md. Everything outside the markers is left untouched, and running
the script twice in a row produces no diff.
Usage:
python scripts/build_readme.py
@@ -20,24 +19,11 @@ from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
REGISTRY_PATH = REPO_ROOT / "data" / "fal_registry.json"
MODELS_PATH = REPO_ROOT / "MODELS.md"
README_PATH = REPO_ROOT / "README.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}"
@@ -117,39 +103,33 @@ def render_generated_section(registry: dict[str, Any]) -> str:
return "\n\n".join([summary, *blocks])
def replace_between_markers(document: str, generated: str) -> str:
begin = document.find(BEGIN_MARKER)
end = document.find(END_MARKER)
def replace_between_markers(readme: str, generated: str) -> str:
begin = readme.find(BEGIN_MARKER)
end = readme.find(END_MARKER)
if begin == -1 or end == -1 or end < begin:
raise SystemExit(
f"MODELS.md must contain '{BEGIN_MARKER}' followed by '{END_MARKER}'"
f"README.md must contain '{BEGIN_MARKER}' followed by '{END_MARKER}'"
)
head = document[: begin + len(BEGIN_MARKER)]
tail = document[end:]
head = readme[: begin + len(BEGIN_MARKER)]
tail = readme[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)
document = read_models_document(MODELS_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
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)")
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)")
return 0
MODELS_PATH.write_text(updated, encoding="utf-8")
README_PATH.write_text(updated, encoding="utf-8")
print(
f"MODELS.md model catalog regenerated: {registry.get('model_count')} models, "
f"README.md model list regenerated: {registry.get('model_count')} models, "
f"{len(group_by_category(registry['models']))} categories"
)
return 0
+1 -7
View File
@@ -17,7 +17,6 @@ Stdlib only. Usage:
import argparse
import json
import logging
import os
import time
import urllib.error
import urllib.request
@@ -622,10 +621,7 @@ def main():
"models": records,
}
# atomic write: the live sidebar refresh runs this inside a running
# ComfyUI — a crash mid-write must not corrupt the tracked registry
tmp_out = args.out + ".tmp"
with open(tmp_out, "w", encoding="utf-8") as handle:
with open(args.out, "w", encoding="utf-8") as handle:
json.dump(
registry,
handle,
@@ -636,8 +632,6 @@ 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)
-11
View File
@@ -4,9 +4,7 @@ from __future__ import annotations
import importlib
import importlib.util
import os
import sys
import tempfile
from pathlib import Path
import pytest
@@ -14,15 +12,6 @@ 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",
str(Path(tempfile.mkdtemp(prefix="fal-api-test-cache-")) / "cache.db"),
)
def _load_package():
if PKG in sys.modules:
-41
View File
@@ -1,41 +0,0 @@
"""Unit tests for the spend guard and balance node."""
from __future__ import annotations
import importlib
import pytest
from conftest import PKG, _load_package
@pytest.fixture()
def billing_mod():
_load_package()
return importlib.import_module(f"{PKG}.nodes.utils.billing")
def test_preflight_noop_when_unconfigured(billing_mod):
# no [spend_guard] section in config → both checks disabled
billing_mod.SpendGuard.preflight("fal-ai/anything")
def test_balance_node_never_raises(pack, monkeypatch, billing_mod):
monkeypatch.setattr(
billing_mod.BillingUtils, "get_balance", staticmethod(lambda force=False: None)
)
cls = pack.NODE_CLASS_MAPPINGS["FalBalance_fal"]
node = cls()
out = getattr(node, cls.FUNCTION)(force_refresh=False)
report, balance = out[0], out[1]
assert isinstance(report, str) and report
assert balance == -1.0
def test_balance_node_reports_value(pack, monkeypatch, billing_mod):
monkeypatch.setattr(
billing_mod.BillingUtils, "get_balance", staticmethod(lambda force=False: 24.5)
)
cls = pack.NODE_CLASS_MAPPINGS["FalBalance_fal"]
node = cls()
out = getattr(node, cls.FUNCTION)(force_refresh=True)
assert out[1] == pytest.approx(24.5)
-64
View File
@@ -1,64 +0,0 @@
"""Unit tests for the durable job inbox store."""
from __future__ import annotations
import importlib
import pytest
from conftest import PKG, _load_package
@pytest.fixture()
def store():
_load_package()
mod = importlib.import_module(f"{PKG}.nodes.utils.job_store")
instance = mod.JobStore()
instance.prune(older_than_days=0) # clear anything from other tests
yield instance
instance.prune(older_than_days=0)
def test_submit_and_collect_lifecycle(store):
store.record_submit("fal-ai/kling-video/v3/pro/image-to-video", "req-a")
store.record_submit("fal-ai/flux-2", "req-b")
assert store.counts()["submitted"] == 2
store.mark_collected("req-a")
counts = store.counts()
assert counts["submitted"] == 1
assert counts["collected"] == 1
pending = store.pending()
assert len(pending) == 1
assert pending[0]["request_id"] == "req-b"
def test_entries_newest_first(store):
store.record_submit("fal-ai/a", "req-1")
store.record_submit("fal-ai/b", "req-2")
entries = store.entries()
assert entries[0]["request_id"] == "req-2"
def test_mark_collected_unknown_id_is_silent(store):
store.mark_collected("req-from-another-session")
entries = store.entries(status="collected")
assert any(e["request_id"] == "req-from-another-session" for e in entries)
def test_report_mentions_pending(store):
store.record_submit("fal-ai/kling-video/v3/pro/image-to-video", "req-x")
report = store.report()
assert "req-x" in report
assert "pending" in report.lower()
def test_inbox_node_outputs(pack, store):
store.record_submit("fal-ai/veo3", "req-latest")
cls = pack.NODE_CLASS_MAPPINGS["FalJobInbox_fal"]
node = cls()
out = getattr(node, cls.FUNCTION)(status_filter="all", limit=20)
report, latest_id, latest_endpoint = out[0], out[1], out[2]
assert isinstance(report, str)
assert latest_id == "req-latest"
assert latest_endpoint == "fal-ai/veo3"
-56
View File
@@ -1,56 +0,0 @@
"""Unit tests for the session cost ledger."""
from __future__ import annotations
import importlib
import threading
import pytest
from conftest import PKG, _load_package
@pytest.fixture()
def ledger(pricing_mod=None):
_load_package()
mod = importlib.import_module(f"{PKG}.nodes.utils.ledger")
instance = mod.SessionLedger()
instance.reset()
yield instance
instance.reset()
def test_record_and_totals(ledger):
ledger.record("fal-ai/a", "req-1", 2.5, 0.10)
ledger.record("fal-ai/b", "req-2", 1.0, None)
entries = ledger.entries()
assert len(entries) == 2
assert ledger.total_cost() == pytest.approx(0.10)
assert ledger.unknown_cost_count() == 1
def test_report_mentions_calls(ledger):
ledger.record("fal-ai/kling-video/v3/pro/image-to-video", "abc123", 12.4, 0.35)
report = ledger.report()
assert "kling" in report
assert "abc123" in report
def test_reset(ledger):
ledger.record("fal-ai/a", None, 1.0, 0.5)
ledger.reset()
assert ledger.entries() == []
assert ledger.total_cost() == 0.0
def test_thread_safety(ledger):
def worker():
for _ in range(100):
ledger.record("fal-ai/t", None, 0.1, 0.01)
threads = [threading.Thread(target=worker) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(ledger.entries()) == 1000
assert ledger.total_cost() == pytest.approx(10.0)
-76
View File
@@ -1,76 +0,0 @@
"""Integration tests for the FAL/Platform utility nodes."""
from __future__ import annotations
import pytest
PLATFORM_KEYS = [
"FalSubmit_fal",
"FalCollect_fal",
"FalResultByRequestId_fal",
"FalCostEstimator_fal",
"FalSessionCosts_fal",
"FalSaveMediaURL_fal",
]
def test_all_platform_nodes_registered(pack):
for key in PLATFORM_KEYS:
assert key in pack.NODE_CLASS_MAPPINGS, key
assert key in pack.NODE_DISPLAY_NAME_MAPPINGS, key
cls = pack.NODE_CLASS_MAPPINGS[key]
assert cls.CATEGORY == "FAL/Platform", key
input_types = cls.INPUT_TYPES()
assert "required" in input_types or "optional" in input_types
def test_submit_returns_handle_type(pack):
cls = pack.NODE_CLASS_MAPPINGS["FalSubmit_fal"]
assert "FAL_HANDLE" in cls.RETURN_TYPES
def test_collect_rejects_bad_handle(pack, errors_mod):
cls = pack.NODE_CLASS_MAPPINGS["FalCollect_fal"]
node = cls()
fn = getattr(node, cls.FUNCTION)
with pytest.raises(errors_mod.FalApiError):
fn(handle="not a handle")
def test_cost_estimator_never_raises(pack):
cls = pack.NODE_CLASS_MAPPINGS["FalCostEstimator_fal"]
node = cls()
fn = getattr(node, cls.FUNCTION)
report, total = fn(endpoint_id="fal-ai/definitely-not-real", runs=5)
assert isinstance(report, str)
assert isinstance(total, float)
def test_session_costs_reports(pack):
cls = pack.NODE_CLASS_MAPPINGS["FalSessionCosts_fal"]
node = cls()
fn = getattr(node, cls.FUNCTION)
out = fn(reset=False)
report, total = out[0], out[1]
assert isinstance(report, str)
assert isinstance(total, float)
def test_save_media_blocks_path_traversal(pack, errors_mod):
import importlib
from conftest import PKG
platform = importlib.import_module(f"{PKG}.nodes.platform_node")
with pytest.raises(errors_mod.FalApiError):
platform._resolve_save_directory("../../../../tmp/evil")
directory, basename = platform._resolve_save_directory("fal/media")
assert basename == "media"
def test_save_media_rejects_empty_url(pack, errors_mod):
cls = pack.NODE_CLASS_MAPPINGS["FalSaveMediaURL_fal"]
node = cls()
fn = getattr(node, cls.FUNCTION)
with pytest.raises((errors_mod.FalApiError, ValueError)):
fn(url="", filename_prefix="fal/test")
-56
View File
@@ -1,56 +0,0 @@
"""Unit tests for the registry pricing parser."""
from __future__ import annotations
import importlib
import pytest
from conftest import PKG, _load_package
@pytest.fixture(scope="session")
def pricing_mod():
_load_package()
return importlib.import_module(f"{PKG}.nodes.utils.pricing")
def test_run_ratio_wins_over_per_unit(pricing_mod):
text = (
"Your request will cost $0.08 per image. For $1.00, you can run "
"this model 12 times."
)
parsed = pricing_mod.PricingUtils.parse(text)
assert parsed["per_run"] == pytest.approx(1.0 / 12.0)
def test_per_unit_only(pricing_mod):
parsed = pricing_mod.PricingUtils.parse(
"Your request will cost $0.05 per second of video."
)
assert parsed["per_run"] is None
assert parsed["per_unit"] == pytest.approx(0.05)
assert "second" in parsed["unit"]
def test_per_image_implies_per_run(pricing_mod):
parsed = pricing_mod.PricingUtils.parse("Your request will cost $0.04 per image.")
assert parsed["per_run"] == pytest.approx(0.04)
def test_junk_never_raises(pricing_mod):
for junk in ("", "free during preview!!", "$", "per per per", None or ""):
parsed = pricing_mod.PricingUtils.parse(junk)
assert parsed["raw"] == junk
def test_estimate_against_real_registry(pricing_mod):
est = pricing_mod.PricingUtils.estimate("fal-ai/nano-banana-2/edit", 10)
assert est["runs"] == 10
report = pricing_mod.PricingUtils.format_report(est)
assert "fal-ai/nano-banana-2/edit" in report
def test_unknown_endpoint_safe(pricing_mod):
est = pricing_mod.PricingUtils.estimate("fal-ai/does-not-exist", 3)
report = pricing_mod.PricingUtils.format_report(est)
assert isinstance(report, str) and report
-85
View File
@@ -1,85 +0,0 @@
"""Unit tests for provenance sidecars and reproduce-from-file."""
from __future__ import annotations
import importlib
import json
import pytest
from conftest import PKG, _load_package
@pytest.fixture()
def platform():
_load_package()
return importlib.import_module(f"{PKG}.nodes.platform_node")
def test_find_request_by_url_escapes_wildcards(platform):
cache_mod = importlib.import_module(f"{PKG}.nodes.utils.result_cache")
cache = cache_mod.ResultCache()
cache.clear()
url = "https://fal.media/files/a%20b/out_1.png"
cache.put(
"fal-ai/flux-2",
{"prompt": "x"},
{"images": [{"url": url}]},
"req-prov",
)
hit = cache.find_request_by_url(url)
assert hit == {"endpoint_id": "fal-ai/flux-2", "request_id": "req-prov"}
# a percent sign must not act as a wildcard
assert cache.find_request_by_url("https://fal.media/files/aXb/out_1.png") is None
cache.clear()
def test_provenance_from_sidecar(platform, tmp_path):
saved = tmp_path / "out_00001.mp4"
saved.write_bytes(b"fake video")
sidecar = tmp_path / "out_00001.mp4.fal.json"
sidecar.write_text(json.dumps({
"version": 1,
"endpoint_id": "fal-ai/veo3",
"request_id": "req-42",
"source_url": "https://fal.media/v.mp4",
"saved_at": 0,
}))
node = platform.FalProvenanceFromFile()
endpoint, request_id, blob = node.read(file_path=str(saved))
assert endpoint == "fal-ai/veo3"
assert request_id == "req-42"
assert json.loads(blob)["source_url"] == "https://fal.media/v.mp4"
def test_provenance_missing_raises(platform, tmp_path, errors_mod):
bare = tmp_path / "no_provenance.bin"
bare.write_bytes(b"data")
node = platform.FalProvenanceFromFile()
with pytest.raises(errors_mod.FalApiError):
node.read(file_path=str(bare))
def test_png_chunk_roundtrip(platform, tmp_path):
from PIL import Image
png = tmp_path / "img.png"
Image.new("RGB", (4, 4), "red").save(png)
payload = {"version": 1, "endpoint_id": "fal-ai/flux-2", "request_id": "req-png"}
platform._embed_png_provenance(str(png), payload)
node = platform.FalProvenanceFromFile()
endpoint, request_id, _ = node.read(file_path=str(png))
assert endpoint == "fal-ai/flux-2"
assert request_id == "req-png"
def test_remember_urls_covers_async_results(platform):
"""Provenance must work for Submit→Collect results, not just cached calls."""
cache_mod = importlib.import_module(f"{PKG}.nodes.utils.result_cache")
cache = cache_mod.ResultCache()
cache.clear()
url = "https://v3.fal.media/files/x/collected_output.jpg"
# no cache.put() — this simulates the async-collect path
cache.remember_urls("fal-ai/veo3", "req-async", {"images": [{"url": url}]})
hit = cache.find_request_by_url(url)
assert hit == {"endpoint_id": "fal-ai/veo3", "request_id": "req-async"}
cache.clear()
-52
View File
@@ -1,52 +0,0 @@
"""Unit tests for the persistent result + upload cache."""
from __future__ import annotations
import importlib
import pytest
from conftest import PKG, _load_package
@pytest.fixture()
def cache():
_load_package()
mod = importlib.import_module(f"{PKG}.nodes.utils.result_cache")
instance = mod.ResultCache()
instance.clear()
yield instance
instance.clear()
def test_round_trip(cache):
args = {"prompt": "a cat", "seed": 7}
assert cache.get("fal-ai/test", args) is None
cache.put("fal-ai/test", args, {"images": [{"url": "https://x/y.png"}]}, "req-1")
hit = cache.get("fal-ai/test", args)
assert hit == {"images": [{"url": "https://x/y.png"}]}
def test_key_is_argument_order_independent(cache):
a = cache.make_key("fal-ai/test", {"a": 1, "b": 2})
b = cache.make_key("fal-ai/test", {"b": 2, "a": 1})
assert a == b
assert a != cache.make_key("fal-ai/other", {"a": 1, "b": 2})
def test_different_args_miss(cache):
cache.put("fal-ai/test", {"prompt": "a"}, {"ok": 1})
assert cache.get("fal-ai/test", {"prompt": "b"}) is None
def test_upload_cache_round_trip(cache):
assert cache.get_upload("hash123") is None
cache.put_upload("hash123", "https://fal.media/up.png")
assert cache.get_upload("hash123") == "https://fal.media/up.png"
def test_clear_and_stats(cache):
cache.put("fal-ai/test", {"p": 1}, {"ok": 1})
cache.clear()
assert cache.get("fal-ai/test", {"p": 1}) is None
stats = cache.stats()
assert "entries" in stats and "db_path" in stats
-50
View File
@@ -1,50 +0,0 @@
"""Unit tests for the /fal_api server routes' pure functions."""
from __future__ import annotations
import importlib
import pytest
from conftest import PKG, _load_package
@pytest.fixture(scope="session")
def routes():
_load_package()
return importlib.import_module(f"{PKG}.nodes.server_routes")
def test_import_without_comfy_server_is_safe(routes):
# loaded via conftest without ComfyUI's `server` module present
assert routes is not None
def test_pricing_map_covers_registry(routes):
pricing_map = routes._pricing_map()
assert len(pricing_map) > 100
sample = next(iter(pricing_map.values()))
assert "label" in sample
for key in pricing_map:
assert key.startswith("FalAPI_")
def test_search_models(routes):
results = routes._search_models(q="kling", category="", max_price=None, limit=10)
assert results
assert all("kling" in r["endpoint_id"].lower() or "kling" in r["title"].lower() for r in results)
def test_search_models_price_filter(routes):
unfiltered = routes._search_models(q="", category="", max_price=None, limit=100)
cheap = routes._search_models(q="", category="", max_price=0.02, limit=100)
assert len(cheap) < len(unfiltered)
def test_session_shape(routes):
payload = routes._session()
assert set(payload) >= {"total_usd", "calls"}
def test_jobs_degrades_gracefully(routes):
payload = routes._jobs(limit=5)
assert "jobs" in payload and "counts" in payload
-67
View File
@@ -1,67 +0,0 @@
"""Unit tests for fal-CDN URL passthrough (twin inputs + URL outputs)."""
from __future__ import annotations
import pytest
from helpers import _input, _model
def test_media_inputs_get_direct_url_twins(schema_to_inputs):
model = _model([
_input("image_url", "string", required=True, media_kind="image"),
_input("video_url", "string", media_kind="video"),
_input("doc_url", "string", media_kind="file"),
])
it = schema_to_inputs.build_input_types(model)
assert "image_url_direct_url" in it["optional"]
assert "video_url_direct_url" in it["optional"]
assert "doc_url_direct_url" not in it["optional"] # file kind excluded
def test_direct_url_wins_over_tensor(arguments_mod, monkeypatch):
calls = []
monkeypatch.setattr(
arguments_mod.ImageUtils,
"upload_image",
staticmethod(lambda v: calls.append(v) or "https://uploaded/x.png"),
)
model = _model([_input("image_url", "string", media_kind="image")])
args = arguments_mod.build_arguments(
model,
{"image_url": object(), "image_url_direct_url": "https://fal.media/direct.png"},
)
assert args["image_url"] == "https://fal.media/direct.png"
assert not calls # no upload happened
def test_direct_url_works_without_tensor(arguments_mod):
model = _model([_input("image_url", "string", media_kind="image")])
args = arguments_mod.build_arguments(
model, {"image_url_direct_url": "https://fal.media/direct.png"}
)
assert args["image_url"] == "https://fal.media/direct.png"
def test_invalid_direct_url_raises(arguments_mod, errors_mod):
model = _model([_input("image_url", "string", media_kind="image")])
with pytest.raises(errors_mod.FalApiError):
arguments_mod.build_arguments(
model, {"image_url_direct_url": "not-a-url"}
)
def test_direct_url_list_splits_commas(arguments_mod):
model = _model([
_input("image_urls", "array", media_kind="image", is_list=True)
])
args = arguments_mod.build_arguments(
model,
{"image_urls_direct_url": "https://a/1.png, https://b/2.png"},
)
assert args["image_urls"] == ["https://a/1.png", "https://b/2.png"]
def test_images_output_includes_urls(outputs_mod):
types, names = outputs_mod.RETURN_SPECS["images"]
assert types == ("IMAGE", "STRING")
assert names == ("images", "image_urls")
-120
View File
@@ -1,120 +0,0 @@
"""Tests for the FAL/Utils node layer (dataset, image, data, video basics)."""
from __future__ import annotations
import importlib
import json
import zipfile
import pytest
import torch
from conftest import PKG, _load_package
@pytest.fixture(scope="session")
def archive_mod():
_load_package()
return importlib.import_module(f"{PKG}.nodes.utils.archive")
@pytest.fixture(scope="session")
def image_nodes(pack):
return pack.NODE_CLASS_MAPPINGS
def test_zip_images_with_captions(archive_mod, tmp_path):
images = torch.rand(3, 8, 8, 3)
zip_path = archive_mod.ArchiveUtils.zip_images(images, captions=["a", "", "c"])
try:
with zipfile.ZipFile(zip_path) as zf:
names = sorted(zf.namelist())
assert "image_0.png" in names and "image_2.txt" in names
assert zf.read("image_0.txt").decode() == "a"
finally:
import os
os.unlink(zip_path)
def test_zip_images_caption_mismatch_raises(archive_mod, errors_mod):
with pytest.raises(errors_mod.FalApiError):
archive_mod.ArchiveUtils.zip_images(torch.rand(2, 8, 8, 3), captions=["only one"])
def test_json_extract(pack):
cls = pack.NODE_CLASS_MAPPINGS["FalJSONExtract_fal"]
node = cls()
fn = getattr(node, cls.FUNCTION)
payload = json.dumps({"video": {"url": "https://x/v.mp4"}, "images": [{"url": "https://x/i.png"}], "seed": 42})
assert fn(json_text=payload, path="video.url", default="")[0] == "https://x/v.mp4"
assert fn(json_text=payload, path="images[0].url", default="")[0] == "https://x/i.png"
assert fn(json_text=payload, path="seed", default="")[1] == 42.0
assert fn(json_text=payload, path="missing.path", default="fallback")[0] == "fallback"
def test_prompt_lines_wraps(pack):
cls = pack.NODE_CLASS_MAPPINGS["FalPromptLines_fal"]
node = cls()
fn = getattr(node, cls.FUNCTION)
text = "one\ntwo\nthree"
assert fn(text=text, index=0, skip_blank=True)[0] == "one"
assert fn(text=text, index=4, skip_blank=True)[0] == "two" # wraps modulo 3
def test_resize_to_preset_dims(pack):
cls = pack.NODE_CLASS_MAPPINGS["FalResizeToPreset_fal"]
node = cls()
fn = getattr(node, cls.FUNCTION)
image = torch.rand(1, 300, 500, 3)
out, width, height = fn(image=image, preset="landscape_16_9", width=1024, height=1024, mode="cover_crop")
assert (width, height) == (1024, 576)
assert tuple(out.shape) == (1, 576, 1024, 3)
def test_base64_round_trip(pack):
cm = pack.NODE_CLASS_MAPPINGS
enc_cls, dec_cls = cm["FalImageToBase64_fal"], cm["FalBase64ToImage_fal"]
image = torch.rand(1, 16, 16, 3)
encoded = getattr(enc_cls(), enc_cls.FUNCTION)(image=image, format="png", data_uri=True)[0]
decoded = getattr(dec_cls(), dec_cls.FUNCTION)(data=encoded)[0]
assert tuple(decoded.shape) == (1, 16, 16, 3)
assert torch.allclose(image, decoded, atol=2 / 255)
def test_image_grid_shape(pack):
cls = pack.NODE_CLASS_MAPPINGS["FalImageGrid_fal"]
node = cls()
fn = getattr(node, cls.FUNCTION)
out = fn(images=torch.rand(4, 32, 32, 3), labels="a\nb\nc\nd", columns=2, cell_padding=4, label_height=16)[0]
assert out.ndim == 4 and out.shape[0] == 1 and out.shape[3] == 3
def test_extract_frames_from_real_video(pack, tmp_path):
cv2 = pytest.importorskip("cv2")
import numpy as np
path = str(tmp_path / "clip.mp4")
writer = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"mp4v"), 8, (32, 32))
for i in range(16):
frame = np.full((32, 32, 3), 255 if i == 15 else 0, dtype=np.uint8)
writer.write(frame)
writer.release()
cls = pack.NODE_CLASS_MAPPINGS["FalExtractFrames_fal"]
node = cls()
fn = getattr(node, cls.FUNCTION)
frames, count = fn(video=path, mode="last", n=1, max_frames=64)
assert count == 16
assert frames.shape[0] == 1
assert frames.mean().item() > 0.9 # last frame is white
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) == 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"):
for name, spec in input_types.get(bucket, {}).items():
if len(spec) > 1 and isinstance(spec[1], dict):
assert "tooltip" in spec[1], f"{key}.{name} missing tooltip"
-67
View File
@@ -1,67 +0,0 @@
// Fetch helpers and tiny utilities for the fal platform extension. No deps.
let apiRef = null;
try {
const mod = await import("../../scripts/api.js");
apiRef = mod?.api ?? null;
} catch (error) {
console.debug("[fal] scripts/api.js unavailable, falling back to fetch()", error);
}
function rawFetch(path, options) {
if (apiRef && typeof apiRef.fetchApi === "function") {
return apiRef.fetchApi(path, options);
}
return fetch(path, options);
}
export async function getJson(path) {
const response = await rawFetch(`/fal_api${path}`);
if (!response.ok) {
throw new Error(`GET /fal_api${path} -> HTTP ${response.status}`);
}
return await response.json();
}
export async function postJson(path, body) {
const response = await rawFetch(`/fal_api${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!response.ok) {
throw new Error(`POST /fal_api${path} -> HTTP ${response.status}`);
}
return await response.json();
}
export function debounce(fn, delayMs) {
let timer = null;
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn(...args);
}, delayMs);
};
}
export function humanAge(unixSeconds) {
if (typeof unixSeconds !== "number") return "?";
const seconds = Math.max(0, Date.now() / 1000 - unixSeconds);
if (seconds < 60) return `${Math.floor(seconds)}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}
export function shortEndpoint(endpoint) {
const parts = String(endpoint || "").split("/").filter(Boolean);
if (parts.length <= 1) return endpoint || "(unknown)";
return parts.slice(-2).join("/");
}
export function formatUsd(value) {
if (typeof value !== "number" || !isFinite(value)) return null;
return `$${value.toFixed(value < 10 ? 4 : 2).replace(/\.?0+$/, "") || "0"}`;
}
-180
View File
@@ -1,180 +0,0 @@
// Endpoint autocomplete for free-typed fal nodes (Any Endpoint, Submit, ...).
import { debounce, getJson } from "./fal_api.js";
import { FREE_TYPED_NODES, refreshNodeBadge } from "./fal_badges.js";
const SEARCH_DEBOUNCE_MS = 300;
const RESULT_LIMIT = 25;
const ENDPOINT_WIDGET = "endpoint_id";
let activePopup = null;
function destroyPopup() {
if (!activePopup) return;
try {
activePopup.cleanup?.();
activePopup.element.remove();
} catch (error) {
console.debug("[fal] popup cleanup failed", error);
}
activePopup = null;
}
function findTarget(canvas, value) {
const pair = canvas?.node_widget;
if (
Array.isArray(pair) &&
pair[0] &&
pair[1]?.name === ENDPOINT_WIDGET &&
FREE_TYPED_NODES.has(pair[0].type)
) {
return pair;
}
const selected = Object.values(canvas?.selected_nodes || {});
for (const node of selected) {
if (!FREE_TYPED_NODES.has(node?.type)) continue;
const widget = (node.widgets || []).find((w) => w?.name === ENDPOINT_WIDGET);
if (widget && String(widget.value ?? "") === String(value ?? "")) return [node, widget];
}
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;
text.append(title, endpoint);
if (model.label) {
const price = document.createElement("span");
price.className = "fal-suggest-price";
price.textContent = model.label;
text.append(price);
}
row.append(text);
row.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopPropagation();
apply(model.endpoint_id);
});
return row;
}
function positionPopup(popup, dialog) {
try {
const rect = dialog.getBoundingClientRect();
popup.style.left = `${Math.max(4, rect.left)}px`;
popup.style.top = `${rect.bottom + 4}px`;
} catch (error) {
console.debug("[fal] popup positioning failed", error);
}
}
function attachAutocomplete(dialog, input, node, widget) {
destroyPopup();
const popup = document.createElement("div");
popup.className = "fal-suggest";
document.body.appendChild(popup);
positionPopup(popup, dialog);
const apply = (endpointId) => {
try {
widget.value = endpointId;
input.value = endpointId;
widget.callback?.(endpointId);
refreshNodeBadge(node, endpointId);
node.setDirtyCanvas?.(true, true);
} catch (error) {
console.debug("[fal] could not apply endpoint suggestion", error);
}
destroyPopup();
};
const search = async (query) => {
try {
const models = await getJson(
`/models?q=${encodeURIComponent(query || "")}&limit=${RESULT_LIMIT}`
);
if (activePopup?.element !== popup) return;
popup.replaceChildren(...(models || []).map((model) => resultRow(model, apply)));
popup.style.display = models?.length ? "block" : "none";
} catch (error) {
console.debug("[fal] endpoint search failed", error);
}
};
const debouncedSearch = debounce(() => search(input.value), SEARCH_DEBOUNCE_MS);
const onInput = () => debouncedSearch();
const onKeyDown = (event) => {
if (event.key === "Escape" || event.key === "Enter") destroyPopup();
};
const onOutsideDown = (event) => {
if (!popup.contains(event.target) && event.target !== input) destroyPopup();
};
input.addEventListener("input", onInput);
input.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onOutsideDown, true);
const aliveCheck = setInterval(() => {
if (!input.isConnected) destroyPopup();
}, 500);
activePopup = {
element: popup,
cleanup: () => {
input.removeEventListener("input", onInput);
input.removeEventListener("keydown", onKeyDown);
document.removeEventListener("mousedown", onOutsideDown, true);
clearInterval(aliveCheck);
},
};
search(input.value);
}
export function installAutocomplete() {
const canvasClass = globalThis.LGraphCanvas;
if (!canvasClass?.prototype?.prompt) {
console.debug("[fal] LGraphCanvas.prompt unavailable; endpoint autocomplete disabled");
return;
}
const originalPrompt = canvasClass.prototype.prompt;
canvasClass.prototype.prompt = function (title, value, callback, event, ...rest) {
const dialog = originalPrompt.call(this, title, value, callback, event, ...rest);
try {
const target = findTarget(this, value);
const input = dialog?.querySelector?.("input, textarea");
if (target && input) attachAutocomplete(dialog, input, target[0], target[1]);
} catch (error) {
console.debug("[fal] autocomplete attach failed", error);
}
return dialog;
};
}
-151
View File
@@ -1,151 +0,0 @@
// Cost badges: a small price pill floating above every priced fal node.
import { debounce, getJson } from "./fal_api.js";
export const FREE_TYPED_NODES = new Set([
"FalAnyEndpoint_fal",
"FalSubmit_fal",
"FalCostEstimator_fal",
"FalResultByRequestId_fal",
]);
const DYNAMIC_NODE_PREFIX = "FalAPI_";
const ENDPOINT_WIDGET = "endpoint_id";
const LIVE_DEBOUNCE_MS = 500;
// node class key -> {label, per_run}; filled asynchronously.
let pricingMap = {};
// endpoint_id -> label|null for free-typed endpoint lookups.
const liveLabelCache = new Map();
export async function loadPricingMap() {
try {
pricingMap = (await getJson("/pricing_map")) || {};
console.debug(`[fal] pricing map loaded (${Object.keys(pricingMap).length} nodes)`);
} catch (error) {
console.debug("[fal] pricing map unavailable", error);
}
}
function titleHeight() {
const lg = globalThis.LiteGraph;
const height = lg && typeof lg.NODE_TITLE_HEIGHT === "number" ? lg.NODE_TITLE_HEIGHT : 30;
return height;
}
function drawPill(node, ctx, text) {
if (!text || node?.flags?.collapsed) return;
ctx.save();
try {
ctx.font = "10px Inter, 'Segoe UI', sans-serif";
const padX = 7;
const height = 16;
const width = ctx.measureText(text).width + padX * 2;
const x = 0;
const y = -titleHeight() - height - 6;
ctx.beginPath();
if (typeof ctx.roundRect === "function") {
ctx.roundRect(x, y, width, height, height / 2);
} else {
ctx.rect(x, y, width, height);
}
ctx.fillStyle = "rgba(12, 12, 18, 0.88)";
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = "rgba(167, 139, 250, 0.45)";
ctx.stroke();
ctx.fillStyle = "#ece9fd";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(text, x + padX, y + height / 2 + 0.5);
} finally {
ctx.restore();
}
}
function labelForNode(node, typeName) {
if (FREE_TYPED_NODES.has(typeName)) return node._falPriceLabel || null;
return pricingMap[typeName]?.label || null;
}
export function refreshNodeBadge(node, endpointValue) {
const endpoint = String(endpointValue ?? "").trim();
if (!endpoint) {
node._falPriceLabel = null;
node.setDirtyCanvas?.(true, true);
return;
}
if (liveLabelCache.has(endpoint)) {
node._falPriceLabel = liveLabelCache.get(endpoint);
node.setDirtyCanvas?.(true, true);
return;
}
getJson(`/pricing?endpoint_id=${encodeURIComponent(endpoint)}`)
.then((data) => {
const label = data?.label || null;
liveLabelCache.set(endpoint, label);
node._falPriceLabel = label;
node.setDirtyCanvas?.(true, true);
})
.catch((error) => console.debug("[fal] live pricing lookup failed", error));
}
function watchEndpointWidget(node) {
const widget = (node.widgets || []).find((w) => w?.name === ENDPOINT_WIDGET);
if (!widget) return;
const refresh = debounce(() => refreshNodeBadge(node, widget.value), LIVE_DEBOUNCE_MS);
const previousCallback = widget.callback;
widget.callback = function (...args) {
const result = previousCallback?.apply(this, args);
try {
refresh();
} catch (error) {
console.debug("[fal] endpoint widget watch failed", error);
}
return result;
};
refreshNodeBadge(node, widget.value);
}
function hookFreeTypedNode(nodeType) {
const previousCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
const result = previousCreated?.apply(this, args);
try {
watchEndpointWidget(this);
} catch (error) {
console.debug("[fal] could not watch endpoint widget", error);
}
return result;
};
const previousConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (...args) {
const result = previousConfigure?.apply(this, args);
try {
const widget = (this.widgets || []).find((w) => w?.name === ENDPOINT_WIDGET);
if (widget) refreshNodeBadge(this, widget.value);
} catch (error) {
console.debug("[fal] badge refresh on configure failed", error);
}
return result;
};
}
export function setupNodeBadges(nodeType, nodeData) {
const typeName = nodeData?.name;
if (!typeName || !nodeType?.prototype) return;
const isFreeTyped = FREE_TYPED_NODES.has(typeName);
if (!isFreeTyped && !typeName.startsWith(DYNAMIC_NODE_PREFIX)) return;
const previousDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx, ...args) {
const result = previousDraw?.apply(this, [ctx, ...args]);
try {
drawPill(this, ctx, labelForNode(this, typeName));
} catch (error) {
console.debug("[fal] badge draw failed", error);
}
return result;
};
if (isFreeTyped) hookFreeTypedNode(nodeType);
}
-268
View File
@@ -1,268 +0,0 @@
/* fal platform extension styles: sidebar panel + endpoint suggestions. */
.fal-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
font-size: 12px;
color: #ece9fd;
}
.fal-panel-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
}
.fal-stats {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border: 1px solid rgba(167, 139, 250, 0.25);
border-radius: 8px;
background: rgba(12, 12, 18, 0.6);
}
.fal-stat {
display: flex;
justify-content: space-between;
gap: 8px;
}
.fal-stat-label {
opacity: 0.65;
}
.fal-stat-value {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.fal-jobs-header {
font-weight: 600;
opacity: 0.85;
}
.fal-jobs {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
max-height: 60vh;
}
.fal-job {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
}
.fal-job-icon {
flex: none;
}
.fal-job-info {
min-width: 0;
flex: 1;
}
.fal-job-endpoint {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fal-job-meta {
font-size: 10px;
opacity: 0.6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.fal-job-meta:hover {
opacity: 0.9;
}
.fal-job-cancel {
flex: none;
padding: 2px 8px;
border: 1px solid rgba(248, 113, 113, 0.5);
border-radius: 5px;
background: transparent;
color: #fca5a5;
font-size: 10px;
cursor: pointer;
}
.fal-job-cancel:hover {
background: rgba(248, 113, 113, 0.15);
}
.fal-jobs-empty {
padding: 6px 2px;
}
.fal-muted {
opacity: 0.55;
}
/* Floating fallback when no sidebar API is available. */
.fal-floating {
position: fixed;
right: 14px;
bottom: 14px;
z-index: 10000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.fal-floating-toggle {
padding: 6px 14px;
border: 1px solid rgba(167, 139, 250, 0.5);
border-radius: 999px;
background: rgba(12, 12, 18, 0.92);
color: #ece9fd;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.fal-floating-panel {
width: 300px;
max-height: 70vh;
overflow-y: auto;
border: 1px solid rgba(167, 139, 250, 0.3);
border-radius: 10px;
background: rgba(12, 12, 18, 0.95);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
/* Endpoint autocomplete popup. */
.fal-suggest {
position: fixed;
z-index: 10001;
width: 380px;
max-height: 320px;
overflow-y: auto;
border: 1px solid rgba(167, 139, 250, 0.35);
border-radius: 8px;
background: rgba(12, 12, 18, 0.97);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
font-size: 12px;
color: #ece9fd;
}
.fal-suggest-item {
display: flex;
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);
}
.fal-suggest-title {
font-weight: 600;
}
.fal-suggest-endpoint {
font-size: 10px;
opacity: 0.65;
}
.fal-suggest-price {
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;
}
-54
View File
@@ -1,54 +0,0 @@
// fal platform extension: cost badges, session/balance/jobs sidebar,
// and endpoint autocomplete for the ComfyUI canvas.
import { app } from "../../scripts/app.js";
import { loadPricingMap, setupNodeBadges } from "./fal_badges.js";
import { registerSidebar } from "./fal_sidebar.js";
import { installAutocomplete } from "./fal_autocomplete.js";
// Start loading the pricing map immediately: node definitions register before
// setup() runs, and the badge drawer looks the map up lazily at draw time.
const pricingReady = loadPricingMap();
function injectStylesheet() {
try {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = new URL("./fal_platform.css", import.meta.url).href;
document.head.appendChild(link);
} catch (error) {
console.debug("[fal] stylesheet injection failed", error);
}
}
app.registerExtension({
name: "fal.platform",
beforeRegisterNodeDef(nodeType, nodeData) {
try {
setupNodeBadges(nodeType, nodeData);
} catch (error) {
console.debug("[fal] badge setup failed", error);
}
},
async setup() {
injectStylesheet();
try {
registerSidebar(app);
} catch (error) {
console.debug("[fal] sidebar registration failed", error);
}
try {
installAutocomplete();
} catch (error) {
console.debug("[fal] autocomplete install failed", error);
}
try {
await pricingReady;
app.graph?.setDirtyCanvas?.(true, true);
} catch (error) {
console.debug("[fal] pricing map warmup failed", error);
}
},
});
-313
View File
@@ -1,313 +0,0 @@
// Sidebar panel: session spend, account balance and the async job inbox.
import { formatUsd, getJson, humanAge, postJson, shortEndpoint } from "./fal_api.js";
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;
function element(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text != null) el.textContent = text;
return el;
}
function copyText(text, feedbackEl) {
const done = () => {
if (!feedbackEl) return;
const original = feedbackEl.textContent;
feedbackEl.textContent = "copied!";
setTimeout(() => {
feedbackEl.textContent = original;
}, 900);
};
try {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(done, () => {});
return;
}
} catch (error) {
console.debug("[fal] clipboard copy failed", error);
}
done();
}
async function cancelJob(job, refresh) {
try {
const result = await postJson("/cancel", {
endpoint_id: job.endpoint,
request_id: job.request_id,
});
if (!result?.ok) console.debug("[fal] cancel refused", result?.error);
} catch (error) {
console.debug("[fal] cancel request failed", error);
}
refresh();
}
function jobRow(job, refresh) {
const row = element("div", "fal-job");
const pending = job.status === "submitted";
row.append(element("span", "fal-job-icon", pending ? "⏳" : "✅"));
const info = element("div", "fal-job-info");
info.append(element("div", "fal-job-endpoint", shortEndpoint(job.endpoint)));
const requestId = String(job.request_id || "-");
const meta = element(
"div",
"fal-job-meta",
`${humanAge(pending ? job.submitted_at : job.collected_at ?? job.submitted_at)} ago · ${requestId}`
);
meta.title = "Click to copy request id";
meta.addEventListener("click", () => copyText(requestId, meta));
info.append(meta);
row.append(info);
if (pending) {
const cancel = element("button", "fal-job-cancel", "Cancel");
cancel.addEventListener("click", () => cancelJob(job, refresh));
row.append(cancel);
}
return row;
}
function buildPanel() {
const root = element("div", "fal-panel");
root.append(element("div", "fal-panel-title", "fal platform"));
const stats = element("div", "fal-stats");
const session = element("div", "fal-stat");
session.append(element("span", "fal-stat-label", "Session"), element("span", "fal-stat-value", "…"));
const balance = element("div", "fal-stat");
balance.append(element("span", "fal-stat-label", "Balance"), element("span", "fal-stat-value", "…"));
stats.append(session, balance);
const jobsHeader = element("div", "fal-jobs-header", "Jobs");
const jobs = element("div", "fal-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;
target.textContent = `${total} · ${calls} call${calls === 1 ? "" : "s"}`;
}
function renderBalance(target, data) {
const balance = formatUsd(data?.balance_usd);
target.textContent = balance ?? "unavailable";
target.classList.toggle("fal-muted", balance == null);
}
function renderJobs(view, data, refresh) {
const jobs = Array.isArray(data?.jobs) ? data.jobs : [];
const counts = data?.counts || {};
view.jobsHeader.textContent = `Jobs · ${counts.submitted ?? 0} pending, ${counts.collected ?? 0} collected`;
view.jobs.replaceChildren(
...(jobs.length
? jobs.map((job) => jobRow(job, refresh))
: [element("div", "fal-muted fal-jobs-empty", "No jobs yet — queue one with Fal Submit.")])
);
}
async function refreshPanel(view) {
const refresh = () => refreshPanel(view).catch(() => {});
const [session, balance, jobs] = await Promise.allSettled([
getJson("/session"),
getJson("/balance"),
getJson(`/jobs?limit=${JOB_LIMIT}`),
]);
try {
if (session.status === "fulfilled") renderSession(view.sessionValue, session.value);
if (balance.status === "fulfilled") renderBalance(view.balanceValue, balance.value);
else renderBalance(view.balanceValue, {});
if (jobs.status === "fulfilled") renderJobs(view, jobs.value, refresh);
} catch (error) {
console.debug("[fal] panel render failed", error);
}
}
function panelVisible() {
return !!panelRoot && panelRoot.isConnected && panelRoot.offsetParent !== null && !document.hidden;
}
function startRefreshLoop(view) {
if (refreshTimer) clearInterval(refreshTimer);
const tick = () => {
if (!panelVisible()) return;
refreshPanel(view).catch((error) => console.debug("[fal] panel refresh failed", error));
};
refreshTimer = setInterval(tick, REFRESH_MS);
refreshPanel(view).catch((error) => console.debug("[fal] initial panel refresh failed", error));
}
export function mountPanel(container) {
const view = buildPanel();
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() {
const wrapper = element("div", "fal-floating");
const panelHost = element("div", "fal-floating-panel");
panelHost.style.display = "none";
const toggle = element("button", "fal-floating-toggle", "fal");
toggle.title = "fal: session cost, balance, jobs";
toggle.addEventListener("click", () => {
const hidden = panelHost.style.display === "none";
panelHost.style.display = hidden ? "block" : "none";
if (hidden) mountPanel(panelHost);
});
wrapper.append(panelHost, toggle);
document.body.appendChild(wrapper);
}
export function registerSidebar(app) {
try {
const manager = app?.extensionManager;
if (manager && typeof manager.registerSidebarTab === "function") {
manager.registerSidebarTab({
id: "fal-platform",
icon: "pi pi-bolt",
title: "fal",
tooltip: "fal: session cost, balance, jobs",
type: "custom",
render: (el) => {
try {
mountPanel(el);
} catch (error) {
console.debug("[fal] sidebar mount failed", error);
}
},
});
return;
}
} catch (error) {
console.debug("[fal] sidebar tab registration failed", error);
}
try {
mountFloatingFallback();
} catch (error) {
console.debug("[fal] floating panel fallback failed", error);
}
}