Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eac683ddcc |
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"}`;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user