折腾了一个多星期,我终于在一台 MacBook 上把参数量 33B 量级的视频生成模型完整跑通了,并且能从 ComfyUI 里直接导出成正常可播放的 MP4。这中间最费劲的不是模型推理,也不是各种依赖安装,而是收尾那一下——如何把模型吐出来的几十帧画面,在本地变成一个真正能双击播放的视频文件。试过系统自带工具、试过各种转封装脚本,最后我决定把 antirez 的 h3.c 封装成一个 ComfyUI 插件,当作视频输出节点来用。这篇笔记不讲玄乎的原理,只聊我怎么设计这个插件、怎么在 macOS 上同时伺候 33B 模型和轻量编码器,以及实际踩过的那些坑。
1. 为什么一定是 h3.c,而不是直接依赖 ffmpeg
1.1 视频生成流程里最大的隐性瓶颈
很多人刚接触 ComfyUI 视频工作流时,会把注意力全放在模型权重、提示词和采样器上,结果生成完才发现,最后一个“保存视频”的节点一直在报错。报错信息五花八门,最常见的就是找不到 ffmpeg。ComfyUI 自带的 Video Combine 节点,底层靠的是系统命令行的 ffmpeg,没有这个可执行文件,工作流就只能停在半路。
在 MacBook 上这个问题会更明显。macOS 默认不带 ffmpeg,装一次通常要走 Homebrew,而 Homebrew 装 ffmpeg 会顺带扯出一大堆依赖库,少则几百 MB,多则上 GB。如果网络环境不好,或者你只是想在一台临时机器上调试工作流,这一关就能消磨掉很多耐心。
更要命的是版本差异。同一个 ffmpeg,不同编译版本对 H.264 编码器、封装格式、比特率控制的表现完全不一样。你在自己机器上跑通的工作流,换一台机器可能直接输出一个 0 字节的文件,而且没有任何人告诉你是谁的责任。
所以问题就很清楚了:我需要的不是一套功能完整的转码全家桶,而是一个能在本地、稳定、傻瓜化地把一组帧变成视频文件的编码器。它最好不依赖系统路径里的任何二进制文件,也不依赖第三方运行时,这样工作流才真正可移植。
1.2 h3.c 这个单文件 C 项目的特殊性
antirez 的 h3.c 在开源社区里属于“极客玩具”和“实用工具”之间的东西。它最大的特征是单文件 C 代码,编译之后不依赖一堆动态库,能在资源非常受限的环境下完成 H.264 相关的编解码能力,而且整个实现保持在一个可以通读的状态。
这恰好命中了我需要的全部条件:
- 它足够小,可以整个塞进一个 ComfyUI 自定义节点目录里,跟着插件一起走。
- 它是纯 C 写的,没有 Python 的 GIL 问题,也没有 Java、Node 这类运行时拖后腿。
- 它面向纯 CPU 场景,不需要额外调用硬件编码器,在 Apple Silicon 上也能跑。
- 它提供的是明确的 C 接口,我可以用 ctypes 直接从 Python 调用,也可以先编译成一个 CLI 小工具再加一层壳。
说实话,第一次看到 h3.c 的代码结构时,我的感觉是“居然还能这么写”。代码里没什么花里胡哨的抽象,几乎所有核心逻辑都摊在一两个文件里。做插件封装的时候,我不用为了兼容某个内部数据结构去读几百行外部库的文档,直接看代码就能知道每一步在干什么。对于想控制编码细节的人来说,这种透明度比功能强大重要得多。
1.3 放进 ComfyUI 插件体系里,到底划算不划算
把 h3.c 做成 ComfyUI 插件,而不是单独写一个 Python 脚本,主要是因为工作流复现方便。使用 ComfyUI 的用户群里,很多人并不是命令行熟练工。他们希望像搭积木一样,把视频生成模型、VAE、解码器、输出节点用一个图形界面串起来。
插件化之后,它的价值是这样的:
- 工作流文件里只会多一个节点,不需要用户额外安装 ffmpeg 或修改 PATH。
- 所有编码参数通过节点输入暴露出来,fps、分辨率、输出路径都能在界面上调整。
- 同一个工作流在别人的机器上打开,只要对方也放了插件目录,大概率能直接跑出来。
- 后续想加音频混合或者字幕,只需扩展节点代码,不用重新教用户操作。
代价当然也有:ComfyUI 自定义节点的开发接口比较固定,我这套 C 封装得迁就它的输入输出类型系统,这部分我在后面会详细讲。
2. 插件结构设计与核心实现
2.1 从目录到节点注册,一个最小的 ComfyUI 插件长什么样
ComfyUI 的插件机制比大部分人想的简单,它并不要求你用任何框架,只要目录里存在 NODE_CLASS_MAPPINGS 就能被识别。我的插件目录结构是这样:
ComfyUI/custom_nodes/ └── H3Encoder/ ├── __init__.py ├── nodes.py ├── h3pack.c ├── build.py └── README.mdinit.py 只做节点注册,逻辑非常薄:
from .nodes import H3EncoderNode NODE_CLASS_MAPPINGS = { "H3VideoEncoder": H3EncoderNode, } NODE_DISPLAY_NAME_MAPPINGS = { "H3VideoEncoder": "H3 Video Encoder (h3.c)", }如果你的init.py 里没有这两个映射,ComfyUI 会直接忽略整个目录,经常有人放了插件没生效,问题往往出在这里。
nodes.py 是真正干活的文件,它需要声明节点的输入、输出、执行函数。ComfyUI 的节点规范我建议直接看官方示例,但有几个点必须注意:
- 输入类型必须用 INPUT_TYPES 明确声明,ComfyUI 会依据这些类型来决定连接线的兼容性。
- RETURN_TYPES 决定了下游节点能不能接收它的输出,如果我想把生成结果接给预览节点,返回类型必须对应。
- FUNCTION 指向的是实际执行的方法名,这个方法签名需要包含你在 INPUT_TYPES 里声明的所有参数。
2.2 把 h3.c 变成可以被 Python 调用的动态库
我的第一个版本没有做动态库,而是直接用 subprocess 调用编译好的命令行工具。问题是每次编码都要开一个进程,帧数据从 Python 传到 C 程序再写文件,绕了一圈,速度倒还能接受,但总觉得不够干净。后来我改成把 h3.c 编译成 dylib(macOS 的动态库),再用 ctypes 从 Python 直接调用,省掉了中间进程,也能在节点运行时内部完成内存分配。
编译命令非常直接,我在 build.py 里写了自动构建逻辑:
cc -O3 -dynamiclib h3pack.c -o libh3pack.dylib如果你的插件要同时支持 Linux,可以在 build.py 里判断系统:
import subprocess import sys from pathlib import Path def build_library(): root = Path(__file__).parent lib = root / "libh3pack.so" if sys.platform == "darwin": lib = root / "libh3pack.dylib" if lib.exists(): return str(lib) src = root / "h3pack.c" cmd = ["cc", "-O3"] if sys.platform == "darwin": cmd += ["-dynamiclib"] else: cmd += ["-shared", "-fPIC"] cmd += [str(src), "-o", str(lib)] subprocess.check_call(cmd) return str(lib)这个文件只依赖系统自带的 cc,不要求用户装 Xcode 全家桶,只要 Command Line Tools 齐全就能编译。我自己的测试环境是 macOS 14,命令行工具装好之后,cc 可以直接出动态库。
如果你觉得每次导入插件时编译太慢,也可以在 README 里给出预编译版本。但我的习惯是编译过程保留下来,因为不同 macOS 版本的兼容性会有细微差异,现场编译更稳。
2.3 ctypes 封装层,让 Python 侧看起来像普通函数
动态库编译好之后,nodes.py 里用 ctypes 把它加载进来。关键是怎么把 ComfyUI 的图片 tensor 转换成 C 函数能接收的字节流。
ComfyUI 的 IMAGE 类型在 Python 侧是一个 torch.Tensor,形状是 (B, H, W, C),取值范围在 0 到 1 之间,float32。h3pack.c 内部需要的帧数据则通常是 YUV 或 RGB 字节流。我选择让 C 函数接收连续的 RGB24 字节数组,也就是每帧 HW3 字节,一次传一帧,由 C 侧完成色彩转换和编码。
Python 侧核心逻辑大致是这样:
import ctypes import tempfile from pathlib import Path import torch _lib = None def _get_lib(): global _lib if _lib is None: lib_path = build_library() _lib = ctypes.CDLL(lib_path) _lib.h3_encode_frame.argtypes = [ ctypes.c_char_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ] _lib.h3_encode_frame.restype = ctypes.c_int return _lib class H3EncoderNode: @classmethod def INPUT_TYPES(cls): return { "required": { "images": ("IMAGE",), "fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 60.0}), "output_path": ("STRING", {"default": "./output.mp4"}), } } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("filepath",) FUNCTION = "encode" CATEGORY = "video" def encode(self, images, fps, output_path): lib = _get_lib() path_bytes = output_path.encode("utf-8") # 转成 RGB24,这里省略逐帧拷贝细节,直接用 tensor.permute + contiguous 再转 uint8 rgb = (images.permute(0, 3, 1, 2).contiguous() * 255.0).to(torch.uint8) frames = rgb.shape[0] h = rgb.shape[2] w = rgb.shape[3] frame_bytes = rgb.numpy().tobytes() ptr = ctypes.c_char_p(frame_bytes) ret = lib.h3_encode_frame(path_bytes, w, h, frames, ptr) if ret != 0: raise RuntimeError(f"h3 encode failed, code={ret}") return (output_path,)我把这段代码简化了很多,实际编写时你要处理大 tensor 的拷贝开销。一个 720p 的 33B 视频模型,生成的帧数量可能是几十帧甚至上百帧,如果每一帧都单独做一次 tensor 操作,Python 侧 GIL 和内存拷贝就会变成瓶颈。我的做法是把整批 frame 拼成一个连续字节缓冲区,一次性传给 C 侧,h3.c 那边自己按帧索引去切。
2.4 节点输出类型的坑:别再掉进 Video 类型的兼容问题
ComfyUI 官方有些节点现在支持 VIDEO 类型,但插件开发社区更稳定的是返回 STRING 类型的文件路径。我一开始把 RETURN_TYPES 写成 ("VIDEO",),以为可以让下游预览节点直接识别,结果不同人用的 ComfyUI 版本不一样,有的版本根本不知道 VIDEO 类型是什么,导致插件被判定为类型错误,挂在加载阶段。
最后我稳妥地选择返回 STRING 路径,然后在节点旁边搭配一个 Preview 节点,或者直接让工作流里预设一个图片加载桥,把生成出来的文件再读进去预览。虽然多了一步,但兼容性强得多。如果你要发布这个插件给别人用,这种保守方案会少很多麻烦。
3. 让 33B 视频模型在 MacBook 本地跑起来的资源管理
3.1 先说结论:能不能跑,取决于你怎么量化
33B 模型的 fp16 权重,光参数就要占用 60GB 级别。一台 MacBook 的内存从 16GB 到 128GB 不等,绝大多数人的机器根本没有 60GB 统一内存。所以第一步永远是量化。
我目前比较推荐 GGUF 系列格式,因为它能直接把权重压到 4bit 或 8bit。一个 33B 模型,如果做到 Q4_K_M,权重体积大约是 19GB 到 21GB,加上 KV Cache、中间激活和 VAE,整体占用能控制在 32GB 以内。换句话说,32GB 内存的 M 系列 MacBook 有机会跑起来,但会很紧;64GB 内存的机器会舒服很多。
这里有个容易误判的点:不要只看参数总量,还要看上下文长度。视频生成模型在处理多帧时,KV Cache 增长非常快。你生成 33 帧,每帧都有大量 token 级的特征参与注意力计算,缓存一多,内存很容易从 30GB 飙到 50GB。所以量化之外,我把上下文长度也做了调整,能适配多少帧设多少帧,不要一味拉满。
3.2 GGUF 插件与 ComfyUI 的适配细节
ComfyUI 默认加载的是 safetensors 格式,要直接加载 GGUF,需要装对应的自定义节点插件,比如 ComfyUI-GGUF。这个插件提供了 Unet Loader 之类的节点,能读取 GGUF 格式的视频模型权重。
配好之后,工作流里的模型加载节点会多出几个参数,包括量化类型选择、权重文件路径、是否启用部分加载等。我建议在模型文件旁边放好它的分词器文件,视频生成模型的文本编码器也需要相应权重。这一步很容易被人漏掉,漏掉之后最典型的现象是:模型能加载,但输出全是噪声。
还有一点,如果用 llama.cpp 系列动态库做推理,有些版本对 Metal 的支持并不彻底,默认会有一小部分算子落到 CPU 上。在 MacBook 上跑 33B 视频模型时,CPU 和 GPU 的调度不均匀会导致生成速度忽快忽慢。我这边测试下来,把 layer 数量适当控制一下,能明显降低内存峰值。
3.3 VAE 才是最后压垮内存的那根稻草
很多人在 Mac 上跑视频生成失败,最后报的错误是内存不足。检查来检查去,发现模型本身没有问题,问题出在 VAE。视频模型的 VAE 比普通图像模型的 VAE 要重很多,因为在解码过程中要处理时空两个维度。
我自己的经验是:视频输出阶段最好把 VAE 单独拆出来,放一部分帧到 CPU 侧解码。ComfyUI 里可以通过节点组织顺序,让 VAE Decode 节点放到最后,前面用采样器输出 latent,再由 VAE 解码成图像。这样内存峰值不会全部集中在采样阶段,能够平滑一点。
另一个顺手做的小优化是关闭不需要的前置节点。许多视频工作流里会同时挂载文本编码器、第一帧条件、末帧条件等多个模型,这些都会被一并载入内存。实际跑的时候,如果只做文生视频,可以先把条件输入设为 None,能省下好几个 GB。
4. 工作流搭建:从加载模型到落盘视频的完整链路
4.1 一份最小可用工作流的节点顺序
我这里给一个我在项目里实际使用的工作流骨架,你可以直接在 ComfyUI 里照着摆:
- 加载 GGUF 格式的视频模型权重。
- 加载文本编码器,输入提示词,获得文本条件。
- 设置视频帧数与分辨率。
- 用采样器生成 latent。
- VAE 解码 latent 得到图片序列。
- 把图片序列送到 H3 Video Encoder 节点,设置 fps 和输出路径。
- 执行,得到 MP4 文件。
有几个节点之间的连线容易出错。采样器输出的 latent 必须和 VAE 的输入维度匹配,否则解码出来全是花屏。视频帧数、宽高也要满足模型内置的整除要求,比如有些模型要求宽高是 16 的倍数,帧数是 4 或 8 的倍数。这些参数如果不匹配,模型不会报错,但输出会变成乱帧。
4.2 我实测的一组参数记录
我手上这台机器是 M 系列芯片、64GB 统一内存。模型选的是 33B 量级的 GGUF 量化版,量化等级 Q4_K_M,分辨率限制在 704x704 左右,一次生成 32 帧左右。整条链路跑下来,采样阶段大概需要几分钟,VAE 解码加 h3.c 编码只占几十秒。
这个速度不能跟云端 4090 相比,但胜在完全本地,断电断网都不影响。说实话,MacBook 的优势从来不是粗暴算力,而是大内存带宽和统一内存架构。33B 模型在 64GB 内存上跑,虽然每帧生成时间比专业显卡慢,但至少能完整跑完,这对产品原型验证和算法调试非常有价值。
如果你手头是 32GB 内存,也不要直接放弃。把量化等级降到 Q4_0,分辨率降到 640x640,帧数控制在 16 帧以内,仍然能跑,只是画面细节会少一点。内存实在不够的时候,ComfyUI 会在界面底部提示低内存警告,这时优先减少帧数,比降低分辨率对画质更友好。
4.3 关于输出文件大小和画质的处理
h3.c 做的是基础 H.264 编码,码率控制不像 ffmpeg 那一套完整工具链那么精细,所以我不会把它用在超高质量交付的场景。但对调试性和快速出片来说,它足够用了。
为了平衡体积和画面,我在节点里加了一个简单的质量参数,直接映射到 h3pack.c 的 quantization parameter。画面细节多的帧,用低数值保留纹理;纯色背景多的视频,可以适当提高数值减小体积。这个参数在节点面板里直接调,不用改代码。
5. 常见问题与排查技巧实录
5.1 冷启动常见错误速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 插件在 ComfyUI 里不显示 | init.py 里 NODE_CLASS_MAPPINGS 缺失 | 检查目录名和映射名是否一致 |
| 点击运行报找不到动态库 | build.py 没执行,libh3pack.dylib 未生成 | 手动在插件目录执行一次 python build.py |
| 视频文件生成但打不开 | C 侧帧数据排列不是 RGB24 | 检查 tensor 转换时 permute 的顺序 |
| 画面花屏或颜色偏绿 | RGB 与 YUV 转换公式不一致 | 调整 C 侧色彩转换矩阵 |
| 内存不够,模型加载到一半被 kill | 量化等级过高或上下文过长 | 换 Q4 量化,减少帧数 |
| 输出文件只有几 KB | 帧数不满足模型整除要求,编码异常中断 | 把帧数补齐到 4 或 8 的倍数 |
5.2 我踩过的几个不起眼但很致命的坑
第一个坑是 Mac 上 ctypes 加载 dylib 时,如果路径包含中文,有些版本的 Python 会找不到动态库。插件目录名不要取中文,输出路径也尽量放在英文目录下。
第二个坑是 ComfyUI 的临时目录清理机制。如果你把输出文件直接写到系统临时目录,跑完工作流后很容易被 ComfyUI 自动清理掉。我一般在界面里配置专用输出目录,并且关掉自动清理相关选项。
第三个坑是 batch size 和视频帧编号的对应关系。ComfyUI 中采样器的 batch_size 决定了 latent 的数量,但它并不等价于视频模型的帧数,有些视频模型内部会把 batch 的每一份当成一帧,有些则当成不同样本。这个关系需要看模型文档。如果设置错了,你会得到一整段完全重复的画面,且无任何报错。
5.3 性能优化的两条实用路
如果你觉得编码阶段慢,最大的可能性是 Python 侧逐帧循环处理。我建议把整批帧的 bytes 一次拼好再传入 C 函数,不要在 Python 层写 for 循环拷贝像素。实测这样做能提升不少速度。
如果你觉得采样阶段慢,优先看模型是否真的在走 Metal。ComfyUI 的终端日志里会打印设备信息,如果是 "cpu" 而不是 "mps" 或 "metal",说明显卡加速没有生效。检查是否启用了对应的推理后端,以及 GGUF 加载器是否支持 Apple Silicon。若还是 CPU,那就接受现实,33B 模型在 CPU 上也属于可跑的范围,只是出片时间更长。
6. 最后再分享一个我在这个项目里学到的经验
把 h3.c 做成插件这件事,看起来只是一个工程包装过程,但它让我重新理解了“本地跑大模型”这句话的分量。模型的推理能力只是其中一环,周边所有工具链——VAE 怎么解、视频怎么编码、内存怎么安排、插件怎么注册——都决定着你到底能不能真正用起来。很多时候用户缺的不是一张 4090,而是一套可靠的本地工具链。
这个插件后续我还在继续扩展,打算加入音频流输入接口和更精细的码率控制。如果你也想在 MacBook 上跑通类似的视频生成链路,我建议先把模型量化等级、帧数、分辨率这三个参数固定下来,再围绕它们调试插件。不要一开始就追求完美画质,先用一条能跑通的最小路径把技术验证做完,后面再逐步加显存优化和画质策略。我踩过最多坑的地方,恰恰都是试图一步到位时漏掉的基础环节。