380 lines
13 KiB
Python
380 lines
13 KiB
Python
|
|
#!/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 schedule(4步)
|
|||
|
|
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()
|