雲端 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 圖形介面與命令列。機型、租用週期與節點,請以訂單確認內容為準。