硬件约束:RTX 5070 Laptop 8GB VRAM / 32GB RAM / Windows。 生成端:ComfyUI 0.40 + MiniMax H3(33B 全模态,int8_convrot)。 编排端:
video_pipeline.py(单文件服务,约 160KB),监听127.0.0.1:8898。 本文只讨论工程实现,不讨论提示词和模型选型。
1. 问题定义
单条生成链路本身已经能跑通:ComfyUI 出 mp4,Whisper 对齐字幕,ffmpeg 烧录,JEV 抽帧质检,ACE-Step 出 BGM。
真正的问题是这条链路不可运营:
- 任务入口靠人拖文件,状态靠人记;
- 任一阶段失败,整条链断,无法从失败点恢复;
- 长片由 16 个分段组成,单段失败会拖垮整片;
- ComfyUI 重启后,编排层丢失"正在生成"的任务;
- 没有统一的 status/event/history,无法做 ETA 和告警。
所以目标不是"再写一个更长的脚本",而是把生成端和编排端解耦,做成一个可恢复、可观测、可回滚的常驻服务。
2. 总体架构
vbnet
┌─────────────────────┐ file system ┌──────────────────────┐
│ ComfyUI :8188 │ ────────────────────────> │ H:\h3cache\raw │
│ H3 / MOSS / ... │ SaveVideo 落盘 │ (任务入口) │
└─────────┬───────────┘ └──────────┬───────────┘
│ /prompt (仅长片分段主动提交) │ detector 每 4s 扫描
│ v
┌─────────┴────────────────────────────────────────────────────────────────┐
│ H3 Director's Desk :8898 │
│ FIFO queue → single worker → 6-stage state machine → history/manifest │
│ /status /download /task/bgm /task/delete /?embed=1 │
└─────────┬────────────────────────────────────────────────────────────────┘
│ 子进程 / HTTP
v
JEV 4B(决策/分镜/质检) · faster-whisper(字幕) · ACE-Step(BGM) · ffmpeg
三个边界:
- ComfyUI 不感知编排层 :导演台不修改采样图、不接管队列,只在长片分段时通过
/prompt主动提交。 - 编排层不感知采样细节:它只消费 mp4、字幕、评分,不关心 H3 用了多少步。
- 所有状态落盘 :
pipeline_history.json、pipeline_eta.json、长片manifest.json,重启可恢复。
3. 任务发现:为什么用"大小稳定"而不是文件事件
ComfyUI 的 SaveVideo 是"先创建文件,再写入"。文件出现瞬间大小为 0,直接入队会把半截视频送进流水线。
检测器每 4 秒扫描 raw,判定条件:
ini
# 伪代码
size = stat(path).st_size
if size > 1 * 1024 * 1024 and size == last_size.get(path):
enqueue(path)
last_size[path] = size
要求"连续两次采样大小一致",用一个扫描周期换掉对文件系统事件的依赖,跨平台、无额外依赖,也避免了 inotify 在 SMB/exFAT 上的各种边界问题。
副作用:最坏情况下任务延迟 8 秒入队。对分钟级的生成任务可以接受。
4. 状态机:阶段可跳过、可失败、可恢复
单条视频六阶段:
| 阶段 | 依赖 | 可跳过 | 失败策略 |
|---|---|---|---|
| 语义决策 | JEV 4B | 否 | 重试 1 次,仍失败则用默认配置 |
| 帧率重构 | ffmpeg minterpolate | 是(24fps 直出) | 跳过并记录 |
| 字幕合成 | H3 自带字幕 / Whisper + ASS | 是(已有 baked subtitle) | 重试 1 次,仍失败则交付无字幕版并标记 |
| 质量评估 | JEV 4B | 是(配置关闭) | 标记 unknown,不阻塞交付 |
| 配乐混音 | ACE-Step + ffmpeg | 是(默认关闭) | 跳过配乐,保留原声 |
| 交付归档 | 文件系统 | 否 | 失败进 失败,写 history |
实现上不是 stage1() && stage2() && ...,而是:
css
for stage in stages:
if stage.should_skip(ctx):
emit(stage, "end", detail="已跳过")
continue
emit(stage, "start")
try:
stage.run(ctx)
except Exception as e:
emit(stage, "error", detail=str(e))
if not stage.retryable:
raise
stage.retry(ctx)
emit(stage, "end", detail=stage.summary)
每个阶段写 start / detail / end 三类事件,事件带全局自增 seq 和任务 id。前端按 seq 增量拉取,不需要轮询整份日志。
恢复策略 :重启后扫描 pipeline_history.json,把处于 running 状态的任务标记为 interrupted;如果输出文件已存在且帧数达标,则从"交付归档"恢复,否则从失败阶段重跑。
5. 长片:段数由剧本决定,重跑按 idx 原位替换
长片入口是 raw<片名>.film.json:
json
{
"style": "3D 卡通都市恋爱",
"lang": "zh",
"lines": ["...", "..."],
"reuse": ["seg_00.mp4", "seg_01.mp4"]
}
5.1 段数计算
lua
每段最多 3 行台词
段数 = ceil(len(lines) / 3)
reuse 只决定"这一段复用已有文件还是重新生成",不参与段数计算。这样同一份剧本无论复用多少段,切分边界都稳定。
5.2 顺序锁
- 分段重跑:结果写临时文件,
os.replace()原位替换原 idx; - 拼接:只按 idx 升序读分段,写新文件,绝不修改分段;
- 全 cut 且编码参数一致:
concat -c copy(无损); - 含 crossfade:
xfade链; - 参数不一致:先生成归一化副本,再拼接。
这条规则的价值在后期:任何一次"重拼"都不会破坏已有分段,也不会产生"拼接把源文件改了"的不可逆事故。
5.3 逐段质检与重跑
每段生成后:
- 画幅归一化、音频统一 48kHz;
- JEV 抽 8 帧评分(broken / flawed / good);
- 检测 H3 是否自带字幕(有则跳过后期烧录);
- 不合格换 seed 原位重跑,最多 2 次。
长片 16 段,单段失败只影响该段,整片仍可继续。
6. 可观测性:状态快照 + 事件流 + 时钟插值
前端每 1.5 秒 GET /status,响应包含:
json
{
"tasks": [...],
"films": [...],
"events": [{"seq": 1024, "task": "...", "stage": "subtitle", "kind": "detail", "text": "..."}],
"stages": [{"key": "decide", "label": "语义决策", "eta": 50.8}],
"overall": {"phase": "idle", "text": "待片中 · 盯住 raw 目录"}
}
两个实现细节:
- 事件增量 :前端记录最后一条
seq,后续只追加新事件,单条线路最多保留 80 行,避免 DOM 无限增长。 - 进度插值:阶段进度每 400ms 用本地时钟插值,服务端只提供阶段起止和 ETA。这样进度条平滑,但不伪造"采样百分比"。
日志采用"粘性跟随":默认跟随底部;用户上滚后固定位置,显示"N 条新输出 · 回到最新";回底恢复跟随。

7. ComfyUI 插件机制:不需要 fork 主程序
ComfyUI 的扩展点只有三个:
ini
# custom_nodes/MyNode/__init__.py
NODE_CLASS_MAPPINGS = {"MyNode": MyNode}
NODE_DISPLAY_NAME_MAPPINGS = {"MyNode": "我的节点"}
WEB_DIRECTORY = "./web" # 可选,纯前端扩展
节点类实现 INPUT_TYPES / RETURN_TYPES / FUNCTION 即可。ComfyUI 负责把输入渲染成控件、把输出连成线、管理显存和采样循环。
这意味着:
- 不要自己写采样循环 。先把模型加载成
MODEL / CLIP / VAE,用原生KSampler、ModelSamplingSD3、VAEDecode跑通。 - 不要 fork 主程序 。所有定制都应该收敛在
custom_nodes/和extra_model_paths.yaml里。 - 失败必须隔离。插件初始化异常不能让 ComfyUI 起不来。
8. 案例 A:MOSS-Image 5B(Apache 2.0)与 INT8 单文件兼容
MOSS-Image 是 5B 文生图模型,官方提供 ComfyUI 插件。导入路径:
makefile
D:\ComfyUI\custom_nodes\moss-image\
D:\ComfyUI\models\diffusion_models\moss_image_sft_int8_convrot.safetensors
D:\ComfyUI\models\text_encoders\qwen3.5_9b_w4a8_gptq.safetensors
D:\ComfyUI\models\vae\flux2-vae_small.safetensors
跨盘模型通过 extra_model_paths.yaml 挂载,不复制第二份。
8.1 为什么需要补丁
Comfy-Org 的 INT8 单文件把 q/k/v 融合成一个 qkv 投影;插件原始实现按三个独立 q/k/v 读权重。直接加载会缺 key,或者更隐蔽:加载成功但注意力计算错误。
补丁让模型类同时支持两种布局:
ini
self.proj = nn.ModuleDict(...)
# Comfy-Org INT8 single-file checkpoints fuse q/k/v into one qkv projection.
self.proj["qkv"] = operations.Linear(dim, 3 * dim, bias=False, ...)
qkv_w = self.proj["qkv"].weight
if qkv_w is not None:
qkv = self.proj["qkv"](y).unflatten(-1, (3 * self.heads, -1))
q, k, v = qkv.chunk(3, dim=2)
else:
q = self.proj["q"](y)
k = self.proj["k"](y)
v = self.proj["v"](y)
8.2 工作流
scss
MOSSImageUNETLoader
→ ModelSamplingSD3 (shift 5.0)
→ SkipLayerGuidanceDiTSimple (single_layers=22)
→ KSampler (euler / sgm_uniform, 24 steps, cfg 3.0)
→ VAEDecode (FLUX.2 small VAE)
→ SaveImage
插件只提供 loader,采样/引导/解码全部复用原生节点。8GB 实测:1024×1024、24 步约 274 秒(含加载)。

8.3 升级风险
插件升级会覆盖 model.py,补丁丢失后模型会静默输出错误结果(不一定是报错)。工程上要把它当成"需要回归验证的变更",而不是普通升级。
9. 案例 B:H3 一致性守卫与 PromptServer 队列钩子
9.1 节点
yaml
H3 一致性提示词守卫 (H3ConsistencyPrompt)
inputs: prompt, profile_path, segment_index, repair, previous_segment
outputs: guarded_prompt, warnings_json
节点只做一层壳:
ini
res = h3_guard.build_prompt(prompt, prof, int(segment_index), repair=bool(repair))
return (res["prompt"], json.dumps(meta, ensure_ascii=False))
规则在服务端 h3_guard.py,ComfyUI 节点和批量脚本共用同一套实现,避免"界面一套、脚本一套"的双份逻辑。
9.2 为什么需要队列钩子
已有工作流不会自动带这个节点。要让守卫对"所有 H3 任务"生效,只能在 PromptServer 的队列入口做一次拦截:
ini
orig_put = q.put
def guarded_put(item=None, *args, **kwargs):
if isinstance(item, tuple) and len(item) >= 3:
prompt = _auto_guard_prompt(item[2], item[3] if len(item) > 3 else None)
if prompt is not item[2]:
item = (item[0], item[1], prompt) + item[3:]
return orig_put(item, *args, **kwargs)
q.put = guarded_put
9.3 隔离条件
钩子必须"只改它认识的工作流":
- 只处理
CLIPTextEncode/CLIPTextEncodeFlux; - 只对匹配到 profile(片名或至少两个角色名)的工作流注入;
- 检测到
MOSSImage*节点直接跳过,生图工作流不能吃电影守卫; - 工作流已手动放守卫节点(提示词包含
【一致性守卫)则不重复注入; - 任何异常只打印,不抛出。
这是"横切关注点"接入共享队列时的最低要求:默认放行,失败放行,只改明确匹配的路径。
10. 案例 C:云端免费模型的节点化与安全边界
CogView-3-Flash 是云端免费模型。节点:
css
CogView-3-Flash (H3)
inputs: prompt, size, quality, remove_watermark, watermark_enabled
outputs: IMAGE, STRING(path)
内部调用本地客户端,把生成结果读成 RGB tensor 返回,因此能直接接 PreviewImage / SaveImage。
10.1 多 provider 注册表
javascript
H:/H3DirectorDesk/cloud_image_providers.json
适配器支持 zhipu_images / minimax_image / openai_images / openai_chat。侧栏通过 /cogview3/providers 读取(key 仅返回掩码),/cogview3/generate 生成,/cogview3/history 查历史,/cogview3/file 取图。
10.2 三条安全约束
- 密钥只在服务端进程内读取,响应/日志不回显;
- 错误文本统一
_redact(),抹掉Bearer令牌和长随机串;
图片目录 :
H:\h3cache\发布\00_通用素材\导演台自动化(下面每张图的括号里都标了该目录下的文件名,直接去这个目录找)
云端节点最常见的漏洞不是功能,是密钥泄漏和路径穿越。这两件事必须在第一版就堵死。
11. 权衡与限制
| 决策 | 代价 | 为什么接受 |
|---|---|---|
| 单 worker 串行 | 吞吐受限于单任务 | 8GB 显存并发会 OOM;稳定优先 |
| 4 秒轮询 + 大小稳定 | 最坏 8 秒延迟 | 跨平台、无文件事件依赖 |
| 状态落盘 JSON | 高频写盘 | 任务分钟级,写频率可忽略;重启可恢复 |
| 队列钩子 | 与 ComfyUI 内部 API 耦合 | 只 hook PromptServer.prompt_queue.put,升级需回归 |
| 插件补丁 | 升级会覆盖 | 补丁小、可脚本化重打;比 fork 主程序可控 |
| JEV 质检 | 8 帧启发式,可能漏检 | 定位是"筛废片",不是"保证质量" |
明确的非目标:
- 不做多机调度;
- 不做 token 级生成进度(采样进度在 ComfyUI 队列里);
- 不让 JEV 参与生成;
- 不保证角色 100% 一致(提示词级守卫有上限)。
12. 结论
这套东西的价值不在"功能多",而在三个工程属性:
- 可恢复:阶段状态落盘,重启从失败点继续;
- 可隔离:单段失败不影响整片,插件异常不影响 ComfyUI;
- 可回滚:长片只读分段写新文件,插件删除目录即回滚。
免费模型接入 ComfyUI 的标准路径也已经很清楚:
官方/社区插件 + 标准模型目录 + 原生采样器 + 最小补丁。
不要把"模型能跑"当成"工程可用"。真正决定能不能长期跑的,是失败路径怎么处理。
文中截图均为本机真实运行;MOSS-Image 为 Apache 2.0,Ideogram 4 为非商业许可,商用前请核对模型卡。