全部文章

关键帧:让曲线编辑、预览与导出逐帧一致

亲手调整贝塞尔曲线,逐帧观察动画数值,再理解时钟、采样、切割与版本化数据如何共同保证关键帧一致性。

曲线面板里的动画看起来没问题,切一刀却跳变,变速后节奏漂移,导出又晚了一帧。

它们看起来像插值 bug,真正的原因却常常是同一份数据被解释成了不同含义:关键帧属于哪个时钟,一帧在哪个时刻采样,哪一层负责应用缓动。

关键帧横跨客户端、媒体引擎与渲染引擎。公式只是其中很小一部分,更难的是让所有使用者在同一时刻算出同一个值。先从一条可以亲手调整的曲线开始。

两个关键帧,中间的值从哪里来

关键帧记录某个时刻的属性值。例如位置在开始时为 0,两秒后为 100,中间各帧由系统补全。

线性插值下,时间走过一半,位置也走过一半。缓动改变的是这两种进度之间的关系:可以慢慢起步、加速、轻轻停下,也可以越过终点再回来。端点相同,过程却不同。

试着选择“缓入”,把时间轴停在第 15 帧:时间过了四分之一,位置却还不到四分之一。保持时间不变切到“线性”比较,再选“超调回落”,拖向结尾,观察位置如何先超过 100 再返回。

两帧之间,节奏由你决定。

拖动曲线控制点或下方滑杆,再移动时间轴,观察每一帧的数值如何变化。

三次贝塞尔缓动曲线横轴是从 0 到 1 的时间进度,纵轴是从 -0.5 到 1.5 的输出进度。也可以用键盘操作带标签的滑杆来调整控制点。-0.500.511.500.250.50.751数值进度 y时间进度 x →P1P2
控制点 P1
控制点 P2

x 限制在 0 到 1 之间;y 可以超出端点,让动画先反向蓄势,或越过终点再回落。

30 帧/秒 · 时长 2 秒
时间 x25.0%
参数 u0.225
进度 y12.9%
位置 0 → 10012.9
线性
当前曲线
0100

曲线上的圆点、数值读数和运动预览使用同一个采样结果。灰色刻线标出两个关键帧的位置,两侧留白用于显示超调。第 60 帧对应结束关键帧。

演示以 30 帧/秒采样两秒区间。第 0 至 59 帧是区间内的帧起点,第 60 帧表示恰好两秒处的结束关键帧。慢设备播放时可能跳过部分显示采样,但逐帧操作始终得到该帧确定的数值;不会自动播放。

x 是时间,y 是进度,u 则是另一回事

三次贝塞尔缓动曲线固定端点 P0=(0,0)P_0=(0,0)、P3=(1,1)P_3=(1,1),两个可编辑控制点为 P1P_1、P2P_2。横轴表达时间进度,纵轴表达输出进度。控制点 x 保持在 [0,1],y 可以超出范围以产生超调,这与 CSS 三次贝塞尔缓动 的约定一致。

曲线本身由参数 u 描述:

B(u)=3(1−u)2u P1+3(1−u)u2 P2+u3P3,u∈[0,1]\begin{aligned} B(u) &= 3(1-u)^2u\,P_1 \\ &\quad + 3(1-u)u^2\,P_2 + u^3P_3,\qquad u\in[0,1] \end{aligned}

u 不等于时间进度。 给定 x,要先求解 Bx(u)=xB_x(u)=x,再读取 By(u)B_y(u)。直接把 x 代入 y 多项式,一般会得到另一条动画。演示同时显示 x 与 u,就是为了让这个差别可见。

从 a 到 b 的标量属性按以下过程计算:

x=t−t0t1−t0u=Bx−1(x)p=By(u)v(t)=a+(b−a) p\begin{aligned} x &= \frac{t-t_0}{t_1-t_0} \\ u &= B_x^{-1}(x) \\ p &= B_y(u) \\ v(t) &= a + (b-a)\,p \end{aligned}

t0t_0、t1t_1 是关键帧时间,pp 是输出进度,v(t)v(t) 是最终插值结果。

反解算法也是共享契约的一部分。可以先进行有限次 Newton 迭代,导数太小、迭代越界或不收敛时退回二分法,端点直接返回。各实现固定容差与迭代上限,还要检查输出值误差:x 残差很小,不代表在平坦 x 切线附近的 y 误差也小。

演示的曲线标记、数值与预览共用一个 TypeScript 求值结果。原生编辑栈则可以用共享 C++ 求值器建立相同边界。

先定义“一致”指什么

求值一致性:相同草稿、属性和输出帧时间戳,在 UI 读数、预览、导出和支持的平台上,结果在约定容差内一致。

编辑保真性:对设计为跟随源素材的动画,保留的源采样点在裁剪或切割后维持原值;变速可以改变它出现在时间线上的时刻,但不能意外赋予它不同的源时间属性值。

第二条是产品选择,不是普遍定律。标题可能有意固定在项目时间结束淡出,不随素材变速改变。数据模型必须说明动画锚定在哪个时钟。

现象常见分歧
切割后跳变新片段重新开始了原缓动
变速后漂移混用源时间与时间线时间
导出错一帧帧起点、帧中心或取整规则不同
跨平台运动不同反解、单位或默认值不同
新版本打开旧草稿后变化插值语义变化没有版本化

给动画一个明确的时钟

时钟含义
时间线时间项目中的绝对位置
片段时间相对片段在时间线起点的偏移
源时间原始媒体中的位置

跟随素材内容的动画可以保存在源时间上,再由片段映射推导 UI 位置。正向恒速播放时:

tsource=ttrimIn+(ttimeline−tclipStart) rt_{\mathrm{source}} = t_{\mathrm{trimIn}} + (t_{\mathrm{timeline}} - t_{\mathrm{clipStart}})\,r

rr 的单位是“每一秒时间线经过多少秒源素材”。变速片段需要积分速度曲线,倒放则沿所选源区间反向遍历。这些规则应放进共享映射函数。

反向映射也不总有唯一答案:定格可能把多个时间线时刻映射到同一源时间,重复或非单调片段也会产生歧义。此时应返回区间,或要求提供片段标识。

移动片段改变时间线起点;裁剪改变可见源区间,隐藏关键帧可保留以便恢复;变速有意改变时间线上的节奏。替换素材则需要单独定义产品行为,相同源时间戳不代表画面内容相同。

求值一次,再交给渲染

视频编辑处理管线

媒体引擎推进时间并管理编解码,渲染引擎应用效果与合成。音量等属性进入对应音频处理链路。

清晰的边界是:渲染前求出最终属性值,随帧交给渲染器;渲染器不再应用第二次 easing。

  1. 根据输出帧序号获得约定时间戳。
  2. 映射到动画所属时钟。
  3. 找到相邻关键帧,范围外按端点策略处理。
  4. 归一化片段时间,求出缓动进度。
  5. 按属性语义插值,并执行必要限制。

曲线面板可以直接绘制控制点几何,但播放头与读数应走同一个求值器。预览可以为了实时性跳帧,真正渲染某个时间戳时则应与导出一致。

不同属性也有不同语义:位置可用归一化画布坐标;正比例缩放若追求比例均匀变化,可选择对数空间,但零或负缩放需另外规定;旋转若要从 0° 完整转到 360°,应保留未折返角度;透明度可以限制到 [0,1],位置则通常保留超调;离散属性需要明确阶跃边界。

先对齐帧,再对齐数值

媒体时间使用整数值与 timescale 组成的有理数,固定输出帧采样规则。若采样第 n 帧起点:

t(n)=n ΔtΔt={130 s,30 fps100130000 s,300001001 fps\begin{aligned} t(n) &= n\,\Delta t \\ \Delta t &= \begin{cases} \frac{1}{30}\,\mathrm{s}, & 30\ \mathrm{fps} \\ \frac{1001}{30000}\,\mathrm{s}, & \frac{30000}{1001}\ \mathrm{fps} \end{cases} \end{aligned}

整数有理数避免反复浮点累加,但仍要处理溢出、重标度与取整。曲线反解和任意变速映射仍可能是有明确误差界限的数值运算。

可变帧率源素材的显示时间戳不等间隔,输出帧序号不能当成源帧序号。吸附源帧时使用源 PTS,判断 UI 是否精确命中关键帧时比较规范化时间戳或规范采样身份。

切一刀,如何不改变原来的运动

在切点插入一个数值正确的新关键帧还不够。若两个新片段各自复用原 easing,归一化时间被重新开始,两边的运动都会变化。

一种稳妥表示是保留原曲线,只限制各片段使用的源区间。若必须生成独立曲线段,则进行细分:先由切点时间 xsx_s 反解 Bx(us)=xsB_x(u_s)=x_s,再使用 de Casteljau 算法。

定义 L(A,B,u)=(1−u)A+uBL(A,B,u)=(1-u)A+uB:

A=L(P0,P1,us)B=L(P1,P2,us)C=L(P2,P3,us)D=L(A,B,us)E=L(B,C,us)F=L(D,E,us)\begin{aligned} A &= L(P_0,P_1,u_s) \\ B &= L(P_1,P_2,u_s) \\ C &= L(P_2,P_3,u_s) \\[4pt] D &= L(A,B,u_s) \\ E &= L(B,C,u_s) \\[4pt] F &= L(D,E,u_s) \end{aligned}

左曲线控制点为 (P0,A,D,F)(P_0,A,D,F),右曲线为 (F,E,C,P3)(F,E,C,P_3),两段共同描出原几何。

保存为归一化缓动段时,还要相对各段端点重新映射 x 与 y。这个变换必须在属性插值空间里做,例如对数缩放空间,而不是已经 clamp 的显示值。如果新控制点无法满足 schema 的范围约束,应保留区间表示,不能直接 clamp 掉新手柄。

尤其要注意:两端值相同,不代表整段恒定。 超调曲线可以离开某个值,再回到同一个值。垂直跨度为零时不能除以零归一化,也不能直接替换成常量。可以保留原曲线与参数区间、使用绝对值样条,或采用能表达这段偏移的其他细分表示。

切点已经是关键帧时直接复用边界。重复时间戳与零时长段应在进入插值之前制定确定策略。

草稿必须保存含义

只存四个控制点数字不够,schema 还要说明时钟、单位、插值空间、默认值与曲线归属。

{
  "keyframeSchema": 2,
  "clock": "source",
  "property": "scale",
  "interpolation": "log",
  "keys": [
    {
      "t": { "value": 0, "timescale": 600 },
      "value": 1.0,
      "outgoingEase": { "type": "cubic", "points": [0.42, 0, 0.58, 1] }
    },
    {
      "t": { "value": 1800, "timescale": 600 },
      "value": 1.5
    }
  ]
}

这个例子中,easing 属于从当前关键帧出发的片段,缺失时默认线性,最后一个关键帧没有出段。非有限数值、越界 x 和重复时间戳如何处理,也必须由所有读取者执行相同规则。

语义和结构都需要版本化。给旧草稿补显式 linear,只有在等价于旧版本行为时才安全;不能在迁移时偷偷把缩放从线性改成对数插值。

旧客户端无法计算三次曲线时,可以按声明帧率烘焙最终值,但只保证兼容时钟与属性语义下那些采样点的结果。采样点之间、修改帧率或再次变速后,不保证等价;同时还会增加体积并失去原始可编辑曲线。不支持的组合应只读打开或明确转换,不能静默改变动画。

发布时先部署兼容读取器,再开启新格式写入。Schema 与 evaluator 版本让问题可以追溯到具体契约。

四层验证

求值器共享黄金输入输出,覆盖端点、线性、平坦切线、超调、近重合时间和无效数据,以最终属性值定义容差。相同源码不自动保证跨编译器逐 bit 一致。

编辑操作生成曲线与切割、裁剪、变速、倒放序列,按锚定策略比较保留源采样点。重复切割和零跨度子曲线尤其值得覆盖。

预览与导出先比较带时间戳的属性日志,定位首个分歧,再按合理像素容差比较选定帧。参数相同仍可能因色彩、效果实现与精度出现像素差异。

兼容性验证旧草稿读取、支持格式往返和烘焙回退的声明采样率。诊断记录版本,不收集用户草稿正文。

最后落到几份明确约定:统一时钟模型、统一求值路径、统一采样规则,以及带版本的动画语义。它们让用户编辑的曲线经过切割、预览与导出之后,仍然表达同一件事。