* feat: v2.1.0 — fal platform utilities (async fan-out, cost tools, recovery) Six new nodes under FAL/Platform, built on fal's queue/request-id/pricing primitives: - Fal Submit + Fal Collect: queue jobs without blocking and collect results later — N generations run in parallel inside one graph - Fal Result by Request ID: re-fetch any past generation from fal's queue without re-generating or re-billing - Fal Cost Estimator: per-run cost report parsed from registry pricing before anything is queued - Fal Session Costs: in-memory ledger of every fal call this session (endpoint, duration, request id, est. cost) with running total - Fal Save Media from URL: persist expiring CDN results into the ComfyUI output directory Core: utils/pricing.py (pricing-text parser + registry lookup), utils/ledger.py (thread-safe session ledger), api.py gains submit_only, result_from_request_id, request-id capture via on_enqueue, and automatic ledger recording; any_endpoint helpers extracted for reuse. 16 new tests (61 total). * feat: v2.2.0 — persistent result cache, spend guard, URL passthrough Three fal-platform features on top of the 2.1 platform pack: - Persistent result cache (sqlite, [cache] config): identical fal calls are served free from disk across ComfyUI restarts; uploads are deduplicated by content hash so identical inputs upload once. Bypass per node via force_rerun. Cache hits are never blocked by the spend guard and never recorded as spend. - Spend Guard + billing ([spend_guard] config): preflight refuses to submit once the session's estimated spend reaches session_budget_usd or the account balance (fal Billing API) falls below min_balance_usd. New Fal Account Balance node; ledger reconciliation against the aggregated usage API (fal exposes no per-request billed amounts). - fal-CDN URL passthrough: every dynamic node's media inputs gain optional *_direct_url twins (URL sent directly, no download/re-upload; 1,405 twins across 992 nodes) and image-kind nodes output image_urls alongside IMAGE — fal→fal chains with zero local I/O. Review fixes: path traversal blocked in FalSaveMediaURL (confined to the output dir, atomic O_EXCL filename claim), FalAnyEndpoint force_rerun now bypasses the cache, cache lookup ordered before spend-guard preflight, result recovery no longer counts as new session spend. 14 new tests (76 total).
68 lines
2.4 KiB
Python
68 lines
2.4 KiB
Python
"""Unit tests for fal-CDN URL passthrough (twin inputs + URL outputs)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from helpers import _input, _model
|
|
|
|
|
|
def test_media_inputs_get_direct_url_twins(schema_to_inputs):
|
|
model = _model([
|
|
_input("image_url", "string", required=True, media_kind="image"),
|
|
_input("video_url", "string", media_kind="video"),
|
|
_input("doc_url", "string", media_kind="file"),
|
|
])
|
|
it = schema_to_inputs.build_input_types(model)
|
|
assert "image_url_direct_url" in it["optional"]
|
|
assert "video_url_direct_url" in it["optional"]
|
|
assert "doc_url_direct_url" not in it["optional"] # file kind excluded
|
|
|
|
|
|
def test_direct_url_wins_over_tensor(arguments_mod, monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
arguments_mod.ImageUtils,
|
|
"upload_image",
|
|
staticmethod(lambda v: calls.append(v) or "https://uploaded/x.png"),
|
|
)
|
|
model = _model([_input("image_url", "string", media_kind="image")])
|
|
args = arguments_mod.build_arguments(
|
|
model,
|
|
{"image_url": object(), "image_url_direct_url": "https://fal.media/direct.png"},
|
|
)
|
|
assert args["image_url"] == "https://fal.media/direct.png"
|
|
assert not calls # no upload happened
|
|
|
|
|
|
def test_direct_url_works_without_tensor(arguments_mod):
|
|
model = _model([_input("image_url", "string", media_kind="image")])
|
|
args = arguments_mod.build_arguments(
|
|
model, {"image_url_direct_url": "https://fal.media/direct.png"}
|
|
)
|
|
assert args["image_url"] == "https://fal.media/direct.png"
|
|
|
|
|
|
def test_invalid_direct_url_raises(arguments_mod, errors_mod):
|
|
model = _model([_input("image_url", "string", media_kind="image")])
|
|
with pytest.raises(errors_mod.FalApiError):
|
|
arguments_mod.build_arguments(
|
|
model, {"image_url_direct_url": "not-a-url"}
|
|
)
|
|
|
|
|
|
def test_direct_url_list_splits_commas(arguments_mod):
|
|
model = _model([
|
|
_input("image_urls", "array", media_kind="image", is_list=True)
|
|
])
|
|
args = arguments_mod.build_arguments(
|
|
model,
|
|
{"image_urls_direct_url": "https://a/1.png, https://b/2.png"},
|
|
)
|
|
assert args["image_urls"] == ["https://a/1.png", "https://b/2.png"]
|
|
|
|
|
|
def test_images_output_includes_urls(outputs_mod):
|
|
types, names = outputs_mod.RETURN_SPECS["images"]
|
|
assert types == ("IMAGE", "STRING")
|
|
assert names == ("images", "image_urls")
|