はじめに
これは 第二のドワンゴ Advent Calendar 2020 20日目 の記事です。
ドワンゴでは主にiOSアプリの開発をしています。
業務内容にはあまり関係ないですが、iOS14やmacOS 11.0よりAVAssetWriterに追加された機能を使い、iOSアプリ上にストリーミングサーバーを動かせるかという検証をしてみたので、その話を書きます。
なお、あくまでコードは例なのでいい感じに補完してください。
検証に使った環境
macOS 10.15.5
Xcode 12.2
Safari 13.1.1
iPhone 12 Pro iOS 14.2
カメラ映像からHLSのセグメントとインデックスファイルを出力する
カメラ映像の取得
AVCaptureDeviceからCMSampleBufferを取得します。
ありふれた処理なのでコードは省略しますが、AVCaptureVideoDataOutputSampleBufferDelegateからCMSampleBufferを取得できるようになっていればOKです。
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
....
}
なお、今回は複雑さ回避のため映像のみで音声は省いています。
AVAssetWriter
AVAssetWriterの初期化をします。
outputFileTypeProfileに.mpeg4AppleHLSを指定します。
self.writer = AVAssetWriter(contentType: UTType(AVFileType.mp4.rawValue)!)
writer.delegate = self
writer.outputFileTypeProfile = .mpeg4AppleHLS
writer.preferredOutputSegmentInterval = CMTime(seconds: 1.0, preferredTimescale: 1)
writer.initialSegmentStartTime = CMTime.zero
let videoOutputSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: 360,
AVVideoHeightKey: 640
]
self.videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoOutputSettings)
videoInput.expectsMediaDataInRealTime = true
writer.add(videoInput)
次にCMSampleBufferを受け取ったときの処理を書きます。
初めて受け取った時に書き込み状態にしてセッションを開始、書き込み中であればPTSを補正してAVAssetWriterInputにCMSampleBufferを書き込みます。
if writer.status == .unknown {
writer.startWriting()
writer.startSession(atSourceTime: CMTime.zero)
}
if writer.status == .writing {
if let offset = offset {
var copyBuffer: CMSampleBuffer?
var count: CMItemCount = 1
var info = CMSampleTimingInfo()
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: count, arrayToFill: &info, entriesNeededOut: &count)
info.presentationTimeStamp = CMTimeSubtract(info.presentationTimeStamp, offset)
CMSampleBufferCreateCopyWithNewTiming(allocator: kCFAllocatorDefault,
sampleBuffer: sampleBuffer,
sampleTimingEntryCount: 1,
sampleTimingArray: &info,
sampleBufferOut: ©Buffer)
if let copyBuffer = copyBuffer, videoInput.isReadyForMoreMediaData {
videoInput.append(copyBuffer)
}
} else {
offset = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
}
}
しばらくすると、AVAssetWriterDelegateのdidOutputSegmentDataが呼ばれ、セグメントのデータが渡されます。
func assetWriter(_ writer: AVAssetWriter, didOutputSegmentData segmentData: Data, segmentType: AVAssetSegmentType, segmentReport: AVAssetSegmentReport?) {
...
}
セグメントファイルとインデックスファイルの作成
次に、セグメントのデータからセグメントのデータファイルとインデックスファイルを作成します。
今回は、Documentsディレクトリ配下に新しくディレクトリを作成し、セグメントとインデックスファイルを保存します。
セグメントファイルは、didOutputSegmentDataで受け取ったsegmentDataをそのままファイルに保存するだけです。
今回は、単純に受け取った順に番号をつけ、segment1.m4sのようなファイル名にして保存しています。
インデックスファイルを作成するためにはいくつかセグメントが必要なので、何個か作成されたタイミングでファイルを作成 or 更新をします。
インデックスファイルは、HLSの仕様に則る必要がありますが、Appleのサンプルコード を参考に生成するだけで問題ないと思われます。
インデックスファイルは、セグメントが生成され渡されてくるたびに更新します。
SwiftNIO Transport Service でサーバーを立てる
swift-nio-transport-servicesは、SwiftNIO をiOSやwatchOS, tvOSでも使えるようにするために拡張したもので、Network.framework を使用しています。
SwiftNIO を使って作られたアプリケーションは、少し書き換えるだけでSwiftNIO Transport Serviceでも問題なく動作するようになるとのことです。
In addition to providing first-class support for Apple platforms, NIO Transport Services takes advantage of the richer API of Network.framework to provide more insight into the behaviour of the network than is normally available to NIO applications. This includes the ability to wait for connectivity until a network route is available, as well as all of the extra proxy and VPN support that is built directly into Network.framework.
All regular NIO applications should work just fine with NIO Transport Services, simply by changing the event loops and bootstraps in use.
(GitHub README より)
SwiftNIO Transport Serviceは、CocoaPodsに対応していますが、Swift Package Managerでも導入可能なので今回はSwift Package Magagerで導入します。
Swift Package Managerで、SwiftNIOとSwiftNIO Transport Serviceを追加し、NIO、NIOHTTP1、NIOTransportServicesのパッケージを依存関係に追加します。
追加した後、SwiftNIO Transport Serviceのサンプルを参考にHTTPサーバーの実装をします。
let group = NIOTSEventLoopGroup()
let channel = try! NIOTSListenerBootstrap(group: group)
.childChannelInitializer { channel in
channel.pipeline.configureHTTPServerPipeline(withPipeliningAssistance: true, withErrorHandling: true).flatMap {
channel.pipeline.addHandler(HTTP1ServerHandler())
}
}
.bind(host: "0.0.0.0", port: 8080)
.wait()
try! channel.closeFuture.wait()
HTTP1ServerHandlerは、ChannelInboundHandlerに準拠させ、以下のようの実装します。
final class HTTP1ServerHandler: ChannelInboundHandler {
typealias InboundIn = HTTPServerRequestPart
typealias OutboundOut = HTTPServerResponsePart
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let part = unwrapInboundIn(data)
guard case .head(let headData) = part else {
return
}
if headData.uri == "/" {
// index.htmlをレスポンスとして返す処理
}
}
}
今回は/
に対してのリクエストに対して、プレイヤーを持ったHTMLを返すようにします。
ファイル名はindex.htmlとして、Bundleに含めます。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HLS Stream Server</title>
</head>
<body>
<header>
<h1>HLS Stream Server</h1>
</header>
<div>
<video width="360" height="640" src="index.m3u8" preload="none" onclick="this.play()" controls />
</div>
</body>
</html>
レスポンスを返すために、以下のようなメソッドを実装して呼び出します。
private func handleIndexPageRequest(context: ChannelHandlerContext, data: NIOAny) {
do {
let path = Bundle.main.path(forResource: "index", ofType: "html")!
let data = try Data(contentsOf: URL(fileURLWithPath: path))
let buffer = context.channel.allocator.buffer(data: data)
var responseHeaders = HTTPHeaders()
responseHeaders.add(name: "Content-Length", value: "\(data.count)")
responseHeaders.add(name: "Content-Type", value: "text/html; charset=utf-8")
let responseHead = HTTPResponseHead(version: .init(major: 1, minor: 1), status: .ok, headers: responseHeaders)
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
} catch {
let responseHead = HTTPResponseHead(version: .init(major: 1, minor: 1), status: .notFound)
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
}
}
インデックスファイルや、セグメントファイルに対するリクエストに対しても同様な処理を実装します。
最後に、HTTPサーバーとカメラ、セグメントを生成する処理を同時に実行するように呼び出します。
検証
Safariから、iPhoneのローカルIPとポートを指定してアクセスします。
デバッグメニューからネットワークを確認すると、インデックスファイルの更新や、セグメントファイルの読み込みが行われていることがわかります。
参考
Author fragmented MPEG-4 content with AVAssetWriter
Live Playlist (Sliding Window) Construction
[iOS] AVFoundation(AVCaptureVideoDataOutput/AVCaptureAudioDataOutput)でVine風の継ぎ足し撮影アプリを作ってみた
https://swiftreviewercom.wordpress.com/2020/03/27/import-swift-nio-to-ios-tvos-in-xcode-11/
https://www.process-one.net/blog/swiftnio-introduction-to-channels-channelhandlers-and-pipelines/