[iOS]AVAssetWriter+swifterでiPhone実機からHLS配信

# iOS で AVAssetWriter + swifter で HLS 配信
* iOS14 から AVAssetWriter に fragmented MPEG-4 ファイルを出力する機能が追加されました！！！

https://developer.apple.com/documentation/avfoundation/avfiletypeprofile/3563936-mpeg4applehls?changes=_6

* [swifter](https://github.com/httpswift/swifter) を使って httpサーバを起動し、 AVAssetWriter で作成した fragmented MPEG-4 ファイルを配布することで、iPhone から HLS で映像配信する事が可能です

# 関連リンク
* リポジトリはこちら

https://github.com/fuziki/VideoCreator

* Apple のゲームアプリのテンプレートの画面を配信する事が可能です

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">iPhoneからHLS配信できた！ <a href="https://t.co/9GFckF9tsP">pic.twitter.com/9GFckF9tsP</a></p>&mdash; ふじき (@fzkqi) <a href="https://twitter.com/fzkqi/status/1426158158922145796?ref_src=twsrc%5Etfw">August 13, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

* 参考になりました

https://developer.apple.com/documentation/avfoundation/media_assets_and_metadata/sample-level_reading_and_writing/writing_fragmented_mpeg-4_files_for_http_live_streaming

# 実装
## fragmented MPEG-4 ファイルの作成
* AVAssetWriter を作って、fragmented MPEG-4 ファイルを作成します

### AVAssetWriter の作成

* contentType に .mpeg4Movie を指定して、AVAssetWriter を作成します
* outputFileTypeProfile を .mpeg4AppleHLS に設定します
* preferredOutputSegmentInterval に期待するセグメントの時間を設定します
  * 今回は、1秒に設定します
* 作成したセグメントを受け取るために、delegate を設定します
* 適当に　AVAssetWriterInput を作って追加します

```swift
let assetWriter = AVAssetWriter(contentType: .mpeg4Movie)
assetWriter.shouldOptimizeForNetworkUse = true
assetWriter.outputFileTypeProfile = .mpeg4AppleHLS
assetWriter.preferredOutputSegmentInterval = CMTime(seconds: 1, preferredTimescale: 1)
assetWriter.delegate = self

let settings = [
    AVVideoCodecKey : AVVideoCodecType.h264,
    AVVideoWidthKey : width,
    AVVideoHeightKey : height,
] as [String : Any]
let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
input.expectsMediaDataInRealTime = true
assetWriter.add(input)
```

### 配信開始
* initialSegmentStartTime を設定して、セッションを開始します

```swift
func start(time: CMTime) throws {
    assetWriter.initialSegmentStartTime = time
    assetWriter.startWriting()
    assetWriter.startSession(atSourceTime: time)
}
```

### 書き込み
* CMSampleBuffer を AVAssetWriterInput に書き込みます

```swift
func write(sample: CMSampleBuffer) throws {
    input.append(sample)
}
```

### セグメントの取得
* AVAssetWriterDelegate を実装し、セグメントのデータを取得します
* これで、fragmented MPEG-4 のデータが取得できました

```swift
extension MediaWriter: AVAssetWriterDelegate {
    func assetWriter(_ writer: AVAssetWriter,
                     didOutputSegmentData segmentData: Data,
                     segmentType: AVAssetSegmentType,
                     segmentReport: AVAssetSegmentReport?) {
        self.onSegmentData?(segmentData)
    }
}
```

* 取得したセグメントデータは、最初のデータと、その後のデータを区別してキャッシュしておきます
    * 理由は後述
* 順序に並べるために、sequence の index をつけたタプルで配列に格納します

```swift
var sequence: Int = -1
var initData: Data? = nil
func onSegmentData(data: Data) {
    sequence += 1
    if sequence == 0 {
        initData = data
        return
    }
    sequences.append((sequence: sequence, data: data))
}
```

## swifter を使ったサーバの起動
### swifter を使った HttpServer を起動
* ハンドラーを設定します

```swift
import Swifter

let server = HttpServer()
// ~~ handler の設定 ~~
try! server.start(8080, forceIPv4: true, priority: .default)
```

### ハンドラーの設定
#### html
* hls.js を使って再生します

```swift
server["/hello"] = { (request: HttpRequest) -> HttpResponse in
    let body = """
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<video id="video" width="240" height="360" autoplay muted></video>
<script>
  var video = document.getElementById('video');
  var videoSrc = 'hls.m3u8';
  if (Hls.isSupported()) {
    var hls = new Hls();
    hls.loadSource(videoSrc);
    hls.attachMedia(video);
  } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
    video.src = videoSrc;
  }
</script>
"""
    return .ok(.htmlBody(body))
}
```

#### m3u8
* 最新の3つ前分のファイルを返します
* 最初のセグメントデータは、`EXT-X-MAP` で利用します
* コンテンツタイプは、`"application/x-mpegURL"` です

```swift
server["/hls.m3u8"] = { [weak self] (request: HttpRequest) -> HttpResponse in
    let body: HttpResponseBody = .data(self!.m3u8.data(using: .utf8)!, contentType: "application/x-mpegURL")
    return .ok(body)
}
private var m3u8: String {
    let template = """
#EXTM3U
#EXT-X-TARGETDURATION:1
#EXT-X-VERSION:9
#EXT-X-MEDIA-SEQUENCE:\(sequence - 2)
#EXT-X-MAP:URI="init.mp4"
#EXTINF:1.0,
files/sequence\(sequence - 2).m4s
#EXTINF:1.0,
files/sequence\(sequence - 1).m4s
#EXTINF:1.0,
files/sequence\(sequence).m4s
"""
    return template
}
```

#### セグメントデータ
* 最初のセグメントを返す `init.mp4` と、キャッシュした最新のセグメントを返す `files/sequenceN.m4s` のリクエストの処理を実装します
* `init.mp4` のコンテンツタイプは、`"video/mp4"` を、`files/sequenceN.m4s` のコンテンツタイプは、`"video/iso.segment"` を指定します

```swift
server["init.mp4"] = { [weak self] (request: HttpRequest) -> HttpResponse in
    let body: HttpResponseBody = .data(self!.initData!, contentType: "video/mp4")
    return .ok(body)
}
server["/files/:path"] = { [weak self] (request: HttpRequest) -> HttpResponse in
    guard let data = self!.sequences.first(where: { request.path.hasPrefix("/files/sequence\($0.sequence)") })?.data else {
        return .notFound
    }
    let body: HttpResponseBody = .data(data, contentType: "video/iso.segment")
    return .ok(body)
}
```

## 動作確認
* 実機でビルドし、`http://{{iPhoneのipアドレス}}:8080/hello` にアクセスします
* 動画が再生されることを確認します

# おわりに
* AVAssetWriter と swifter を使うことで、HLS 配信を容易に実装する事ができました
* HLS なので遅延は3秒程度発生してしまいます
* AVAssetWriter は cmaf も対応したようなので、試してみたいです