☰
用h3.c自制ComfyUI视频输出节点:MacBook上跑通33B视频生成模型
2026/9/28 8:55:12 网站建设 项目流程

折腾了一个多星期,我终于在一台 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.md

init.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 里照着摆:

  1. 加载 GGUF 格式的视频模型权重。
  2. 加载文本编码器,输入提示词,获得文本条件。
  3. 设置视频帧数与分辨率。
  4. 用采样器生成 latent。
  5. VAE 解码 latent 得到图片序列。
  6. 把图片序列送到 H3 Video Encoder 节点,设置 fps 和输出路径。
  7. 执行,得到 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 上跑通类似的视频生成链路,我建议先把模型量化等级、帧数、分辨率这三个参数固定下来,再围绕它们调试插件。不要一开始就追求完美画质,先用一条能跑通的最小路径把技术验证做完,后面再逐步加显存优化和画质策略。我踩过最多坑的地方,恰恰都是试图一步到位时漏掉的基础环节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询