CapCut 视频剪辑类工具的时间轴复刻

193 阅读9分钟

CapCut 时间轴功能梳理

视频长度

case1:当我们拖入一个时长为 30s 的视频时,可以看到视频占据整条轨道长度的 1/4 左右

case2:当把屏幕宽度缩小时,重新拖入 30s 的视频,视频长度仍然占据 1/4 的轨道

case3:这次我们拖入一个 3s 的视频,刻度分布发生变化了,但是视频长度还是 1/4

case4:当我们拖入 2 个时长为 30s 的视频,并把缩放倍率调整到最小,可以看到 2 个视频长度也占据 1/4 轨道

总结:

根据上面 4 种情况,可以得到以下结论

  1. 在最小缩放倍率时,轨道中的视频总长度总是占据整条轨道长度(会受显示宽度影响)的 1/4
  2. 在最小缩放倍率时,轨道中的视频长度与时长无关,但会影响刻度的分布

刻度规律

在分析刻度前,做个名称统一,把相邻两个字符刻度之间的距离称为固定刻度(下图红线),把固定刻度之间相连两个无字符刻度之间的距离称为最小刻度(下图蓝线)

分布规律

观察下面的视频,当缩放比例不断放大时,CapCut 的固定刻度和最小刻度数量有以下规律:

  • 10h -> 5h -> 3h -> 2h -> 1h (最小刻度数量 10)
  • -> 30min -> 10min -> 5min -> 3min -> 2min -> 1min (最小刻度数量 10)
  • -> 30s -> 10s -> 5s -> 3s -> 2s -> 1s (最小刻度数量 10)
  • -> 15f -> 10f -> 5f -> 3f -> 2f (最小刻度数量分别为 5、10、5、3、2)

123.gif

变化规律

通过观察上面的视频可以发现,固定刻度会在其长度达到某个临界点时,切换为下一等级的固定刻度

比如 10min 这个固定刻度,会在其长度达到 300 px 时,切换为 5min300px 这个临界值不会因为轨道长度变化而改变

经过测试发现,300 px 这个临界值只适用于 15f 刻度以上的情况,当固定刻度小于 15f 后,临界值会发生变化:

10f:临界值为 250 px

5f: 临界值为 160 px

3f:临界值为 120 px

最大缩放倍率

Capcut 可以缩放的最大值是以 2f 固定刻度 的长度来限制的,如下图中当 2f 固定刻度长度 达到整条 时间轴的 1/3 时就不能继续放大了。后续经过测试,发现这个限制的长度与轨道长度无关,是一个固定值 400px

总结:

  1. Capcut 的固定刻度遵循 10、5、3、2、1 的规律,但在不同单位刻度过渡时会有差异,比如分钟到秒1min(对应60s) 到 30s秒到帧1s(对应30f) 到 15f,是除以2的规律
  2. Capcut 固定刻度会在前一个等级刻度实际像素达到 300 px ****转换为下一等级刻度,但在 15f 刻度以下时会有变化,每个等级有自己的长度临界点
  3. Capcut 会将轨道长度的 1/4 对应的固定刻度设置为最小缩放倍率也就是 1,将最小等级的固定刻度(目前是2f) 长度达到 400 px ****的时的倍率设置为最大缩放倍率

渲染&滚动

时间轴的宽度始终保持可视区域宽度,并不会随着轨道元素缩放而变化,比如图中一直保持在 761px

CapCut 内部使用一个 viewportStart 参数来表示当前可视区域的起始时间点,时间轴根据这个起始点往后渲染刻度

viewportStart 会随着滚动条的左右滚动而变化,存在一个滚动 像素 ->时间偏移量的转换

总结:

  1. 时间轴宽度是一个固定值,不会随着内容撑开
  2. 内容滚动时,并不是时间轴在滚动,而是改变一个起始时间点参数,时间轴根据这个参数渲染后续刻度

复刻思路

转换规则

根据视频长度的总结,我们知道视频总长度与轨道长度有关,轨道长度与可视区域像素宽度有关,所有我们要找到视频时间与实际像素之间的转换规则,以这个规则作为计算标准。

因为视频总长度总是占据轨道长度的 1/4,我们可以得到公式:

L视频像素=C×D视频时长=L轨道长度×1/4L_{视频像素} = C \times D_{视频时长} = L_{轨道长度} \times 1/4

假设当前轨道长度为 1200 px,视频时长为 30s,计算可以得到:

C=300px÷30000ms=0.01px/msC = 300px \div 30000ms = 0.01 px / ms

这里的常数C就是我们要的转换规则,表示1 毫秒 对应的 像素

由于刻度尺的最小单位为帧, 为了计算方便,将最终的转换规则定为 一帧对应的像素值(basePixelPerFrame)

basePixelPerFrame=L轨道长度×1/4÷D视频时长÷FPS视频帧率basePixelPerFrame = L_{轨道长度} \times 1/4 \div D_{视频时长} \div FPS_{视频帧率}

精度问题

基于整数帧的绝对时间基准

我们采用帧序号(整数)为基本单位,而非直接依赖浮点时间计算。比如 0.0333...s 对应的是 第1帧,内部存储的是帧序号而不是实际时间。

时间公式:时间 = 帧序号 / 帧率

帧序号公式:帧序号 = ⌊时间×帧率+0.5⌋ (四舍五入)

关键点:所有操作基于整数帧序号,避免直接存储无限循环小数

有理数表示法

虽然 JavaScript 的双精度浮点数(IEEE 754 双精度)已经可以满足大多数需求,但浮点数之间的运算还是会出现精度丢失的问题,尤其是在遇到无限循环小数的情况下。在之前的实现中发现,当时间和像素之间经过多次浮点数运算转换后,会造成指示器的位置与实际刻度对不齐的情况(如下图)

为了解决这个问题,内部采用分数形式表示帧率和一帧对应的像素值。比如在 30 ****fps 下,一帧的时长被存储为 1/30,而不是 0.03333..., 每帧对应的像素值也以同样情况存储。当输入时间为 1s(30帧) 时,两者都可以通过约分的形式计算出最终值,减少中间过程的浮点运算,减小误差。

关键帧对齐与截断

  • 截断处理: 当用户输入的时间不精确对应某一帧时,强制对齐到最近的关键帧(四舍五入)。

    • 例如:在 30fps 下,0.034s 也对应 第 1 帧(0.033...s) ,而不是显示“半帧”
  • 误差限制: 单次操作的误差被限制在一帧以内,不会累积到后续操作

刻度枚举

通过对 Capcut 刻度规律的分析,我们可以简单的得到以下数据结构。

  • step:当前固定刻度的长度,单位是帧, 与转换规则里的最小单位保持一致
  • group:当前固定刻度可以拆分的最小刻度的数量
  • criticalW:临界宽度,比如当 10f 这个固定刻度计算得到的实际 像素 小于 250 时,切换到 5f,反之切换到 15f

缩放&刻度切换

前面我们已经得到了帧到 像素的转换规则,要实现缩放效果,我们还需要一个 scale。

让我们思考这么一个过程:

  1. 拖入一个 10s 帧率为 30 视频,假设轨道总长度为1200px,这时候视频占据整个轨道长度的 1/4,也就是 300px,通过计算可以得到 basePixelPerFrame = 300px / 10s / 30fps = 1px / f,即 1帧1 像素,同时 scale 的值应该为 1,因为初始化的时候没有缩放

  2. 这时候通过 basePixelPerFramescale 可以算出刻度枚举中每个刻度的实际像素值

    1. ...
    2. 3s 刻度:实际宽度 90 px,临界宽度 300px
    3. 5s 刻度:实际宽度 150 px,临界宽度 300px
    4. 10s 刻度:实际宽度 300 px,临界宽度 300px
    5. ...
    6.   根据前面提到的刻度变化规律,遍历刻度,第一个满足 实际宽度 < 临界宽度 的,即为当前显示的固定刻度,所以初始化的时候这里应该显示 5s 这个刻度。让我们去 Capcut 验证一下:
  3. 现在我们来考虑缩放的情况,假设现在放大2倍,也就是 scale = 2,这时候上面的实际像素都要 x2:

    1. ...
    2. 3s 刻度:实际宽度 180 px,临界宽度 300px
    3. 5s 刻度:实际宽度 300 px,临界宽度 300px
    4. 10s 刻度:实际宽度 600 px,临界宽度 300px
    5. ...
    6.   根据变化规律,这时候应该显示的是 3s 这个刻度!
    7.   * (参考下面 Capcut 的截图,至此我们的逻辑完美闭环!)*

渲染&滚动

关于刻度的渲染,在无滚动的情况下:

  1. 我们知道当前的刻度等级,比如当前刻度是 5s
  2. 可以根据 固定刻度等级的帧数、basePixelPerFramescale,计算得到当前固定刻度的实际长度
  3. 轨道总长度 / 固定刻度长度 可以得到当前固定刻度在轨道上的个数
  4. 遍历固定刻度个数,对刻度实际像素累加渲染可得固定刻度线
  5. 固定刻度 像素 / group 可得最小刻度长度,在每组固定刻度的起始点对最小刻度像素累加渲染可得最小刻度线

上面我们提到时间轴的滚动并不是其自身在滚动,而是渲染上的模拟滚动。

这就要求dom结构要和下图中的类似:

  • 父级容器需要设置为可滚动,宽度由轨道元素撑开
  • 时间轴容器设置为 sticky 布局,宽度和父级容器一致

所以我们需要设置一个视口偏移量 viewportStart,通过这个值计算出滚动条滚动距离与渲染偏移量的关系

仔细观察上图可以发现:

  1. 当前固定刻度为 5s,刻度是从 00:10 开始渲染的(也就是第 3 个,前面有 00:00,00:05),但只渲染了一部分,00:15 刻度并不在视口起点
  2. 那么可以通过 Math.floor(滚动条滚动距离 / 固定刻度宽度) ,得到当前是从第几个刻度开始渲染的,再通过 每个刻度距起始点的长度 - 滚动条滚动距离 得到刻度在刻度尺上的 x 轴坐标

最终效果

1234.gif