クラウド Mac で検証する iOS メディア時刻:CMTime の精度と丸め
編集ツールでクリップの終了時刻を浮動小数点の秒数として保存したところ、プロジェクトを開き直すと、つなぎ目に1フレーム余分に入ってしまう。こんなとき、まず再生プレーヤーの設定を変えるのは得策ではありません。時刻の計算を UI から切り離し、クラウド Mac 上で入力、変換ルール、検証条件を固定したほうが、シークバーを動かすよりも原因を絞り込めます。ここでは Swift と CoreMedia だけを使います。動画素材も、GUI セッションも必要ありません。
まず時刻の意味を定義する
CMTime は value / timescale で有理数の時間を表し、そのほかにフラグと epoch を持ちます。タイムスケールはフレームレートではなく、大きくすれば必ず正しくなるものでもありません。たとえば、フレームレートが毎秒 30000/1001 フレームなら、1フレームの長さは 1001/30000 秒で表すべきです。先にフレームレートを 29.97 に丸めてから逆数を求めてはいけません。
今回の契約では、すべての値を数値として有効な時間に限定し、epoch はゼロとします。継続時間は負の値を認めず、オフセットは負の値も認めます。条件を満たさない入力はエラーとして返し、ゼロで代用しません。ゼロに置き換えると、破損したレコードが冒頭の時刻として解釈されてしまいます。
「同じ秒数として表示される」ことと「時刻が等しい」ことは別です。UI では表示を整形しても、内部の境界比較では 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 に統一しても、誤差はなくなりません。3分の1秒は正確に表せますが、先ほどの1フレームの長さは正確に表せないためです。各フレームを変換してから加算すると、丸め誤差が回数に応じて蓄積します。元のタイムスケールを保持して演算を終え、出力先のインターフェースが求める形式に合わせて最後に変換します。
.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 の両方で入力し、分子やタイムスケールの一致に判定が依存していないことも確認します。開始点の1目盛り前、終了点の1目盛り前、開始と終了が逆転した区間も追加でテストします。最後のケースでは、開始点と終了点を黙って入れ替えるのではなく、必ずエラーにしなければなりません。
ビルドに組み込み、受け入れ検証の範囲を明確にする
スクリプトとアプリケーションの時間処理関数を同じリポジトリに置き、時間アルゴリズムを変更したら一緒に実行します。単独のスクリプトは基礎となる動作の確認に使いますが、プロジェクトのテストでは、本番の実装を直接呼び出す必要があります。「サンプルは正しいのに、アプリケーションは古いロジックのまま」という状態を防ぐためです。
ジョブごとにコミット識別子、xcrun swift --version、終了コードを記録します。時間のテストに失敗したら、後続の書き出し処理を止めてください。再試行を追加して、決定的に再現するエラーを覆い隠してはいけません。回帰テスト用のサンプルを追加するときは元の分数を保持し、サンプルファイル内であらかじめ小数に変換しないようにします。
最後に、有理数の計算が正しいか、タイムラインのマッピングが正しいか、実際のメディアが期待どおりに再生されるかを、それぞれ別の層として検証します。このスクリプトがカバーするのは、最初の層と、明示的に定義した区間の規則だけです。可変フレームレートの素材では、実際のタイムスタンプを読み取る必要があり、固定のフレーム長から推測してはいけません。各層の入力と検証結果を明確にしてから映像を調べることで、1フレームのずれがどこで生じたのかを突き止められます。
よくある質問
CMTime を Double の秒数に変換して比較してもよいですか?
表示には使えますが、厳密な境界判定には CMTimeCompare を使います。浮動小数点への変換では有理数の精度が失われる可能性があり、epoch や無効な時刻の状態も秒数だけでは表現できません。
タイムスケールをすべて 600 に統一すれば安全ですか?
いいえ。600 では正確に表せないフレーム時間があります。元の時刻を保持し、入出力の境界で丸め方を指定して変換し、hasBeenRounded を確認してください。
ビルドは専有クラウド Mac におまかせ
Apple Silicon 搭載の物理ノードを選び、マシンを丸ごと専有できます。macOS のグラフィカル環境もコマンドラインもそのまま利用可能です。モデル、利用期間、ノードは注文内容をご確認ください。