docs: LTX2.3 数字人工作流实现计划(5 任务)
获取API导出 → 改造工作流(标记+删RecordAudio) → 补 media.py image_path 转发 → 校验探测 → 真实联调。硬依赖用户导出 API 格式 JSON。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
# 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。
|
||||
|
||||
---
|
||||
|
||||
<!-- TASKS -->
|
||||
|
||||
## 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:
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
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。
|
||||
|
||||
<!-- T2 -->
|
||||
|
||||
## Task 2: 改造工作流(加标记 + 删 RecordAudio + 落 comfy/)
|
||||
|
||||
**Files:**
|
||||
- Create: `/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json`
|
||||
|
||||
- [ ] **Step 1: 找到要标记的节点 id(LoadImage/LoadAudio/文本/RecordAudio)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
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 实际字段定。
|
||||
|
||||
```bash
|
||||
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 解析):
|
||||
```bash
|
||||
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`。
|
||||
|
||||
<!-- T3 -->
|
||||
|
||||
## 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` 做精确插入最稳妥:
|
||||
|
||||
```bash
|
||||
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: 校验语法 + 重启容器**
|
||||
|
||||
```bash
|
||||
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 部署说明:
|
||||
```bash
|
||||
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 非本仓库)。
|
||||
|
||||
<!-- T4 -->
|
||||
|
||||
## Task 4: 校验探测(确认 Comfy Cloud 接受工作流)
|
||||
|
||||
**Files:**
|
||||
- 临时:`/tmp/digital_ltx2_probe.json`
|
||||
|
||||
- [ ] **Step 1: 用占位输入构建 probe payload**
|
||||
|
||||
注入标记的字段需要具体值才能提交校验。用占位文件名探测(校验在执行前,不真正读文件):
|
||||
```bash
|
||||
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: 提交探测,读校验结果**
|
||||
|
||||
```bash
|
||||
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。不要跳过。
|
||||
|
||||
<!-- T5 -->
|
||||
|
||||
## 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:
|
||||
```bash
|
||||
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 不存在属正常,真实运行靠注入覆盖。
|
||||
- 单人肖像;多人对话不在范围。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user