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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user