TypeScript 决定跑什么 shader,Rust 和 wgpu 负责怎么跑
先把状态说清楚,再谈技术:本文描述的是 OpenCut 旧版(classic)的行为。该代码库已归档、不再维护,opencut.app 线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。 核对日 2026-08-09。下文所有文件路径、字段名都来自已归档仓库 OpenCut-app/opencut-classic 的 docs/ 架构文档与目录结构,我们没有编译或运行过任何版本的 OpenCut。
一句话的边界,比一张架构图更有用
docs/effects-renderer.md 里有一句话,值得单独拎出来读:所有特效共用同一个 GPU 渲染器,TypeScript 决定跑哪些 shader 标识符、传哪些 uniform;Rust 和 wgpu 拥有设备创建、纹理和 pass 执行。
这句话之所以重要,是因为它把「加特效」这件事从一个模糊的跨语言问题,压缩成了一个可以回答的问题:你要加的东西,是要跑什么,还是怎么跑?前者在 TypeScript 侧,后者在 Rust 侧。绝大多数「加一个调色特效」「加一个模糊变体」的需求落在前者,你根本不需要碰 Rust——这是这条边界给出的第一个判断依据。
反过来说,凡是涉及设备/适配器怎么拿、纹理怎么分配和复用、一个 pass 怎么被真正提交上去的问题,TypeScript 侧再怎么改都够不着。这类问题一旦出现,你要找的是 Rust 那一层,不是特效定义文件。
这条边界在目录上长什么样
classic 仓库的 rust/ 目录,README 描述为平台无关的核心:GPU 合成器、特效、蒙版、WASM 绑定,并写明他们正在把业务逻辑从 TypeScript 迁过来。实读 rust/crates/ 下面是六个 crate:bridge、compositor、effects、gpu、masks、time;另有 rust/wasm/,发布成 npm 包 opencut-wasm。
有一个细节能佐证「同一套 GPU 代码要同时服务浏览器与原生」:gpu crate 的 Cargo.toml 里有 [target.'cfg(target_arch = "wasm32")'.dependencies] 段,也就是它针对 wasm32 目标有一组单独的依赖。同一份 Rust 代码,编到 wasm32 给浏览器用,编到原生给桌面端用——桌面端在 classic 的 README 里标注为 in progress,用 GPUI 构建。
另一头,classic 的 apps/web/package.json 的 60 个 dependencies 里能看到 opencut-wasm 赫然在列,媒体侧还有 mediabunny,浏览器端模型侧有 @huggingface/transformers。从包名能确认的只是用途方向:媒体与渲染的重活放在浏览器本地做,这跟 classic README 自述的三条 Why 中的 Privacy(视频留在你自己设备上)是一致的——那三条 Why 是项目方的主张,不是我们的评价,另外两条是 Free features 与 Simple,原文出自 classic README。
一个特效在代码里由什么构成
classic 的 docs/effects-renderer.md 给的新增流程是三步:在 apps/web/src/lib/effects/definitions/(classic 仓库)建一个文件(文档举例 brightness.ts),导出一个 EffectDefinition(参照同目录的 blur.ts),然后在该目录的 index.ts 里注册。
EffectDefinition 的字段是这条边界的具体化:
| 字段 | 职责 | 落在哪一侧 |
|---|---|---|
type | 唯一字符串标识 | TypeScript |
name | 显示名 | TypeScript |
keywords | 供搜索用 | TypeScript |
params | 面向用户的控件,滑块、开关等 | TypeScript |
renderer | GPU pass 模板,解析成 shader 标识符 + uniforms | TypeScript 描述,Rust/wgpu 执行 |
前四个字段一眼就知道是产品侧的东西。真正跨过边界的是 renderer:它是一份模板,被解析成 shader 标识符和 uniforms 之后交出去。TypeScript 在这里写的不是渲染逻辑,是一份「跑哪个、带什么参数」的清单。
最简形态是这样:
renderer: {
passes: [
{ shader: "my-effect-shader", uniforms: ({ effectParams }) => ({ ... }) },
],
}
uniforms 是个函数,从 effectParams 里取值——也就是用户在 params 里调的那些控件值,最终以 uniform 的形式喂给这一趟 pass。这条链路是理解整个特效系统的主干:用户拨滑块 → effectParams 变 → uniforms 重新算 → 同一个 shader 标识符带着新参数再跑一遍。
什么时候一个 pass 不够
渲染器支持 passes 数组,单 pass 特效(比如调色一类)就只有一个条目。文档给出的判断标准很干脆:当特效必须处理自己的输出时,就需要多 pass。
文档举的例子有三个:模糊(先横后纵)、辉光 bloom(提取 → 模糊 → 合成)、glow。这三个例子放在一起看,规律就出来了——它们的共同点不是「效果复杂」,而是后一步的输入是前一步的输出。横向模糊的结果要再被纵向模糊一遍;bloom 要先把亮部提取出来,模糊之后再跟原图合成。你没法把这些塞进一趟里,因为一趟 pass 读的是同一份输入。
所以你要判断自己的特效是不是多 pass,别看它「看起来难不难」,问一句:中间是否存在一个必须先落地、再被当作输入读回来的结果?没有,就是单 pass;有,几趟就是几趟。
buildPasses:这里是最容易踩的一脚
有些特效的 pass 数量不是固定的,会随参数变。文档举的例子是模糊——在高强度时需要更多迭代来保证质量。这种情况下加一个 buildPasses 函数:
renderer: {
passes: [ /* 静态兜底 —— buildPasses 缺席时使用 */ ],
buildPasses: ({ effectParams, width, height }) => {
// 返回带预计算 uniform 的 EffectPass[]
},
}
buildPasses 拿到的除了 effectParams,还有 width 和 height——尺寸参与 pass 的构造,这本身就说明它算的不只是「几趟」,还有跟分辨率相关的预计算 uniform。
关键规则在这里,写错会让人排查很久:文档明写,buildPasses 存在时,所有渲染路径都用它,不再使用静态 passes 数组;静态数组保留下来是作为结构参考与兜底。
这条规则的实际后果是:你如果给一个已经有 buildPasses 的特效去改 passes 数组里的参数,改完一点反应都没有,因为那个数组已经不在生效路径上了。这类「改了没反应」的问题最耗时间,因为它不报错。所以碰到特效行为跟你的改动对不上,第一件事不是去怀疑 shader,而是回到这个特效的定义文件,看它有没有 buildPasses——有,就去改 buildPasses;没有,才去改 passes。
同一份文档里还有一节强调,pass 的解析必须走 resolveEffectPasses。把这两条放在一起看,意思就清楚了:静态数组与 buildPasses 这两种写法之间的取舍,是在解析这一步统一裁决的,绕开它自己去读 passes,你拿到的就可能不是真正会被执行的那一份。任何自行读取 pass 定义的地方都是隐患。
所以你的改动该落在哪一侧
把上面的东西收成一条可执行的判断路径:
- 想加一个新效果,或者调一个已有效果的参数范围、控件、默认值 → 全部在
apps/web/src/lib/effects/definitions/里,EffectDefinition的前四个字段,不碰 Rust。 - 想改一个效果跑几趟、每趟带什么 uniform → 还是 TypeScript 侧,但要先确认这个特效是靠静态
passes还是buildPasses在跑,改错地方等于没改。 - 想改一个效果算得对不对、算法本身长什么样 → 那是 shader,文档另有
Writing shaders与Coordinate systems两节,我们只读了标题,没读正文,这里不展开也不猜。 - 遇到的问题是设备/纹理/pass 执行层面的 → 边界的另一侧,
rust/crates/下的gpu、effects、compositor那几个 crate,跟bridge与rust/wasm/的绑定一起看。
这套分法对读者的价值不限于 OpenCut 本身。「声明式的一侧描述要跑什么,另一侧拥有资源与执行」是个复用性很强的结构:描述侧可以用弱类型语言快速迭代、随时热改,执行侧把设备与内存握在自己手里、不让上层直接摸。代价是要额外维护一层解析(也就是 resolveEffectPasses 干的事),以及像 buildPasses 覆盖 passes 这种优先级规则必须写进文档——否则就会变成只有作者知道的隐性知识。
顺便把重写版划清楚
需要特别说明的是,上面这一整套东西属于 classic(旧版)仓库,OpenCut-app/opencut-classic,最后推送 2026-05-17,已归档,star 213(核对日 2026-08-09)。
重写版是另一个仓库 OpenCut-app/OpenCut,star 81917(同一核对日),README 的 Status 段明写 OpenCut 正在被从头重写,并列出了一批「即将到来」的东西——Editor API、一等公民的第三方插件、桌面移动浏览器共用一套代码库(Rust 核心)、面向 AI agent 的 MCP server、无头模式(自动化与批量渲染)、编辑器内置的脚本标签页。这几条都是官方 README 声明的计划,尚未发布,不是现有功能,我们没有见到可用实现。
而且重写版的 Rust 部分现在跟 classic 完全不是一回事:我们实读主仓的 Cargo.toml,workspace 只有 apps/desktop 一个成员,crates/* 那一行是被注释掉的。也就是说 classic 里那六个 crate 的结构,不能拿来描述重写版当前的样子。README 自己也说旧版本仍在 opencut-app/opencut-classic,那才是今天该用的那个,opencut.app 跑的仍然是 classic,重写版会先落在 new.opencut.app,直到它准备好接管——没有给时间表。
如果你打算基于本文的内容去做点什么,把版本对上是第一步:读 classic 的 docs/effects-renderer.md,改 classic 的 definitions/。重写版目前不接受外部贡献,README 原文说架构还在设计中,他们还没准备好接受外部贡献,想跟进可以去 issue 或官方 Discord。
延伸阅读
- OpenCut Actions 系统:快捷键、按钮、右键菜单的统一触发层
- classic 的技术栈:Next.js、Rust WASM 与浏览器里的媒体处理
- 为什么模糊要分两趟:单 pass、多 pass 与 buildPasses
本文依据 OpenCut 官方仓库(github.com/OpenCut-app/OpenCut 与已归档的 github.com/OpenCut-app/opencut-classic)的 README、docs/ 架构文档、package.json、Cargo.toml 与目录结构整理,核对日 2026-08-09。本文内容为仓库源码与文档口径,我们没有编译或运行过任何版本的 OpenCut。
本文描述的是 OpenCut 旧版(classic)的行为。该代码库已归档、不再维护,
opencut.app线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。
涉及重写版的部分,描述的是其仓库当前的代码结构与官方 README 声明的路线图。该版本尚未发布,Editor API、插件体系、MCP server、无头模式等均为官方声明的计划,我们没有见到可用实现。
许可条款请以官方 LICENSE 原文为准,本文不构成法律意见。