Files
aiturk-hermes-ide/tests/plugins/video_gen/test_fal_plugin.py
T

630 lines
24 KiB
Python

"""Tests for the FAL video gen plugin — family routing, payload shape."""
from __future__ import annotations
from unittest.mock import Mock
import pytest
from agent import video_gen_registry
@pytest.fixture(autouse=True)
def _reset_registry():
video_gen_registry._reset_for_tests()
yield
video_gen_registry._reset_for_tests()
def test_fal_provider_registers():
from plugins.video_gen.fal import FALVideoGenProvider, DEFAULT_MODEL
provider = FALVideoGenProvider()
video_gen_registry.register_provider(provider)
assert video_gen_registry.get_provider("fal") is provider
assert provider.display_name == "FAL"
# DEFAULT_MODEL is the cheap-tier default
assert provider.default_model() == DEFAULT_MODEL
assert DEFAULT_MODEL in {"pixverse-v6", "ltx-2.3"}
def test_kling_4k_uses_start_image_url():
"""Kling v3 4K's image-to-video endpoint expects start_image_url,
not image_url. The family must declare image_param_key='start_image_url'."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["kling-v3-4k"]
assert meta.get("image_param_key") == "start_image_url"
payload = _build_payload(
meta,
prompt="x",
image_url="https://example.com/i.png",
duration=5,
aspect_ratio="16:9",
resolution="720p",
negative_prompt=None,
audio=None,
seed=None,
)
assert payload.get("start_image_url") == "https://example.com/i.png"
assert "image_url" not in payload
def test_minimax_h3_int_duration_and_resolution_alias():
"""MiniMax H3 requires duration as a JSON integer and uses the
768P/2K/4K resolution enum — the tool's 720p/1080p values must map."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["minimax-h3"]
payload = _build_payload(
meta,
prompt="x",
image_url=None,
duration=7,
aspect_ratio="16:9",
resolution="720p",
negative_prompt=None,
audio=True,
seed=None,
)
assert payload["duration"] == 7 and isinstance(payload["duration"], int)
assert payload["resolution"] == "768P"
assert payload["aspect_ratio"] == "16:9"
# H3 has no generate_audio key (audio is native/always-on)
assert "generate_audio" not in payload
hi = _build_payload(
meta, prompt="x", image_url=None, duration=5, aspect_ratio="16:9",
resolution="1080p", negative_prompt=None, audio=None, seed=None,
)
assert hi["resolution"] == "2K"
def test_image_drop_keys_strips_aspect_ratio_on_i2v():
"""Seedance 2.5 / MiniMax H3 / Grok 1.5 i2v endpoints derive the
aspect ratio from the input image; sending the key is rejected."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
for fid in ("seedance-2.5", "minimax-h3", "grok-imagine-1.5"):
meta = FAL_FAMILIES[fid]
i2v = _build_payload(
meta, prompt="x", image_url="https://example.com/i.png",
duration=5, aspect_ratio="16:9", resolution="480p",
negative_prompt=None, audio=None, seed=None,
)
assert "aspect_ratio" not in i2v, fid
# ...but text-to-video keeps it
t2v = _build_payload(
meta, prompt="x", image_url=None, duration=5,
aspect_ratio="16:9", resolution="480p",
negative_prompt=None, audio=None, seed=None,
)
assert t2v.get("aspect_ratio") == "16:9", fid
def test_seedance_25_string_duration_up_to_30():
"""Seedance 2.5 keeps the stringified duration convention and supports
the full 4-30s range."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["seedance-2.5"]
payload = _build_payload(
meta, prompt="x", image_url=None, duration=30, aspect_ratio="1:1",
resolution="480p", negative_prompt=None, audio=True, seed=None,
)
assert payload["duration"] == "30"
assert payload["generate_audio"] is True
def test_gemini_omni_flash_is_image_only():
"""Gemini Omni Flash has no t2v endpoint on FAL — text jobs must
error cleanly instead of submitting to a None endpoint."""
from plugins.video_gen.fal import FAL_FAMILIES
meta = FAL_FAMILIES["gemini-omni-flash"]
assert meta.get("text_endpoint") is None
assert meta.get("image_endpoint")
def test_every_family_has_required_metadata():
"""Invariant: every family entry carries the picker-facing metadata and
at least one endpoint."""
from plugins.video_gen.fal import FAL_FAMILIES
for fid, meta in FAL_FAMILIES.items():
assert meta.get("display"), fid
assert meta.get("tier") in {"cheap", "premium"}, fid
assert meta.get("text_endpoint") or meta.get("image_endpoint"), fid
class TestFamilyRouting:
"""The headline behavior: image_url presence picks the endpoint."""
@pytest.fixture
def with_fake_fal(self, monkeypatch):
"""Stub fal_client.submit to capture which endpoint we hit."""
import sys
import types
captured = {"endpoint": None, "arguments": None}
class FakeHandle:
def get(self):
return {"video": {"url": "https://fake/out.mp4"}}
fake = types.ModuleType("fal_client")
def _submit(endpoint, arguments=None, headers=None):
captured["endpoint"] = endpoint
captured["arguments"] = arguments
return FakeHandle()
fake.submit = _submit # type: ignore
monkeypatch.setitem(sys.modules, "fal_client", fake)
# Reset the lazy global so it picks up our stub
from plugins.video_gen import fal as fal_plugin
fal_plugin._fal_client = None
# Also reset the managed client cache
fal_plugin._managed_fal_video_client = None
fal_plugin._managed_fal_video_client_config = None
monkeypatch.setenv("FAL_KEY", "test")
# Force direct mode — no managed gateway
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
return captured
def test_text_to_video_routes_to_text_endpoint(self, with_fake_fal):
from plugins.video_gen.fal import FALVideoGenProvider
result = FALVideoGenProvider().generate(
"a dog running",
model="pixverse-v6",
)
assert result["success"] is True
assert with_fake_fal["endpoint"] == "fal-ai/pixverse/v6/text-to-video"
assert result["modality"] == "text"
assert with_fake_fal["arguments"]["prompt"] == "a dog running"
assert "image_url" not in with_fake_fal["arguments"]
def test_image_to_video_routes_to_image_endpoint(self, with_fake_fal):
from plugins.video_gen.fal import FALVideoGenProvider
result = FALVideoGenProvider().generate(
"animate this dog",
model="pixverse-v6",
image_url="https://example.com/dog.png",
)
assert result["success"] is True
assert with_fake_fal["endpoint"] == "fal-ai/pixverse/v6/image-to-video"
assert result["modality"] == "image"
assert with_fake_fal["arguments"]["image_url"] == "https://example.com/dog.png"
def test_default_family_text_routing(self, with_fake_fal):
"""No model arg → DEFAULT_MODEL → text-to-video endpoint."""
from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL
result = FALVideoGenProvider().generate("a dog")
assert result["success"] is True
expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["text_endpoint"]
assert with_fake_fal["endpoint"] == expected_endpoint
def test_unknown_family_falls_back_to_default(self, with_fake_fal):
from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL
result = FALVideoGenProvider().generate(
"x",
model="not-a-real-family",
)
assert result["success"] is True
expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["text_endpoint"]
assert with_fake_fal["endpoint"] == expected_endpoint
def test_premium_seedance_routing(self, with_fake_fal):
"""Sanity check the premium-tier seedance routes correctly."""
from plugins.video_gen.fal import FALVideoGenProvider
result = FALVideoGenProvider().generate(
"a dog",
model="seedance-2.0",
image_url="https://example.com/dog.png",
)
assert result["success"] is True
assert with_fake_fal["endpoint"] == "bytedance/seedance-2.0/image-to-video"
# Seedance uses regular image_url (not start_image_url)
assert with_fake_fal["arguments"]["image_url"] == "https://example.com/dog.png"
class TestFamilyKeyNormalization:
def test_full_endpoint_paths_resolve_to_their_own_family(self):
"""A configured endpoint path must resolve to the family that declares
it. The segment scan alone reads the "seedance-2.0" in
".../seedance-2.0/mini/..." and bills the full-price family."""
from plugins.video_gen.fal import FAL_FAMILIES, _normalize_family_key
for fid, meta in FAL_FAMILIES.items():
for key in ("text_endpoint", "image_endpoint"):
endpoint = meta.get(key)
if endpoint:
assert _normalize_family_key(endpoint) == fid, endpoint
def test_bare_and_prefixed_ids_still_resolve(self):
from plugins.video_gen.fal import _normalize_family_key
assert _normalize_family_key("seedance-2.5") == "seedance-2.5"
assert _normalize_family_key("bytedance/seedance-2.5") == "seedance-2.5"
assert _normalize_family_key(" pixverse-v6 ") == "pixverse-v6"
assert _normalize_family_key("nonsense/thing") is None
def test_truncated_endpoint_stems_resolve(self):
"""Config often stores the FAL app path without the modality leaf."""
from plugins.video_gen.fal import _normalize_family_key
assert _normalize_family_key("bytedance/seedance-2.0/mini") == "seedance-2.0-mini"
assert _normalize_family_key("bytedance/seedance-2.0") == "seedance-2.0"
assert _normalize_family_key("minimax/h3") == "minimax-h3"
assert _normalize_family_key("xai/grok-imagine-video/v1.5") == "grok-imagine-1.5"
assert _normalize_family_key("google/gemini-omni-flash") == "gemini-omni-flash"
assert _normalize_family_key("blackforestlabs/flux-3") == "flux-3"
def test_capabilities_span_longest_family_duration(self):
"""capabilities() is active-MODEL-aware (#95681 diet): it reports
the resolved family's real window, so the schema doesn't overstate
short families or understate Seedance 2.5. The union fallback
(resolution failure) must still span the 30s ceiling."""
from unittest.mock import patch as _patch
import plugins.video_gen.fal as _fp
from plugins.video_gen.fal import FAL_FAMILIES, FALVideoGenProvider
# Active model resolved → that family's actual window.
meta = FAL_FAMILIES["seedance-2.5"]
with _patch.object(_fp, "_resolve_family",
return_value=("seedance-2.5", meta)):
caps = FALVideoGenProvider().capabilities()
assert caps["max_duration"] >= 30
# A short family must NOT be inflated to the union ceiling.
short = FAL_FAMILIES["pixverse-v6"]
durs = short.get("durations")
hi = durs[1] if isinstance(durs, tuple) else max(durs)
with _patch.object(_fp, "_resolve_family",
return_value=("pixverse-v6", short)):
caps = FALVideoGenProvider().capabilities()
assert caps["max_duration"] == hi
# Resolution failure → union fallback still spans the ceiling.
with _patch.object(_fp, "_resolve_family",
side_effect=RuntimeError("no config")):
caps = FALVideoGenProvider().capabilities()
assert caps["max_duration"] >= 30
assert caps["min_duration"] <= 1
class TestPayloadBuilder:
def test_drops_unsupported_keys(self):
"""Veo enum-clamps duration, supports aspect+resolution+audio+neg."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["veo3.1"]
p = _build_payload(
meta,
prompt="x",
image_url=None,
duration=12, # not in enum (4,6,8) — snap to 8
aspect_ratio="16:9",
resolution="720p",
negative_prompt="ugly",
audio=True,
seed=42,
)
assert p["prompt"] == "x"
assert p["duration"] == "8s" # veo3.1 uses "Ns" format per FAL API
assert p["aspect_ratio"] == "16:9"
assert p["resolution"] == "720p"
assert p["generate_audio"] is True
assert p["negative_prompt"] == "ugly"
assert p["seed"] == 42
def test_pixverse_range_clamps_correctly(self):
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["pixverse-v6"]
p = _build_payload(
meta,
prompt="x",
image_url="https://i.png",
duration=99, # over max → 15
aspect_ratio="16:9",
resolution="540p",
negative_prompt=None,
audio=None,
seed=None,
)
assert p["duration"] == "15"
@pytest.mark.parametrize(
"family_id",
[
"seedance-2.0",
"seedance-2.0-mini",
"seedance-2.5",
"minimax-h3",
"flux-3",
"grok-imagine-1.5",
"gemini-omni-flash",
],
)
def test_seed_dropped_for_families_without_seed_support(self, family_id):
"""These FAL endpoints declare no `seed`; the gateway forwards whatever
we send, so an unknown key would reach the vendor."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
p = _build_payload(
FAL_FAMILIES[family_id],
prompt="x",
image_url="https://i.png",
duration=None,
aspect_ratio="16:9",
resolution="720p",
negative_prompt=None,
audio=None,
seed=42,
)
assert "seed" not in p
def test_minimax_h3_uses_uppercase_resolution_enum(self):
"""FAL spells MiniMax H3 resolutions "768P"/"2K"/"4K"; tool-style
values like "720p" are aliased via resolution_aliases."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["minimax-h3"]
accepted = _build_payload(
meta, prompt="x", image_url=None, duration=7, aspect_ratio="16:9",
resolution="2K", negative_prompt=None, audio=None, seed=None,
)
assert accepted["resolution"] == "2K"
assert accepted["duration"] == 7
aliased = _build_payload(
meta, prompt="x", image_url=None, duration=7, aspect_ratio="16:9",
resolution="720p", negative_prompt=None, audio=None, seed=None,
)
assert aliased["resolution"] == "768P"
def test_audio_only_sent_for_families_that_declare_it(self):
"""minimax-h3 and the i2v-only families have no generate_audio field."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
for family_id in ("minimax-h3", "grok-imagine-1.5", "gemini-omni-flash"):
p = _build_payload(
FAL_FAMILIES[family_id],
prompt="x", image_url="https://i.png", duration=None,
aspect_ratio="16:9", resolution="720p", negative_prompt="ugly",
audio=True, seed=None,
)
assert "generate_audio" not in p, family_id
assert "negative_prompt" not in p, family_id
@pytest.mark.parametrize(
"family_id,expected",
[
("minimax-h3", 7), # FAL types duration as an integer
("flux-3", 7), # mixed ["auto", 5, 6, ...] literal enum
("grok-imagine-1.5", 7),
("gemini-omni-flash", 7),
("seedance-2.5", "7"), # FAL enum is strings: "auto","4",...
("seedance-2.0-mini", "7"),
("pixverse-v6", "7"), # unchanged legacy string form
("veo3.1", "6s"), # unchanged suffix form (7 snaps to 6)
],
)
def test_duration_is_emitted_in_the_form_fal_declares(self, family_id, expected):
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
p = _build_payload(
FAL_FAMILIES[family_id],
prompt="x", image_url=None, duration=7, aspect_ratio="16:9",
resolution="720p", negative_prompt=None, audio=None, seed=None,
)
assert p["duration"] == expected
assert type(p["duration"]) is type(expected)
def test_i2v_only_families_declare_no_text_endpoint(self):
"""Catalog invariant: Gemini Omni Flash animates an existing image only."""
from plugins.video_gen.fal import FAL_FAMILIES
meta = FAL_FAMILIES["gemini-omni-flash"]
assert meta.get("text_endpoint") is None
assert meta["image_endpoint"]
def test_ltx_omits_duration_aspect_resolution(self):
"""LTX 2.3 doesn't declare duration/aspect/resolution enums —
the payload should NOT include those keys (let FAL default)."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["ltx-2.3"]
p = _build_payload(
meta,
prompt="x",
image_url=None,
duration=8,
aspect_ratio="16:9",
resolution="720p",
negative_prompt="ugly",
audio=True,
seed=None,
)
assert "duration" not in p
assert "aspect_ratio" not in p
assert "resolution" not in p
# But audio + negative are advertised
assert p["generate_audio"] is True
assert p["negative_prompt"] == "ugly"
def test_range_families_omit_duration_when_unspecified(self):
"""Range-based families must omit `duration` when the caller doesn't
specify one so FAL applies its endpoint default, not the minimum."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
for family_id in ("pixverse-v6", "seedance-2.0", "kling-v3-4k"):
meta = FAL_FAMILIES[family_id]
p = _build_payload(
meta,
prompt="x",
image_url=None,
duration=None,
aspect_ratio="16:9",
resolution="720p",
negative_prompt=None,
audio=None,
seed=None,
)
assert "duration" not in p, (
f"{family_id}: duration=None should omit the field, "
f"got {p.get('duration')!r}"
)
def test_happy_horse_minimal_payload(self):
"""Happy Horse has sparse docs — payload should be minimal."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["happy-horse"]
p = _build_payload(
meta,
prompt="a horse galloping",
image_url=None,
duration=8,
aspect_ratio="16:9",
resolution="720p",
negative_prompt="watermark",
audio=True,
seed=None,
)
# Only prompt — no payload bloat for fields we can't verify
assert p == {"prompt": "a horse galloping"}
class TestUpscalePass:
"""Opt-in SeedVR2 upscale chain after generation."""
@pytest.fixture
def with_fake_fal(self, monkeypatch):
"""Stub fal_client.submit, capturing every endpoint hit in order."""
import sys
import types
captured = {"calls": []}
class FakeHandle:
def __init__(self, endpoint):
self._endpoint = endpoint
def get(self):
if self._endpoint.endswith("upscale/video"):
return {"video": {"url": "https://fake/upscaled.mp4"}}
return {"video": {"url": "https://fake/native.mp4"}}
fake = types.ModuleType("fal_client")
def _submit(endpoint, arguments=None, headers=None):
captured["calls"].append((endpoint, arguments))
return FakeHandle(endpoint)
fake.submit = _submit # type: ignore
monkeypatch.setitem(sys.modules, "fal_client", fake)
from plugins.video_gen import fal as fal_plugin
fal_plugin._fal_client = None
fal_plugin._managed_fal_video_client = None
fal_plugin._managed_fal_video_client_config = None
monkeypatch.setenv("FAL_KEY", "test")
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
return captured
def test_upscale_chains_seedvr(self, with_fake_fal):
from plugins.video_gen.fal import FALVideoGenProvider, UPSCALER_ENDPOINT
result = FALVideoGenProvider().generate(
"a dog", model="pixverse-v6", upscale=True,
)
assert result["success"] is True
assert result["video"] == "https://fake/upscaled.mp4"
assert result["upscaled"] is True
assert result["upscale_factor"] == 2
endpoints = [c[0] for c in with_fake_fal["calls"]]
assert endpoints == ["fal-ai/pixverse/v6/text-to-video", UPSCALER_ENDPOINT]
# Upscale request carries the native URL + factor mode.
upscale_args = with_fake_fal["calls"][1][1]
assert upscale_args["video_url"] == "https://fake/native.mp4"
assert upscale_args["upscale_mode"] == "factor"
def test_no_upscale_by_default(self, with_fake_fal):
from plugins.video_gen.fal import FALVideoGenProvider
result = FALVideoGenProvider().generate("a dog", model="pixverse-v6")
assert result["success"] is True
assert result["video"] == "https://fake/native.mp4"
assert result["upscaled"] is False
assert len(with_fake_fal["calls"]) == 1
def test_upscale_failure_falls_back_to_native(self, with_fake_fal, monkeypatch):
from plugins.video_gen import fal as fal_plugin
from plugins.video_gen.fal import FALVideoGenProvider
monkeypatch.setattr(
fal_plugin,
"_upscale_video",
lambda url, source_request_id=None: None,
)
result = FALVideoGenProvider().generate(
"a dog", model="pixverse-v6", upscale=True,
)
assert result["success"] is True
assert result["video"] == "https://fake/native.mp4"
assert result["upscaled"] is False
def test_managed_upscale_binds_the_source_request(self, monkeypatch):
from plugins.video_gen import fal as fal_plugin
captured = {}
class FakeHandle:
def get(self):
return {"video": {"url": "https://fake/upscaled.mp4"}}
monkeypatch.setattr(
fal_plugin,
"_resolve_managed_fal_video_gateway",
lambda: object(),
)
monkeypatch.setattr(
fal_plugin,
"_submit_fal_video_request",
lambda endpoint, arguments: (
captured.update(endpoint=endpoint, arguments=arguments)
or FakeHandle()
),
)
assert (
fal_plugin._upscale_video(
"https://fake/native.mp4",
"source-request-1",
)
== "https://fake/upscaled.mp4"
)
assert captured["arguments"]["source_request_id"] == "source-request-1"
def test_managed_upscale_without_source_request_falls_back(self, monkeypatch):
from plugins.video_gen import fal as fal_plugin
submit = Mock()
monkeypatch.setattr(
fal_plugin,
"_resolve_managed_fal_video_gateway",
lambda: object(),
)
monkeypatch.setattr(fal_plugin, "_submit_fal_video_request", submit)
assert fal_plugin._upscale_video("https://fake/native.mp4") is None
submit.assert_not_called()