fix(video_gen): cap LTX 2.5 at 10s for 1440p/2160p; omit unset enum durations

fal's LTX 2.5 fast endpoints accept 6-20s only up to 1080p — "At 1440p and
2160p, all frame rates support up to 10 seconds" — so a 4K request with the
family's 20s ceiling was rejected by the vendor. Families can now declare
`duration_cap_by_resolution`, applied after the enum snap / range clamp on the
resolved resolution enum.

An unset duration on a duration_enum family also snapped to enum[0] (6s),
silently overriding the endpoint's own "auto" default; None now omits the key
for enum families exactly as it already did for range families.

test_managed_media_gateways asserts the alibaba/happy-horse/ namespace by
prefix rather than the exact v1.1 literal so the next version bump doesn't
flip an unrelated gateway test.
This commit is contained in:
teknium1
2026-09-14 21:23:00 -07:00
committed by Teknium
parent 1c1980dc1f
commit d8053f4806
3 changed files with 27 additions and 12 deletions

View File

@@ -19,8 +19,9 @@ logger = logging.getLogger(__name__)
# Family catalog. Capability flags gate which keys reach the payload — keys a family doesn't advertise are never sent (the
# managed gateway forwards everything verbatim). Enums default to None (endpoint decides), flags to False. ``durations`` is always a
# ``(min, max)`` range (clamp); a family whose endpoint only accepts discrete values adds ``duration_enum`` (snap to nearest; None →
# first entry). Extras: audio_native (always on; description line only),
# ``(min, max)`` range (clamp); a family whose endpoint only accepts discrete values adds ``duration_enum`` (snap to nearest; None
# stays None so the endpoint default applies); ``duration_cap_by_resolution`` lowers the ceiling per resolution enum (applied after
# the snap/clamp). Extras: audio_native (always on; description line only),
# duration_int (JSON int, default queue-API string), duration_suffix ("4s"), image_param_key (i2v key when not `image_url`),
# image_drop_keys (i2v endpoint rejects), audio_param_key (toggle key when not `generate_audio`), resolution_aliases (tool value → endpoint enum), static_payload (always required).
def _family(display: str, speed: str, tier: str, strengths: str, text: Optional[str], image: str, **caps: Any) -> Dict[str, Any]:
@@ -42,7 +43,7 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"ltx-2.5": _family("LTX 2.5", "~30-90s", "cheap", "Lightricks open-source audio-video model. Native audio, up to 20s / 4K (i2v), camera-motion presets.",
"lightricks/ltx-2.5/text-to-video/fast", "lightricks/ltx-2.5/image-to-video/fast", duration_int=True, aspect_ratios=("16:9", "9:16"),
resolutions=("720p", "1080p", "1440p", "2160p"), resolution_aliases={"2k": "1440p", "4k": "2160p"},
durations=(6, 20), duration_enum=tuple(range(6, 21, 2)), audio=True),
durations=(6, 20), duration_enum=tuple(range(6, 21, 2)), duration_cap_by_resolution={"1440p": 10, "2160p": 10}, audio=True),
"pixverse-v6": _family("Pixverse v6", "~30-90s", "cheap", "Affordable. Negative prompts. 1-15s durations.", "fal-ai/pixverse/v6/text-to-video",
"fal-ai/pixverse/v6/image-to-video", resolutions=("360p", "540p", "720p", "1080p"), durations=(1, 15), audio=True, negative=True, seed=True),
"seedance-2.0-mini": _family("Seedance 2.0 Mini", "~30-90s", "cheap", "ByteDance. Faster/cheaper Seedance tier, audio + lip-sync, 4-15s.",
@@ -121,14 +122,20 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
DEFAULT_MODEL = "pixverse-v6" # cheap, both modalities, sane defaults
def _clamp_duration(family: Dict[str, Any], duration: Optional[int]) -> Optional[int]:
"""Snap to the nearest ``duration_enum`` entry (None → first) when the family declares one, else clamp into the
``durations`` ``(min, max)`` range (None stays None: endpoint default)."""
def _clamp_duration(family: Dict[str, Any], duration: Optional[int], resolution: Optional[str] = None) -> Optional[int]:
"""Snap to the nearest ``duration_enum`` entry when the family declares one, else clamp into the ``durations``
``(min, max)`` range; None stays None (endpoint default). A ``duration_cap_by_resolution`` ceiling for the resolved
*resolution* is applied last (fal rejects LTX 2.5 >10s at 1440p/2160p)."""
if duration is None:
return None
enum = family.get("duration_enum")
if enum:
return enum[0] if duration is None else min(enum, key=lambda d: abs(d - duration))
lo, hi = family["durations"]
return None if duration is None else max(lo, min(hi, duration))
clamped = min(enum, key=lambda d: abs(d - duration))
else:
lo, hi = family["durations"]
clamped = max(lo, min(hi, duration))
cap = (family.get("duration_cap_by_resolution") or {}).get(resolution)
return clamped if cap is None else min(clamped, cap)
def _modalities(meta: Dict[str, Any]) -> List[str]:
@@ -174,7 +181,7 @@ def _build_payload(family: Dict[str, Any], *, prompt: str, image_url: Optional[s
resolution: str, negative_prompt: Optional[str], audio: Optional[bool], seed: Optional[int]) -> Dict[str, Any]:
"""Build a family-specific payload, dropping keys the family doesn't declare (unsupported enums → endpoint default)."""
resolved = (family.get("resolution_aliases") or {}).get((resolution or "").lower(), resolution)
clamped = _clamp_duration(family, duration) if family["durations"] else None
clamped = _clamp_duration(family, duration, resolved) if family["durations"] else None
payload: Dict[str, Any] = {key: value for ok, key, value in (
(prompt, "prompt", prompt),
(image_url, family.get("image_param_key") or "image_url", image_url),

View File

@@ -667,6 +667,14 @@ class TestPayloadBuilder:
resolution="720p", negative_prompt=None, audio=None, seed=7)
assert p == {"prompt": "animate", "image_url": "https://example.com/f.png", "aspect_ratio": "9:16", "resolution": "720p", "duration": 18}
# fal caps 1440p/2160p at 10s regardless of frame rate: 18 at 720p stays 18, at 4k it is capped to 10; and an
# unspecified duration is omitted so the endpoint's own default ("auto") applies instead of the enum minimum.
kw = dict(prompt="x", image_url=None, aspect_ratio="16:9", negative_prompt=None, audio=None, seed=None)
assert _build_payload(meta, duration=18, resolution="4k", **kw)["duration"] == 10
assert _build_payload(meta, duration=18, resolution="2k", **kw)["duration"] == 10
assert _build_payload(meta, duration=18, resolution="1080p", **kw)["duration"] == 18
assert "duration" not in _build_payload(meta, duration=None, resolution="4k", **kw)
def test_kling_o3_payload(self):
"""Kling O3: string duration, i2v drops aspect_ratio, no seed."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload

View File

@@ -346,5 +346,5 @@ def test_video_gen_happy_horse_uses_alibaba_namespace():
spec.loader.exec_module(plugin_mod)
hh = plugin_mod.FAL_FAMILIES["happy-horse"]
assert hh["text_endpoint"] == "alibaba/happy-horse/v1.1/text-to-video"
assert hh["image_endpoint"] == "alibaba/happy-horse/v1.1/image-to-video"
assert hh["text_endpoint"].startswith("alibaba/happy-horse/") and hh["text_endpoint"].endswith("/text-to-video")
assert hh["image_endpoint"].startswith("alibaba/happy-horse/") and hh["image_endpoint"].endswith("/image-to-video")