关键帧系统的三层:数据模型、注册表、UI

2026-08-09

先把状态说清楚,再说技术。本文描述的是 OpenCut 旧版(classic)的行为,仓库是 OpenCut-app/opencut-classic。该代码库已归档、不再维护,opencut.app 线上目前仍运行该版本;重写版仓库 OpenCut-app/OpenCut 正在开发中,功能与操作可能变化。文中所有文件路径、类型名与字段名都来自 classic 仓库 docs/keyframes.md 与相关目录,我们没有编译或运行过任何版本的 OpenCut,也不会描述界面长什么样。

关键帧为什么必须分层

给视频编辑器加动画,最省事的写法是:在元素对象上塞一个 opacity 字段,再塞一个「关键帧数组」,播放时谁有数组用数组、没有用字段。这种写法在只有两三个可动画属性时能跑,属性一多就会崩——每加一个属性,播放器、导出器、属性面板三处都要改一遍,而且改漏一处只会在导出时才暴露。

classic 的 docs/keyframes.md 把这件事拆成了三层:数据模型(怎么存)、注册表(哪些属性支持关键帧、怎么读写它们)、UI(把前两层接起来的 hooks 与组件)。这个分法值得单独讲,因为它给出的边界很干净:数据层不知道有哪些属性,注册表不知道值怎么插值,UI 不知道数据长什么样。下面逐层看,并在每层末尾说清楚它给你留下的判断依据。

第一层:数据模型只关心「桶」

数据模型的入口是时间线元素。文档的说法是,每个 BaseTimelineElement 上挂一个可选的 animations?: ElementAnimations,这个类型本身极简:

interface ElementAnimations {
    channels: Record<string, AnimationChannel | undefined>;
}

注意这里的两个设计:

第一,animations可选的。没有做过任何动画的元素身上根本不存在这个字段,静态属性还是老老实实写在元素自己身上。这决定了整套系统是「叠加」而不是「替换」——你不必为了支持关键帧就把所有属性改成通道形式。

第二,channels 是一个 Record<string, ...>,键是属性路径。文档给的例子是 "opacity""background.color"。也就是说,关键帧不是挂在属性上的,而是挂在「路径字符串」上的,嵌套属性用点号表达。这一步是后面所有事情的地基:因为键是字符串,数据层就可以对属性一无所知;也正因为键是字符串,才必须有第二层来管住「哪些字符串是合法的」。

通道分三种类型:NumberAnimationChannelColorAnimationChannelDiscreteAnimationChannel。前两种从名字就能对上号——数值和颜色的插值规则完全不同,硬塞进一个类型里只会得到一堆分支判断。第三种 DiscreteAnimationChannel,文档在这一节只给了类型名,我们没有读到它的字段定义,所以这里不替它补语义,你要用之前先去仓库里读实现。

这一层给你的判断依据:当你要加的东西不是「某个值随时间变化」,而是「某段时间内元素结构不同」,那它就不该进 channels。channels 的假设是同一个属性路径在整条时间轴上一直存在、只是取值在变。

第二层:注册表是唯一的权威清单

第二层的核心是两个文件,但它必须和求值器、渲染器一起看才说得通。下面这张表把 classic 仓库 docs/keyframes.md 列出的四个协作模块一并放出(路径均为 classic 仓库内的路径,重写版没有这套结构):

模块文件职责
路径清单src/types/animation.tsANIMATION_PROPERTY_PATHS 是合法路径的权威列表
注册表src/lib/animation/property-registry.ts定义哪些属性路径可动画、怎么在元素上读写它们
求值器src/lib/animation/resolve.ts返回某属性在给定局部时间的有效值;没有关键帧时回退到元素的静态值
渲染器src/services/renderer/绘制前先调 resolve,使动画属性在导出与预览时都正确插值

先说前两个。文档给了新增一个可动画属性的完整示例,用的是 "background.paddingX":第一步在 src/types/animation.tsANIMATION_PROPERTY_PATHS 里注册这条路径;第二步才在 property-registry.ts 里加条目,条目字段包含 valueKind(取值为 "number" | "color" | "discrete")和 defaultInterpolation(示例值 "linear")。

顺序不能反,原因就是上一层留下的那个洞:数据模型用字符串当键,如果没有一份权威清单,任何拼写错误都会安静地生成一个永远不会被读取的通道。把清单单独放在类型文件里,等于让类型系统替你挡住这类错误。

valueKind 的三个取值正好对上三种 channel 类型,这不是巧合——注册表在这里承担了「路径 → 通道类型」的映射。所以你在第一层纠结的那个问题(我的属性算数值还是离散),在这一层必须给出一个明确答案,含糊不过去。

property-registry.ts 的另一半职责更容易被忽略:怎么在元素上读写它们。属性路径是 "background.paddingX" 这样的字符串,而元素是一个嵌套对象,两者之间需要一段存取逻辑。把这段逻辑集中到注册表里,意味着 UI 和渲染器都不需要自己解析路径字符串。

这一层给你的判断依据:如果你发现自己在业务代码里写了 element.background?.paddingX 这种手动取值,说明你绕过了注册表,后面加第二个属性时会重复一遍。正确的落笔位置是注册表条目。

求值器:整套设计里最关键的那句话

resolve.ts 的定位是返回某个属性在给定局部时间下的有效值。文档里跟着的半句才是重点:没有关键帧时,回退到元素的静态值

这一句把整套系统从「两条代码路径」压成了「一条」。渲染器不需要判断这个元素有没有动画,它只管在绘制前调 resolve;有关键帧就得到插值结果,没有就得到静态值。于是「支持关键帧」这件事对渲染器来说是透明的。

src/services/renderer/ 的文档职责写得很明确:绘制前先调 resolve,使动画属性在导出与预览时都正确插值。这句里「导出与预览」并列出现是有分量的——预览和导出走同一个求值入口,就不会出现「预览里动画是对的、导出出来不对」这种排查起来极其痛苦的问题。这类 bug 的成因几乎永远是两条路径各自实现了一遍插值。

注意 resolve 接受的是局部时间,不是时间轴上的绝对时间。也就是说关键帧的时间坐标是相对元素自身的。按这个语义推下来,元素在时间轴上的起止位置发生整体位移时,关键帧的时间坐标不需要跟着批量改写——这是从「局部时间」这四个字推出的结构性结论,不是我们观察到的运行结果。

这一层给你的判断依据:你要加的效果如果无法表达成「给定局部时间返回一个值」这种纯函数形式(比如它依赖上一帧的结果、或者依赖别的元素的状态),那它就不属于关键帧系统,硬塞进来会破坏预览与导出的一致性。

第三层:UI 的两个 hook 替你处理状态切换

UI 层住在 src/components/editor/panels/properties/hooks/,文档点名了两个 hook:useKeyframedNumberProperty 用于数值字段(文档举的例子是不透明度、位置、缩放等),useKeyframedColorProperty 用于颜色选择器。

它们的职责有两块。一块是处理「切换 / 添加 / 删除关键帧」的流程,这部分是显式的。另一块更值得注意:两个 hook 都会根据这个属性当前是否已有关键帧,自动在「写静态属性」与「写动画通道」之间切换

这条正好补上了第一层留下的那个语义。数据模型允许 animations 缺席,于是「把某个属性改成一个新值」这件事就有了两种含义:这个属性还没有任何关键帧时,写入应该落到静态值上;已经有关键帧了,写入应该落到(或新建)当前时间点的关键帧上。如果把这个判断交给每一个属性控件自己写,那么控件数量就等于这段逻辑被重复实现的次数,而且总有一个会写错。

这一层给你的判断依据:接入一个新的属性控件时,先确认它的值类型能不能落到这两个 hook 之一。数值和颜色有现成的;如果是离散类型的属性,文档这一节没有列出对应 hook,你需要先去仓库里确认有没有,而不是假设它存在。

把三层串起来:新增一个可动画属性的落笔顺序

按文档给出的信息,顺序是清楚的:

  1. src/types/animation.tsANIMATION_PROPERTY_PATHS 里注册路径——先让这条路径合法;
  2. src/lib/animation/property-registry.ts 加条目,定下 valueKinddefaultInterpolation——先让它可读写、可插值;
  3. 再去属性面板接对应的 hook——最后才是用户能碰到的部分。

反过来做(先做界面、再补注册)会踩的坑是:控件已经能动了,但通道键没进清单,或者 valueKind 定错,问题要到渲染或导出阶段才暴露。

顺带说一句,classic 的另一份文档 docs/actions.md 里有一条同类经验:一个 action 只要带了 defaultShortcuts,就必须同时写 keybindings 迁移,理由是快捷键持久化在 localStorage 里,新的默认值只对全新安装生效。这条和关键帧没有直接关系,但道理是同一个——「改默认值」和「让已有用户拿到新默认值」是两件事。做关键帧属性时同样要想一下:已经存在的项目数据里没有你这条通道,回退路径是不是通的(按 resolve 的语义,应该回退到静态值,但值得自己验一遍)。

边界:哪些我们没读,以及别把两个仓库搞混

诚实交代几件事。

docs/keyframes.md 里我们摘的是数据模型、四个协作模块与两个 hook 这几部分。插值算法的具体实现、DiscreteAnimationChannel 的字段定义、defaultInterpolation"linear" 以外还支持哪些取值,我们都没有依据,本文一个字都没写,你要用请自己去读源码。

另外,classic 仓库的 rust/crates/ 下有六个 crate:bridgecompositoreffectsgpumaskstime,另有 rust/wasm/ 发布为 npm 包 opencut-wasm。关键帧求值出现在 src/services/renderer/ 的调用链上,属于 TypeScript 侧;本文没有材料说明它与 Rust 侧的分工细节,所以不展开。

最后再强调一次仓库归属:以上全部是 classic(旧版) 的结构。重写版仓库 OpenCut-app/OpenCut 的 README 明写「OpenCut is being rewritten from the ground up」(OpenCut 正在被从头重写),它的 Cargo.toml 里 workspace 只有 apps/desktop 一个成员、crates/* 还被注释着。重写版 README 列出的 Editor API、第三方插件、三端共用一套代码库、MCP server、无头模式、编辑器内置脚本标签页,都是官方 README 声明的计划,尚未发布,我们没有见到可用实现。所以本文讲的关键帧分层不能拿去描述重写版,重写版最终会怎么组织这部分,现在没有依据可谈。

两个仓库的元数据(2026-08-09 快照):OpenCut-app/OpenCut 有 81917 个 star,未归档;OpenCut-app/opencut-classic 有 213 个 star,已归档、最后推送 2026-05-17。star 数只作为时间锚点记录,不用来推导任何质量或适用性结论。

延伸阅读


本文依据 OpenCut 官方仓库(github.com/OpenCut-app/OpenCut 与已归档的 github.com/OpenCut-app/opencut-classic)的 README、docs/ 架构文档、package.jsonCargo.toml 与目录结构整理,核对日 2026-08-09。本文内容为仓库源码与文档口径,我们没有编译或运行过任何版本的 OpenCut。

本文描述的是 OpenCut 旧版(classic)的行为。该代码库已归档、不再维护,opencut.app 线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。

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