一、三个看起来毫不相干的 bug
做视频编辑器的关键帧功能,你会陆续收到这样三条反馈:
- "我把片段开头裁掉了一点,动画全乱了。" 本来匀速飘进画面的贴纸,裁完之后从一半开始,最后还甩了一下。
- "时间轴放大之后,之前那个关键帧点不中了。" 缩小回去又能点中。
- "我只想让它移动,结果之前调好的透明度被锁死了。" 用户在第 3 秒打了个点改位置,回头发现第 1 秒调的淡入没了。
三条反馈来自三个不同的功能入口,看起来是三个 bug。它们的根因是同一个:关键帧的数据模型。
这件事上最容易产生的误解是"关键帧系统的核心是插值算法"。插值是全场最简单的部分——线性插值五行代码,写完就不用再碰。真正决定这个系统能做多复杂的动画、能不能和裁剪/变速/分割/撤销共存的,是三个建模决定:
- 一个关键帧持有什么(整帧快照,还是单属性通道)
- 一个关键帧的时间锚在哪条时间轴上
- 哪些状态是真相,哪些是派生
这篇按我踩过的顺序讲这三件事。示例代码是等价重写的示意(真实工程是 Objective-C,我用 Swift 写清楚结构),字段名按通用叫法改过。
二、一个关键帧持有什么:整帧快照 vs 属性通道
两种模型,选哪个决定了后面所有事。
模型 A:整帧快照。 一个关键帧是"这一时刻,这个片段的所有可动画属性的值":
final class KeyFrame: NSObject, NSCoding, NSCopying {
var time: CGFloat // 关键帧在片段内的时间
var originTime: CGFloat // 对应的源素材时间(第三节细说)
// 几何
var scale: CGFloat
var position: CGPoint
var rotate: CGFloat
var opacity: CGFloat
// 调色(一整排)
var filterValue: CGFloat
var contrast: CGFloat
var luminance: CGFloat
var saturation: CGFloat
var sharpness: CGFloat
var hue: CGFloat
var temperature: CGFloat
var shadowHighlight: CGFloat
var vignette: CGFloat
// 音量
var volume: CGFloat
// 蒙版
var maskCenter: CGPoint
var maskScale: CGFloat
var maskGradient: CGFloat
var maskRotation: CGFloat
var maskRect: CGSize
}
二十来个字段,一个扁平的属性包。
模型 B:属性通道。 每个属性一条独立的轨,各自有各自的关键帧列表:
struct AnimatedClip {
var channels: [PropertyKey: [Keyframe<Value>]] // "position" → [...], "opacity" → [...]
}
我当时选了 A。理由很实际:
- UI 直接对应:时间轴上就是一排点,用户点一下加一个点,所见即所得。模型 B 的 UI 要么给每个属性一条轨(界面爆炸),要么把多条通道折叠成一排点(那就回到了 A 的交互,却要承担 B 的复杂度)。
- 存档简单:这个对象要
NSCoding进工程文件,一个扁平结构比一张嵌套字典好写、好兼容、好调试。 - 撤销重做便宜:整个数组拷一份就是一次快照,不用按通道 diff。
代价就是开头那条反馈 3。看清楚机制:
// 用户在某个时刻点了"加关键帧"
let kf = KeyFrame()
kf.time = timelineTime - clip.trackIn
// ↓ 把片段"此刻的全部状态"抄进去
kf.scale = clip.scale
kf.position = clip.position
kf.rotate = clip.rotate
kf.opacity = clip.alpha
kf.volume = clip.volume
kf.filterValue = clip.filterPower
// ... 二十来个字段全抄
用户以为自己打的是"位置的点",实际打的是"所有属性的点"。 于是第 3 秒这一帧上,透明度也有了一个确定值(= 当时的值)。用户之前在第 1 秒设的淡入,插值到第 3 秒时会被这一帧的值接管——从用户视角看,就是"我的淡入没了"。
这不是 bug,是模型 A 的定义。能做的缓解有限,而且都很糙:
- 加关键帧时只抄"当前面板正在编辑的那一类属性",其余字段沿用前一帧的插值结果。听起来聪明,实际上会产生幽灵状态:同一个关键帧对象,有的字段是"用户的意图",有的字段是"当时算出来的中间值",两者在数据上不可区分。半年后没人说得清某个字段到底是谁写的。
- 老老实实走模型 B。

所以真正的结论是一条选型前必须问的问题:
产品会不会要"只给位置打关键帧、不影响透明度"?
会 → 从第一天就上属性通道。不会 → 整帧快照能省掉一半代码。
这个决定基本不可逆:模型 A 换 B,要同时改存档格式(老工程文件得能升级)、时间轴 UI、撤销重做的记录粒度、以及所有读写关键帧的调用点。它比"插值算法选哪种"重要一个数量级,但通常在项目第一周、由一个正在赶工期的人顺手定下来。
三、时间的身份:一个关键帧有三个时间
这是整个系统里最难、也最值钱的部分。
用户在屏幕上看到播放头停在某处,点了"加关键帧"。这一刻的"时间"是什么? 有三个候选:
| 时间 | 定义 | 谁会破坏它 |
|---|---|---|
| 全局时间轴时间 | 播放头在整条时间轴上的位置 | 拖动片段、前面插入/删除片段 |
| 片段局部时间 | timelineTime − clip.trackIn | 裁剪片段开头、改变速 |
| 源素材时间 | 这一帧对应原始视频文件里的哪一刻 | 几乎不被破坏 |
一个个排除:
存全局时间是错的。 用户把片段往后拖两秒,动画不应该跟着"错位"——它应该整体跟着片段走。存全局时间意味着每次移动片段都要遍历修正所有关键帧,而且漏一个地方就出错。
只存局部时间也是错的。 用户裁掉片段开头 1 秒,局部时间的原点就变了:原本在局部 2.0s 的关键帧,现在应该在 1.0s。同样要遍历修正——这就是开头那条反馈 1。更糟的是变速:速度从 1.0 改成 2.0,局部时间轴整体压缩一半,所有关键帧都得重算。
源素材时间最稳。 裁剪、变速、移动都不改变"这一帧钉在原始素材的第几秒"这件事。
真实工程里的做法是两个都存,分清主从:
// 加关键帧时,同时算出两个时间
kf.time = timelineTime - clip.trackIn // 派生:片段局部时间
if clip.mediaType == .photo {
// 图片没有"源时间轴",也不受变速影响
kf.originTime = (timelineTime - clip.trackIn) + clip.trimIn
} else {
// 视频/音频:先回到原速,再叠上左裁剪量
kf.originTime = (timelineTime - clip.trackIn) / clip.speed + clip.trimIn / clip.speed
}
originTime 是真相,time 是派生缓存(因为渲染和 UI 每帧都要用它,不能每次都算)。
于是任何改变片段几何的操作之后,都要重建派生态:
/// 裁剪、改时长、改速度之后统一调用
func rebuildKeyFrames(of clip: Clip) {
let leftTrim = clip.trimIn
let rightTrim = clip.isTimeScalable
? leftTrim + clip.trimDuration / clip.speed
: leftTrim + clip.trimDuration
var kept: [KeyFrame] = []
for kf in clip.keyFrames {
// 1. 落在裁剪区间外的关键帧,丢弃
guard kf.originTime >= leftTrim, kf.originTime <= rightTrim else { continue }
// 2. 区间内的,用源时间反推新的局部时间
kf.time = clip.isTimeScalable
? (kf.originTime - leftTrim) * clip.speed
: (kf.originTime - leftTrim)
kept.append(kf)
}
clip.keyFrames = kept
}

三个细节,每个都是踩出来的:
第一,图片是例外,而且例外必须写进模型。 图片没有源时间轴,变速对它无意义(它的"时长"是用户设的,不是素材固有的)。如果不把这个例外收进一个 isTimeScalable 之类的属性里,你会在十几个调用点各写一遍 if mediaType == .photo——然后在第十一个地方漏掉。
第二,被裁掉区间外的关键帧是真的被删了。 不是隐藏,是从数组里移除。这直接带来下一节的问题。
第三,这套映射只在"速度是一个标量"时成立。 第七节细说。
四、删除关键帧时,有一个所有人都会漏掉的步骤
删除一个关键帧,看起来就是 array.remove(at:)。
实际上还有一步。假设片段上有两个关键帧,播放头停在它们中间,画面此刻显示的是插值出来的中间状态(比如 scale = 1.35)。现在用户删掉其中一个关键帧——那个 1.35 是谁给的?没人。它是上一次插值留在片段属性上的残留。
结果就是:用户删掉关键帧后,画面定格在一个诡异的缩放上,而时间轴上看不出任何异常,属性面板显示的数字也"没人改过"。这种 bug 极难复现,因为它取决于删除时播放头的位置。
正确做法是:任何改变关键帧集合的操作,末尾都要重建片段的当前属性:
func deleteKeyFrame(at index: Int, of clip: Clip) {
clip.keyFrames.remove(at: index)
// 关键的一步:把片段当前属性回灌成某个仍然存在的关键帧的值
if let anchor = clip.keyFrames.last {
clip.apply(anchor) // scale / position / rotate / opacity / volume / mask ...
}
}
背后的原则比这段代码重要:
在动画系统里,"片段当前的属性值"是派生态,不是状态。
派生态的清单比想象中长:片段的当前属性、每个关键帧的局部时间、时间轴上那排按钮的横坐标、以及"这个片段有没有动画"的标记。它们都必须能从真相(关键帧数组 + 片段几何)重建出来,而且每条修改路径结束时都要重建一次。
判断自己有没有做对,有个很简单的检验:把片段的所有派生态清零,再从关键帧数组重算一遍,画面应该一模一样。 做不到,说明某个地方偷偷把派生态当成了真相。
五、裁剪和分割:被吃掉的关键帧,撤销要能吐回来
上一节说裁剪会真的删掉区间外的关键帧。那么撤销呢?
用户裁掉片段开头 → 三个关键帧被删 → 用户后悔了 → 撤销。如果撤销记录里只存了"裁剪前的 trimIn",那么恢复 trimIn 之后,那三个关键帧不会自己回来——它们已经不在数组里了。
所以撤销记录必须把被这次操作删掉的关键帧单独存一份。分割同理,而且更绕:分割会把一个片段变成两个,原来的关键帧要按分割点分给两段,落在分割点上的那个还要复制成两份(前段的尾 + 后段的头),否则分割处会出现跳变。
真实工程里,撤销模型除了"关键帧数组"之外,确实还带着一个"因分割而被删除的关键帧数组"字段。字段名本身就是这段历史的化石。
顺带一个分割时的判断——分割点落在哪里,决定了要不要复制:
/// 分割点是否夹在两个关键帧之间(前面有、后面也有)
func isSplitBetweenKeyFrames(at timelineTime: CGFloat, clip: Clip) -> Bool {
let t = timelineTime - clip.trackIn
let hasFront = clip.keyFrames.contains { $0.time < t }
let hasBack = clip.keyFrames.contains { $0.time > t }
return hasFront && hasBack
}
这三行代码对应的产品决策是:夹在两帧之间的分割,两段都必须拿到一个"边界帧",否则前段的末尾和后段的开头会各自去 clamp 到自己剩下的那个关键帧,画面在分割处跳一下。用户不会说"分割处跳变",他会说"我剪了一刀,动画坏了"。
我在讲撤销重做那篇里写过一句:"撤销系统的 bug 九成不在撤销代码里。"关键帧是这句话最好的例子——撤销代码没错,错在它记录的东西不足以还原现场。
六、命中判定:容差是像素,不是时间
用户点击时间轴上的关键帧标记,怎么判断点中了哪一个?
直觉答案:找时间最接近播放头的那个,差值小于某个阈值(比如 0.1 秒)就算命中。
真实工程里的写法不是这样:
// 命中容差 = 标记按钮宽度的一半,换算成时间
let tolerance = markerWidth / 2.0 / pixelsPerSecond
for (i, kf) in clip.keyFrames.enumerated() {
if kf.time - tolerance > currentTime { return nil } // 已经越过,没命中
if kf.time - tolerance <= currentTime,
kf.time + tolerance >= currentTime { return i } // 命中
}
return nil
注意 pixelsPerSecond:时间轴缩放会改变命中容差。放大时间轴,容差变小;缩小,容差变大。

第一次看到这段代码的人几乎都会想改掉它——容差怎么能随缩放漂移呢?
但它是对的,而且是我在这套系统里最喜欢的一个设计:
用户点的不是一个时刻,是屏幕上的一个按钮。 容差的正确单位就是像素,时间只是它的投影。
如果用固定的时间容差,会出现这种情况:把时间轴放到最大,两个相距 0.05 秒的关键帧在屏幕上相隔几百像素,用户精准地点在右边那个上——却因为 0.05 < 0.1 被判给了左边那个。用户会觉得这个编辑器"点不准"。反过来,缩到最小时,一个按钮宽度可能对应两秒钟,此时固定容差会让大量点击全部落空。
代价也要认下来,它们都是真实的:
- 同一个操作在不同缩放下结果不同,所以自动化测试必须把缩放固定成一个已知值,否则用例会随机飘红。
- 关键帧的最小间距有了下界:两个关键帧如果在屏幕上重叠,永远有一个点不中。所以"加关键帧"要挡一下——当前位置已经命中某个关键帧时,就是更新它而不是新增一个。这一步不做,用户会在同一个位置堆出好几个关键帧,然后发现删不干净。
七、缓动:这套系统里没有缓动函数,这不是偷懒
说个可能让人意外的事实:这套关键帧系统只有线性插值,没有任何缓动函数。没有贝塞尔手柄,没有 ease-in/ease-out 枚举,什么都没有。
func value(at t: CGFloat, from a: KeyFrame, to b: KeyFrame) -> CGFloat {
guard b.time > a.time else { return a.value }
let k = (t - a.time) / (b.time - a.time) // 线性,仅此而已
return a.value + (b.value - a.value) * k
}
这是个有意识的取舍,理由有三条:
- C 端剪辑用户不画曲线。 会用贝塞尔手柄的人本来就在用专业工具。给他们提供一个残缺版手柄,不如不提供。
- 缓动和"整帧快照"打架。 模型 A 里一个关键帧承载二十来个属性,缓动挂在哪?挂在关键帧上,意味着所有属性共享同一条缓动——位置在加速的同时透明度也必须加速,这经常不是用户要的。挂在属性上,就等于被迫走向模型 B。
- 密集关键帧能顶掉大部分需求。 用户想要"慢进快出",多打两个点就有了,而且所见即所得。
什么时候必须上缓动?当产品要的是线性拼不出来的东西——弹性回弹、过冲、抖动。这些用密集关键帧硬凑,用户要打十几个点,而且调不准。
真要加,我会这样加(注意这是个可加字段,老工程文件读不到它时按 .linear 处理,不破坏任何已有存档):
enum Easing: Int, Codable {
case linear = 0, easeIn = 1, easeOut = 2, easeInOut = 3
}
// 缓动描述的是"从这一帧到下一帧"的过程,所以挂在起始帧上
kf.easingToNext = .easeOut
枚举,不是自由手柄。 四个选项覆盖九成场景,UI 是四个图标,存档多一个 int。自由手柄要配一个曲线编辑器、要处理手柄越界和非单调(同一时刻两个值)、要定义手柄在变速后怎么变换——那是另一个季度的工作量。
八、当 speed 从标量变成曲线
第三节那个时间映射:
kf.time = (kf.originTime - leftTrim) * clip.speed
它成立的前提是 speed 是一个常数。整段片段 2 倍速,源时间和局部时间之间是一个线性变换,乘一下就完事。
然后产品要曲线变速——速度在片段内部连续变化(比如前半段 0.5 倍慢放、中间加速到 3 倍、结尾回到 1 倍)。速度本身被描述成一串点:
struct SpeedPoint {
var time: CGFloat // 在片段内的位置
var speedValue: CGFloat // 该点的速度
var percentTime: CGFloat // 归一化位置
}
这一刻,上面那行乘法就不成立了。源时间到局部时间的关系不再是乘一个系数,而是对速度曲线求积分:
localTime(t_origin) = ∫[trimIn → t_origin] 1 / v(τ) dτ
即:从左裁剪点积到目标源时刻,被 1/速度 加权。
速度恒定时它退化成一次乘法 —— 那就是上面那行代码。
如果继续用标量 speed 去算,关键帧会在变速区间里逐渐漂移——慢放段漂得多,匀速段看不出来。用户的描述会是"变速之后动画对不上了,但只有中间那一段不对"。
正确的做法不是在每个调用点都写一遍积分,而是把时间映射收成一个对象:
/// 源时间 ↔ 片段局部时间的双向映射。标量变速是它的一个特例。
protocol TimeMap {
func toLocal(_ originTime: CGFloat) -> CGFloat
func toOrigin(_ localTime: CGFloat) -> CGFloat
}
struct ConstantSpeedMap: TimeMap { let speed: CGFloat; let trimIn: CGFloat /* 乘除 */ }
struct CurveSpeedMap: TimeMap { let points: [SpeedPoint] /* 预积分成查找表,再二分 */ }
关键帧、波形图、贴纸出入点、字幕时间——所有需要在两条时间轴之间换算的地方,只调这个接口。加曲线变速时,改的是一个实现,不是二十个调用点。
而一旦把速度写成 [SpeedPoint],会发现一件很值得停下来想一想的事:
速度曲线本身就是一组关键帧。
(time, speedValue)和关键帧数组是同构的:一串带时间的采样点,中间靠插值,两端要 clamp,改动要进撤销栈,存档要兼容。
我是在做完曲线变速之后才意识到这个同构的。如果一开始就看见,变速完全可以复用关键帧的"通道 + 插值 + 时间映射"那套代码,而不是独立实现一遍——包括它独立踩一遍的边界问题。
这条经验可以推广:当你在一个系统里第二次写出"带时间戳的点列 + 插值"时,停下来,把它抽象成一个东西。 第三次出现的时候,你已经没机会了——那时它们已经各自长出了不兼容的边界行为。
九、求值:唯一一段必须是纯函数的代码
渲染时按时间取值,逻辑很短:
func evaluate(_ clip: Clip, at localTime: CGFloat) -> ClipState {
let frames = clip.keyFrames // 已按 time 排序
guard let first = frames.first else { return clip.staticState }
guard frames.count > 1 else { return ClipState(first) }
if localTime <= first.time { return ClipState(first) } // 左端 clamp
if localTime >= frames.last!.time { return ClipState(frames.last!) } // 右端 clamp
// 二分找到 localTime 落在哪两帧之间
var lo = 0, hi = frames.count - 1
while hi - lo > 1 {
let mid = (lo + hi) / 2
if frames[mid].time <= localTime { lo = mid } else { hi = mid }
}
let a = frames[lo], b = frames[hi]
let k = (localTime - a.time) / (b.time - a.time)
return ClipState.interpolate(a, b, k)
}
三条纪律:
第一,两端必须 clamp,不能外推。 第一个关键帧之前、最后一个之后,属性保持不变。外推会在用户裁剪出一段"没有关键帧覆盖"的区域时产生离谱的值(缩放变成负数、透明度超过 1)。
第二,这必须是纯函数。 同一个 localTime 必须永远给出同一个结果,不依赖"上一帧算到哪了"。
第二条看起来是废话,直到你发现预览和导出走的是两条不同的时间推进路径:预览由播放器驱动、可能跳帧、用户还会随机 seek;导出是按固定帧率一帧帧推进的。如果求值函数里藏了任何"上次的状态"——比如为了省事缓存了"当前在第几段区间"——那么预览正常、导出错位,而且错得很隐蔽(只在跳帧或 seek 之后才对不上)。
这类"预览对、导出不对"的问题,我的排查顺序永远是:先确认求值是不是纯的,再看时间映射,最后才怀疑渲染。九成停在第一步。
第三,属性子集要显式。 贴纸没有音量,音频没有蒙版。整帧快照模型里所有字段都存在,所以必须有一张"这个媒体类型参与哪些属性"的表,写在一处。否则给音频片段插值蒙版参数这种事,会悄无声息地发生,然后在某次重构后变成一个诡异的显示问题。
十、什么时候不该自己写
- 只是界面动效 → Core Animation / SwiftUI 的动画,别自造。它们的缓动、打断、反向播放都比你写得好。
- 只是播放一段固定动效 → Lottie 或序列帧(上一篇讲特效素材协议时提过这个边界)。设计师做好、端上播放,不需要"用户能编辑"。
- 必须自己写的情况只有一种:用户要在你的时间轴上打点,而且这份动画数据要存档、要进撤销栈、要跟裁剪/变速/分割联动。
第三种情况下,工作量的分布大概是:插值 5%,UI 25%,时间映射与派生态重建 70%。如果你的排期是按"插值算法有多难"估的,那个排期从一开始就是错的。
十一、收尾:三条可以带走的
第一,关键帧系统的难点是时间的身份,不是插值。 一个"时刻"在不同坐标系下有不同的名字,而破坏坐标系的操作(裁剪、变速、移动、分割)每一个都是产品必备功能。先想清楚谁是真相、谁是派生,再写第一行插值代码。这条对所有"内容 + 时间轴"的系统都成立:字幕、波形、弹幕、埋点回放。
第二,派生态必须能被整体重建,而且要有一个检验它的办法。 把派生态清零、从真相重算一遍、结果应该一模一样——这个检验能写成测试,也能在开发期当断言跑。做不到这一点的系统,会在每次新增编辑操作时冒出一个新的"显示不对"。
第三,第二次写出"带时间戳的点列 + 插值"就该抽象。 关键帧和曲线变速是同一个东西的两次实现,我是做完第二次才看出来的。代价不是多写了一份代码,是两份代码各自长出了不一样的边界行为——之后再想合并,就得先做一轮"哪边的行为才是对的"的考古。
回到开头那三条反馈:裁剪导致动画乱掉,是因为时间锚错了坐标系;放大后点不中,是命中容差的单位问题(而那个"奇怪"的写法恰恰是对的);改位置锁死透明度,是整帧快照模型的定义。三个问题,三个不同的建模决定,没有一个能靠改插值算法解决。
如果你的关键帧动画"预览是对的、导出不对",先别查渲染——把求值函数拿出来,确认它对同一个时间输入永远给同一个输出。九成问题停在这一步。
关于本文的代码与数据
文中 Swift 示例是对真实实现(Objective-C)的等价重写,用于说明数据模型与时间映射的结构,未逐行编译验证;字段名、类型名按通用叫法改写,不对应任何产品的线上代码。裁剪后重建、删除后回灌、命中容差按像素换算、分割点夹在两帧之间的判断,均为真实实现中存在的机制,描述的是机制本身而非任何产品的业务数据。曲线变速的积分式是对"速度非常数时时间映射应有的形状"的推导,不是某个具体实现的还原。文中没有任何产品指标或内部数据。