云端 Mac 上的 iOS 媒体时间验收:CMTime 精度与舍入

云端 Mac 上的 iOS 媒体时间验收:CMTime 精度与舍入

剪辑工具把片段结束时间存成浮点秒数,重新打开工程后,拼接处却多出一帧。先不要调整播放器。把时间计算从界面中拆出来,在云端 Mac 上固定输入、转换规则和断言,比拖动进度条更容易定位问题。下面只使用 Swift 与 CoreMedia,不需要视频素材,也不依赖图形会话。

先约定时间的含义

CMTime 用 value / timescale 表示有理数时间,另有标志与 epoch。刻度不是帧率,也不是精度越大就越正确。以每秒 30000/1001 帧为例,一帧应表示为 1001/30000 秒,不要先把帧率缩成 29.97 再取倒数。

本次契约限定:所有值必须是数值时间,使用零 epoch;持续时长不得为负,偏移量可以为负。输入不满足条件就返回错误,不能用零代替,否则损坏记录会被解释成片头。

“显示成相同秒数”不等于“时间相等”。界面可以格式化,内部边界比较应保留 CMTime,并使用 CMTimeCompare。

还要区分时刻和时长。两者虽然共用一个类型,业务含义不同;跨时间轴的时刻不能不经映射直接相加。

建立可执行的精度样本

在构建用户的工作目录保存以下文件为 media-time.swift。它先检查输入,再验证帧时长累加、等值分数及转换时的舍入。检查函数使用显式失败退出,不依赖可能被优化配置移除的断言。

import CoreMedia
import Foundation
import Darwin

func check(_ condition: Bool, _ name: String) {
    guard condition else {
        FileHandle.standardError.write(Data("FAIL: \(name)\n".utf8))
        exit(1)
    }
}

enum TimeError: Error {
    case unsupported
}

func accepted(_ time: CMTime) throws -> CMTime {
    guard time.isNumeric, time.epoch == 0 else {
        throw TimeError.unsupported
    }
    return time
}

let frame = try accepted(CMTime(value: 1001, timescale: 30000))
var total = CMTime.zero
for _ in 0..<30 {
    total = CMTimeAdd(total, frame)
}
check(
    CMTimeCompare(total, CMTime(value: 1001, timescale: 1000)) == 0,
    "frame accumulation"
)

let third = CMTime(value: 1, timescale: 3)
let exact = CMTimeConvertScale(
    third, timescale: 600, method: .roundHalfAwayFromZero
)
check(exact.value == 200, "exact conversion value")
check(!exact.hasBeenRounded, "exact conversion flag")

for sign: Int64 in [-1, 1] {
    let source = CMTime(value: sign, timescale: 1200)
    let rounded = CMTimeConvertScale(
        source, timescale: 600, method: .roundHalfAwayFromZero
    )
    check(rounded.value == sign, "signed halfway rounding")
    check(rounded.hasBeenRounded, "rounding flag")
}

for bad in [CMTime.invalid, CMTime.indefinite, CMTime.positiveInfinity] {
    do {
        _ = try accepted(bad)
        check(false, "reject nonnumeric time")
    } catch TimeError.unsupported {
    }
}

print("PASS: media time precision")

运行前确认开发工具可用,再执行脚本:

set -eu
xcrun --find swift
xcrun swift media-time.swift

预期只有成功标记;失败返回非零退出码。这里验证的是时间数学,不是解码速度,也不是某个节点的性能。

把舍入放在接口边界

统一成 600 刻度并不能消除误差。它能精确表达三分之一秒,却不能精确表达上述帧时长。若每帧先转换再累加,舍入误差会随次数叠加;应保留源刻度,完成运算后,再按输出接口要求转换。

.roundHalfAwayFromZero 的中点规则对正负值对称:半个目标单位分别远离零。若业务要求向零截断或向下取整,必须替换方法并补充负数样本,不能只测试正向播放。

检查转换后的证据

失败记录至少包含输入的 value、timescale、epoch,以及目标刻度、舍入方法和输出标志。hasBeenRounded 是精度曾发生损失的信号,不是自动失败条件,也不能独自描述误差大小。允许多少误差,应由导出格式或产品规则明确规定。

用半开区间检查拼接点

把剪辑区间定义成 [start, end):起点属于当前片段,终点属于下一片段。这样相邻片段共用边界时,不会同时命中两个片段。在前面的脚本末尾加入以下检查:

func contains(_ point: CMTime, start: CMTime, end: CMTime) throws -> Bool {
    let p = try accepted(point)
    let a = try accepted(start)
    let b = try accepted(end)
    guard CMTimeCompare(a, b) <= 0 else {
        throw TimeError.unsupported
    }
    return CMTimeCompare(p, a) >= 0 && CMTimeCompare(p, b) < 0
}

let start = CMTime.zero
let end = CMTime(value: 1, timescale: 1)
check(try contains(start, start: start, end: end), "inclusive start")
check(!(try contains(end, start: start, end: end)), "exclusive end")
check(
    !(try contains(start, start: start, end: start)),
    "empty interval"
)

同一边界还应分别以 1/1 和 600/600 输入,确认判断不依赖分子或刻度相同。补测起点前一个刻度、终点前一个刻度及反向区间;最后一种必须报错,而非偷偷交换起终点。

接入构建并划清验收范围

把脚本与业务时间函数放进同一仓库,修改时间算法时一起运行。独立脚本用于确认底层语义,工程测试还必须直接调用生产实现,防止“样本正确、应用仍走旧逻辑”。

每次任务记录提交标识、xcrun swift --version 和退出码。时间测试失败应阻断后续导出,不要加重试掩盖确定性错误。新增回归样本时保留原始分数,避免在样本文件中预先写成小数。

最后分别验收三层:有理数计算是否正确、时间轴映射是否正确、实际媒体是否按预期播放。本脚本只覆盖第一层和明确约定的区间规则;变帧率素材应读取实际时间戳,不能靠固定帧时长推算。先让每层都有清楚的输入和结论,再排查画面,才能知道差的一帧究竟从哪里产生。

常见问题

把 CMTime 转成 Double 秒数再比较可以吗?

显示进度时可以转换,判断精确边界时应使用 CMTimeCompare。浮点秒数可能丢失有理数精度,也无法单独表达 epoch 和无效时间等状态。

所有媒体时间统一转换到 600 刻度就安全了吗?

不是。600 不能精确表示所有帧时长。应保留源时间,在接口边界按明确规则转换,并检查 hasBeenRounded;是否允许舍入由业务契约决定。

把构建任务交给独享云端 Mac

选择 Apple Silicon 物理节点,独占整台机器,使用原生 macOS 图形界面与命令行。机型、周期和节点以订单确认内容为准。