加一个可动画属性要动几个文件
先把状态说清楚,再往下看:
本文描述的是 OpenCut 旧版(classic)的行为。该代码库已归档、不再维护,
opencut.app线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。核对日 2026-08-09。
这句话不是免责套话。OpenCut 现在有两个仓库(以下 GitHub 元数据均为 2026-08-09 快照):OpenCut-app/opencut-classic 是旧版,元数据里 archived 为 true,最后推送停在 2026-05-17,star 213;OpenCut-app/OpenCut 是重写版,star 81917,README 的 Status 段落明写「OpenCut is being rewritten from the ground up」(OpenCut 正在被从头重写)。star 数只是这一天的快照,推不出任何关于质量或稳定性的结论。本文讲的关键帧机制、下面所有文件路径和类型名,全部来自 classic 那一侧的 docs/keyframes.md 与目录结构。重写版目前是另一套代码,别把两边混着用。
先回答标题的问题
按 classic 的 docs/keyframes.md,「新增一个可动画属性」这件事被明确拆成两步:
- 在
src/types/animation.ts的ANIMATION_PROPERTY_PATHS里注册这个属性的路径; - 在
property-registry.ts(文档写作src/lib/animation/property-registry.ts)里加一条条目,字段包含valueKind与defaultInterpolation。
也就是说,必须动手写的是两个文件。但只改这两个文件就交差,是新手最常见的误判——另外还有两个模块你不改也得读懂,否则不知道自己改的东西什么时候生效、生效在哪一层。文档把整个关键帧子系统描述为三层:数据模型(怎么存)、注册表(哪些属性支持关键帧、怎么读写)、UI(把两者接起来的 hooks 与组件)。你的两处改动全落在中间那层,上下两层是它的约束条件。
数据模型:你注册的「路径」是拿来当键用的
每个 BaseTimelineElement 上有一个可选字段 animations?: ElementAnimations,类型是这样的:
interface ElementAnimations {
channels: Record<string, AnimationChannel | undefined>;
}
channels 是一个按属性路径键控的关键帧桶,文档给的键名例子是 "opacity" 和 "background.color"。看到这里,第一步为什么要往 ANIMATION_PROPERTY_PATHS 里注册就清楚了:那个常量是合法路径的权威列表,而你注册进去的字符串,最后会原样成为这个 Record 的键。路径写错、大小写不一致、和实际读写元素时用的路径对不上,表现不会是编译报错,而是数据存进了一个谁都不去读的键里。
channel 有三种类型:NumberAnimationChannel、ColorAnimationChannel、DiscreteAnimationChannel。这三种正好对应注册条目里 valueKind 的三个取值 "number" | "color" | "discrete"。所以第二步填 valueKind 不是随手写的元数据,它决定了这条属性走哪一类 channel。
四个协作模块,两个你改、两个你读
| 模块 | 文件(文档写法) | 职责 | 本次要不要动 |
|---|---|---|---|
| 路径清单 | src/types/animation.ts | ANIMATION_PROPERTY_PATHS 是合法路径的权威列表 | ★ 必改 |
| 注册表 | src/lib/animation/property-registry.ts | 定义哪些属性路径可动画、怎么在元素上读写它们 | ★ 必改 |
| 求值器 | src/lib/animation/resolve.ts | 返回某个属性在给定局部时间的有效值;没有关键帧时回退到元素的静态值 | 读懂 |
| 渲染器 | src/services/renderer/ | 绘制前先调 resolve,使动画属性在导出与预览时都正确插值 | 读懂 |
这张表值得多看一眼的是最后两行。求值器有一条很关键的语义:没有关键帧时回退到元素的静态值。这意味着你注册完一个新属性、但用户一个关键帧都没打的时候,它的行为应该和注册之前完全一样——这是你自查有没有把老逻辑改坏的天然基准。而渲染器那条写的是「绘制前先调 resolve,使动画属性在导出与预览时都正确插值」——按这句话的字面口径,预览和导出取的是同一个求值入口,而不是各写一套插值逻辑。这是文档的说法,我们没有跑过任何一侧去比对。
UI 层:先想清楚你的属性归哪个 hook
文档在 src/components/editor/panels/properties/hooks/ 下给出了两个 hook:useKeyframedNumberProperty 用于数值字段(文档举例:不透明度、位置、缩放等),useKeyframedColorProperty 用于颜色选择器。两者都处理「切换 / 添加 / 删除关键帧」的流程,并且会根据是否已有关键帧,自动在「写静态属性」与「写动画通道」之间切换。
这个自动切换是整套设计里最省事的一块:控件不需要自己判断当前该写 animations.channels 还是写元素上的静态字段,hook 替你决定了。反过来说,如果你新增的属性是 "number" 或 "color" 之一,UI 层基本不用新写胶水逻辑,挂对 hook 就行。文档在这一节只给了这两个 hook,"discrete" 类型对应的 UI 接法它没有写,我们也没读到——真要做离散属性,得自己去仓库里翻,别按本文推。
可直接复制:把 classic 跑起来的命令
改代码之前先有个能跑的本地环境。以下是 classic README 的原文步骤,前置是 Bun、Docker 与 Docker Compose,README 注明 Docker 是可选但推荐的(用来跑本地数据库与 Redis),只做前端可以跳过:
# 1. fork 并 clone 仓库
# 2. 复制环境变量文件
cp apps/web/.env.example apps/web/.env.local # Unix/Linux/Mac
Copy-Item apps/web/.env.example apps/web/.env.local # Windows PowerShell
# 3. 起数据库与 Redis
docker compose up -d db redis serverless-redis-http
# 4. 装依赖并起开发服务
bun install
bun dev:web
应用在 http://localhost:3000。README 说 .env.example 的默认值与 Docker Compose 配置对应,开箱即用。Windows 侧注意第 2 步是 Copy-Item 那一行,别照抄 cp。桌面端(apps/desktop/,README 标注 in progress)是 opt-in 的,只改关键帧属性完全不需要碰它,也不需要 Rust 工具链——rust/ 那套 WASM 本地构建流程只有在你改 rust/wasm 时才需要。
定位文件时,与其一层层点目录,不如直接在仓库根搜标识符:
rg "ANIMATION_PROPERTY_PATHS"
rg "useKeyframedNumberProperty"
我们没有跑过这些搜索的输出,这里也就不写它会打印几行;这只是省时间的定位方式,不是官方步骤。
产出物长什么样
改完之后,git status 上应该出现的东西是可预期的:src/types/animation.ts 里多一条路径字符串,property-registry.ts 里多一条注册条目。文档给的完整示例属性是 "background.paddingX",注册条目里要填的字段是 valueKind(取 "number" | "color" | "discrete" 之一)和 defaultInterpolation(示例值 "linear")。如果你的改动扩散到了求值器 resolve.ts 或渲染器 src/services/renderer/,那说明你做的已经不只是「加一个可动画属性」,而是在动通用机制,这时候该停下来重新审一遍范围。
怎么验收
按机制的因果链逐层检查,四步:
- 路径一致性:
ANIMATION_PROPERTY_PATHS里注册的字符串,和你在注册表里读写元素时用的路径,必须是同一个。前面说过,channels是按这个字符串键控的,写错不报错、只是永远命中不到。 valueKind与 channel 类型对得上:"number"对NumberAnimationChannel,"color"对ColorAnimationChannel,"discrete"对DiscreteAnimationChannel。- 零关键帧时的回退:求值器的语义是没有关键帧就回退到元素的静态值。所以先只注册、不打任何关键帧,确认这个属性的原有行为没变,再去验插值。这一步能把「注册动作本身破坏了静态路径」这类问题单独隔离出来。
- 预览与导出用同一条路径:渲染器是绘制前调 resolve 的,两者共用求值结果。如果你观察到两边不一致,那大概率不是关键帧注册的问题,得往渲染器那层去查。
最容易出错的是第 1 步和第 3 步。第 1 步错了症状极安静,第 3 步错了会被误当成「新功能没生效」,其实是老路径被改坏了。
什么情况不适用
- 你在看重写版仓库:本文所有路径、类型名都出自 classic。重写版的
apps/desktop/src/是 Rust + gpui,apps/web/src/是另一套结构,Cargo.toml的 workspace 里crates/*还被注释着。照本文去重写版里找property-registry.ts,找不到是正常的。 - 你想把改动提回上游:classic 已归档,归档意味着不再接受提交与 issue。代码是 MIT 许可,仍可 fork 自行维护;许可条款请以官方 LICENSE 原文为准。重写版那边 README 也写明架构还在设计中、暂不接受外部贡献。
- 你要的其实是快捷键:给新属性配快捷键属于另一个子系统(Actions,
docs/actions.md),要在src/lib/actions/definitions.ts的ACTIONS里加条目,而且文档明确提示:只要带了defaultShortcuts,就必须写 keybindings 迁移,因为快捷键持久化在 localStorage 里,新的默认值只对全新安装生效,迁移文件放在src/stores/keybindings/migrations/vN-to-vN+1.ts。这条经验可以直接搬到任何带用户配置的产品:「改默认值」和「让老用户拿到新默认值」是两件事。 - 你要的是一个新特效:那是第三个子系统,落点在
apps/web/src/lib/effects/definitions/,导出EffectDefinition,跟本文的动画注册表不是一条链路。
延伸阅读
本文依据 OpenCut 官方仓库(github.com/OpenCut-app/OpenCut 与已归档的 github.com/OpenCut-app/opencut-classic)的 README、docs/ 架构文档、package.json、Cargo.toml 与 changelog/ 整理,核对日 2026-08-09。本文内容为仓库源码与文档口径,我们没有编译或运行过任何版本的 OpenCut。
本文描述的是 OpenCut 旧版(classic)的行为。该代码库已归档、不再维护,
opencut.app线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。
许可条款请以官方 LICENSE 原文为准,本文不构成法律意见。