380 lines
13 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
LTX 2.3 I2V - ComfyUI API 调用脚本
直接用 ComfyUI API 提交 LTX 2.3 蒸馏版图生视频工作流
"""
import json, sys, os, time, uuid, requests
COMFY_HOST = "http://localhost:8188"
def submit_workflow(input_image="test_scene.png", prompt="A cinematic shot:", output_prefix="ltx23_output", seed=42, width=768, height=512, num_frames=25):
"""
构建并提交 LTX 2.3 蒸馏版 I2V 工作流
节点结构基于 ComfyUI 内置模板展开
"""
# 节点 ID 分配
N = {k: i for i, k in enumerate([
"ckpt_loader", "text_encoder_loader",
"load_image", "preprocess",
"clip_positive", "clip_negative", "cond_zero",
"ltxv_cond", "crop_guides",
"empty_video_latent", "empty_audio_latent",
"concat_av", "img_to_video",
"cfgguider", "ksampler", "sigmas", "noise",
"sampler_custom",
"separate_av",
"vae_decode", "audio_vae_loader", "audio_vae_decode",
"create_video", "save_video",
])}
prompt_json = {
# 1. 加载模型
str(N["ckpt_loader"]): {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "ltx-2.3-22b-dev.safetensors"}
},
# 2. 文本编码器Gemma
str(N["text_encoder_loader"]): {
"class_type": "LTXAVTextEncoderLoader",
"inputs": {
"text_encoder": "gemma_3_12B_it.safetensors",
"ckpt_name": "ltx-2.3-22b-dev.safetensors",
"device": "default"
}
},
# 3. 加载输入图
str(N["load_image"]): {
"class_type": "LoadImage",
"inputs": {"image": input_image}
},
# 4. 预处理(缩放到目标尺寸)
str(N["preprocess"]): {
"class_type": "LTXVPreprocess",
"inputs": {
"image": [str(N["load_image"]), 0],
"size": 18
}
},
# 5. 正向提示词
str(N["clip_positive"]): {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": [str(N["text_encoder_loader"]), 0],
"text": prompt
}
},
# 6. 负向提示词
str(N["clip_negative"]): {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": [str(N["text_encoder_loader"]), 0],
"text": "bad quality, ugly, blurry, distorted, deformed"
}
},
# 7. 归零负向条件
str(N["cond_zero"]): {
"class_type": "ConditioningZeroOut",
"inputs": {
"conditioning": [str(N["clip_negative"]), 0]
}
},
# 8. LTXV 条件处理
str(N["ltxv_cond"]): {
"class_type": "LTXVConditioning",
"inputs": {
"positive": [str(N["clip_positive"]), 0],
"negative": [str(N["cond_zero"]), 0],
"frame_rate": 25.0
}
},
# 9. 空视频 latent
str(N["empty_video_latent"]): {
"class_type": "EmptyLTXVLatentVideo",
"inputs": {
"width": width,
"height": height,
"length": num_frames,
"batch_size": 1
}
},
# 10. 空音频 latent
str(N["empty_audio_latent"]): {
"class_type": "LTXVEmptyLatentAudio",
"inputs": {
"length": num_frames,
"num_frames_per_batch": 25,
"batch_size": 1
}
},
# 11. 拼接音视频 latent
str(N["concat_av"]): {
"class_type": "LTXVConcatAVLatent",
"inputs": {
"video_latent": [str(N["empty_video_latent"]), 0],
"audio_latent": [str(N["empty_audio_latent"]), 0]
}
},
# 12. 图生视频条件in-place
str(N["img_to_video"]): {
"class_type": "LTXVImgToVideoInplace",
"inputs": {
"vae": [str(N["ckpt_loader"]), 2],
"image": [str(N["preprocess"]), 0],
"latent": [str(N["concat_av"]), 0],
"strength": 1.0,
"bypass": False
}
},
# 13. 裁剪 guide对齐 latent 尺寸)
str(N["crop_guides"]): {
"class_type": "LTXVCropGuides",
"inputs": {
"positive": [str(N["ltxv_cond"]), 0],
"negative": [str(N["ltxv_cond"]), 1],
"latent": [str(N["img_to_video"]), 0]
}
},
# 14. CFG Guider蒸馏版 CFG=1
str(N["cfgguider"]): {
"class_type": "CFGGuider",
"inputs": {
"model": [str(N["ckpt_loader"]), 0],
"positive": [str(N["crop_guides"]), 0],
"negative": [str(N["crop_guides"]), 1],
"cfg": 1.0
}
},
# 15. KSampler 选择
str(N["ksampler"]): {
"class_type": "KSamplerSelect",
"inputs": {"sampler_name": "euler"}
},
# 16. 蒸馏版 sigma schedule4步
str(N["sigmas"]): {
"class_type": "ManualSigmas",
"inputs": {"sigmas": "0.909375, 0.725, 0.421875, 0.0"}
},
# 17. 随机噪声
str(N["noise"]): {
"class_type": "RandomNoise",
"inputs": {"noise_seed": seed}
},
# 18. 自定义采样器
str(N["sampler_custom"]): {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": [str(N["noise"]), 0],
"guider": [str(N["cfgguider"]), 0],
"sampler": [str(N["ksampler"]), 0],
"sigmas": [str(N["sigmas"]), 0],
"latent_image": [str(N["crop_guides"]), 2]
}
},
# 19. 分离音视频 latent
str(N["separate_av"]): {
"class_type": "LTXVSeparateAVLatent",
"inputs": {
"av_latent": [str(N["sampler_custom"]), 0]
}
},
# 20. VAE 解码视频
str(N["vae_decode"]): {
"class_type": "VAEDecode",
"inputs": {
"vae": [str(N["ckpt_loader"]), 2],
"samples": [str(N["separate_av"]), 0]
}
},
# 21. 加载音频 VAE
str(N["audio_vae_loader"]): {
"class_type": "LTXVAudioVAELoader",
"inputs": {"ckpt_name": "ltx-2.3-22b-dev.safetensors"}
},
# 22. 音频 VAE 解码
str(N["audio_vae_decode"]): {
"class_type": "LTXVAudioVAEDecode",
"inputs": {
"samples": [str(N["separate_av"]), 1],
"audio_vae": [str(N["audio_vae_loader"]), 0]
}
},
# 23. 合成视频
str(N["create_video"]): {
"class_type": "CreateVideo",
"inputs": {
"images": [str(N["vae_decode"]), 0],
"audio": [str(N["audio_vae_decode"]), 0],
"fps": 25,
"frame_rate": 25,
"bit_depth": 8
}
},
# 24. 保存视频
str(N["save_video"]): {
"class_type": "SaveVideo",
"inputs": {
"video": [str(N["create_video"]), 0],
"filename_prefix": output_prefix,
"format": "mp4",
"codec": "h264"
}
},
}
return prompt_json
def queue_prompt(prompt_workflow):
"""提交工作流到 ComfyUI 并返回 prompt_id"""
payload = {"prompt": prompt_workflow, "client_id": str(uuid.uuid4())}
r = requests.post(f"{COMFY_HOST}/prompt", json=payload)
r.raise_for_status()
data = r.json()
return data.get("prompt_id"), data
def get_history(prompt_id):
"""查询 prompt 执行历史"""
r = requests.get(f"{COMFY_HOST}/history/{prompt_id}")
if r.status_code == 200:
return r.json().get(prompt_id)
return None
def wait_for_completion(prompt_id, timeout=600, check_interval=10):
"""等待 prompt 执行完成"""
start = time.time()
while time.time() - start < timeout:
history = get_history(prompt_id)
if history and history.get("status", {}).get("completed") is True:
outputs = history.get("outputs", {})
elapsed = time.time() - start
return {"status": "completed", "outputs": outputs, "elapsed": elapsed}
if history and history.get("status", {}).get("status_str") == "error":
return {"status": "error", "error": history.get("status", {}).get("error_message", "未知错误")}
# 查询队列
r = requests.get(f"{COMFY_HOST}/queue")
if r.status_code == 200:
queue_data = r.json()
r2 = requests.get(f"{COMFY_HOST}/execution/{prompt_id}")
if r2.status_code == 200:
exec_data = r2.json()
progress = exec_data.get("data", {})
print(f" 进度: {progress.get('progress', 0)*100:.0f}% ({elapsed:.0f}s)" if progress.get('progress') else f" 运行中... ({time.time()-start:.0f}s)")
time.sleep(check_interval)
return {"status": "timeout", "elapsed": time.time() - start}
def check_available_models():
"""检查 ComfyUI 可用模型"""
r = requests.get(f"{COMFY_HOST}/object_info/CheckpointLoaderSimple")
if r.status_code == 200:
info = r.json()
models = info.get("CheckpointLoaderSimple", {}).get("input", {}).get("required", {}).get("ckpt_name", [])
return models
return []
def get_queue_status():
"""获取队列状态"""
try:
r = requests.get(f"{COMFY_HOST}/queue")
if r.status_code == 200:
return r.json()
except:
pass
return {}
def clear_queue():
"""清空队列"""
try:
r = requests.post(f"{COMFY_HOST}/queue", json={"clear": True})
if r.status_code == 200:
print("✅ 队列已清空")
except:
pass
def main():
print("=" * 60)
print("🎬 LTX 2.3 I2V - ComfyUI API 测试脚本")
print("=" * 60)
# 参数
input_image = sys.argv[1] if len(sys.argv) > 1 else "test_scene.png"
prompt_text = sys.argv[2] if len(sys.argv) > 2 else "A cinematic shot of a character in a fantasy scene, epic, dramatic lighting, motion blur"
output_prefix = sys.argv[3] if len(sys.argv) > 3 else "ltx23_output"
print(f"\n📷 输入图: {input_image}")
print(f"📝 Prompt: {prompt_text[:50]}...")
print(f"💾 输出: {output_prefix}")
# 检查队列
q = get_queue_status()
running = q.get("queue_running", [])
pending = q.get("queue_pending", [])
if running:
print(f"\n⚠️ 队列中已有 {len(running)} 个任务在跑")
running_ids = [str(item[1]) if len(item) > 1 else "?" for item in running]
print(f" 运行中 prompt_id: {running_ids}")
if pending:
print(f"⚠️ 队列中 {len(pending)} 个任务待执行")
# 检查模型
models = check_available_models()
ltx_models = [m for m in models if isinstance(m, str) and "ltx" in m.lower()] + [m for m in models if isinstance(m, dict) and "ltx" in str(m).lower()]
print(f"\n📦 可用 LTX 模型: {ltx_models}")
# 构建工作流
print(f"\n🔧 构建工作流...")
workflow = submit_workflow(
input_image=input_image,
prompt=prompt_text,
output_prefix=output_prefix,
seed=42,
width=768,
height=512,
num_frames=25
)
print(f"✅ 工作流构建完成 (25个节点)")
# 提交
print(f"\n🚀 提交任务到 ComfyUI...")
try:
prompt_id, resp = queue_prompt(workflow)
print(f"✅ 任务已提交! prompt_id: {prompt_id}")
print(f" 队列中还有 {len(pending)} 个任务待执行")
except Exception as e:
print(f"❌ 提交失败: {e}")
# 打印详细错误
if hasattr(e, 'response') and e.response is not None:
print(f" 响应: {e.response.text[:500]}")
sys.exit(1)
# 等待完成
print(f"\n⏳ 等待任务完成...")
result = wait_for_completion(prompt_id, timeout=600)
if result["status"] == "completed":
print(f"\n✅ 任务完成! 耗时: {result['elapsed']:.1f}")
outputs = result["outputs"]
for node_id, node_out in outputs.items():
if "videos" in node_out:
for vid in node_out["videos"]:
print(f" 🎬 视频: {vid['filename']} ({vid.get('type','')})")
if "images" in node_out:
for img in node_out["images"]:
print(f" 🖼️ 图片: {img['filename']}")
# 查找输出文件
out_dir = "/home/ls/comfy/ComfyUI/output"
import glob
files = sorted(glob.glob(f"{out_dir}/{output_prefix}*"))
print(f"\n📁 输出文件:")
for f in files:
size = os.path.getsize(f) / 1024 / 1024
print(f" {f} ({size:.1f}MB)")
elif result["status"] == "error":
print(f"\n❌ 任务失败: {result.get('error', '未知错误')}")
else:
print(f"\n⏰ 超时 ({result['elapsed']:.0f}秒)")
if __name__ == "__main__":
main()