获取API导出 → 改造工作流(标记+删RecordAudio) → 补 media.py image_path 转发 → 校验探测 → 真实联调。硬依赖用户导出 API 格式 JSON。 Co-Authored-By: Claude <noreply@anthropic.com>
15 KiB
LTX2.3 数字人工作流(Comfy Cloud)实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 让 Pixelle 数字人(说话头像)走 Comfy Cloud:把官方 LTX2.3 id_lora 模板做成 comfy/digital_ltx2.json 工作流,补上 Pixelle media.py 的 image_path 转发缺口,经已建好的 comfy-cloud 适配器执行。
Architecture: 工作流文件落 Pixelle 宿主 data/workflows/comfy/(经 ./data 挂载),用 comfykit 的 _meta.title 标记($~image_path!/$~audio_path!/$prompt!)注入肖像/音频/脚本;media.py 非 api 分支补 image_path 进 workflow_params;comfy/ 通道经 comfyui_url(=BoosAPI /comfy-cloud 适配器)打到 Comfy Cloud。
Tech Stack: Python (Pixelle/comfykit), ComfyUI API-format JSON, Comfy Cloud API, Docker。
环境约定: Pixelle 跑在 Docker 容器
pixelle-video-api,代码/app/pixelle_video/,工作流/app/data/workflows/(宿主/opt/Pixelle-Video/data/workflows/,bind mount,改宿主即生效)。容器内 python:/app/.venv/bin/python。BoosAPI 适配端点:容器内经http://172.17.0.1:4000/comfy-cloud,宿主经http://127.0.0.1:4000/comfy-cloud。改 media.py 后需docker compose restart api。
硬依赖(Task 1 阻塞全局): 需用户从 Comfy Cloud 网页导出
video_ltx2_3_id_lora的 API 格式 JSON 到/tmp/ltx2_api.json。服务端无法代取。
文件结构
- 用户提供:
/tmp/ltx2_api.json—— Comfy Cloud 导出的 API 格式工作流(子图已展开)。 - Create
/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json—— 改造后的数字人工作流(加标记、删 RecordAudio)。 - Modify(容器内)
/app/pixelle_video/services/media.py—— 非 api 分支把 image_path 加入 workflow_params;同步宿主持久化方案。 - 临时:
/tmp/digital_ltx2_probe.json—— 校验探测用 payload。
Task 1: 获取并校验 API 格式导出(硬依赖)
Files:
-
用户提供:
/tmp/ltx2_api.json -
Step 1: 确认导出文件存在且是 API 格式
用户在 Comfy Cloud 网页打开 video_ltx2_3_id_lora(或 share 71f221d28df8)→ Workflow → Export (API Format) → 放到 /tmp/ltx2_api.json。
Run: rtk proxy docker exec pixelle-video-api sh -lc "ls -la /tmp/ltx2_api.json 2>/dev/null || echo MISSING"
注意:文件在宿主还是容器?用户可能放宿主 /tmp。两处都查:
Run(宿主): ls -la /tmp/ltx2_api.json 2>/dev/null || echo HOST_MISSING
Expected: 文件存在(任一处)。若两处都 MISSING → BLOCKED,等用户提供。
- Step 2: 校验是 API 格式(不是 UI 格式)
API 格式是 {节点id: {class_type, inputs, ...}},无顶层 nodes 数组。
Run:
python3 -c "
import json
wf=json.load(open('/tmp/ltx2_api.json'))
is_ui = isinstance(wf,dict) and 'nodes' in wf
print('UI格式(需重导)' if is_ui else 'API格式 OK')
if not is_ui:
print('节点数:', len(wf))
has_sub = any('subgraph' in str(v.get('class_type','')).lower() for v in wf.values() if isinstance(v,dict))
print('含未展开子图?', has_sub)
"
Expected: API格式 OK,节点数 >10,无未展开子图。若是 UI 格式或含子图 → 让用户用 API Format 重新导出(普通 Export 是 UI 格式)。
- Step 3: 列出节点类型 + 模型引用,核对 Comfy Cloud 兼容
Run:
python3 -c "
import json
wf=json.load(open('/tmp/ltx2_api.json'))
from collections import Counter
c=Counter(v.get('class_type') for v in wf.values() if isinstance(v,dict))
print('节点类型:')
for t,n in c.most_common(): print(f' {n}x {t}')
print('--- 模型引用 ---')
for nid,nd in wf.items():
if not isinstance(nd,dict): continue
for k,v in (nd.get('inputs') or {}).items():
if isinstance(v,str) and v.endswith('.safetensors'):
print(f' {nid}.{k} = {v}')
"
Expected: 看到 LTX2.3 节点 + 模型文件名。记录下来,Task 2 核对它们在 Comfy Cloud 池里(之前已验证 ltx-2.3-* 系列在;若导出引用了不同文件名,需在此对照 /tmp/cc_nodes.json 的池子)。
- Step 4: 无需提交(纯校验,无文件改动)
本任务只确认输入就绪。无 commit。
Task 2: 改造工作流(加标记 + 删 RecordAudio + 落 comfy/)
Files:
-
Create:
/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json -
Step 1: 找到要标记的节点 id(LoadImage/LoadAudio/文本/RecordAudio)
Run:
python3 -c "
import json
wf=json.load(open('/tmp/ltx2_api.json'))
for nid,nd in wf.items():
if not isinstance(nd,dict): continue
ct=nd.get('class_type','')
if ct in ('LoadImage','LoadAudio','RecordAudio','SaveVideo','PrimitiveStringMultiline','CLIPTextEncode'):
print(nid, ct, '| inputs:', json.dumps(nd.get('inputs',{}),ensure_ascii=False)[:100])
"
Expected: 看到 LoadImage、LoadAudio、RecordAudio、SaveVideo、文本节点各自的 id。记下:
-
LoadImage 的 id(标
$~image_path!) -
LoadAudio 的 id(标
$~audio_path!) -
文本提示节点 id(标
$prompt!)—— 优先PrimitiveStringMultiline;若文本经 CLIPTextEncode 的text字段直接给,则标 CLIPTextEncode -
RecordAudio 的 id(删除)
-
Step 2: 写改造脚本生成 comfy/digital_ltx2.json
把下面
IMAGE_ID/AUDIO_ID/TEXT_ID/RECORD_ID替换为 Step 1 查到的实际 id。 文本节点字段名:若 PrimitiveStringMultiline 用value字段,标记写$prompt.value!; 若 CLIPTextEncode 用text字段,写$prompt.text!。按 Step 1 实际字段定。
python3 -c "
import json
wf=json.load(open('/tmp/ltx2_api.json'))
IMAGE_ID='REPLACE_IMAGE_ID'
AUDIO_ID='REPLACE_AUDIO_ID'
TEXT_ID='REPLACE_TEXT_ID'
TEXT_MARKER='\$prompt.value!' # 或 \$prompt.text! 视字段而定
RECORD_ID='REPLACE_RECORD_ID' # 若无 RecordAudio 留空字符串
def set_title(nid, marker):
nd=wf[nid]
nd.setdefault('_meta',{})['title']=marker
set_title(IMAGE_ID, '\$~image_path!')
set_title(AUDIO_ID, '\$~audio_path!')
set_title(TEXT_ID, TEXT_MARKER)
# 删 RecordAudio 并清理引用它的连线(把指向它的输入置空/删除)
if RECORD_ID and RECORD_ID in wf:
wf.pop(RECORD_ID)
for nid,nd in wf.items():
if not isinstance(nd,dict): continue
for k,v in list((nd.get('inputs') or {}).items()):
if isinstance(v,list) and len(v)==2 and str(v[0])==RECORD_ID:
# 该输入原本接 RecordAudio;数字人用 LoadAudio,删除这个悬空连接
del nd['inputs'][k]
import os
os.makedirs('/opt/Pixelle-Video/data/workflows/comfy', exist_ok=True)
json.dump(wf, open('/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json','w'), ensure_ascii=False, indent=2)
print('written, 节点数:', len(wf))
# 校验标记落位
for nid in [IMAGE_ID,AUDIO_ID,TEXT_ID]:
print(nid, '->', wf.get(nid,{}).get('_meta',{}).get('title'))
"
Expected: 写入成功,三个标记落位正确,RecordAudio 已删。
⚠️ RecordAudio 与 LoadAudio 的关系:数字人用 LoadAudio 输入旁白。若导出的图里音频源是 RecordAudio 而非 LoadAudio(录音节点直接接到音频处理),则不能简单删 RecordAudio——要把音频处理节点的输入改接到 LoadAudio。Step 1 查清音频实际从哪个节点流出;若是 RecordAudio 流出,本步改为:保留一个 LoadAudio(标
$~audio_path!)并把下游音频输入接到 LoadAudio 的输出。具体连接以 Step 1 的图为准。
- Step 3: 校验生成的 JSON 合法 + comfykit 能解析出参数
Run(容器内,用 comfykit 解析):
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
import json
from comfykit.comfyui.workflow_parser import WorkflowParser
wf=json.load(open('/app/data/workflows/comfy/digital_ltx2.json'))
p=WorkflowParser()
params=[]
for nid,nd in wf.items():
if not isinstance(nd,dict): continue
pl,ml,om=p.parse_node(nid,nd)
for x in pl: params.append((x.name,x.required,x.need_upload))
print('解析出的参数:', params)
"
Expected: 看到 image_path(upload=True)、audio_path(upload=True)、prompt 三个参数。
- Step 4: Commit(工作流文件在 Pixelle 宿主目录,非 BoosAPI 仓库——仅记录到计划备注)
工作流文件不在 git 仓库内。无 git commit;在此记录文件已落 /opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json。
Task 3: 补 media.py 转发 image_path 到 comfy 通道
Files:
- Modify(容器内):
/app/pixelle_video/services/media.py
注:
pixelle_video源码在镜像内置,不在挂载卷。容器内改docker compose restart能留, 但docker compose down && up/重建镜像会丢。持久化方案见 Step 4。
- Step 1: 定位 workflow_params 构建处
media.py 的 __call__ 里,api/ 分支之后、workflow_params = {"prompt": prompt} 起的那段
(约 line 232)。image_path 是 __call__ 的具名参数但没进 workflow_params。
Run: rtk proxy docker exec pixelle-video-api sh -lc "grep -n 'workflow_params = {\"prompt\"' /app/pixelle_video/services/media.py"
Expected: 定位到该行号(约 232)。
- Step 2: 在 workflow_params 构建处加入 image_path
在 workflow_params = {"prompt": prompt} 之后插入(用 sed 或 python 改文件)。注意这是容器内文件,
用 docker exec ... python 做精确插入最稳妥:
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
p='/app/pixelle_video/services/media.py'
src=open(p).read()
anchor='workflow_params = {\"prompt\": prompt}'
assert anchor in src, 'anchor not found'
add='''workflow_params = {\"prompt\": prompt}
if image_path is not None:
workflow_params[\"image_path\"] = image_path'''
if 'workflow_params[\"image_path\"]' in src:
print('already patched')
else:
src=src.replace(anchor, add, 1)
open(p,'w').write(src)
print('patched')
"
Expected: patched(或 already patched)。
- Step 3: 校验语法 + 重启容器
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "import ast; ast.parse(open('/app/pixelle_video/services/media.py').read()); print('syntax OK')"
cd /opt/Pixelle-Video && rtk proxy docker compose restart api && sleep 7 && rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
Expected: syntax OK;容器 Up ... (healthy)。
- Step 4: 持久化方案(防镜像重建丢失)
把改动备份到宿主,并记录到 Pixelle 部署说明:
rtk proxy docker cp pixelle-video-api:/app/pixelle_video/services/media.py /opt/Pixelle-Video/patches/media.py.patched 2>/dev/null || (mkdir -p /opt/Pixelle-Video/patches && rtk proxy docker cp pixelle-video-api:/app/pixelle_video/services/media.py /opt/Pixelle-Video/patches/media.py.patched)
echo "已备份改后 media.py 到 /opt/Pixelle-Video/patches/;重建镜像后需重新 docker cp 回 /app/pixelle_video/services/media.py 或固化进 Dockerfile"
Expected: 备份文件存在。无 git commit(Pixelle 非本仓库)。
Task 4: 校验探测(确认 Comfy Cloud 接受工作流)
Files:
-
临时:
/tmp/digital_ltx2_probe.json -
Step 1: 用占位输入构建 probe payload
注入标记的字段需要具体值才能提交校验。用占位文件名探测(校验在执行前,不真正读文件):
python3 -c "
import json
wf=json.load(open('/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json'))
# probe 直接提交节点图本身(标记字段已有默认值或占位);comfykit 注入是运行时的事,
# 这里验证 Comfy Cloud 是否接受这套节点+模型
json.dump({'prompt':wf}, open('/tmp/digital_ltx2_probe.json','w'), ensure_ascii=False)
print('probe payload bytes:', len(open('/tmp/digital_ltx2_probe.json').read()))
"
Expected: payload 生成。
注:工作流里 LoadImage/LoadAudio 的默认 widget 值(模板自带的示例文件名)可能在 Comfy Cloud 不存在,导致校验报"文件不存在"。这是预期且可接受的——它证明节点+模型校验已过,只差真实输入。 关注点是有没有
unsupported node type/ 模型缺失类错误。
- Step 2: 提交探测,读校验结果
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:4000/comfy-cloud/prompt -H "Content-Type: application/json" --data @/tmp/digital_ltx2_probe.json 2>&1 | head -15
Expected 判读:
-
{"node_errors":{},"prompt_id":"..."}HTTP 200 → 完美,全兼容。 -
400 ... unsupported node type 'X'→ 节点 X 不在 Comfy Cloud,需在工作流里替换/移除该节点(回 Task 2 处理)。 -
400 ... 文件/模型 不存在(针对 LoadImage 示例图或某 .safetensors)→ 若是输入文件(LoadImage 默认图)属预期;若是 .safetensors 模型缺失,需换 Comfy Cloud 有的等价模型(对照/tmp/cc_nodes.json池)。 -
Step 3: 据结果迭代或放行
-
全兼容 → 进 Task 5。
-
节点/模型问题 → 回 Task 2 调整工作流(换节点或模型名),重跑 Task 4。不要跳过。
Task 5: 真实数字人任务联调
Files: 无(运行时验证)
- Step 1: 在 Pixelle 前端发起数字人任务,视频/动画工作流选
comfy/digital_ltx2.json
数字人流程的 image→video 动画步(api_video_workflow 或对应 UI 项)填/选 comfy/digital_ltx2.json。
触发一个任务(给个肖像图 + 文本)。
- Step 2: 观察任务流转
预期:LLM 脚本步 → 生成肖像图(comfy 图片工作流)+ TTS 音频 → digital_ltx2 动画步: 注入 image_path/audio_path/prompt → Comfy Cloud 建 job → 轮询 completed → 取回说话视频 → HTML 合成。
监控 BoosAPI 侧 proxy_logs:
rtk proxy sqlite3 boosapi.db "SELECT id, created_at, client_family, http_status, substr(error_message,1,80) FROM proxy_logs ORDER BY id DESC LIMIT 5;"
Expected: 新日志(id 增长),数字人任务最终 success / 出片。
-
Step 3: 出错则按错误类型归因
-
unsupported node/ 模型缺失 → 回 Task 2/4。 -
image_path 未注入(LoadImage 仍用默认图)→ 回 Task 3 确认 media.py 改动生效(
grep image_path /app/.../media.py)+ Task 2 确认$~image_path!标记落位。 -
执行期失败(Comfy Cloud job failed)→ 真实生成问题(参数/分辨率/时长),据 job 错误调工作流默认值。
-
Step 4: 成功标准
数字人任务在 Comfy Cloud 出现真实 job、跑完、返回带音频的说话视频,Pixelle 合成出片。
完成标准
comfy/digital_ltx2.json落地、comfykit 能解析出 image_path/audio_path/prompt 三参数。- media.py 已转发 image_path 到 comfy 通道(并备份到宿主 patches/)。
- 校验探测确认 Comfy Cloud 接受工作流(节点+模型兼容)。
- 真实数字人任务经 comfy/Comfy Cloud 出片。
已知风险 / 备注
- 硬依赖用户导出 API JSON(Task 1);拿不到则全卡。
- media.py 改动在镜像内置层,重建镜像会丢,已备份到
/opt/Pixelle-Video/patches/,长期应固化进 Dockerfile/启动脚本。 - 工作流默认 widget 值(示例图/音频文件名)在 Comfy Cloud 不存在属正常,真实运行靠注入覆盖。
- 单人肖像;多人对话不在范围。