Rewrite each node to faithfully mirror the official comfy_api_nodes/
contract (apis/*.py + Comfy-Org/embedded-docs). The ComfyOrg proxy
forwards the body verbatim, so the official request/response models
ARE the real vendor API.
- Video nodes now return VIDEO (InputImpl.VideoFromFile), not file paths
- Audio nodes now return AUDIO ({waveform, sample_rate}), not file paths
- Request/response bodies match official pydantic models field-for-field
- Model ids, options, defaults, ranges match official + embedded docs
- Every node exposes api_key + base_url (overridable for relays/proxies)
- utils.py: add ComfyUI-native helpers — video_from_bytes,
download_video_output, audio_from_bytes, download_audio_output,
poll_until, validate_prompt, tensor_to_data_uri
- Rewrite nodes_minimax.py (submit -> poll -> retrieve -> download VIDEO)
- nodes_extra.py: base_url on all three, poll_until, validate_prompt
- Clean __init__.py (drop duplicate grok import/keys); 37 nodes total
- README: output-type alignment, node list, relay examples
Co-Authored-By: Claude <noreply@anthropic.com>
137 lines
5.3 KiB
Python
137 lines
5.3 KiB
Python
"""LTXV (Lightricks LTX-2) video generation nodes (bring-your-own-key).
|
|
|
|
Mirrors the official ComfyUI ``LtxvApiTextToVideo`` node (comfy_api_nodes/nodes_ltxv.py)
|
|
but calls the Lightricks LTX-2 API directly with the user's own API key instead of
|
|
routing through the ComfyOrg proxy.
|
|
|
|
The official node POSTs to ``/proxy/ltx/v1/text-to-video`` with ``as_binary=True``; the
|
|
proxy forwards the body verbatim to the real vendor, so the real endpoint is
|
|
``https://api.ltx.video/v1/text-to-video``. The request model
|
|
(``ExecuteTaskRequest``: prompt / model / duration / resolution / fps / generate_audio)
|
|
and the synchronous ``video/mp4`` binary response are taken directly from the official
|
|
source. No polling is needed — the MP4 bytes are returned in the response body.
|
|
"""
|
|
|
|
import aiohttp
|
|
|
|
from .utils import run_async, validate_prompt, video_from_bytes
|
|
|
|
# Real Lightricks LTX-2 vendor host (overridable for relays).
|
|
# Official node path is /proxy/ltx/v1/text-to-video -> real path is /v1/text-to-video.
|
|
LTXV_API_BASE = "https://api.ltx.video/v1"
|
|
|
|
# Display label -> vendor model id (mirrors official MODELS_MAP).
|
|
MODELS_MAP = {
|
|
"LTX-2 (Pro)": "ltx-2-pro",
|
|
"LTX-2 (Fast)": "ltx-2-fast",
|
|
}
|
|
|
|
MODEL_LABELS = list(MODELS_MAP.keys())
|
|
DURATION_OPTIONS = [6, 8, 10, 12, 14, 16, 18, 20]
|
|
RESOLUTION_OPTIONS = ["1920x1080", "2560x1440", "3840x2160"]
|
|
FPS_OPTIONS = [25, 50]
|
|
|
|
|
|
async def _post_video_bytes(url: str, headers: dict, body: dict, timeout: float) -> bytes:
|
|
"""POST a JSON body and return the raw binary (video/mp4) response."""
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
url,
|
|
headers=headers,
|
|
json=body,
|
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
|
) as resp:
|
|
data = await resp.read()
|
|
if resp.status >= 400:
|
|
raise RuntimeError(
|
|
f"LTXV API error (HTTP {resp.status}): "
|
|
f"{data[:2000].decode('utf-8', 'replace')}"
|
|
)
|
|
return data
|
|
|
|
|
|
class BYOKey_LTXV_Text2Video:
|
|
"""LTXV (LTX-2) text-to-video. Output: VIDEO.
|
|
|
|
Generates a professional-quality video from a text prompt with customizable
|
|
duration, resolution and frame rate, optionally with AI-generated audio.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"api_key": ("STRING", {"default": "", "tooltip": "Your Lightricks LTX API key (Bearer token)."}),
|
|
"base_url": ("STRING", {"default": LTXV_API_BASE, "tooltip": "Vendor API base URL. Override for relays/proxies."}),
|
|
"model": (MODEL_LABELS, {"default": MODEL_LABELS[0]}),
|
|
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
|
"duration": (DURATION_OPTIONS, {"default": 8}),
|
|
"resolution": (RESOLUTION_OPTIONS, {"default": RESOLUTION_OPTIONS[0]}),
|
|
"fps": (FPS_OPTIONS, {"default": 25}),
|
|
},
|
|
"optional": {
|
|
"generate_audio": ("BOOLEAN", {"default": False, "tooltip": "When true, the generated video will include AI-generated audio matching the scene."}),
|
|
"max_wait": ("INT", {"default": 600, "min": 60, "max": 3600, "tooltip": "Maximum seconds to wait for the synchronous response."}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("VIDEO",)
|
|
RETURN_NAMES = ("video",)
|
|
FUNCTION = "execute"
|
|
CATEGORY = "api/byokey/video"
|
|
DESCRIPTION = "LTXV (Lightricks LTX-2) text-to-video. Professional-quality videos with customizable duration and resolution."
|
|
|
|
def execute(
|
|
self,
|
|
api_key,
|
|
base_url,
|
|
model,
|
|
prompt,
|
|
duration,
|
|
resolution,
|
|
fps=25,
|
|
generate_audio=False,
|
|
max_wait=600,
|
|
):
|
|
# Match official validation: validate_string(prompt, min_length=1, max_length=10000)
|
|
prompt = validate_prompt(prompt, min_length=1)
|
|
if len(prompt) > 10000:
|
|
raise ValueError("Prompt must be at most 10,000 characters long.")
|
|
|
|
if model not in MODELS_MAP:
|
|
raise ValueError(f"Unknown model '{model}'. Choose one of: {MODEL_LABELS}")
|
|
|
|
duration = int(duration)
|
|
fps = int(fps)
|
|
|
|
# Official constraint: durations over 10s require Fast model, 1920x1080, 25 fps.
|
|
if duration > 10 and (model != "LTX-2 (Fast)" or resolution != "1920x1080" or fps != 25):
|
|
raise ValueError(
|
|
"Durations over 10s are only available for the Fast model at "
|
|
"1920x1080 resolution and 25 FPS."
|
|
)
|
|
|
|
base = (base_url or LTXV_API_BASE).rstrip("/")
|
|
url = f"{base}/text-to-video"
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "video/mp4",
|
|
}
|
|
|
|
# Request body matches official ExecuteTaskRequest field-for-field.
|
|
body = {
|
|
"prompt": prompt,
|
|
"model": MODELS_MAP[model],
|
|
"duration": duration,
|
|
"resolution": resolution,
|
|
"fps": fps,
|
|
"generate_audio": bool(generate_audio),
|
|
}
|
|
|
|
mp4_bytes = run_async(_post_video_bytes(url, headers, body, float(max_wait)))
|
|
if not mp4_bytes:
|
|
raise RuntimeError("LTXV returned an empty response body.")
|
|
|
|
return (video_from_bytes(mp4_bytes),)
|