Files

434 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Example client for comfy-workflow-api (LTX-2 Text-to-Video).
Submits a prompt to ComfyUI and receives generated video via webhook.
Usage:
python example_ltx2_client.py <prompt>
Examples:
python example_ltx2_client.py "a cat walking on the beach at sunset"
python example_ltx2_client.py "timelapse of clouds moving over mountains"
Requirements:
pip install requests
Make sure ComfyUI is running with LTX-2 models loaded.
"""
import gzip
import json
import sys
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
import requests
# Configuration (override via env vars)
import os
COMFYUI_URL = os.environ.get("COMFYUI_URL", "http://localhost:8188")
CALLBACK_URL = os.environ.get("CALLBACK_URL", "http://localhost:5001/webhook")
CALLBACK_PORT = 5001
OUTPUT_DIR = Path("./outputs")
# LTX-2 Text-to-Video workflow with spatial upscaling
WORKFLOW = {
"98": {
"inputs": {
"field": "prompt",
"debug": True,
"webhook_context": ["99", 0],
},
"class_type": "WebhookTransformer",
"_meta": {"title": "Workflow API Transformer"},
},
"99": {
"inputs": {
"default_callback_url": "",
"default_timeout": 60,
"default_max_retries": 3,
"debug": True,
},
"class_type": "WebhookReceiver",
"_meta": {"title": "Workflow API Receiver"},
},
"100": {
"inputs": {
"field_1_name": "video",
"field_2_name": "output_2",
"field_3_name": "output_3",
"field_4_name": "output_4",
"field_5_name": "output_5",
"debug": False,
"batch_outputs": False,
"webhook_context": ["99", 0],
"field_1": ["92:97", 0],
},
"class_type": "WebhookSend",
"_meta": {"title": "Workflow API Send"},
},
"92:9": {
"inputs": {
"steps": 20,
"max_shift": 2.05,
"base_shift": 0.95,
"stretch": True,
"terminal": 0.1,
"latent": ["92:56", 0],
},
"class_type": "LTXVScheduler",
"_meta": {"title": "LTXVScheduler"},
},
"92:60": {
"inputs": {
"text_encoder": "gemma_3_12B_it_fp4_mixed.safetensors",
"ckpt_name": "ltx-2-19b-dev-fp8.safetensors",
"device": "default",
},
"class_type": "LTXAVTextEncoderLoader",
"_meta": {"title": "LTXV Audio Text Encoder Loader"},
},
"92:73": {
"inputs": {"sigmas": "0.909375, 0.725, 0.421875, 0.0"},
"class_type": "ManualSigmas",
"_meta": {"title": "ManualSigmas"},
},
"92:76": {
"inputs": {"model_name": "ltx-2-spatial-upscaler-x2-1.0.safetensors"},
"class_type": "LatentUpscaleModelLoader",
"_meta": {"title": "Load Latent Upscale Model"},
},
"92:81": {
"inputs": {
"positive": ["92:22", 0],
"negative": ["92:22", 1],
"latent": ["92:80", 0],
},
"class_type": "LTXVCropGuides",
"_meta": {"title": "LTXVCropGuides"},
},
"92:82": {
"inputs": {
"cfg": 1,
"model": ["92:68", 0],
"positive": ["92:81", 0],
"negative": ["92:81", 1],
},
"class_type": "CFGGuider",
"_meta": {"title": "CFGGuider"},
},
"92:90": {
"inputs": {
"upscale_method": "lanczos",
"scale_by": 0.5,
"image": ["92:89", 0],
},
"class_type": "ImageScaleBy",
"_meta": {"title": "Upscale Image By"},
},
"92:91": {
"inputs": {"image": ["92:90", 0]},
"class_type": "GetImageSize",
"_meta": {"title": "Get Image Size"},
},
"92:51": {
"inputs": {
"frames_number": ["92:62", 0],
"frame_rate": ["92:99", 0],
"batch_size": 1,
"audio_vae": ["92:48", 0],
},
"class_type": "LTXVEmptyLatentAudio",
"_meta": {"title": "LTXV Empty Latent Audio"},
},
"92:22": {
"inputs": {
"frame_rate": ["92:102", 0],
"positive": ["92:3", 0],
"negative": ["92:4", 0],
},
"class_type": "LTXVConditioning",
"_meta": {"title": "LTXVConditioning"},
},
"92:43": {
"inputs": {
"width": ["92:91", 0],
"height": ["92:91", 1],
"length": ["92:62", 0],
"batch_size": 1,
},
"class_type": "EmptyLTXVLatentVideo",
"_meta": {"title": "EmptyLTXVLatentVideo"},
},
"92:56": {
"inputs": {
"video_latent": ["92:43", 0],
"audio_latent": ["92:51", 0],
},
"class_type": "LTXVConcatAVLatent",
"_meta": {"title": "LTXVConcatAVLatent"},
},
"92:4": {
"inputs": {
"text": "blurry, low quality, still frame, frames, watermark, overlay, titles, has blurbox, has subtitles",
"clip": ["92:60", 0],
},
"class_type": "CLIPTextEncode",
"_meta": {"title": "CLIP Text Encode (Prompt)"},
},
"92:89": {
"inputs": {"width": 1280, "height": 720, "batch_size": 1, "color": 0},
"class_type": "EmptyImage",
"_meta": {"title": "EmptyImage"},
},
"92:62": {
"inputs": {"value": 121},
"class_type": "PrimitiveInt",
"_meta": {"title": "Length"},
},
"92:41": {
"inputs": {
"noise": ["92:11", 0],
"guider": ["92:47", 0],
"sampler": ["92:8", 0],
"sigmas": ["92:9", 0],
"latent_image": ["92:56", 0],
},
"class_type": "SamplerCustomAdvanced",
"_meta": {"title": "SamplerCustomAdvanced"},
},
"92:67": {
"inputs": {"noise_seed": 0},
"class_type": "RandomNoise",
"_meta": {"title": "RandomNoise"},
},
"92:11": {
"inputs": {"noise_seed": 10},
"class_type": "RandomNoise",
"_meta": {"title": "RandomNoise"},
},
"92:80": {
"inputs": {"av_latent": ["92:41", 0]},
"class_type": "LTXVSeparateAVLatent",
"_meta": {"title": "LTXVSeparateAVLatent"},
},
"92:83": {
"inputs": {
"video_latent": ["92:84", 0],
"audio_latent": ["92:80", 1],
},
"class_type": "LTXVConcatAVLatent",
"_meta": {"title": "LTXVConcatAVLatent"},
},
"92:84": {
"inputs": {
"samples": ["92:81", 2],
"upscale_model": ["92:76", 0],
"vae": ["92:1", 2],
},
"class_type": "LTXVLatentUpsampler",
"_meta": {"title": "spatial"},
},
"92:70": {
"inputs": {
"noise": ["92:67", 0],
"guider": ["92:82", 0],
"sampler": ["92:66", 0],
"sigmas": ["92:73", 0],
"latent_image": ["92:83", 0],
},
"class_type": "SamplerCustomAdvanced",
"_meta": {"title": "SamplerCustomAdvanced"},
},
"92:3": {
"inputs": {"text": ["98", 0], "clip": ["92:60", 0]},
"class_type": "CLIPTextEncode",
"_meta": {"title": "CLIP Text Encode (Prompt)"},
},
"92:97": {
"inputs": {
"fps": ["92:102", 0],
"images": ["92:98", 0],
"audio": ["92:96", 0],
},
"class_type": "CreateVideo",
"_meta": {"title": "Create Video"},
},
"92:48": {
"inputs": {"ckpt_name": "ltx-2-19b-dev-fp8.safetensors"},
"class_type": "LTXVAudioVAELoader",
"_meta": {"title": "LTXV Audio VAE Loader"},
},
"92:94": {
"inputs": {"av_latent": ["92:70", 1]},
"class_type": "LTXVSeparateAVLatent",
"_meta": {"title": "LTXVSeparateAVLatent"},
},
"92:98": {
"inputs": {
"tile_size": 512,
"overlap": 64,
"temporal_size": 4096,
"temporal_overlap": 8,
"samples": ["92:94", 0],
"vae": ["92:1", 2],
},
"class_type": "VAEDecodeTiled",
"_meta": {"title": "VAE Decode (Tiled)"},
},
"92:96": {
"inputs": {"samples": ["92:94", 1], "audio_vae": ["92:48", 0]},
"class_type": "LTXVAudioVAEDecode",
"_meta": {"title": "LTXV Audio VAE Decode"},
},
"92:47": {
"inputs": {
"cfg": 4,
"model": ["92:1", 0],
"positive": ["92:22", 0],
"negative": ["92:22", 1],
},
"class_type": "CFGGuider",
"_meta": {"title": "CFGGuider"},
},
"92:102": {
"inputs": {"value": 24},
"class_type": "PrimitiveFloat",
"_meta": {"title": "Frame Rate(float)"},
},
"92:99": {
"inputs": {"value": 24},
"class_type": "PrimitiveInt",
"_meta": {"title": "Frame Rate(int)"},
},
"92:68": {
"inputs": {
"lora_name": "ltx-2-19b-distilled-lora-384.safetensors",
"strength_model": 1,
"model": ["92:1", 0],
},
"class_type": "LoraLoaderModelOnly",
"_meta": {"title": "LoraLoaderModelOnly"},
},
"92:8": {
"inputs": {"sampler_name": "euler_ancestral"},
"class_type": "KSamplerSelect",
"_meta": {"title": "KSamplerSelect"},
},
"92:66": {
"inputs": {"sampler_name": "euler_ancestral"},
"class_type": "KSamplerSelect",
"_meta": {"title": "KSamplerSelect"},
},
"92:1": {
"inputs": {"ckpt_name": "ltx-2-19b-dev-fp8.safetensors"},
"class_type": "CheckpointLoaderSimple",
"_meta": {"title": "Load Checkpoint"},
},
}
class WebhookHandler(BaseHTTPRequestHandler):
"""Handles webhook callbacks from ComfyUI."""
files_received = []
done = threading.Event()
def log_message(self, *args):
pass
def do_POST(self):
content_type = self.headers.get("Content-Type", "")
content_encoding = self.headers.get("Content-Encoding", "")
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
if "gzip" in content_encoding:
body = gzip.decompress(body)
if "application/json" in content_type:
data = json.loads(body)
event = data.get("event")
if event == "workflow.progress":
progress = data.get("progress", {})
print(f" Progress: {progress.get('value')}/{progress.get('max')}")
elif event == "workflow.completed":
print(f" Completed in {data.get('execution_time_ms', 'N/A')}ms")
elif event == "workflow.error":
print(f" Error: {data.get('error')}")
WebhookHandler.done.set()
elif "multipart" in content_type:
boundary = content_type.split("boundary=")[1]
parts = body.split(f"--{boundary}".encode())
OUTPUT_DIR.mkdir(exist_ok=True)
for part in parts:
if b"filename=" in part and b"\r\n\r\n" in part:
header, content = part.split(b"\r\n\r\n", 1)
filename = header.decode().split('filename="')[1].split('"')[0]
content = content.rstrip(b"\r\n--")
path = OUTPUT_DIR / filename
path.write_bytes(content)
WebhookHandler.files_received.append(str(path))
print(f" Saved: {path}")
WebhookHandler.done.set()
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
def main():
if len(sys.argv) < 2:
print("Usage: python example_ltx2_client.py <prompt>")
print('Example: python example_ltx2_client.py "a cat walking on the beach"')
sys.exit(1)
prompt = " ".join(sys.argv[1:])
print(f"Prompt: {prompt}")
print(f"ComfyUI: {COMFYUI_URL}")
print(f"Callback: {CALLBACK_URL}")
print(f"Output: {OUTPUT_DIR.absolute()}")
print(f"Video: 1280x720, 121 frames @ 24fps (~5s)\n")
server = HTTPServer(("0.0.0.0", CALLBACK_PORT), WebhookHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
print("Submitting workflow...")
response = requests.post(
f"{COMFYUI_URL}/api/webhook",
files={
"workflow": (None, json.dumps(WORKFLOW), "application/json"),
"callback_url": (None, CALLBACK_URL),
"prompt": (None, prompt),
},
)
result = response.json()
print(f" Request ID: {result.get('request_id')}")
print(f" Prompt ID: {result.get('prompt_id')}\n")
print("Processing (this may take a few minutes)...")
WebhookHandler.done.wait(timeout=600)
server.shutdown()
if WebhookHandler.files_received:
print(f"\nDone! Video saved to {OUTPUT_DIR.absolute()}")
else:
print("\nNo files received.")
if __name__ == "__main__":
main()