OpenCut Actions 系统:快捷键、按钮、右键菜单的统一触发层
状态说明(先读这一段):本文描述的是 OpenCut 旧版(classic,仓库
OpenCut-app/opencut-classic)的行为。该代码库已于 2026-05 归档、不再维护,opencut.app线上目前仍运行该版本;重写版(仓库OpenCut-app/OpenCut)正在开发中,功能与操作可能变化。核对日 2026-08-09。
任何一个稍微像样的编辑器,用户触发同一件事的入口都不止一个。「删除选中片段」这件事,键盘上有快捷键,工具栏上有按钮,时间轴上右键菜单里还有一条。三个入口,如果各写各的处理函数,短期看没问题,三个月后就会变成:按钮改了行为,快捷键没跟上;右键菜单里那条早就调的是另一个函数了。
OpenCut classic 的 docs/actions.md 把这件事写得很直白:Actions 是用户发起操作的触发层,负责把键盘快捷键、UI 按钮、右键菜单连到编辑器功能上。这篇就顺着这份文档,把这一层的机制拆开,重点讲你自己做一个带快捷键的产品时该照着做什么判断。
一条 action 的最小定义
新增一个 action,第一步是在 src/lib/actions/definitions.ts(OpenCut classic,下同)的 ACTIONS 对象里加条目。文档给的形状是这样的:
"my-action": {
description: "What it does",
category: "editing", // playback | navigation | editing | selection | history | timeline | controls
defaultShortcuts: ["ctrl+m"], // 可选
args: { someValue: "number" }, // 可选,只在需要参数时
},
四个字段,两个必填两个可选。看着简单,但每个字段背后都有一个你必须自己拿主意的判断。
判断一:什么东西该进 ACTIONS,什么不该
文档给 Actions 的定位是「用户发起操作的触发层」,这句话里有两个限定词值得抠。
「用户发起」——是人主动做的动作,不是系统内部的状态流转。比如「用户按下播放」是用户发起的;「播放到片段末尾自动停」不是,那是播放器内部逻辑。后者不需要注册成 action,因为它没有触发入口可言。
「触发层」——它只负责把入口连到功能,不负责实现功能。判断依据可以简化成一句:同一件事是否存在(或将来可能存在)不止一个触发入口。只有一个入口、而且你能确定不会再加第二个的操作,直接在组件里写 handler 更省事;一旦这件事有可能同时出现在快捷键和菜单里,就该进 ACTIONS。
这个判断的成本是不对称的。一开始就登记进去,多写四行;等到第二个入口出现再回头收拢,要动的是所有已经散出去的调用点。所以边界模糊的时候,倾向于登记。
判断二:七个 category 怎么选
category 的枚举是固定的七个:playback、navigation、editing、selection、history、timeline、controls。
文档只给了这七个枚举值,没有进一步说明每一类的判定标准,所以选哪一类是留给你自己的判断。可以这样理解:这七个对应的是用户脑子里对操作的分类方式,而不是代码调用链上的分层。这里最容易犹豫的是几组边界:
editing与timeline:改的是内容本身(剪、删、替换属性),还是时间轴这个视图上的组织动作(轨道、排布、缩放层级)。selection与navigation:选中了什么 vs 看到了哪里。移动播放头是导航,扩选一个片段是选择。history与editing:撤销重做属于history,它们的语义是「回退一步操作」,而不是「做一次编辑」。controls:兜底类,放那些既不改内容也不改视图的控制项。
拿不准的时候,判断依据不是「它在代码里调了什么」,而是「用户会在哪一栏找它」。分类是给人看的,不是给调用链看的。
判断三:给不给 defaultShortcuts
defaultShortcuts 是可选的。给不给,是这四个字段里代价最高的一个决定,原因在下一节。
先说选键本身。快捷键位是全局稀缺资源,一个编辑器里能用的组合就那么多,先占先得。判断依据可以按三个层次来:这个操作是不是高频到值得占一个键位?它和已有的键位冲不冲?如果冲了,谁该让路?没想清楚这三个,宁可先不给 defaultShortcuts——action 本身照常注册,按钮和右键菜单照常能触发,只是暂时不占键位。后加一个默认快捷键是有办法的(见下节),临时撤掉一个已经发出去的默认快捷键,则会直接打断用户已经形成的肌肉记忆。
选到特殊键还有一道坎。文档明确提醒:快捷键用到特殊键(非字母数字)时,要检查 src/stores/keybindings-store.ts 里的 getPressedKey,缺了就补一个 case,例如:
if (key === "escape") return "escape";
这是键盘事件处理里非常典型的一类坑。字母数字键的标识天然是稳定的,特殊键(Escape、方向键、功能键之类)则需要一层显式的归一化映射。文档专门把「缺了就补一个 case」列成注意事项,本身就说明这类缺失是静默的——按这套写法推断,映射表里没登记的键不会抛错也不会告警,只是匹配不上、不生效。所以如果你给一个 action 配了带特殊键的快捷键却没有生效,第一个该去看的地方就是 getPressedKey 里有没有对应的 case,而不是怀疑 action 本身没注册上。
判断四:★★ 有 defaultShortcuts 就必须写迁移
这是整份 docs/actions.md 里最值得单独拎出来的一条,原文的规则是:有 defaultShortcuts 就必须写 keybindings 迁移。
理由原文写得很清楚:快捷键持久化在 localStorage 里,新的默认值只对全新安装生效。 迁移文件放在 src/stores/keybindings/migrations/vN-to-vN+1.ts。
值得把这句话在脑子里推演一遍。用户第一次打开编辑器,一份键位表被写进 localStorage。此后每次启动,读的都是那份本地副本。你在代码里把某个 action 的 defaultShortcuts 从 A 改成 B,或者给一个新 action 配了默认键位——对开发机上刚清过缓存的你来说,一切正常;对所有老用户来说,什么都没发生,因为他们的 localStorage 里那份表压根没这一条,也不会因为代码里的默认值变了而自动更新。
这就是持久化配置的经典陷阱:「改默认值」和「让已有用户拿到新默认值」是两件完全不同的事,前者改一行代码,后者需要一段迁移逻辑显式地去改写那份已经落盘的用户数据。而且这类 bug 有个恶劣的特点——它在开发者本机上几乎不可能复现,因为开发过程中缓存被清得最勤的就是你自己。等到用户来报「文档里写的快捷键我按了没反应」,你在本地怎么试都是好的。
这条经验完全可以搬到任何带用户配置的产品里,不限于视频编辑器。只要你有「默认值 + 用户可覆盖 + 持久化」这三件套,就一定要同时准备版本号和迁移路径。判断依据很简单:问自己一句,一个从上个版本升上来的老用户,会拿到这个新默认值吗? 答案是「不会」,就该写迁移了。
判断五:args 什么时候需要
args 也是可选的,文档标注为「只在需要参数时」写。示例形状是 { someValue: "number" },即声明参数名与类型。
判断依据是看这个 action 是不是同一个动作的一族变体。「向前跳一帧」和「向前跳十帧」如果注册成两个独立 action,行为逻辑要写两遍;注册成一个带参数的 action,则由触发方决定传什么值。反过来,一个动作只有一种固定行为,就别为了「将来可能要扩展」提前塞参数——多出来的参数会让每一个调用入口都要多想一件事。
顺带说一句:description 字段虽然不需要什么判断,但别糊弄。这类描述文本通常是要面向用户露出的,写成开发者视角的内部黑话,最后受苦的是读快捷键列表的人。
这套设计的边界,以及不属于它的东西
Actions 层解决的是入口收敛:多个触发方式指向同一份定义。它不解决执行时机、不解决撤销栈、不解决权限判断,这些各有各的位置。看到一个和触发无关的问题就往 ACTIONS 里塞,反而会让这一层变成又一个杂物间。
关于版本,最后再明确一次边界。上面讲的全部机制——ACTIONS 对象、七个 category、getPressedKey、keybindings 迁移——都属于 **classic(旧版)**这个已归档的代码库。重写版仓库(OpenCut-app/OpenCut)尚未发布,它的 README 明写「OpenCut 正在被从头重写」,并列出了 Editor API、一等公民的第三方插件、桌面与移动与浏览器共用一套代码库、MCP server、无头模式、编辑器里内置的脚本标签页这几条「即将到来」的东西。这几条都是官方 README 声明的计划,尚未发布,不是现有能力——重写版今天既没有可用的插件体系,也没有 MCP server 和无头批渲染,我们没有见到任何可用实现,也无从判断重写版会不会保留 classic 这套 Actions 的形态。想跟进重写版进展的话,主仓 README 也说明了架构还在设计中、暂时不接受外部贡献,别贸然去提 PR。
真正能带走的不是 OpenCut 的某个文件路径,而是那两条判断:多入口的操作要收敛到一层定义,以及改了默认值不等于用户拿到了新默认值。这两条在哪个技术栈里都成立。
延伸阅读
- classic 的技术栈:Next.js、Rust WASM 与浏览器里的媒体处理
- 为什么模糊要分两趟:单 pass、多 pass 与 buildPasses
- 关键帧系统的三层:数据模型、注册表、UI
本文依据 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线上目前仍运行该版本;重写版正在开发中,功能与操作可能变化。