From d8053f480669a0feed9fdb5fe5352a78560fa762 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:23:00 -0700 Subject: [PATCH] fix(video_gen): cap LTX 2.5 at 10s for 1440p/2160p; omit unset enum durations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugins/video_gen/fal/__init__.py | 27 ++++++++++++++-------- tests/plugins/video_gen/test_fal_plugin.py | 8 +++++++ tests/tools/test_managed_media_gateways.py | 4 ++-- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/plugins/video_gen/fal/__init__.py b/plugins/video_gen/fal/__init__.py index a01e6e4b8a..2dbd0e9f46 100644 --- a/plugins/video_gen/fal/__init__.py +++ b/plugins/video_gen/fal/__init__.py @@ -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), diff --git a/tests/plugins/video_gen/test_fal_plugin.py b/tests/plugins/video_gen/test_fal_plugin.py index b4a14d8fdb..675872f028 100644 --- a/tests/plugins/video_gen/test_fal_plugin.py +++ b/tests/plugins/video_gen/test_fal_plugin.py @@ -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 diff --git a/tests/tools/test_managed_media_gateways.py b/tests/tools/test_managed_media_gateways.py index 3938b8cfe5..ffac787550 100644 --- a/tests/tools/test_managed_media_gateways.py +++ b/tests/tools/test_managed_media_gateways.py @@ -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")