blender harness:3D 场景是怎么被命令行捏出来的

2026-08-10

CLI-Anything 里的 harness 高度同构,看多了容易眼花。所以看单个 harness 时,值得盯的不是「它也有 core/utils/tests」,而是这三件:它拿什么办法去动那个宿主软件、状态存在哪、测试要不要真装那个软件。blender 这个 harness 在这三件上都有可以落到行号的答案,而且其中一处跟名字给人的第一印象是相反的。

先给规模。我们采集时(2026-08-10,仓库快照 39634a6),blender harness 的非测试代码 5958 行、测试代码 2729 行,主入口 blender/agent-harness/cli_anything/blender/blender_cli.py 单文件 1157 行。core/ 下 9 个模块:animation.pylighting.pymaterials.pymodifiers.pyobjects.pypreview.pyrender.pyscene.pysession.py,其中 preview.py 最大,852 行;utils/ 下是 blender_backend.py(128 行)、bpy_gen.py(577 行)、preview_bundle.py(468 行)、repl_skin.py(567 行)。

命令面是 10 个顶层组加 1 个嵌套组,组内命令 53 条,另有一条顶层 replblender_cli.py:1071)。逐组是 scene 6、object 7、material 5、modifier 6、camera 4、light 3、animation 5、render 5、preview 3、preview live 5、session 4。组的定义位置也都能直接跳过去看,比如 scene 组在 blender_cli.py:232、modifier 组在 :513、session 组在 :1029

它是怎么动 Blender 的:JSON → bpy 脚本 → 子进程

这条链路在仓库里写得很直白。utils/bpy_gen.py 开头 1 到 4 行的模块说明写着,它把一份场景 JSON 翻译成一段完整的 bpy 脚本,而这段脚本是用来这样跑的:blender --background --python script.py。真正的拼装在 utils/blender_backend.py:54,那一行就是 cmd = [blender, "--background", "--python", script_path],紧接着 :56subprocess.run

围绕这一次子进程调用,backend 里的几处细节值得记:

  • 定位可执行文件用 shutil.which("blender")blender_backend.py:14-24),找不到会抛 RuntimeError,错误消息里附了 apt / brew 的安装指令。
  • 调用参数是 subprocess.run(capture_output=True, text=True, timeout=timeout),默认 timeout=300 秒(:37-60)。
  • 脚本不是常驻文件:生成的 bpy 脚本内容写进 tempfile.NamedTemporaryFile(suffix=".py", prefix="blender_render_"),跑完在 finallyos.unlink(script_path):85-89,127-128)。
  • 产物是要校验的:返回码非 0 时抛错并截取 stderr 末尾 500 个字符;输出文件不存在时,会依次试 000100001 这三种帧号后缀(Blender 单帧渲染会追加帧号),都不存在才抛 “produced no output file”(:94-118)。

这里必须把话说明白:这个 harness 的正常工作方式就是在你本机启动一个外部程序,并把它自己生成的一段 Python 脚本喂给这个程序执行。 脚本内容由上层的 JSON 场景描述翻译而来,而在 agent 场景里,那份 JSON 往往是模型写出来的。你要不要在自己的机器上开这条链路,需要结合自身环境评估,本文不给建议。同时,Blender 本身得你自己装——shutil.which 找不到就直接抛错,仓库不负责替你装。

反直觉的那一处:render execute 不会启动 Blender

在 render 组里看到 execute 这个名字,几乎所有人第一反应都是「这条命令会开始渲染」。它不会。

core/render.py:182-238 这段逻辑做的事情是:把脚本写成文件,然后在返回结果里给你一个 "command" 字段,值是 blender --background --python <script> 这样一条命令行字符串。CLI 侧对应的提示语也没打算掩盖这一点,blender_cli.py:858-871 打出来的是 “Render script generated: …”。包内 README 的说法一致,它让你自己再跑一遍 blender --background --python /path/to/_render_script.pyblender/agent-harness/cli_anything/blender/README.md:44-46)。

也就是说,命令名叫 execute,文档和提示语说的却是 generate,两处措辞不一致,而文档这一侧是如实写明了的。我们只陈述到这里。

这一处为什么值得单独拎出来讲:它直接决定了你把这个 harness 接进 agent 工作流时的边界在哪。如果你按名字理解,会以为「跑完 render execute 就有图了」,然后在后面接一步检查输出文件——那一步会落空,因为这条命令的产物是脚本,不是图像。而 backend 里那套完整的子进程调用与产物校验(上一节列的 :54:94-118)是存在的,utils/blender_backend.py 在包内被 core/preview.py 与测试文件 import,主渲染路径与它是分开的两条道。

怎么自己核实这一处,不用装 Blender:

sed -n '182,238p' blender/agent-harness/cli_anything/blender/core/render.py
sed -n '858,871p' blender/agent-harness/cli_anything/blender/blender_cli.py
sed -n '44,46p'   blender/agent-harness/cli_anything/blender/README.md
grep -rn 'blender_backend' blender/agent-harness --include='*.py'

看第一条的返回体里有没有那个 "command" 字段、看第二条打的是不是 “generated”,最后一条能看清 backend 到底被谁引用。三处对上,这件事就确认了。

测试全程不需要装 Blender

blender harness 的测试数量在 12 个代表应用里属于靠前的:test_core.py 165 个 def test_test_full_e2e.py 63 个,这两个文件合计 228;再加上 TEST.md 未登记的第三个文件 test_bpy_gen.py 的 6 个,三个文件共 234 个测试函数(我们采集时按 grep 'def test_' 计数,那第三个文件的来龙去脉留到最后一节说)。

有意思的是 e2e 这个文件的开头:tests/test_full_e2e.py:1-6 明确写着不需要真的安装 Blender。也就是说这套端到端测试验的是 bpy 脚本的生成、工程文件的往返、以及 CLI 子进程的调用,而不是「渲出来的图对不对」。这跟上一节那个设计是自洽的——主路径本来就止步于生成脚本。

同一个仓库里,别的 harness 在这一点上做法并不一样。krita 那份 e2e 的文件头写的是要调真实 Krita 应用做导出、并明说不做优雅降级、Krita 必须已安装(krita/.../tests/test_full_e2e.py:1-6)。同样是同构目录结构下的 e2e 文件,一个不需要宿主、一个硬要求宿主。所以「这个 harness 的测试绿了」这句话,在不同 harness 下的含义完全不同,你要判断某个 harness 的成色,得自己去翻它 e2e 文件头那几行 docstring。这类逐个应用的横向差异另有一篇专门在讲,这里只借 krita 做一个对照。

顺带一句合规口径:我们没有运行过任何一个 harness 的任何一个测试,上面关于「需不需要宿主」的说法,全部来自测试文件的 docstring 与 import 语句本身。

状态:50 步撤销,以及 Windows 上少一层锁

core/session.py:36-39Session 类带一个类常量 MAX_UNDO = 50。工程状态是一份 JSON,落盘走同文件 :10-33_locked_save_json,它会尝试用 fcntl.flock 加排他锁再写。

Windows 侧要注意的就在这句降级逻辑上:平台没有 fcntl 时,这层锁是静默跳过的,写入照常进行。 同一套写法在这个仓库里是通用做法,freecad 的 core/session.py:20-23 干脆把这件事写进了 docstring,原文大意是在缺少 fcntl 的平台(Windows)上写入不加锁。这不是 bug 报告,只是一条你在 Windows 上并发使用同一份工程 JSON 时需要知道的事实。

还有一条自动保存的规则值得记,因为它决定了你的改动什么时候才真正落盘:blender_cli.py:216-229@cli.result_callback() 注册了 auto_save_on_exit,触发条件是非 REPL 模式、非 --dry-run、会话被标记为已修改(_modified)、且有 project_path;保存失败时只打一条 Warning,不中断。--dry-run 在这个 harness 里出现 4 处。

REPL 那边则是复用同一棵命令树:根组不带子命令时直接进 REPL(:212-213),REPL 内用 shlex.split 切行、再 cli.main(args, standalone_mode=False) 走一遍 Click,并吞掉 SystemExit:1135-1141)。所以 REPL 里报错不会把你踢出去——错误处理的装饰器 handle_error 也是这么设计的,只有非 REPL 模式才 sys.exit(1):160-186)。

preview 这一层是少数派

core/preview.py 852 行,是这个 harness 里最大的一个模块,但它对外只有 3 条命令,加上嵌套的 preview live 5 条(start/push/status/stop/monitor)。core/preview.py:40-48 里 recipe 只有一个,叫 quick,描述是两张静帧、主预设 eevee_preview、副预设 workbench、timeout 300。渲染预设本身是 7 个(core/render.py:14:cycles_default、cycles_high、cycles_preview、eevee_default、eevee_high、eevee_preview、workbench)。modifier 注册表 8 项(core/modifiers.py:7:subdivision_surface、mirror、array、bevel、solidify、decimate、boolean、smooth)。

preview 在整个仓库里是少数派能力:我们采集时只有 5 个 harness 有 core/preview.py(blender、freecad、openscreen、renderdoc、shotcut),而规范里 preview 用的是 SHOULD 而非 MUST(cli-anything-plugin/HARNESS.md:503-512)。blender 与 freecad 这两家共用同一套协议——两边的 utils/preview_bundle.py 都是 468 行,都声明 PROTOCOL_VERSION = "preview-bundle/v1"TRAJECTORY_PROTOCOL_VERSION = "preview-trajectory/v1"blender/.../utils/preview_bundle.py:14-15),preview live 组两边也都是同样 5 条。我们只比对了命令名、行数与协议常量,没有逐行 diff 这两个文件是否完全相同。另外 docs/ 下还有 PREVIEW_PROGRESS.mdPREVIEW_MECHANISM_PROGRESS.md 两份进度文档,docs/PREVIEW_PROTOCOL.md 末节标题是 ## Initial 4-PR Plan:693)——这套协议按仓库自己的说法仍在分批落地。

三处文档口径对不上的地方

写这类文章绕不开数字,这个 harness 有三处可以自己核的差异,说完就停:

一、TEST.md 与代码。blender/.../tests/TEST.md:7-9 的表格写 test_core.py 8 个类 156 个测试、test_full_e2e.py 5 个类 44 个测试、合计 13 类 200 个,文件底部贴的 pytest 输出块也写着 200 passed(:167-172)。我们采集时按 grep -cE '^\s+def test_' 数到的是 test_core.py 165 个、9 个类,test_full_e2e.py 63 个、9 个类,另外还有 TEST.md 完全没提到的第三个测试文件 test_bpy_gen.py(6 个测试,只含一个类 TestGeneratedScriptSyntax)。需要说明的是,我们的计数用的是正则匹配函数定义,没有展开 @pytest.mark.parametrize,所以这是「声明数与函数定义数不一致」,不是「实际通过数不一致」。

二、README 里的运行路径。包内 README 的 Quick Start 通篇用 python3 -m cli.blender_cli ...README.md:20-46),而 ls -d */agent-harness/cli | wc -l 的结果是 0,仓库里不存在这样的包目录;实际可用的路径是 python3 -m cli_anything.blender,由 __main__.py 提供,而 __main__.py 自己的 docstring 也写着同一条过期路径。

三、SKILL.md 的自述。这份文件 299 行,skills/SKILL.md:11 说明它用 JSON 场景描述格式加 bpy 脚本生成;而 frontmatter 里的 description(:5)以字面的 ... 结尾,是被截断的——SKILL.md 在这个项目里是用正则从源码抽取生成的,不是运行时反射。另外 skills/SKILL.md:237 那句 “Undo/Redo: Up to 50 levels of history” 与模板里的固定文案一字不差,这一句恰好和代码里的 MAX_UNDO = 50 对得上。

一句话记住这个 harness 的形状

装进包的命令名是 cli-anything-blenderblender/agent-harness/setup.py:58-60),打包声明里的成熟度是 Development Status :: 4 - Betasetup.py:77)——顺带一提,我们采集时全仓没有任何一个 harness 标 Production/Stable。

把上面几节压成一句:blender harness 是一个离线编场景、在线出脚本的工具。JSON 工程 + 50 步撤销这一半完全不碰 Blender,脚本生成这一半也只产出文本,真正会走到那次子进程调用的,按 import 关系看是 preview 那条路(blender_backend 在包内只被 core/preview.py 与测试引用),要么就是你自己把它给你的那行命令敲下去——我们没有运行过,这一点只是从引用关系上读出来的。想清楚这一点,你再决定要不要把它接进 agent 流水线,判断会准得多。


本文依据 CLI-Anything 官方仓库(github.com/HKUDS/CLI-Anything)的 README、registry.jsondocs/ 与各 agent-harness/ 下的源码整理,核对日 2026-08-10,对应仓库快照 39634a6。 本文内容为仓库源码与文档口径,我们没有安装或运行过其中任何一个 harness, 也没有在本机驱动过任何一款宿主软件,因此不涉及实际操控效果的任何描述。 这类 harness 会在本机执行外部程序与脚本,是否使用请结合自身环境评估。 注册表与 harness 内容随上游更新而变动,请以仓库最新内容为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。