[Partner Nodes] feat(LTX): add new nodes for model version 2.5 (#15501)

* [Partner Nodes] feat(LTX): add new nodes for model version 2.5

Signed-off-by: Alexander Piskun <bigcat88@icloud.com>
This commit is contained in:
Alexander Piskun 2026-08-11 20:50:42 +03:00 committed by GitHub
parent 57ce8e1a27
commit ce4fc13094
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 380 additions and 0 deletions

View File

@ -6,8 +6,12 @@ from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, Input, InputImpl
from comfy_api_nodes.util import (
ApiEndpoint,
download_url_to_video_output,
get_number_of_images,
poll_op,
sync_op,
sync_op_raw,
upload_audio_to_comfyapi,
upload_images_to_comfyapi,
validate_string,
)
@ -17,6 +21,11 @@ MODELS_MAP = {
"LTX-2 (Fast)": "ltx-2-fast",
}
V25_MODELS_MAP = {
"LTX-2.5 (Fast)": "ltx-2-5-fast",
"LTX-2.5 (Pro)": "ltx-2-5-pro",
}
class ExecuteTaskRequest(BaseModel):
prompt: str = Field(...)
@ -26,6 +35,48 @@ class ExecuteTaskRequest(BaseModel):
fps: int | None = Field(25)
generate_audio: bool | None = Field(True)
image_uri: str | None = Field(None)
last_frame_uri: str | None = Field(None)
class AudioToVideoRequest(BaseModel):
prompt: str = Field(...)
model: str = Field(...)
resolution: str = Field(...)
audio_uri: str = Field(...)
image_uri: str | None = Field(None)
class Ltx25SubmitResponse(BaseModel):
id: str = Field(...)
class Ltx25JobResult(BaseModel):
video_url: str | None = Field(None)
class Ltx25JobStatusResponse(BaseModel):
id: str = Field(...)
status: str = Field(...)
result: Ltx25JobResult | None = Field(None)
async def _v25_submit_and_poll(cls: type[IO.ComfyNode], route: str, data: BaseModel) -> IO.NodeOutput:
submit = await sync_op(
cls,
ApiEndpoint(f"/proxy/ltx/v2/{route}", "POST"),
response_model=Ltx25SubmitResponse,
data=data,
max_retries=1,
)
job = await poll_op(
cls,
ApiEndpoint(f"/proxy/ltx/v2/{route}/{submit.id}"),
response_model=Ltx25JobStatusResponse,
status_extractor=lambda r: r.status,
)
if not job.result or not job.result.video_url:
raise RuntimeError(f"LTX job {job.id} completed without a video URL.")
return IO.NodeOutput(await download_url_to_video_output(job.result.video_url, cls=cls))
PRICE_BADGE = IO.PriceBadge(
@ -43,6 +94,128 @@ PRICE_BADGE = IO.PriceBadge(
""",
)
V25_PRICE_BADGE = IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model", "model.duration", "model.resolution"]),
expr="""
(
$prices := {
"ltx-2.5 (fast)": {
"1280x720":0.1287,"720x1280":0.1287,
"1920x1080":0.1859,"1080x1920":0.1859,
"2560x1440":0.2717,"1440x2560":0.2717,
"3840x2160":0.429,"2160x3840":0.429
},
"ltx-2.5 (pro)": {
"1280x720":0.1716,"720x1280":0.1716,
"1920x1080":0.2431,"1080x1920":0.2431
}
};
$model := $lookup(widgets, "model");
$table := $type($model) = "string" ? $lookup($prices, $model) : undefined;
$res := $lookup(widgets, "model.resolution");
$pps := $type($table) = "object" and $type($res) = "string" ? $lookup($table, $res) : undefined;
$durRaw := $lookup(widgets, "model.duration");
$dur := $type($durRaw) in ["string", "number"] ? $number($durRaw) : undefined;
$type($pps) = "number" and $type($dur) = "number"
? {"type":"usd","usd": $pps * $dur}
: undefined
)
""",
)
V25_A2V_PRICE_BADGE = IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model"]),
expr="""
(
$rates := {"ltx-2.5 (fast)":0.1859, "ltx-2.5 (pro)":0.2431};
$model := $lookup(widgets, "model");
$rate := $type($model) = "string" ? $lookup($rates, $model) : undefined;
$type($rate) = "number"
? {"type":"usd","usd": $rate, "format":{"suffix":"/second"}}
: undefined
)
""",
)
def _v25_generation_inputs(
durations: list[str], resolutions: list[str], fps_options: list[str], tooltip: str | None
) -> list:
return [
IO.Combo.Input(
"duration",
options=durations,
default="8",
tooltip=tooltip,
),
IO.Combo.Input(
"resolution",
options=resolutions,
default="1920x1080",
),
IO.Combo.Input("fps", options=fps_options, default="25"),
IO.Boolean.Input(
"generate_audio",
default=True,
tooltip="When true, the generated video will include AI-generated audio matching the scene.",
advanced=True,
),
]
def _v25_model_combo() -> IO.DynamicCombo.Input:
return IO.DynamicCombo.Input(
"model",
options=[
IO.DynamicCombo.Option(
"LTX-2.5 (Fast)",
_v25_generation_inputs(
["2", "3", "4", "5", "6", "8", "10", "12", "14", "16", "18", "20"],
[
"1280x720",
"720x1280",
"1920x1080",
"1080x1920",
"2560x1440",
"1440x2560",
"3840x2160",
"2160x3840",
],
["24", "25", "48", "50"],
"Video duration in seconds. Durations over 10s require a 720p/1080p resolution and 24/25 FPS.",
),
),
IO.DynamicCombo.Option(
"LTX-2.5 (Pro)",
_v25_generation_inputs(
["2", "3", "4", "5", "6", "8", "10"],
["1280x720", "720x1280", "1920x1080", "1080x1920"],
["24", "25", "50"],
"Video duration in seconds.",
),
),
],
)
def _v25_seed_input() -> IO.Int.Input:
return IO.Int.Input(
"seed",
default=42,
min=0,
max=0xFFFFFFFF,
control_after_generate=True,
tooltip="Seed to determine if node should re-run; "
"actual results are nondeterministic regardless of seed.",
)
def _v25_validate_settings(model: dict) -> None:
if int(model["duration"]) > 10 and (
int(model["fps"]) > 25 or model["resolution"] in ("2560x1440", "1440x2560", "3840x2160", "2160x3840")
):
raise ValueError("Durations over 10s require a 720p or 1080p resolution and 24/25 FPS.")
class TextToVideoNode(IO.ComfyNode):
@classmethod
@ -86,6 +259,7 @@ class TextToVideoNode(IO.ComfyNode):
IO.Hidden.unique_id,
],
is_api_node=True,
is_deprecated=True,
price_badge=PRICE_BADGE,
)
@ -164,6 +338,7 @@ class ImageToVideoNode(IO.ComfyNode):
IO.Hidden.unique_id,
],
is_api_node=True,
is_deprecated=True,
price_badge=PRICE_BADGE,
)
@ -203,12 +378,217 @@ class ImageToVideoNode(IO.ComfyNode):
return IO.NodeOutput(InputImpl.VideoFromFile(BytesIO(response)))
class Ltx25TextToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="LtxApi25TextToVideo",
display_name="LTX 2.5 Text To Video",
category="partner/video/LTXV",
description="Professional-quality videos with customizable duration and resolution.",
inputs=[
_v25_model_combo(),
IO.String.Input(
"prompt",
multiline=True,
default="",
),
_v25_seed_input(),
],
outputs=[
IO.Video.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=V25_PRICE_BADGE,
)
@classmethod
async def execute(
cls,
model: dict,
prompt: str,
seed: int = 42,
) -> IO.NodeOutput:
validate_string(prompt, min_length=1, max_length=10000)
_v25_validate_settings(model)
return await _v25_submit_and_poll(
cls,
"text-to-video",
ExecuteTaskRequest(
prompt=prompt,
model=V25_MODELS_MAP[model["model"]],
duration=int(model["duration"]),
resolution=model["resolution"],
fps=int(model["fps"]),
generate_audio=model["generate_audio"],
),
)
class Ltx25ImageToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="LtxApi25ImageToVideo",
display_name="LTX 2.5 Image To Video",
category="partner/video/LTXV",
description="Professional-quality videos with customizable duration and resolution based on start image.",
inputs=[
IO.Image.Input("image", tooltip="First frame to be used for the video."),
_v25_model_combo(),
IO.String.Input(
"prompt",
multiline=True,
default="",
),
_v25_seed_input(),
IO.Image.Input(
"last_frame",
optional=True,
tooltip="Last frame to be used for the video.",
),
],
outputs=[
IO.Video.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=V25_PRICE_BADGE,
)
@classmethod
async def execute(
cls,
image: Input.Image,
model: dict,
prompt: str,
seed: int = 42,
last_frame: Input.Image | None = None,
) -> IO.NodeOutput:
validate_string(prompt, min_length=1, max_length=10000)
_v25_validate_settings(model)
if get_number_of_images(image) != 1:
raise ValueError("Currently only one input image is supported.")
last_frame_uri = None
if last_frame is not None:
if get_number_of_images(last_frame) != 1:
raise ValueError("Currently only one last frame image is supported.")
last_frame_uri = (await upload_images_to_comfyapi(cls, last_frame, max_images=1, mime_type="image/png"))[0]
return await _v25_submit_and_poll(
cls,
"image-to-video",
ExecuteTaskRequest(
image_uri=(await upload_images_to_comfyapi(cls, image, max_images=1, mime_type="image/png"))[0],
last_frame_uri=last_frame_uri,
prompt=prompt,
model=V25_MODELS_MAP[model["model"]],
duration=int(model["duration"]),
resolution=model["resolution"],
fps=int(model["fps"]),
generate_audio=model["generate_audio"],
),
)
class Ltx25AudioToVideoNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="LtxApi25AudioToVideo",
display_name="LTX 2.5 Audio To Video",
category="partner/video/LTXV",
description="Generate a video driven by an audio track, with an optional first frame image.",
inputs=[
IO.Audio.Input(
"audio",
tooltip="Audio track driving the video. Its length (2-20 seconds) sets the video duration.",
),
IO.DynamicCombo.Input(
"model",
options=[
IO.DynamicCombo.Option(
"LTX-2.5 (Fast)",
[IO.Combo.Input("resolution", options=["1920x1080", "1080x1920"])],
),
IO.DynamicCombo.Option(
"LTX-2.5 (Pro)",
[IO.Combo.Input("resolution", options=["1920x1080", "1080x1920"])],
),
],
),
IO.String.Input(
"prompt",
multiline=True,
default="",
),
_v25_seed_input(),
IO.Image.Input(
"image",
optional=True,
tooltip="Optional first frame to be used for the video.",
),
],
outputs=[
IO.Video.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=V25_A2V_PRICE_BADGE,
)
@classmethod
async def execute(
cls,
audio: Input.Audio,
model: dict,
prompt: str,
seed: int = 42,
image: Input.Image | None = None,
) -> IO.NodeOutput:
validate_string(prompt, min_length=1, max_length=10000)
audio_duration = audio["waveform"].shape[-1] / audio["sample_rate"]
if not 2 <= audio_duration <= 20:
raise ValueError(f"Audio duration must be between 2 and 20 seconds, got {audio_duration:.1f}s.")
image_uri = None
if image is not None:
if get_number_of_images(image) != 1:
raise ValueError("Currently only one input image is supported.")
image_uri = (await upload_images_to_comfyapi(cls, image, max_images=1, mime_type="image/png"))[0]
return await _v25_submit_and_poll(
cls,
"audio-to-video",
AudioToVideoRequest(
prompt=prompt,
model=V25_MODELS_MAP[model["model"]],
resolution=model["resolution"],
audio_uri=await upload_audio_to_comfyapi(cls, audio),
image_uri=image_uri,
),
)
class LtxvApiExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
TextToVideoNode,
ImageToVideoNode,
Ltx25TextToVideoNode,
Ltx25ImageToVideoNode,
Ltx25AudioToVideoNode,
]