变速与倒放:PTS 重排的那些坑

9 阅读18分钟

一、四条"不像 bug 的 bug"

给视频编辑器加变速和倒放,功能本身一周就能跑通。然后你会陆续收到这样的反馈:

  1. "我设了前 5 秒 1 倍速、后 5 秒 2 倍速,结果前面那段也在慢慢变快。" 播放时长也不对,比算出来的短了一截。
  2. "我裁了片段中间那 3 秒,点了倒放,留下来的变成了视频另一头的 3 秒。"
  3. "倒放之后,抠图的人物和背景对不上了。" 人物已经倒着走,蒙版还在正着走。
  4. "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);
}

这个决定带来三个产品层面的后果,都要当成需求来做:

  1. 要有进度条,要能取消。 一分钟的 4K 素材倒放要转码几十秒,没有进度条,用户会以为 App 卡死了。
  2. 结果要缓存。 用户来回点倒放很常见,同一个输出文件已经存在就直接复用。
  3. 原文件不能删。 取消倒放就是把片段指回原文件——这一点下一节细说。

七、坑五:倒放之后,所有和时间绑定的东西都要一起镜像

这是开头第 2、3 条反馈的来源,也是这篇最值钱的一节。

裁剪点:镜像,而不是保持不变

用户在 20 秒的素材里选了第 12 到 15 秒。倒放生成新文件后,同样的画面在新文件里是第 5 到 8 秒:

trimIn  = 素材时长 − 旧 trimOut   = 2015 = 5trimOut = 素材时长 − 旧 trimIn    = 2012 = 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,曲线左右对调。子弹时间那种对称的曲线翻过来看不出差别;不对称的曲线(先慢后快),翻过来就变成先快后慢。用户其实期待这个结果:倒放本来就应该倒着走。

检查的方法很简单:列出片段上所有带时间坐标的字段,逐个问"倒放时它翻不翻"。 答案是"不翻"的,要能说出理由。


八、什么时候不该自己做这一套

  • 只有常速变速AVMutableCompositionscaleTimeRange(_: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 个半音都是公式计算值,不是线上测量值。scaleTimeRangeAVAudioTimePitchAlgorithm 的能力描述来自 Apple 公开文档,具体行为以你的系统版本为准。文中没有任何产品指标或内部数据。