AVFoundation 공부

애플의 AVFoundation Programming GuideMedia Playback Programming Guide을 통해 AVFoundation에 대해 알아보았다.

AVFoundation?

AVFoundation은 시간 기반 시청각 미디어 자료를 플레이하고 생성할 수 있는 프레임워크 중 하나이다. 이 프레임워크를 통해 미디어 파일을 조사하고 조작하는 것이 가능하다.

AVFoudation에서 미디어 자료를 표현하는 가장 대표적인 타입인 AVAsset부터 보자.

Understanding the Asset Model

AVFoudation에서 가장 중요한 타입은 AVAsset이다. AVAsset은 오디오와 비디오 트랙, 제목, 길이, 자연스러운 영상 사이즈 등 다양한 데이터의 집합체이다. 또한 대부분의 미디어 파일 포맷을 지원하고 HTTP Live Streaming(HLS)도 지원한다. 이를 통해 개발자로 하여금 미디어 포맷으로부터의 독립성과 미디어 자료의 위치에 대한 독립성을 주어 좀 더 개발에 집중할 수 있도록 한다.

AVAsset은 AVAssetTrack 타입의 다양한 트랙이 모여 만들어진다. 즉, 비디오 트랙과 오디오 트랙, 자막 트랙 등으로 나뉘어지고 이것이 합쳐져 하나의 AVAsset을 이루는 구조이다.

Creating an Asset

AVAsset은 추상적인 클래스로 초기화 시에는 url을 사용해 AVAsset의 인스턴스가 아닌 AVURLAsset 타입의 인스턴스가 생성된다. init 메서드는 다음과 같다.

1
init(url URL: URL, options: [String : Any]? = nil)

이 때 전달하는 options 인자에는 다음과 같은 key와 value가 들어간다.

  • AVURLAssetPreferPreciseDurationAndTimingKey: NSNumber로 감싸진 boolean 값을 value로 가지며 asset이 준비될 때 정확한 duration과 시간으로 random access가 가능하게끔 하는 여부를 판단한다. 기본값은 false지만 만약 true로 하는 경우 asset을 준비하는 데에 보통의 경우보다 좀 더 시간이 걸리기 때문에 asset을 조작하거나 하는 경우에만 사용하는 것이 좋다.
  • AVURLAssetReferenceRestrictionsKey: NSNumber로 래핑된 AVAssetReferenceRestrictions의 열거형 값을 value로 가지며 이 값들을 조합해 외부의 미디어 데이터를 사용하는 것에 제한을 둔다.
  • AVURLAssetHTTPCookiesKey: HTTP 리퀘스트 시 보내는 쿠키를 값으로 가진다.
  • AVURLAssetAllowsCellularAccessKey: 미디어 자료를 볼 때 셀룰러 네트워크를 사용할 것인가를 boolean 값으로 가지는 key로 기본값은 true이다.

Preparing Assets for Use

AVAsset는 지금 재생이 가능한지 여부, 총 길이, 생성 날짜 등 다양한 정보들을 담고 있다. 이러한 정보들은 플레이어를 만들 때 유용하게 쓰일 것이다. 그런데 이러한 프로퍼티들은 AVAsset이 생성될 때 자동으로 같이 나오지 않고 요청이 오면 그제서야 불러오기 시작한다. 원활한 UI 작업을 위해 요청은 비동기적으로 처리하라.

AVAsset과 AVAssetTrack은 AVAsynchronousKeyValueLoading 프로토콜을 준수하며 이를 여기에 정의된 두 메서드로 프로퍼티가 사용 가능 여부 확인과 불러오기 요청을 할 수 있다.

  • loadValuesAsynchronously(forKeys:completionHandler:): AVAsset의 인스턴스에 key의 배열에 전달된 값에 해당하는 프로퍼티들을 비동기적으로 요청하는 메서드이다. 필요한 프로퍼티들을 불러와 사용이 가능할 때의 처리를 해주면 된다.
  • statusOfValue(forKey:error:): key에 해당하는 프로퍼티가 즉시 사용 가능한지 여부를 확인하는 메서드로 호출하는 스레드를 블록하지 않는다.

다음은 애플의 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// URL of a bundle asset called 'example.mp4'
let url = Bundle.main.url(forResource: "example", withExtension: "mp4")!
let asset = AVAsset(url: url)
let playableKey = "playable"

// Load the "playable" property
asset.loadValuesAsynchronously(forKeys: [playableKey]) {
var error: NSError? = nil
let status = asset.statusOfValue(forKey: playableKey, error: &error)
switch status {
case .loaded:
// Sucessfully loaded. Continue processing.
case .failed:
// Handle error
case .cancelled:
// Terminate processing
default:
// Handle all other cases
}
}

Working with Metadata

미디어 자료는 각각 메타데이터(metadata)를 담고 있다. 미디어의 포맷에 따라 메타데이터의 종류가 달라지기 때문에 iOS에서는 AVMetadataItem이라는 클래스를 제공해 메타 데이터를 다루는 데 용이하도록 하였다. AVMetadataItem의 인스턴스는 키-값의 쌍으로 존재한다.

Retrieving a Collection of Metadata

AVFoundation에서는 메타데이터를 보다 쉽게 찾고 구별할 수 있도록 두 개의 그룹으로 메타데이터의 키를 구분한다.

  • Format-specific key spaces: 미디어 자료의 포맷만의 메타데이터의 키를 의미하며 metadata 프로퍼티를 사용해 접근할 수 있다.
  • Common key space: 미디어 자료의 포맷에 국한되지 않는 메타데이터의 키들을 의미하며 예로는 생성일, 설명 등이 있다. commonMetadata 프로퍼티로 접근이 가능하다.

asset이 어떤 메타데이터를 포함하고 있는지를 알아보려면 availableMetadataFormats 프로퍼티를 비동기적으로 불러와서 사용한다. 이 프로퍼티로 접근하면 포함하고 있는 메타데이터들의 문자열로 된 식별자들의 배열이 반환된다.

다음은 애플의 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
let url = Bundle.main.url(forResource: "audio", withExtension: "m4a")!
let asset = AVAsset(url: url)
let formatsKey = "availableMetadataFormats"
asset.loadValuesAsynchronously(forKeys: [formatsKey]) {
var error: NSError? = nil
let status = asset.statusOfValue(forKey: formatsKey, error: &error)
if status == .loaded {
for format in asset.availableMetadataFormats {
let metadata = asset.metadata(forFormat: format)
// process format-specific metadata collection
}
}
}

Finding and Using Metadata Values

메타데이터의 컬렉션을 얻었다면 개별 메타데이터에 접근할 차례이다. AVMetadataItem 클래스의 클래스 메서드를 사용해서 메타데이터 별로 구분해서 얻어올 수 있는데 그 중 가장 쉬운 방법은 식별자를 사용해서 얻어오는 것이다.

다음은 애플의 예제 코드이다.

1
2
3
4
5
6
let metadata = asset.commonMetadata
let titleID = commonIdentifierTitle // Objective-C에서는 AVMetadataCommonIdentifierTitle
let titleItems = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: titleID)
if let item = titleItems.first {
// process title item
}

AVMetadataItem의 필터링 메서드들은 하나의 메타데이터가 아니라 컬렉션 타입을 반환한다. 대부분의 경우 컬렉션 안에 하나의 메타데이터가 들어 있지만 로컬라이즈되거나 공통 키공간에서 데이타를 받고 여러 키공간에서 같은 값을 가지고 있는 경우 여러 데이타가 반환될 수 있다.

메타데이터를 받은 다음에는 그 안에 들어있는 실제적 데이터를 value 프로퍼티를 통해 받아온다. 반환되는 값은 NSObject를 상속하고 NSCopying 프로토콜을 준수하는 데 이를 타입 캐스팅하여 사용할 때는 stringValue, numberValue, dateValue, dataValue 프로퍼티를 사용해 안전하고 쉽게 변환하는 것이 권장된다.

다음은 애플의 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Collection of "common" metadata
let metadata = asset.commonMetadata
// Filter metadata to find the asset's artwork
let artworkItems =
AVMetadataItem.metadataItems(from: metadata,
filteredByIdentifier: commonIdentifierArtwork)
if let artworkItem = artworkItems.first {
// Coerce the value to an NSData using its dataValue property
if let imageData = artworkItem.dataValue {
let image = UIImage(data: imageData)
// process image
} else {
// No image data found
}
}

Playing Media

AVAsset이 미디어 자료를 표현하는 중요한 클래스지만 미디어를 재생하는 데에 추가적인 클래스들이 이용된다.

AVPlayer

AVPlayer는 미디어 에셋을 재생하고 시간을 조절하는 중심 컨트롤러 클래스이다. 로컬로, 다운로드하면서 또는 스트리밍 데이터를 재생하는 데 모두 사용된다.

AVPlayer는 하나의 미디어 자료를 재생하는 클래스이다. 연속적으로 미디어를 재생하려면 AVPlayer를 상속하는 AVQueuePlayer를 사용하면 된다.

AVPlayerItem

AVAsset은 미디어의 정적인 측면을 모델링하기 때문에 AVPlayer의 재생에는 맞지 않는다. 대신 동적인 성격을 갖고 있는 AVPlayerItem의 인스턴스를 대신 사용한다. 이 클래스는 AVPlayer에 의해 재생되는 asset의 재생 상태를 표현한다. AVPlayerItem의 프로퍼티와 메서드를 사용해 미디어의 특정 시간대로 가거나 화면 크기, 현재 시간 등을 알 수 있다.

AVKit and AVPlayerLayer

AVPlayer와 AVPlayerItem은 눈에 보이는 객체들이 아니기 때문에 이것을 스크린에 띄워줄 클래스가 필요하다. 두 가지 옵션이 있다.

  • AVKit: 비디오 컨텐츠를 보여주는 가장 좋은 방법은 AVKit 프레임워크 안의 AVPlayerViewController이다. 이 객체는 재생 관련 메서드들을 이미 가지고 있다.
  • AVPlayerLayer: 만약 커스텀 플레이어를 만들고 싶다면 CALayer를 상속하는 AVFoundation의 AVPlayerLayer를 사용해야 한다. 이 레이어를 뷰의 하위 레이어로 정하거나 레이어 계층에 포함시켜 비디오 컨텐트를 보이게 한다. 그러나 AVPlayerLayer에는 보여주는 기능밖에 없기 때문에 다른 재생 관련 제어는 개발자가 직접 구현해야 한다.

Observing Playback State

AVPlayer와 AVPlayerItem은 상태가 계속 변하는 동적인 객체들이다. 당신은 이 상태의 변화에 따라 반응하고 그에 따른 처리를 하고 싶은 경우가 많을 것이다. 이 때 사용되는 것이 Key-Value Observing(KVO)이다. KVO를 통해 AVPlayer와 AVPlayerItem의 상태의 변화를 추적하고 그에 따른 처리를 해주면 된다.

AVPlayerItem의 프로퍼티 중 가장 많이 감지하는 것들 중 하나가 status이다. 이 프로퍼티는 AVPlayerItem의 재생 가능 여부를 알아볼 때 가장 많이 사용된다.

다음은 애플의 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
let url: URL = // Asset URL

var asset: AVAsset!
var player: AVPlayer!
var playerItem: AVPlayerItem!

// Key-value observing context
private var playerItemContext = 0

let requiredAssetKeys = [
"playable",
"hasProtectedContent"
]

func prepareToPlay() {
// Create the asset to play
asset = AVAsset(url: url)

// Create a new AVPlayerItem with the asset and an
// array of asset keys to be automatically loaded
playerItem = AVPlayerItem(asset: asset,
automaticallyLoadedAssetKeys: requiredAssetKeys)

// Register as an observer of the player item's status property
playerItem.addObserver(self,
forKeyPath: #keyPath(AVPlayerItem.status),
options: [.old, .new],
context: &playerItemContext)

// Associate the player item with the player
player = AVPlayer(playerItem: playerItem)
}

이렇게 등록한 옵저버는 playerItem의 상태 변화가 일어나면 메시지를 보내주는 데 이를 observeValueForKeyPath:ofObject:change:context:를 사용해서 받는다.

다음은 애플의 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
override func observeValue(forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey : Any]?,
context: UnsafeMutableRawPointer?) {

// Only handle observations for the playerItemContext
guard context == &playerItemContext else {
super.observeValue(forKeyPath: keyPath,
of: object,
change: change,
context: context)
return
}

if keyPath == #keyPath(AVPlayerItem.status) {
let status: AVPlayerItemStatus
if let statusNumber = change?[.newKey] as? NSNumber {
status = AVPlayerItemStatus(rawValue: statusNumber.intValue)!
} else {
status = .unknown
}
// Switch over status value
switch status {
case .readyToPlay:
// Player item is ready to play.
case .failed:
// Player item failed. See error.
case .unknown:
// Player item is not yet ready.
}
}
}

Performing Time-Based Operations

미디어 재생은 시간 기반 활동이다. AVPlayer나 AVPlayerItem의 많은 핵심 기능들도 미디어의 타이밍을 제어하는 것과 관련이 있다. 이러한 기능을 효과적으로 다루기 위해서는 AVFoundation에서 시간을 어떻게 표현하는지에 대해 알아야 하는데 이것이 바로 CMTime이다.

CMTime은 Core Media 프레임워크의 데이타 타입으로 시간을 부분으로 쪼개서 표현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public struct CMTime {
public var value: CMTimeValue
public var timescale: CMTimeScale
public var flags: CMTimeFlags
public var epoch: CMTimeEpoch
}
```

CMTime의 가장 중요한 두 프로퍼티는 value와 timescale이다. `CMTimeValue`는 64비트 integer 값의 시간의 분자값이고 `CMTimeScale`은 32비트 integer 값의 시간의 분모값이다. 이 두개가 이루어져 시간을 표현한다.

``` Swift
// 0.25 seconds
let quarterSecond = CMTime(value: 1, timescale: 4)

// 10 second mark in a 44.1 kHz audio file
let tenSeconds = CMTime(value: 441000, timescale: 44100)

// 3 seconds into a 30fps video
let cursor = CMTime(value: 90, timescale: 30)

Core Media 프레임워크에서는 CMTime 간 산술, 비교, 증명, 변환 연산을 지원한다.

Observing Time

일반적으로 미디어 플레이어는 재생 시간을 감지해 재생의 진행 정도를 캐치해 UI에 적용한다. 앞서 상태를 감지하는 KVO 기법은 시간의 연속적인 변화에 따른 상태 변화를 감지하는 데에는 바르지 않은 선택이다. 대신 AVPlayer는 두 가지 방법을 제공한다.

Periodic Observations

일정 시간 간격으로 시간을 감지하는 방법으로 일반적으로 커스텀 플레이어를 만들 때 현재 시간을 업데이트하는 용도로 사용된다.

addPeriodicTimeObserverForInterval:queue:usingBlock: 메서드를 통해 감지하며 인자로 감지할 시간 간격, serial한 디스패치 큐, 그리고 감지한 CMTime을 인자로 갖는 콜백 블럭을 전달한다.

다음은 애플의 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var player: AVPlayer!
var playerItem: AVPlayerItem!
var timeObserverToken: Any?

func addPeriodicTimeObserver() {
// Notify every half second
let timeScale = CMTimeScale(NSEC_PER_SEC)
let time = CMTime(seconds: 0.5, preferredTimescale: timeScale)
timeObserverToken = player.addPeriodicTimeObserver(forInterval: time,
queue: .main) {
[weak self] time in
// update player transport UI
}
}

func removePeriodicTimeObserver() {
if let timeObserverToken = timeObserverToken {
player.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil
}
}

Boundary Observations

이 방법은 정해진 시간 구간 내에서 옵저버를 달아두는 것으로 위의 방법보다는 덜 사용하는 방법이다. addBoundaryTimeObserverForTimes:queue:usingBlock: 메서드를 통해 이루어지며 인자로 시간 구간을 전달한다.

Seeking Media

미디어의 현재 시간을 원하는 위치로 이동시키는 것으로 AVKit에서는 기본 제공되는 기능이다. 그러나 커스텀으로 플레이어를 만들 경우 직접 구현해주어야 한다.

AVPlayer와 AVPlayerItem에 이를 가능하게 하는 메서드들이 있지만 가장 일반적인 방법은 seek(to:) 메서드를 사용하는 것이다. 플레이어의 시간을 인자로 전달된 CMTime으로 바꾸는 것이다.

1
2
3
// Seek to the 2 minute mark
let time = CMTime(value: 120, timescale: 1)
player.seek(to: time)

seek(to:) 메서드는 정확성보다 빠르게 이동시키는 속도에 초점이 맞춰져 있기 때문에 정확성이 떨어질 수 있다. 즉, 목표 시간과 오차가 있는 시간으로 이동할 수 있다는 뜻이다. 만약 좀 더 정확한 타이밍으로 가려면 seekToTime:toleranceBefore:toleranceAfter: 메서드를 사용해 목표 시간의 전후에 얼마나 오차를 허용할 지를 전달하면 된다.

seekToTime:toleranceBefore:toleranceAfter: 메서드의 오차 허용값으로 작거나 0의 값을 전달할 경우 타이밍을 찾는 것이 딜레이를 일으킬 수 있다.

Reference