# 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: ```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。 ## 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`。 ## 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 非本仓库)。 ## 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。不要跳过。 ## 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 不存在属正常,真实运行靠注入覆盖。 - 单人肖像;多人对话不在范围。