클라우드 Mac의 iOS 미디어 시간 검증: CMTime 정밀도와 반올림

클라우드 Mac의 iOS 미디어 시간 검증: CMTime 정밀도와 반올림

클라우드 Mac의 iOS 미디어 시간 검증: CMTime 정밀도와 반올림

편집 도구에서 클립의 종료 시각을 부동소수점 초 단위로 저장했는데, 프로젝트를 다시 열자 클립 연결 지점에 프레임 하나가 더 생겼습니다. 이럴 때는 플레이어부터 조정하지 마세요. 시간 계산을 UI에서 분리하고 클라우드 Mac에서 입력, 변환 규칙, 검증 조건을 고정하면 재생 막대를 움직이는 것보다 문제를 쉽게 좁힐 수 있습니다. 여기서는 Swift와 CoreMedia만 사용합니다. 영상 소스도, 그래픽 세션도 필요하지 않습니다.

먼저 시간의 의미를 정의하기

CMTime은 value / timescale로 유리수 시간을 표현하며, 별도로 플래그와 epoch를 갖습니다. 타임스케일은 프레임 레이트가 아니며, 값을 크게 잡는다고 더 정확해지는 것도 아닙니다. 예를 들어 초당 30000/1001 프레임이라면 한 프레임의 길이는 1001/30000초로 표현해야 합니다. 프레임 레이트를 먼저 29.97로 줄인 다음 역수를 구하면 안 됩니다.

이번에 적용할 규약은 다음과 같습니다. 모든 값은 수치로 유효한 시간이어야 하며 epoch는 0이어야 합니다. 지속 시간은 음수일 수 없지만 오프셋은 음수여도 됩니다. 조건에 맞지 않는 입력은 오류를 반환해야 하며 0으로 대체해서는 안 됩니다. 0으로 대체하면 손상된 레코드를 영상의 시작 시점으로 해석하게 됩니다.

“같은 초 값으로 표시된다”는 것이 “같은 시간이다”라는 뜻은 아닙니다. 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

예상 출력은 성공 메시지 하나뿐입니다. 실패하면 0이 아닌 종료 코드를 반환합니다. 여기서 검증하는 것은 시간 연산이지, 디코딩 속도나 특정 노드의 성능이 아닙니다.

반올림은 인터페이스 경계에서 수행하기

타임스케일을 600으로 통일해도 오차가 사라지지는 않습니다. 3분의 1초는 정확히 표현할 수 있지만, 앞서 살펴본 프레임 길이는 정확히 표현할 수 없기 때문입니다. 각 프레임을 먼저 변환한 뒤 더하면 횟수가 늘어날수록 반올림 오차가 누적됩니다. 원본 타임스케일을 유지한 채 연산을 마치고, 마지막에 출력 인터페이스의 요구 사항에 맞춰 변환해야 합니다.

.roundHalfAwayFromZero의 중간값 처리 규칙은 양수와 음수에서 대칭입니다. 대상 단위의 절반에 해당하는 값은 각각 0에서 멀어지는 방향으로 반올림됩니다. 요구 사항이 0 방향 절삭이나 음의 무한대 방향 내림이라면 방식을 바꾸고 음수 샘플도 추가해야 합니다. 정방향 재생만 테스트해서는 안 됩니다.

변환 결과의 근거 확인하기

실패 기록에는 적어도 입력의 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 그래픽 인터페이스와 터미널을 그대로 이용할 수 있습니다. 모델, 이용 기간, 노드는 주문 확인 내용을 기준으로 합니다.