导出进度不是一个百分比:AVFoundation 收尾阶段的状态建模

问题与范围

做 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 中怎样说明"输入已完成、正在收尾"这一段状态?

源码与参考实现

相关推荐
大熊猫侯佩1 小时前
macOS27 上可变界面尺寸 iOS App 揭秘
macos·ios·swiftui
用户2181697049301 小时前
LLVM
ios
茶底世界之下1 小时前
为什么媒体源不能在回调仍执行时提前报告 finish?
swift
zeqinjie1 小时前
Flutter 获取 iPhone Duo 预留区位置
前端·flutter·ios
二流小码农1 小时前
鸿蒙开发:ArrayList,可不会让UI更新哦
android·ios·harmonyos
茶底世界之下2 小时前
为什么视频处理器必须提前声明“它能处理什么帧”
swift
茶底世界之下2 小时前
录制结束,不代表视频文件已经交付:AVFoundation 录制完成状态的工程化判断
swift
茶底世界之下6 天前
为什么预览、录制、离线导出不能共享同一个背压策略?
ios·swift
2501_916007476 天前
苹果商店iOS应用上架费用全面解析:开发者计划与审核费用计算
android·ios·小程序·https·uni-app·iphone·webview