- 面板: 会话持久化(oc_sess.json)/余额实时/文件栏(搜索+MD+拖拽)/朗读(edge-tts)/工具过程实时显示/漫剧画布 - 面板: 经验自动检索注入+存经验按钮(experience.py) - 管线: local_motion本地运镜/IMAGE-FIRST-GUIDE/LOCAL-PIPELINE-V1 - 经验: EED-EXPER-014 面板工程+管线+飞书扒取全记录 - 资料: 飞书《清欢AIGC伪真人短剧全流程》归档
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()
|