Compare commits

...
Author SHA1 Message Date
Gokay Aydogan eac683ddcc feat: v2.3.0 — durable job inbox, provenance, in-canvas cost UI
- Durable Job Inbox: every async submit is journaled to the shared
  sqlite store and survives ComfyUI restarts; the Fal Job Inbox node
  lists pending/collected jobs and outputs the newest pending
  request_id/endpoint for one-wire recovery via Result by Request ID.
- Provenance: Fal Save Media from URL writes a <file>.fal.json sidecar
  and embeds a fal_provenance PNG text chunk (preserving existing text
  chunks and ICC profiles), returning the request_id as a second output;
  new Fal Provenance from File node reads saved files back into
  endpoint_id + request_id for free re-materialization.
- First web extension (web/ + /fal_api server routes): per-node cost
  pills rendered above every priced fal node (live estimates on
  free-typed endpoint fields), a fal sidebar tab with session spend,
  account balance and a job list with copy/cancel, and in-canvas
  endpoint search with price labels. All features degrade silently on
  older frontends; routes are exception-guarded and cached.

Review fixes: PNG provenance embed preserves source text chunks and ICC
profile; jobs route limit clamped.

15 new tests (91 total).
2026-07-02 15:17:25 +03:00
20 changed files with 1940 additions and 8 deletions
+19 -1
View File
@@ -226,7 +226,7 @@ If ~893 extra nodes is more than you want, use the `[dynamic_nodes]` config sect
## Platform Utilities
New in 2.1/2.2: utilities built on fal's platform primitives (queue, request ids, per-model pricing) — found under `FAL/Platform`.
New in 2.1–2.3: utilities built on fal's platform primitives (queue, request ids, per-model pricing) — found under `FAL/Platform`.
### Async fan-out: Fal Submit + Fal Collect
@@ -256,6 +256,24 @@ Set `[spend_guard] session_budget_usd` and/or `min_balance_usd` in config.ini an
Every auto-generated node's media input has an optional `*_direct_url` twin, and image nodes output `image_urls` alongside the IMAGE tensor (video/audio/file nodes already output URLs). Wire a fal node's URL output into the next fal node's `*_direct_url` input and the intermediate media **never touches your machine** — no download, no re-upload. Chain image → video → upscale at fal speed.
### Durable Job Inbox (2.3)
Every `Fal Submit` is journaled to disk, so queued jobs **survive ComfyUI restarts**. The **Fal Job Inbox** node lists pending/collected jobs and outputs the newest pending `request_id` + `endpoint_id` — wire them into *Fal Result by Request ID* to collect yesterday's generations after a restart. Submit tonight, collect tomorrow.
### Provenance: every output is a receipt (2.3)
*Fal Save Media from URL* now writes a `<file>.fal.json` sidecar (and embeds a `fal_provenance` text chunk into PNGs) recording the endpoint, request id, and source URL, and returns the `request_id` as a second output. The **Fal Provenance from File** node reads any previously saved file back into `endpoint_id` + `request_id` — re-materialize the generation for free, months later.
### In-canvas cost badges, job monitor, and endpoint search (2.3)
The pack now ships a small web extension:
- **Cost badges** — every fal node with published pricing shows a pill just above it ("≈$0.08/run"); nodes with free-typed endpoint fields (Any Endpoint, Submit, Cost Estimator) estimate live as you type.
- **fal sidebar** — session spend, account balance, and a live job list (with copy-able request ids and per-job Cancel) in a sidebar tab.
- **Endpoint search** — editing an `endpoint_id` field pops up catalog search with prices; click to fill.
All frontend features degrade silently on older ComfyUI versions.
### Fal Save Media from URL
fal result URLs eventually expire from the CDN. This node downloads any result URL into your ComfyUI `output/` directory (subfolders + collision-safe numbering) so generations are persisted with your other outputs.
+13 -1
View File
@@ -10,6 +10,7 @@ node_list = [
"upscaler_node",
"platform_node",
"billing_node",
"inbox_node",
]
NODE_CLASS_MAPPINGS = {}
@@ -41,4 +42,15 @@ except Exception as _dynamic_error: # never break static nodes
)
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
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"]
+2
View File
@@ -13,6 +13,7 @@ from .utils import (
FalApiError,
FalConfig,
ImageUtils,
JobStore,
MediaUtils,
PricingUtils,
ResultCache,
@@ -28,6 +29,7 @@ __all__ = [
"FalApiError",
"FalConfig",
"ImageUtils",
"JobStore",
"MediaUtils",
"PricingUtils",
"ResultCache",
+97
View File
@@ -0,0 +1,97 @@
"""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)",
}
+172 -5
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import json
import os
import shutil
import time
from typing import Any
from urllib.parse import urlparse
@@ -13,6 +15,7 @@ from .fal_utils import (
FalApiError,
MediaUtils,
PricingUtils,
ResultCache,
SessionLedger,
logger,
)
@@ -367,17 +370,118 @@ def _claim_unique_destination(directory: str, basename: str, suffix: str) -> str
)
_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",)
RETURN_NAMES = ("path",)
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."
"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
@@ -401,7 +505,7 @@ class FalSaveMediaURL:
},
}
def save(self, url: str, filename_prefix: str = "fal/media") -> tuple[str]:
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(
@@ -433,7 +537,68 @@ class FalSaveMediaURL:
saved = os.path.abspath(destination)
logger.info("FalSaveMediaURL: saved %s -> %s", target_url, saved)
return (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 = {
@@ -443,6 +608,7 @@ NODE_CLASS_MAPPINGS = {
"FalCostEstimator_fal": FalCostEstimator,
"FalSessionCosts_fal": FalSessionCosts,
"FalSaveMediaURL_fal": FalSaveMediaURL,
"FalProvenanceFromFile_fal": FalProvenanceFromFile,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -452,4 +618,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"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)",
}
+332
View File
@@ -0,0 +1,332 @@
"""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
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"),
}
for model, info in hits[:capped]
]
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 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),
("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()
+2
View File
@@ -5,6 +5,7 @@ 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
@@ -17,6 +18,7 @@ __all__ = [
"FalApiError",
"FalConfig",
"ImageUtils",
"JobStore",
"MediaUtils",
"PricingUtils",
"ResultCache",
+13
View File
@@ -9,6 +9,7 @@ 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
@@ -273,6 +274,12 @@ class ApiHandler:
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
@@ -316,6 +323,12 @@ class ApiHandler:
_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)
return result
@staticmethod
+248
View File
@@ -0,0 +1,248 @@
"""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"
+35
View File
@@ -301,6 +301,41 @@ class ResultCache:
"misses": self._misses,
}
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.
Scans cached results (most recently used first) for one whose JSON
contains ``url`` as an exact substring. Only rows that recorded a
request_id qualify. Best-effort: any failure is a miss.
"""
try:
target = (url or "").strip()
if not target:
return None
# 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:
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "fal-api"
description = "Custom nodes for using fal API with auto-generated full-catalog coverage of fal.ai models. Video generation with Kling, Runway, Luma. Image generation with Flux. LLMs and VLMs OpenAI, Claude, Llama and Gemini."
version = "2.2.0"
version = "2.3.0"
license = {file = "LICENSE"}
requires-python = ">=3.9"
dependencies = [
+64
View File
@@ -0,0 +1,64 @@
"""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"
+72
View File
@@ -0,0 +1,72 @@
"""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"
+50
View File
@@ -0,0 +1,50 @@
"""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
@@ -0,0 +1,67 @@
// 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"}`;
}
+154
View File
@@ -0,0 +1,154 @@
// 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 resultRow(model, apply) {
const row = document.createElement("div");
row.className = "fal-suggest-item";
const title = document.createElement("span");
title.className = "fal-suggest-title";
title.textContent = model.title || model.endpoint_id;
const endpoint = document.createElement("span");
endpoint.className = "fal-suggest-endpoint";
endpoint.textContent = model.endpoint_id;
row.append(title, endpoint);
if (model.label) {
const price = document.createElement("span");
price.className = "fal-suggest-price";
price.textContent = model.label;
row.append(price);
}
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
@@ -0,0 +1,151 @@
// 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);
}
+190
View File
@@ -0,0 +1,190 @@
/* 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: column;
gap: 1px;
padding: 6px 10px;
cursor: pointer;
}
.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;
}
+54
View File
@@ -0,0 +1,54 @@
// 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);
}
},
});
+204
View File
@@ -0,0 +1,204 @@
// 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;
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");
root.append(stats, jobsHeader, jobs);
return {
root,
sessionValue: session.lastChild,
balanceValue: balance.lastChild,
jobsHeader,
jobs,
};
}
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);
}
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);
}
}