加一个可动画属性要动几个文件

2026-08-09

先把状态说清楚,再往下看:

本文描述的是 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,「新增一个可动画属性」这件事被明确拆成两步:

  1. src/types/animation.tsANIMATION_PROPERTY_PATHS 里注册这个属性的路径;
  2. property-registry.ts(文档写作 src/lib/animation/property-registry.ts)里加一条条目,字段包含 valueKinddefaultInterpolation

也就是说,必须动手写的是两个文件。但只改这两个文件就交差,是新手最常见的误判——另外还有两个模块你不改也得读懂,否则不知道自己改的东西什么时候生效、生效在哪一层。文档把整个关键帧子系统描述为三层:数据模型(怎么存)、注册表(哪些属性支持关键帧、怎么读写)、UI(把两者接起来的 hooks 与组件)。你的两处改动全落在中间那层,上下两层是它的约束条件。

数据模型:你注册的「路径」是拿来当键用的

每个 BaseTimelineElement 上有一个可选字段 animations?: ElementAnimations,类型是这样的:

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

channels 是一个按属性路径键控的关键帧桶,文档给的键名例子是 "opacity""background.color"。看到这里,第一步为什么要往 ANIMATION_PROPERTY_PATHS 里注册就清楚了:那个常量是合法路径的权威列表,而你注册进去的字符串,最后会原样成为这个 Record 的键。路径写错、大小写不一致、和实际读写元素时用的路径对不上,表现不会是编译报错,而是数据存进了一个谁都不去读的键里。

channel 有三种类型:NumberAnimationChannelColorAnimationChannelDiscreteAnimationChannel。这三种正好对应注册条目里 valueKind 的三个取值 "number" | "color" | "discrete"。所以第二步填 valueKind 不是随手写的元数据,它决定了这条属性走哪一类 channel。

四个协作模块,两个你改、两个你读

模块文件(文档写法)职责本次要不要动
路径清单src/types/animation.tsANIMATION_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/,那说明你做的已经不只是「加一个可动画属性」,而是在动通用机制,这时候该停下来重新审一遍范围。

怎么验收

按机制的因果链逐层检查,四步:

  1. 路径一致性ANIMATION_PROPERTY_PATHS 里注册的字符串,和你在注册表里读写元素时用的路径,必须是同一个。前面说过,channels 是按这个字符串键控的,写错不报错、只是永远命中不到。
  2. valueKind 与 channel 类型对得上"number"NumberAnimationChannel"color"ColorAnimationChannel"discrete"DiscreteAnimationChannel
  3. 零关键帧时的回退:求值器的语义是没有关键帧就回退到元素的静态值。所以先只注册、不打任何关键帧,确认这个属性的原有行为没变,再去验插值。这一步能把「注册动作本身破坏了静态路径」这类问题单独隔离出来。
  4. 预览与导出用同一条路径:渲染器是绘制前调 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.tsACTIONS 里加条目,而且文档明确提示:只要带了 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.jsonCargo.tomlchangelog/ 整理,核对日 2026-08-09。本文内容为仓库源码与文档口径,我们没有编译或运行过任何版本的 OpenCut。

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

许可条款请以官方 LICENSE 原文为准,本文不构成法律意见。

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