一、四条"不像 bug 的 bug"
给视频编辑器加变速和倒放,功能本身一周就能跑通。然后你会陆续收到这样的反馈:
- "我设了前 5 秒 1 倍速、后 5 秒 2 倍速,结果前面那段也在慢慢变快。" 播放时长也不对,比算出来的短了一截。
- "我裁了片段中间那 3 秒,点了倒放,留下来的变成了视频另一头的 3 秒。"
- "倒放之后,抠图的人物和背景对不上了。" 人物已经倒着走,蒙版还在正着走。
- "2 倍速之后,人声像花栗鼠。"
这四条来自四个不同的入口,但根子是同一件事:变速和倒放都不是"改一下播放速率",而是把时间重新映射了一遍。 源素材里的每一帧,都要被安排到时间线上一个新的时刻;所有和时间绑定的东西(音频、裁剪点、蒙版、关键帧)都得跟着走同一份映射。只要有一样东西没跟上,就是上面某一条。
这篇按踩坑顺序讲。示例代码里有一段是完整可运行的时间映射模型,文中所有数字都由它算出来;其余片段是等价重写的示意,字段名按通用叫法改过。
二、先把问题说清楚:变速是一个函数,不是一个倍数
常速变速最容易理解:2 倍速,时间线上的 1 秒对应源素材的 2 秒。PTS 重排就是一次乘法。
但编辑器里的变速有三种,它们在数据上完全不是一回事:
| 类型 | 用户看到的 | 数据上是什么 |
|---|---|---|
| 常速 | 整段 2 倍 | 一个标量 |
| 分段变速 | 前 5 秒 1 倍,后 5 秒 2 倍 | 一个阶跃函数 |
| 曲线变速 | 子弹时间:慢—快—慢 | 一条连续曲线 |
一旦速度随时间变化,"源时刻"和"时间线时刻"之间就不再是一次乘法,而是一个积分:
时间线时刻 T(t) = ∫₀ᵗ 1 / v(τ) dτ (t 是源时刻,v 是 t 处的速度)
在速度快的地方,源时间过得多、时间线过得少;慢的地方反过来。积分就是把每一小段"源时间 ÷ 速度"加起来。
所以变速在工程上的第一原则是:速度不是一个属性,是一个函数;而你真正需要的,是这个函数的积分,以及积分的反函数。
为什么两个方向都要?
- 渲染要"时间线 → 源":播放头走到时间线第 3.2 秒,要知道该去源文件的第几秒取帧。
- 编辑要"源 → 时间线":用户在源素材某一刻打的关键帧、加的字幕,要知道画在时间线的哪里。
这正是我用过的变速引擎对外暴露的接口形状:一对互逆的换算函数,再加一个按源时间区间求时间线时长的函数。
- (int64_t)getRealPtsMs:(int64_t)rawPtsMs; // 源 → 时间线
- (int64_t)getRawPtsMs:(int64_t)realPtsMs; // 时间线 → 源
- (int64_t)getRealDurationMs:(int64_t)beginRawPtsMs End:(int64_t)endRawPtsMs;
- (instancetype)trimRangeMs:(int64_t)beginRawPtsMs End:(int64_t)endRawPtsMs;
我在上一篇讲关键帧时说过:"把时间映射收成一个对象,所有需要在两条时间轴之间换算的地方只调这个接口。"这里就是它在真实系统里的样子。
顺带说一个容易被质疑的点:这对接口用的是整数毫秒。在音画同步那篇里,我专门批评过"毫秒整数逐帧累加会每秒漂 9.47 毫秒"。这里不矛盾:累加才会漂,而这里的每个 PTS 都是用映射函数独立换算出来的,误差不会一帧一帧传下去。这和手写 muxer 那篇里"先换算每个 PTS、再做差"的写法 A 是同一个道理。整数本身没问题,累加才有问题。
三、坑一:分段变速的阶跃,要写成两个同时刻的点
症状
用户设置了"前 5 秒 1 倍、后 5 秒 2 倍"。实际效果是前 5 秒一路在加速,到第 5 秒才到 2 倍;整段时长也比预期短。
根因
大多数变速引擎(包括我用的这个)接收的都是一串速度点,点与点之间线性插值。这个格式天然适合曲线变速。可分段变速不是曲线,是阶跃。
最自然的写法是"每一段一个点":
(0s, 1x) (5s, 2x) (10s, 2x)
线性插值会把第一个点和第二个点之间连成一条斜坡:速度从 0 秒的 1 倍,均匀涨到 5 秒的 2 倍。用户说的"前 5 秒 1 倍速",实际上一刻也没有发生过:2.5 秒处的速度是 1.5 倍。
解法
在交界时刻写两个时间相同的点:一个带前一段的速度,一个带后一段的速度。
(0s, 1x) (5s, 1x) (5s, 2x) (10s, 2x)
两点之间宽度为 0,插值没有空间可以"连斜坡",速度在 5 秒处直接跳变。真实代码里分段变速就是这么转换的(重写成示意):
for (int i = 0; i < segments.count; i++) {
if (i > 0) {
Segment *front = segments[i - 1];
Segment *behind = segments[i];
// 同一个时刻写两次:一次带前段的速度,一次带后段的速度
[times addObject:@(behind.startMs)]; [speeds addObject:@(front.speed)];
[times addObject:@(behind.startMs)]; [speeds addObject:@(behind.speed)];
} else {
[times addObject:@(segments[0].startMs)]; [speeds addObject:@(segments[0].speed)];
}
}
用下面这个模型算一下两种写法的差别:
import Foundation
/// 速度是源时间上的分段线性函数:点按源时间排序,允许两个点同一时刻(= 阶跃)。
struct SpeedPoint { let source: Double; let speed: Double }
struct VariantSpeedMap {
let points: [SpeedPoint]
/// 源时间 → 时间线时间:∫ dτ / v(τ)
func timelineTime(atSource t: Double) -> Double {
var acc = 0.0
for (a, b) in zip(points, points.dropFirst()) {
if t <= a.source { break }
let end = min(t, b.source)
let width = b.source - a.source
guard width > 0 else { continue } // 同一时刻的两个点:阶跃,面积为 0
let k = (b.speed - a.speed) / width
let vEnd = a.speed + k * (end - a.source)
acc += abs(k) < 1e-12 ? (end - a.source) / a.speed : log(vEnd / a.speed) / k
}
return acc
}
/// 时间线时间 → 源时间(渲染时由播放头找源帧要用这个方向)
func sourceTime(atTimeline T: Double) -> Double {
var acc = 0.0
for (a, b) in zip(points, points.dropFirst()) {
let width = b.source - a.source
guard width > 0 else { continue }
let k = (b.speed - a.speed) / width
let segment = abs(k) < 1e-12 ? width / a.speed : log(b.speed / a.speed) / k
if T <= acc + segment {
let tau = T - acc
return abs(k) < 1e-12 ? a.source + a.speed * tau : a.source + a.speed * (exp(k * tau) - 1) / k
}
acc += segment
}
return points.last!.source
}
}
| 写法 | 10 秒素材在时间线上的时长 |
|---|---|
| 阶跃(交界处写两个点) | 7.5000 s(5 + 2.5,和心算一致) |
| 每段一个点(被插值成斜坡) | 5.9657 s,少了 1.53 秒 |

模型里有一行注释值得单独拎出来:guard width > 0 else { continue }。重复时间点的宽度是 0,求斜率 k = Δv / Δt 时会除以零。 自己实现映射的时候,最常见的崩法不是算错,而是在阶跃处算出一个 NaN,然后这个 NaN 顺着积分传到后面每一帧的 PTS 上,整段画面卡在同一帧。它不崩溃,只是"卡住"——和VideoToolbox 那篇说的一样,最难查的错误往往不报错。
这里有一个反直觉的地方:
分段和曲线用的是同一个数据格式。区分它们的不是点的数量,而是允许不允许重复的时间点。
如果你的格式(或者你写的校验)要求"时间点严格递增",那它就表达不了分段变速——看起来是一条很合理的输入校验,实际上是在禁止一个需求。
四、坑二:曲线变速存绝对时间还是比例,决定了裁剪之后会发生什么
症状
用户给片段套了一个"子弹时间"预设:开头正常、中间慢放、结尾正常。然后把片段开头裁掉了 2 秒。慢放那一段跑到了片段的另一个位置——或者干脆被裁没了。
根因
速度点的时间,可以存成两种坐标:
- 绝对时间:第 3.2 秒处 0.3 倍速。
- 比例:片段 40% 处 0.3 倍速,再乘上片段当前的长度。
两种都对,但它们在裁剪之后的行为完全不同:
| 用户操作 | 绝对时间的速度点 | 比例坐标的速度点 |
|---|---|---|
| 裁掉开头 2 秒 | 曲线原地不动,前 2 秒的部分被裁掉 | 曲线整体压缩到剩下的区间里 |
| 拉长片段 | 新露出来的部分没有速度点 | 曲线跟着拉伸 |
真实代码里,这两种坐标同时存在:
- 分段变速存绝对时间(毫秒)。用户是在时间线上"这里到这里 2 倍"地框出来的,裁剪不应该挪动它。
- 曲线变速存比例(
percentTime),编译时再乘上当前的裁剪区间:
[speedMap setRatioPoints:ratios // 0…1
Speeds:speeds
StartMs:clip.trimIn * 1000
EndMs:clip.trimOut * 1000];
预设曲线表达的是"这个片段的节奏":前段快、中段慢、后段快。它应该跟着片段走,不管片段被裁成多长。
所以这里的结论是:
"速度点用什么坐标"不是一个技术问题,是一个产品语义问题:用户认为这段速度属于"时间线上的某一刻",还是属于"这个片段的节奏"?
选错了不会有任何报错。你只会收到"我裁了一下,变速就乱了"这样的反馈,而且每个人描述的"乱"都不一样。
这里还有一个容易漏的点:比例坐标的曲线,裁剪之后总时长会变,而且不是线性变化。 同一条曲线压缩到更短的区间,每一小段源时间都更短,积分出来的时间线时长按曲线的形状非线性地缩短。时间线上的后续片段要整体挪动。所以裁剪之后必须用映射重新求时长(上面接口里的 getRealDurationMs),不能拿"原时长 × 裁剪比例"去估。
五、坑三:音频和视频必须共用同一份映射
症状
变速之后,画面和声音一开始是对齐的,越往后越错开。常速变速时一般没问题,一上曲线变速就出现。
根因
视频和音频各自算了一遍时间映射。只要两边的实现有一丁点差异——一个用浮点、一个用整数,一个在阶跃处多截了半帧,一个按采样点、一个按帧——误差就会随时间累积。
解法
一份映射,同时交给所有和这段素材绑定的轨道。 真实代码里,速度点字符串是同一个对象同时设给视频和音频的:
[speedMap setPoints:times Speeds:speeds];
NSString *points = [speedMap getPointStr];
[clip.videoClip setVaraintSpeed:points];
if (clip.audioClip) {
[clip.audioClip setVaraintSpeed:points]; // 同一份,不各算各的
}
裁剪点也是同一个 setter 里同时设两边。这看起来是废话,但我见过的"变速后音画不同步",十有八九是有人在某一处单独给音频算了时长。
这就是音画同步那篇的原则在变速场景下的样子:同步不是对齐一次,而是不要让两条流有机会各走各的时间。
花栗鼠:变速的第二个维度
还有一个问题:2 倍速之后,声音怎么处理?
最直接的做法是把音频按 2 倍速重采样,相当于把磁带放快。频率翻倍,音高升一个八度:12 × log₂2 = 12 个半音。1.5 倍速也要升 7.02 个半音,差不多一个纯五度。这就是花栗鼠声音的来源。
所以音频的"速度"其实有三个独立的旋钮。真实引擎的接口就是三个参数:
- (void)setTone:(double)pitchSemiTones // 变调不变速
TempoChange:(double)tempoChange // 变速不变调(时间伸缩)
RateChange:(double)rateChange; // 变速且变调(重采样)
- rate:重采样,速度和音高一起变。实现最便宜,效果就是花栗鼠。
- tempo:时间伸缩,只改速度不改音高,是"变速不变调"。需要 WSOLA 或相位声码器这类算法,有计算成本,快得多了会有金属感。
- pitch:只改音高不改速度,是"变声"。
对剪辑产品来说,人声素材变速时几乎总是要 tempo,不要 rate。曲线变速更是如此:rate 模式下,音高会随着速度曲线连续地滑上滑下,像一盘被人用手拨动的磁带。
六、坑四:倒放不是"倒着解码"
症状
倒放功能的第一版做成了实时的:播放时把帧倒着送进解码器。短视频还行,长一点的就卡顿、发热,有时直接因为内存被系统杀掉。
根因
压缩视频只能正着解码。一个 P 帧依赖它前面的帧,要解出 GOP 里的第 29 帧,必须先解第 0 到 28 帧。倒着播放,意味着要先拿到每个 GOP 的最后一帧。
实时倒放只有两种做法,把代价算一下:
- 整组缓存:解出整个 GOP,倒着吐出来。1080p 的 NV12 一帧是 1920 × 1080 × 1.5 = 3.11 MB,GOP 为 30 时,一组要缓存 93.3 MB。这还只是一路视频、一组 GOP。
- 不缓存,逐帧重解:倒着出第 k 帧,就从 I 帧开始解 k 帧。一个 GOP 倒着出 30 帧,要解 1 + 2 + … + 30 = 465 帧,是正常播放的 15.5 倍。

两条路都不适合放在一个还要叠滤镜、叠贴纸、叠画中画的实时预览里。
解法
倒放做成离线转码:生成一个新文件,内容就是原视频倒过来。 之后这个片段就指向新文件,播放、剪辑、导出全都是正向的,其他模块完全不用知道"倒放"这件事。
真实实现里,倒放是一个独立的转码任务(重写成示意):
- (void)reverseClipToPath:(NSString *)output progress:(void (^)(float, Status))progress {
// 同一个输出已经存在:直接复用,不重转
if ([[NSFileManager defaultManager] fileExistsAtPath:output]) {
self.clipFilePath = output;
self.reverseStatus = ReverseStatusDone;
progress(1.0, StatusEnd);
return;
}
self.reverser = [[Reverser alloc] initWithInput:self.sourcePath output:output];
self.reverser.delegate = self; // 进度、完成、出错三种回调
self.progressBlock = progress;
[self.reverser start];
}
- (void)cancelReverse {
[self.reverser stop];
self.reverser = nil;
self.progressBlock(0, StatusCancelled);
}
这个决定带来三个产品层面的后果,都要当成需求来做:
- 要有进度条,要能取消。 一分钟的 4K 素材倒放要转码几十秒,没有进度条,用户会以为 App 卡死了。
- 结果要缓存。 用户来回点倒放很常见,同一个输出文件已经存在就直接复用。
- 原文件不能删。 取消倒放就是把片段指回原文件——这一点下一节细说。
七、坑五:倒放之后,所有和时间绑定的东西都要一起镜像
这是开头第 2、3 条反馈的来源,也是这篇最值钱的一节。
裁剪点:镜像,而不是保持不变
用户在 20 秒的素材里选了第 12 到 15 秒。倒放生成新文件后,同样的画面在新文件里是第 5 到 8 秒:
新 trimIn = 素材时长 − 旧 trimOut = 20 − 15 = 5
新 trimOut = 素材时长 − 旧 trimIn = 20 − 12 = 8
如果裁剪点保持 12–15 不变,留下来的就是原视频第 5–8 秒的内容,倒着播。这就是开头第 2 条。
真实代码在倒放完成的回调里做了这一步,取消倒放时又做了同样的一步:
- (void)onReverseEnd {
float newIn = self.sourceDuration - self.clipTrimOut;
float newOut = self.sourceDuration - self.clipTrimIn;
self.clipFilePath = self.reversedFilePath;
self.clipTrimIn = newIn;
self.clipTrimOut = newOut;
self.isReversed = YES;
}
- (void)toggleReverse { // 已经倒放过:来回切换,不重转
self.clipFilePath = self.isReversed ? self.originalFilePath : self.reversedFilePath;
self.isReversed = !self.isReversed;
float newIn = self.sourceDuration - self.clipTrimOut; // 同一个公式
float newOut = self.sourceDuration - self.clipTrimIn;
self.clipTrimIn = newIn;
self.clipTrimOut = newOut;
}
两个方向用的是同一个公式。 这不是偷懒:x → 时长 − x 是一个对合(involution),做两次等于什么都没做。所以"取消倒放"不需要记录倒放前的裁剪点,再镜像一次就回来了,来回切多少次都不会漂。
有一个前提必须守住:镜像用的必须是素材的完整时长,不能是裁剪后的时长。 用裁剪后的时长,这个变换就不再是对合,每切换一次,区间就漂移一点。
附属轨:蒙版要一起倒
开头第 3 条反馈(抠图对不上)的原因是:抠图的蒙版是另一个文件,它和原视频逐帧对应。视频倒了,蒙版没倒,人物就和轮廓对不上了。
真实实现里,片段有抠图蒙版时,倒放会另起一个转码任务,把蒙版文件也倒一遍。主视频转完时,进度只报到 99%,要等蒙版也转完才报 100%:
- (void)onReverseEnd {
// …裁剪点镜像、切到倒放文件…
if (self.maskFilePath.length) {
self.progressBlock(0.99, StatusWorking); // 视频好了,蒙版还没好
} else {
self.progressBlock(1.0, StatusEnd);
}
}
这 1% 的意义在于:视频和蒙版必须一起切换。先切视频、等蒙版转完再切蒙版,中间那段时间用户看到的就是错位的画面。

把这件事推广一下,就是这一节的结论:
倒放不是视频的属性,是这个片段所有时间坐标的一次镜像。 裁剪点、蒙版、逐帧的分析结果、用绝对时间存的关键帧和字幕、用绝对时间存的分段变速点——凡是"在源素材第几秒"的数据,都要一起翻。
比例坐标的曲线变速也要翻,只是公式不同:比例 p 变成 1 − p,曲线左右对调。子弹时间那种对称的曲线翻过来看不出差别;不对称的曲线(先慢后快),翻过来就变成先快后慢。用户其实期待这个结果:倒放本来就应该倒着走。
检查的方法很简单:列出片段上所有带时间坐标的字段,逐个问"倒放时它翻不翻"。 答案是"不翻"的,要能说出理由。
八、什么时候不该自己做这一套
- 只有常速变速 →
AVMutableComposition的scaleTimeRange(_:toDuration:)就够了。音频配合AVAudioTimePitchAlgorithm(.spectral或.timeDomain)就是现成的"变速不变调",不用自己写时间伸缩。 - 分段变速,段数不多 → 同样可以用多次
scaleTimeRange拼出来:每段一个时间区间、一个目标时长,阶跃是天然的。 - 曲线变速 → 这才是官方方案不好覆盖的地方。用
scaleTimeRange近似,要把曲线切成很多小段,段越多越平滑、合成越重。要做"子弹时间"这类效果,你需要一个真正的时间映射。 - 倒放只针对几秒的短片段 →
AVAssetReader读出全部帧、倒序用AVAssetWriter写回,是网上最常见的写法。帧数少的时候完全可以用;长视频上这就是第六节那 93 MB × N 的内存问题。
我做的场景是:曲线变速、倒放、抠图、关键帧要同时存在,而且都要能撤销。在这个前提下,时间映射必须是一等公民。如果你的需求只到"整段 0.5 倍 / 2 倍",请直接用系统方案,别学我。
九、收尾:时间是一个函数
回到开头那四条反馈:
- 前段也在变快:阶跃被插值成了斜坡,交界处要写两个同时刻的点。
- 裁剪后留下的是另一头:倒放没有镜像裁剪点。
- 抠图对不上:蒙版这条附属轨没跟着倒。
- 花栗鼠:用了 rate,应该用 tempo。
四条都不是渲染问题,都是时间坐标没跟上。
三条可以带走的原则:
第一,把时间映射当成一等公民。 它是一个函数(有正、反两个方向),不是一个倍数。所有需要在"源时间"和"时间线时间"之间换算的地方,都只调这一个对象。所有和这段素材绑定的轨道,都用同一份映射。音画不同步的根源,几乎总是有人在某处单独算了一遍。
第二,数据的坐标系是产品语义。 绝对时间和比例坐标都没有错,错的是它和用户心里的"这段速度属于谁"不一致。这一条对任何"内容挂在时间轴上"的系统都成立:字幕、弹幕、埋点、音频标记。每加一种挂在时间轴上的数据,先问一句:用户裁剪、变速、倒放的时候,它应该怎么动?
第三,可逆操作要设计成对合。 倒放的镜像做两次就回到原样,所以"取消"不需要保存任何状态。能用对合表达的开关(倒放、水平翻转、交换 A/B 轨),都比"记住之前的状态再恢复"更不容易出错,因为你不需要保证状态被正确保存和清理。
如果你的变速"在交界处慢慢加速",先别查插值算法——看看交界处是不是只写了一个点。
相关阅读:手写 fMP4 muxer · 音画同步不是对齐一次 · 滤镜图内核与撤销重做
这个系列的可运行 demo(每个论断都是一条能重跑的测试)在 github.com/DongQi-Yang…。
关于本文的代码与数据
文中的 Swift 时间映射模型(VariantSpeedMap)是完整可运行的,表格里的 7.5000 s、5.9657 s,以及源→时间线→源往返的误差(小于 10⁻¹⁴),都由它实际算出。Objective-C 片段是对真实实现的等价重写,用来说明机制,未逐行编译验证;接口名按通用叫法做了调整。3.11 MB、93.3 MB、465 次、12 个半音、7.02 个半音都是公式计算值,不是线上测量值。scaleTimeRange、AVAudioTimePitchAlgorithm 的能力描述来自 Apple 公开文档,具体行为以你的系统版本为准。文中没有任何产品指标或内部数据。