问题与范围
做 AVFoundation 导出时,最容易先写出一个 Double 驱动进度条:completed / total。它能表达处理到了哪里,却不一定能表达导出是否已经完成。尤其是自定义 reader-writer 链路中,视频读完、音频读完、writer 收尾完成和调用方拿到可处理的结果并不是同一时刻。
本文讨论多轨 reader-writer 导出中的进度与终态模型:怎样保留阶段信息、怎样处理 finishWriting、怎样让取消及迟到回调不改写结果。它不覆盖编码器兼容性、设备性能或最终文件的真实播放器验证。
先给结论
- 一个总百分比只适合 UI;任务事实还需要视频、音频、收尾进度与当前阶段。
- 即使百分比已经到 100%,只要仍处于
finishing,就不能把任务标为交付成功。 - 成功的唯一可信入口是导出完成回调中的
Result<URL, Error>;取消、失败和成功是互斥终态。 - 取消后必须过滤普通进度和迟到完成回调;若会紧接着启动下一次导出,还要隔离旧 run 的回调。
把进度拆成可解释的阶段
多轨导出至少有三类观察值:视频消费进度、音频消费进度和 writer 收尾进度。它们与状态机一起,才能回答"为什么进度条不再动":是某条轨道仍在消费,还是输入已结束、正等待写入器收口。
下面是一个简化的通用模型;具体聚合公式取决于产品的展示策略:
swift
struct ExportProgress {
var videoFraction: Double?
var audioFraction: Double?
var finishFraction: Double?
var phase: Phase
enum Phase {
case consuming, finishing, completed, cancelled, failed
}
}
不要让 overallFraction == 1 直接触发完成 UI。它可以用来展示"输入消费已经结束",但必须同时看 phase 和最终 completion。进度快照也应原样向上交付,而不是在任务末尾由控制器补造一个"看起来合理"的数字。
消费完成不等于文件完成
reader 没有更多样本,只说明输入已经耗尽;writer 仍可能处在最终写入或容器收尾阶段。更可靠的时序是:
text
consuming → finishing → completed
而不是:
text
progress == 1.0 → completed
伪代码中的关键是让终态只由收尾结果决定:
swift
func runExport() async {
state = .consuming
await consumeInputs()
guard state.isActive else { return }
state = .finishing
let result = await finishWriting()
guard state.isActive else { return }
state = result.isSuccess ? .completed : .failed
}
这里每个 await 之后都需要重新检查状态,因为取消可能发生在任意返回点附近。进度和终态不是同一条事实链:前者解释过程,后者决定是否可以交付 URL。
取消是终态,不是普通进度事件
取消要保持幂等:任务已经取消后,新的 progress callback 不能把它改回 consuming 或 finishing;finishWriting 的迟到成功也不能再交付输出。
swift
func cancel() {
guard state.isActive else { return }
state = .cancelled
job.cancel()
}
func didFinish(_ result: Result<URL, Error>) {
guard state != .cancelled else { return }
state = result.isSuccess ? .completed : .failed
}
如果取消后会立即创建下一次导出,还应把 callback 绑定到本次 run 的 token。旧任务的迟到回调只能被忽略,不能污染新任务的状态或进度。
Kakapos 中的进度合同
Kakapos 的 ReaderWriterExportJob.ProgressInfo 明确保存 videoProgress、audioProgress、finishWritingProgress 和 phase。TimelineExportTask 把综合进度提供给简单 UI,同时把完整 progressInfo 向调用方开放;它只在底层导出回调成功时交付输出 URL。
这个设计也说明了一个容易忽略的边界:当前 overallFractionCompleted 选择了编码进度和收尾进度中更高的值。因此视频和音频都为 1、收尾仍为 0 时,总进度仍可能是 1。进度条的 100% 于是表达"已处理完输入",不是"文件已经可以当作成功结果交付"。调用方需要同时展示或记录 phase == .finishing,并以 completion 的 Result 判定最终结果。
现有集成测试分别构造视频、音频和 finishWriting 的进度,并断言任务能保留最新快照;调试摘要还会输出 tracks、processors、phase、video/audio/finish 的细分值。这些验证的是进度模型与状态转发合同,不是某台设备上的完整端到端导出验收。
需要逐帧 GPU 处理时,Harbeth 可以负责输入帧的图像处理与输出合同;它不拥有媒体导出、writer 收尾或导出完成状态。Kakapos 仍是媒体生命周期与导出编排的 owner,两者保持可替换的处理后端边界。
验证与适用边界
建议至少覆盖以下测试:输入轨道各自完成、进入 finishing、进度到 100% 但尚未 completion、取消先于迟到回调,以及新 run 忽略旧 run 的 callback。真实产品还需要在目标设备上验证磁盘空间、写入权限、异常编码器状态、长视频内存行为和产物可播放性。
这套模型适合多轨、异步收尾且可取消的导出。若任务是单一同步操作,引入完整阶段模型反而会增加不必要的状态复杂度。
小结与讨论
把导出进度当成"一个数",会让 UI 和任务状态在最后阶段失去解释力。保留轨道进度与收尾阶段、让 completion 独占结果交付权、让取消拥有终态优先级,才能把 100% 与"真正完成"区分开。
你会在导出 UI 中怎样说明"输入已完成、正在收尾"这一段状态?