CapCut 时间轴功能梳理
视频长度
case1:当我们拖入一个时长为 30s 的视频时,可以看到视频占据整条轨道长度的 1/4 左右
case2:当把屏幕宽度缩小时,重新拖入 30s 的视频,视频长度仍然占据 1/4 的轨道
case3:这次我们拖入一个 3s 的视频,刻度分布发生变化了,但是视频长度还是 1/4
case4:当我们拖入 2 个时长为 30s 的视频,并把缩放倍率调整到最小,可以看到 2 个视频长度也占据 1/4 轨道
总结:
根据上面 4 种情况,可以得到以下结论
- 在最小缩放倍率时,轨道中的视频总长度总是占据整条轨道长度(会受显示宽度影响)的 1/4
- 在最小缩放倍率时,轨道中的视频长度与时长无关,但会影响刻度的分布
刻度规律
在分析刻度前,做个名称统一,把相邻两个字符刻度之间的距离称为固定刻度(下图红线),把固定刻度之间相连两个无字符刻度之间的距离称为最小刻度(下图蓝线)
分布规律
观察下面的视频,当缩放比例不断放大时,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)
变化规律
通过观察上面的视频可以发现,固定刻度会在其长度达到某个临界点时,切换为下一等级的固定刻度
比如 10min 这个固定刻度,会在其长度达到 300 px 时,切换为 5min,300px 这个临界值不会因为轨道长度变化而改变
经过测试发现,300 px 这个临界值只适用于 15f 刻度以上的情况,当固定刻度小于 15f 后,临界值会发生变化:
10f:临界值为 250 px
5f: 临界值为 160 px
3f:临界值为 120 px
最大缩放倍率
Capcut 可以缩放的最大值是以 2f 固定刻度 的长度来限制的,如下图中当 2f 固定刻度长度 达到整条 时间轴的 1/3 时就不能继续放大了。后续经过测试,发现这个限制的长度与轨道长度无关,是一个固定值 400px
总结:
- Capcut 的固定刻度遵循 10、5、3、2、1 的规律,但在不同单位刻度过渡时会有差异,比如分钟到秒 是 1min(对应60s) 到 30s,秒到帧 是 1s(对应30f) 到 15f,是除以2的规律
- Capcut 固定刻度会在前一个等级刻度实际像素达到 300 px ****转换为下一等级刻度,但在 15f 刻度以下时会有变化,每个等级有自己的长度临界点
- Capcut 会将轨道长度的 1/4 对应的固定刻度设置为最小缩放倍率也就是 1,将最小等级的固定刻度(目前是2f) 长度达到 400 px ****的时的倍率设置为最大缩放倍率
渲染&滚动
时间轴的宽度始终保持可视区域宽度,并不会随着轨道元素缩放而变化,比如图中一直保持在 761px
CapCut 内部使用一个 viewportStart 参数来表示当前可视区域的起始时间点,时间轴根据这个起始点往后渲染刻度
viewportStart 会随着滚动条的左右滚动而变化,存在一个滚动 像素 ->时间偏移量的转换
总结:
- 时间轴宽度是一个固定值,不会随着内容撑开
- 内容滚动时,并不是时间轴在滚动,而是改变一个起始时间点参数,时间轴根据这个参数渲染后续刻度
复刻思路
转换规则
根据视频长度的总结,我们知道视频总长度与轨道长度有关,轨道长度与可视区域像素宽度有关,所有我们要找到视频时间与实际像素之间的转换规则,以这个规则作为计算标准。
因为视频总长度总是占据轨道长度的 1/4,我们可以得到公式:
假设当前轨道长度为 1200 px,视频时长为 30s,计算可以得到:
这里的常数C就是我们要的转换规则,表示1 毫秒 对应的 像素 值
由于刻度尺的最小单位为帧, 为了计算方便,将最终的转换规则定为 一帧对应的像素值(basePixelPerFrame)
精度问题
基于整数帧的绝对时间基准
我们采用帧序号(整数)为基本单位,而非直接依赖浮点时间计算。比如 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。
让我们思考这么一个过程:
-
拖入一个 10s 帧率为 30 视频,假设轨道总长度为1200px,这时候视频占据整个轨道长度的 1/4,也就是 300px,通过计算可以得到 basePixelPerFrame = 300px / 10s / 30fps = 1px / f,即 1帧1 像素,同时 scale 的值应该为 1,因为初始化的时候没有缩放
-
这时候通过 basePixelPerFrame 和 scale 可以算出刻度枚举中每个刻度的实际像素值
- ...
- 3s 刻度:实际宽度 90 px,临界宽度 300px
- 5s 刻度:实际宽度 150 px,临界宽度 300px
- 10s 刻度:实际宽度 300 px,临界宽度 300px
- ...
- 根据前面提到的刻度变化规律,遍历刻度,第一个满足 实际宽度 < 临界宽度 的,即为当前显示的固定刻度,所以初始化的时候这里应该显示 5s 这个刻度。让我们去 Capcut 验证一下:
-
现在我们来考虑缩放的情况,假设现在放大2倍,也就是 scale = 2,这时候上面的实际像素都要 x2:
- ...
- 3s 刻度:实际宽度 180 px,临界宽度 300px
- 5s 刻度:实际宽度 300 px,临界宽度 300px
- 10s 刻度:实际宽度 600 px,临界宽度 300px
- ...
- 根据变化规律,这时候应该显示的是 3s 这个刻度!
- * (参考下面 Capcut 的截图,至此我们的逻辑完美闭环!)*
渲染&滚动
关于刻度的渲染,在无滚动的情况下:
- 我们知道当前的刻度等级,比如当前刻度是 5s
- 可以根据 固定刻度等级的帧数、basePixelPerFrame 和 scale,计算得到当前固定刻度的实际长度
- 用 轨道总长度 / 固定刻度长度 可以得到当前固定刻度在轨道上的个数
- 遍历固定刻度个数,对刻度实际像素累加渲染可得固定刻度线
- 固定刻度 像素 / group 可得最小刻度长度,在每组固定刻度的起始点对最小刻度像素累加渲染可得最小刻度线
上面我们提到时间轴的滚动并不是其自身在滚动,而是渲染上的模拟滚动。
这就要求dom结构要和下图中的类似:
- 父级容器需要设置为可滚动,宽度由轨道元素撑开
- 时间轴容器设置为 sticky 布局,宽度和父级容器一致
所以我们需要设置一个视口偏移量 viewportStart,通过这个值计算出滚动条滚动距离与渲染偏移量的关系
仔细观察上图可以发现:
- 当前固定刻度为 5s,刻度是从 00:10 开始渲染的(也就是第 3 个,前面有 00:00,00:05),但只渲染了一部分,00:15 刻度并不在视口起点
- 那么可以通过 Math.floor(滚动条滚动距离 / 固定刻度宽度) ,得到当前是从第几个刻度开始渲染的,再通过 每个刻度距起始点的长度 - 滚动条滚动距离 得到刻度在刻度尺上的 x 轴坐标