どうも。Reoです。
今回は、以前書いた記事「【Swift 3】UIImageの配列から動画を生成する【AVAssetWriter】」の別バージョンを紹介させていただきます。
というのも、この記事に対して「
簡易的にこんなものを作ってみたので、まずはGifをみてください。
まずフレームレートを決めて、そこから画像をタップした順に繋ぎ合わせて動画を生成します。
以前の場合は、
- UIImageを入れる配列を用意する
- (画像をタップする)
- タップされた画像を配列に入れる
- (動画を生成ボタンを押す)
- 動画の生成を開始
- 配列から一枚ずつ取り出してバッファに変換して動画に足す
- 動画生成終了
- (動画を表示)
という流れでした。今回は、
- (画像をタップする)
- 動画の生成を開始
- UIImageを一枚バッファに変換して最初のコマにする
- 画像をタップする
- バッファに変換して動画に足す
- (動画を生成ボタンを押す)
- 動画生成終了
- (動画を表示)
という流れで実装しました。
前者では2-3が、後者では1-5までがセットになっています。 後者だとタップした時点で変換して継ぎ足しまでしています。
説明が下手でよくわからんですが、まぁ配列に一回いれなくてもいいよってことです。中身を紐解いてみるとやってることはほとんど変わらんです。
以前のやり方ではまとめてガバッと画像変換処理をするので、すごい負荷がかかってたんですが、今回のやり方にすると結構いい感じでした。
でもタップした時点で動画に足してしまうので、一度配列に入れる方式と違って、やっぱこの画像入れたくない〜ができないんですね。(できるのかもですが私にはわからない!)
今回このコードをGitHubに公開しました。
「uruly/ MovieCreator: 【iOS】UIImageからmp4を生成する」
初めてのgithubだぜ・・・!
プロジェクトごとドーンと公開してるのでクローンかダウンロードして頂ければ、上のgifと同じやつが実行できます。
コード公開しちゃうともうgithubで見てどうにかしてって言いたくなるような気もしますが、なんか記事放棄しちゃってる気がせんでもないので、ちゃんとここでも説明させていただきます。
MovieCreatorクラスの全体コード
まずは全体はこんな感じです。(画像リサイズ処理はちょっと省きました)
import AVFoundation
import UIKit
class MovieCreator {
//保存先のURL
var url:URL?
//フレーム数
var frameCount = 0
// FPS
let fps: __int32_t = 60
var time:Int = 60 // (time / fps) VCからいじる
var videoWriter:AVAssetWriter?
var writerInput:AVAssetWriterInput?
var adaptor:AVAssetWriterInputPixelBufferAdaptor!
//適当に画像サイズ
let imageSize = CGSize(width:1280,height:960)
//イチバン最初はこれを呼び出す
func createFirst(image:UIImage,size:CGSize){
//保存先のURL
url = NSURL(fileURLWithPath:NSTemporaryDirectory()).appendingPathComponent("\(NSUUID().uuidString).mp4")
// AVAssetWriter
guard let firstVideoWriter = try? AVAssetWriter(outputURL: url!, fileType: AVFileTypeQuickTimeMovie) else {
fatalError("AVAssetWriter error")
}
videoWriter = firstVideoWriter
//画像サイズ
let width = size.width
let height = size.height
// AVAssetWriterInput
let outputSettings = [
AVVideoCodecKey: AVVideoCodecH264,
AVVideoWidthKey: width,
AVVideoHeightKey: height
] as [String : Any]
writerInput = AVAssetWriterInput(mediaType: AVMediaTypeVideo, outputSettings: outputSettings as [String : AnyObject])
videoWriter!.add(writerInput!)
// AVAssetWriterInputPixelBufferAdaptor
adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: writerInput!,
sourcePixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32ARGB),
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
]
)
writerInput?.expectsMediaDataInRealTime = true
// 動画の生成開始
// 生成できるか確認
if (!videoWriter!.startWriting()) {
// error
print("error videoWriter startWriting")
}
// 動画生成開始
videoWriter!.startSession(atSourceTime: kCMTimeZero)
// pixel bufferを宣言
var buffer: CVPixelBuffer? = nil
// 現在のフレームカウント
frameCount = 0
if (!adaptor.assetWriterInput.isReadyForMoreMediaData) {
return
}
// 動画の時間を生成(その画像の表示する時間/開始時点と表示時間を渡す)
let frameTime: CMTime = CMTimeMake(Int64(__int32_t(frameCount) * __int32_t(time)), fps)
//時間経過を確認(確認用)
let second = CMTimeGetSeconds(frameTime)
print(second)
// CGImageからBufferを生成
buffer = self.pixelBufferFromCGImage(cgImage: image.cgImage!)
// 生成したBufferを追加
if (!adaptor.append(buffer!, withPresentationTime: frameTime)) {
// Error!
print("adaptError")
print(videoWriter!.error!)
}
frameCount += 1
}
//2回め以降はこれを呼び出す
func createSecond(image:UIImage){
//videoWriterがなければ終了
if videoWriter == nil{
return
}
// pixel bufferを宣言
var buffer: CVPixelBuffer? = nil
if (!adaptor.assetWriterInput.isReadyForMoreMediaData) {
return
}
// 動画の時間を生成(その画像の表示する時間/開始時点と表示時間を渡す)
let frameTime: CMTime = CMTimeMake(Int64(__int32_t(frameCount) * __int32_t(time)), fps)
//時間経過を確認(確認用)
let second = CMTimeGetSeconds(frameTime)
print(second)
// CGImageからBufferを生成
buffer = self.pixelBufferFromCGImage(cgImage: image.cgImage!)
// 生成したBufferを追加
if (!adaptor.append(buffer!, withPresentationTime: frameTime)) {
// Error!
print(videoWriter!.error!)
}
print("frameCount :\(frameCount)")
frameCount += 1
}
//終わったら後始末をしてURLを返す
func finished(_ completion:@escaping (URL)->()){
// 動画生成終了
if writerInput == nil || videoWriter == nil{
return
}
writerInput!.markAsFinished()
videoWriter!.endSession(atSourceTime: CMTimeMake(Int64((__int32_t(frameCount)) * __int32_t(time)), fps))
videoWriter!.finishWriting(completionHandler: {
// Finish!
print("movie created.")
self.writerInput = nil
self.videoWriter = nil
if self.url != nil {
completion(self.url!)
}
})
}
//ピクセルバッファへの変換
func pixelBufferFromCGImage(cgImage: CGImage) -> CVPixelBuffer {
let options = [
kCVPixelBufferCGImageCompatibilityKey as String: true,
kCVPixelBufferCGBitmapContextCompatibilityKey as String: true
]
var pxBuffer: CVPixelBuffer? = nil
let width = cgImage.width
let height = cgImage.height
CVPixelBufferCreate(kCFAllocatorDefault,
width,
height,
kCVPixelFormatType_32ARGB,
options as CFDictionary?,
&pxBuffer)
CVPixelBufferLockBaseAddress(pxBuffer!, CVPixelBufferLockFlags(rawValue: 0))
let pxdata = CVPixelBufferGetBaseAddress(pxBuffer!)
let bitsPerComponent: size_t = 8
let bytesPerRow: size_t = 4 * width
let rgbColorSpace: CGColorSpace = CGColorSpaceCreateDeviceRGB()
let context = CGContext(data: pxdata,
width: width,
height: height,
bitsPerComponent: bitsPerComponent,
bytesPerRow: bytesPerRow,
space: rgbColorSpace,
bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue)
context?.draw(cgImage, in: CGRect(x:0, y:0, width:CGFloat(width),height:CGFloat(height)))
CVPixelBufferUnlockBaseAddress(pxBuffer!, CVPixelBufferLockFlags(rawValue: 0))
return pxBuffer!
}
}
コードのベースは以前の記事と同じです。
まずは、適当にMovieCreatorというクラスを作成しました。AVAssetWriterとかを使いたいので、AVFoundationをimportしておきます。
import UIKit
imort AVFoundation
class MovieCreator{
}
まずはメンバ変数として以下のものを用意します。
class MovieCreator {
//保存先のURL
var url:URL?
//フレーム数
var frameCount = 0
// FPS
let fps: __int32_t = 60
var time:Int = 60 // (time / fps) VCからいじる
var videoWriter:AVAssetWriter?
var writerInput:AVAssetWriterInput?
var adaptor:AVAssetWriterInputPixelBufferAdaptor!
//適当に画像サイズ
let imageSize = CGSize(width:1280,height:960)
}
動画をどこに生成するかを示すURLと、動画の現在のフレーム数と、フレームレートの値を用意しておきます。
以前のやり方では1つの関数内でしか用いなかったんですが、今回は1コマ目と2コマ目以降で違う関数を用いたいので、メンバ変数にしました。
videoWriterとかも複数の関数をまたがって使用したいので、メンバ変数として外に出してあります。
次に最初の1コマ目に呼び出したい関数を用意します。
//イチバン最初はこれを呼び出す
func createFirst(image:UIImage,size:CGSize){
//保存先のURL
url = NSURL(fileURLWithPath:NSTemporaryDirectory()).appendingPathComponent("\(NSUUID().uuidString).mp4")
// AVAssetWriterを作成
guard let firstVideoWriter = try? AVAssetWriter(outputURL: url!, fileType: AVFileTypeQuickTimeMovie) else {
fatalError("AVAssetWriter error")
}
videoWriter = firstVideoWriter
//画像サイズ
let width = size.width
let height = size.height
// AVAssetWriterInput
let outputSettings = [
AVVideoCodecKey: AVVideoCodecH264,
AVVideoWidthKey: width,
AVVideoHeightKey: height
] as [String : Any]
writerInput = AVAssetWriterInput(mediaType: AVMediaTypeVideo, outputSettings: outputSettings as [String : AnyObject])
videoWriter!.add(writerInput!)
// AVAssetWriterInputPixelBufferAdaptor
adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: writerInput!,
sourcePixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32ARGB),
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
]
)
writerInput?.expectsMediaDataInRealTime = true
// 動画の生成開始
// 生成できるか確認
if (!videoWriter!.startWriting()) {
// error
print("error videoWriter startWriting")
}
// 動画生成開始
videoWriter!.startSession(atSourceTime: kCMTimeZero)
// pixel bufferを宣言
var buffer: CVPixelBuffer? = nil
// 現在のフレームカウント
frameCount = 0
if (!adaptor.assetWriterInput.isReadyForMoreMediaData) {
return
}
// 動画の時間を生成(その画像の表示する時間/開始時点と表示時間を渡す)
let frameTime: CMTime = CMTimeMake(Int64(__int32_t(frameCount) * __int32_t(time)), fps)
//時間経過を確認(確認用)
let second = CMTimeGetSeconds(frameTime)
print(second)
// CGImageからBufferを生成
buffer = self.pixelBufferFromCGImage(cgImage: resize.cgImage!)
// 生成したBufferを追加
if (!adaptor.append(buffer!, withPresentationTime: frameTime)) {
// Error!
print("adaptError")
print(videoWriter!.error!)
}
frameCount += 1
}
まずは保存先のURLを決めます。今回はmp4を書き出すので.mp4つけています
//保存先のURL
url = NSURL(fileURLWithPath:NSTemporaryDirectory()).appendingPathComponent("\(NSUUID().uuidString).mp4")
次にAVAssetWriterを用意します。
これが用意できれば、メンバ変数のAVAssetWriterに入れておきます。
// AVAssetWriterを作成
guard let firstVideoWriter = try? AVAssetWriter(outputURL: url!, fileType: AVFileTypeQuickTimeMovie) else {
fatalError("AVAssetWriter error")
}
videoWriter = firstVideoWriter
用意できなければエラーが出てこれ以後の処理はしません。
(最近ようやくこのguardの使い方を知ったのです・・・!)
AVAssetWriterに貼り付けるAVAssetWriterInputを用意します。
// AVAssetWriterInput
let outputSettings = [
AVVideoCodecKey: AVVideoCodecH264,
AVVideoWidthKey: width,
AVVideoHeightKey: height
] as [String : Any]
writerInput = AVAssetWriterInput(mediaType: AVMediaTypeVideo, outputSettings: outputSettings as [String : AnyObject])
videoWriter!.add(writerInput!)
どういうサイズで、何を書き込みたいのかっていう設定みたいな感じです。
こんだけのサイズの(テキストや音声では無く)ビデオを作りたいんだよ〜!って書いてます。
writerInputは先ほどメンバ変数で用意したやつで、セットした後はnilにはならないので!つけています。
次にAVAssetWriterInputPixelBufferAdaptorを用意します。
// AVAssetWriterInputPixelBufferAdaptor
adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: writerInput!,
sourcePixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32ARGB),
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
]
)
長くてなんやこれって感じですね。
UIImageをCGImageに変換して、それをさらにピクセルバッファにして動画を生成するんですが、そのピクセルバッファと先ほどのAVAssetWriterInputをつなぐのがこいつです。
ピクセルバッファをこのアダプターに足してやれば、勝手にAVAssetWriterInputに適合するようにどうやらしてるんだと思います。
そしてこれ
writerInput?.expectsMediaDataInRealTime = true
正直何してるか知らにゃい。というか無くても動く。
これもしかしてカメラからの映像を取得してきた時とかに書くやつですかね・・・?今回はあってもなくてもなんも変わらんです。
ここまできたら準備が整っているのかチェックします。
// 生成できるか確認
if (!videoWriter!.startWriting()) {
// error
print("error videoWriter startWriting")
}
// 動画生成開始
videoWriter!.startSession(atSourceTime: kCMTimeZero)
おkだったら動画生成を開始します。時間はゼロから始めます。
空のピクセルバッファを用意し、アダプターを通してassetWriterInputの準備ができているのかチェックします。
// pixel bufferを宣言
var buffer: CVPixelBuffer? = nil
// 現在のフレームカウント
frameCount = 0
if (!adaptor.assetWriterInput.isReadyForMoreMediaData) {
return
}
ついでにフレームカウントを0にしておきます。
動画の時間を生成します。
// 動画の時間を生成(その画像の表示する時間/開始時点と表示時間を渡す)
let frameTime: CMTime = CMTimeMake(Int64(__int32_t(frameCount) * __int32_t(time)), fps)
//時間経過を確認(確認用)
let second = CMTimeGetSeconds(frameTime)
print(second)
画像をどこに埋め込むかの時間、と言えばいいんでしょうか。最初は0で、次回以降はフレームカウントに合わせて増えます。
画像をバッファに変換して、時間とともにアダプターに追加します。
// CGImageからBufferを生成
buffer = self.pixelBufferFromCGImage(cgImage: image.cgImage!)
// 生成したBufferを追加
if (!adaptor.append(buffer!, withPresentationTime: frameTime)) {
// Error!
print("adaptError")
print(videoWriter!.error!)
}
frameCount += 1
追加できなければエラーを吐きます。
その後フレーム数を進めます。
これで1枚目の処理は終わりです。
CGImageからBufferを生成する関数を追加します。
//ピクセルバッファへの変換
func pixelBufferFromCGImage(cgImage: CGImage) -> CVPixelBuffer {
let options = [
kCVPixelBufferCGImageCompatibilityKey as String: true,
kCVPixelBufferCGBitmapContextCompatibilityKey as String: true
]
var pxBuffer: CVPixelBuffer? = nil
let width = cgImage.width
let height = cgImage.height
CVPixelBufferCreate(kCFAllocatorDefault,
width,
height,
kCVPixelFormatType_32ARGB,
options as CFDictionary?,
&pxBuffer)
CVPixelBufferLockBaseAddress(pxBuffer!, CVPixelBufferLockFlags(rawValue: 0))
let pxdata = CVPixelBufferGetBaseAddress(pxBuffer!)
let bitsPerComponent: size_t = 8
let bytesPerRow: size_t = 4 * width
let rgbColorSpace: CGColorSpace = CGColorSpaceCreateDeviceRGB()
let context = CGContext(data: pxdata,
width: width,
height: height,
bitsPerComponent: bitsPerComponent,
bytesPerRow: bytesPerRow,
space: rgbColorSpace,
bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue)
context?.draw(cgImage, in: CGRect(x:0, y:0, width:CGFloat(width),height:CGFloat(height)))
CVPixelBufferUnlockBaseAddress(pxBuffer!, CVPixelBufferLockFlags(rawValue: 0))
return pxBuffer!
}
ここの説明は割愛!(正直詳しくはよく知らない)
次に2回目以降に呼び出す関数です。
//2回め以降はこれを呼び出す
func createSecond(image:UIImage){
//videoWriterがなければ終了
if videoWriter == nil{
return
}
// pixel bufferを宣言
var buffer: CVPixelBuffer? = nil
if (!adaptor.assetWriterInput.isReadyForMoreMediaData) {
return
}
// 動画の時間を生成(その画像の表示する時間/開始時点と表示時間を渡す)
let frameTime: CMTime = CMTimeMake(Int64(__int32_t(frameCount) * __int32_t(time)), fps)
//時間経過を確認(確認用)
let second = CMTimeGetSeconds(frameTime)
print(second)
// CGImageからBufferを生成
buffer = self.pixelBufferFromCGImage(cgImage: image.cgImage!)
// 生成したBufferを追加
if (!adaptor.append(buffer!, withPresentationTime: frameTime)) {
// Error!
print(videoWriter!.error!)
}
print("frameCount :\(frameCount)")
frameCount += 1
}
2回目以降は先ほどの、時間を計算し、UIImageをバッファに変換してアダプタに加えるという処理を書いているだけです。
っていうかこれ1回目もここ呼び出しちゃった方がハヤクナーイってことに今気づきました。
2回目ではちゃんとAVAssetWriterがあるかどうかを最初に確認しています。
//videoWriterがなければ終了
if videoWriter == nil{
return
}
最後に動画生成を終了する関数を作ります。
//終わったら後始末をしてURLを返す
func finished(_ completion:@escaping (URL)->()){
// 動画生成終了
if writerInput == nil || videoWriter == nil{
return
}
writerInput!.markAsFinished()
videoWriter!.endSession(atSourceTime: CMTimeMake(Int64((__int32_t(frameCount)) * __int32_t(time)), fps))
videoWriter!.finishWriting(completionHandler: {
// Finish!
print("movie created.")
self.writerInput = nil
self.videoWriter = nil
if self.url != nil {
completion(self.url!)
}
})
}
writerInputかvideoWriterがなければ動画生成をしていないので何もせずに抜けます。
最後に終わりの時間を決めます。
videoWriter!.endSession(atSourceTime: CMTimeMake(Int64((__int32_t(frameCount)) * __int32_t(time)), fps))
そして動画の生成を終えます。
videoWriter!.finishWriting(completionHandler: {
//動画生成が終わったらここが呼ばれるよ
})
動画生成が終わったら呼ばれるところに、この関数の引数に指定したクロージャを呼び出します。
self.writerInput = nil
self.videoWriter = nil
if self.url != nil {
completion(self.url!)
}
urlが決まってなければ呼び出しません。
あとはwirterInputとvideoWriterをnilに戻しておきます。
githubの方のコードはどっちかだけnilにしてた気がするけど多分両方のがいい気がします。
MovieCreatorクラスを使ってみる
先ほど作ったMovieCreatorを適当なViewControllerで使ってみます。
class ViewController: UIViewController {
let movieCreator = MovieCreator()
//1コマ目かどうか
var isFirstTap = true
override func viewDidLoad() {
super.viewDidLoad()
/* なんか適当にボタンやらを用意しておく */
}
//ボタンを押した時の処理 ボタンの画像を押すたびに動画に継ぎ足し
func btnTapped(sender:UIButton){
//ボタンの画像があるかをチェック
if let image = sender.imageView?.image{
//1枚目の画像だけセットアップを含む
if isFirstTap {
movieCreator.createFirst(image: image, size: CGSize(width:image.size.width,
height:image.size.height))
isFirstTap = false
}else{
movieCreator.createSecond(image: image)
}
}
}
//動画を生成する
func createBtnTapped(){
//動画生成終了を呼び出してURLを得る
movieCreator.finished { (url) in
//ここでurlが使える
}
isFirstTap = true
}
}
ざっくりと割愛しましたが、先ほどのMovieCreatorクラスを用いてるのはこんだけです。
VC内に1つMovieCreatorクラスを生成しておいて適当にボタンを押したら画像を追加しています。
1コマ目の時は
movieCreator.createFirst(image: image, size: CGSize(width:image.size.width,
height:image.size.height))
を呼び出して、UIImageと動画のサイズを渡してあげます。
2コマ目ではUIImageだけを渡してあげます。
movieCreator.createSecond(image: image)
動画を作り終えたい時は
movieCreator.finished { (url) in
//urlがあればここが呼び出される。
}
を呼び出します。{}の中は先ほどのcompletionの中身です。動画を作成し終えた後にurlを使って何か処理を書くこともできます。
AVPlayerViewControllerと組み合わせて使おう
最後におまけ。
作った動画をAVPlayerViewControllerにのっけてみます。最初に乗せたgifの動画を表示している部分です。
まずAVPlayerViewControllerを使うにはMediaPlayerとAVKitをimportします。
import UIKit
import MediaPlayer
import AVKit
class ViewController: UIViewController {
}
AVPlayerViewControllerを用意します。
//動画を表示するやつ
var avPlayerVC:AVPlayerViewController!
override func viewDidLoad() {
super.viewDidLoad()
//動画を表示するビュー
avPlayerVC = AVPlayerViewController()
avPlayerVC.view.frame = CGRect(x:0,y:0,width:320,height:240)
avPlayerVC.view.center = CGPoint(x:self.view.frame / 2,y:self.view.frame / 4)
avPlayerVC.view.backgroundColor = UIColor.gray
self.addChildViewController(avPlayerVC)
self.view.addSubview(avPlayerVC.view)
}
サイズやらはちょっと適当で。
ここに動画を表示させます。
先ほどのMovieCreatorのfinishedでurlを取得できるのでそれを使います。
movieCreator.finished { (url) in
DispatchQueue.main.async{
let avPlayer = AVPlayer(url:url)
self.avPlayerVC.player = avPlayer
avPlayer.play()
}
}
これちょっとアレなんですが、MovieCreatorのfinished(_:)側から見るとわかるんですが、
func finished(_ completion:@escaping (URL)->()){
/* ~~ */
videoWriter!.finishWriting(completionHandler: {
// Finish!
if self.url != nil {
completion(self.url!)
}
})
}
completionを呼び出しているのがfinishWritingのクロージャ内なんですね。
んでこれがおそらく別スレッドでの処理になってるんですね。
そうするとこのavPlayerがどうやら書いてるところの処理はメインスレッドじゃないとダメだよ!って怒られるんです。
This application is modifying the autolayout engine from a background thread after the engine was accessed from the main thread. This can lead to engine corruption and weird crashes.
って言われます。
ログにこんな風に出てきて以下にブワーーーっと続くやつがでます。
なので
DispatchQueue.main.async{
}
を使ってメインスレッドに戻してあげています。怒られる割には一応動くんですけども。
なんか頑張って書いたら結構なボリュームになってしまいましたが、コード見りゃわかるって人は「uruly/ MovieCreator: 【iOS】UIImageからmp4を生成する」で見てください。
というか結構頑張ってスッキリしたコード書いたつもりだったんですが、それでもまだまだ改善できますね・・・。initもちゃんと用意した方が良いんかなとも思ったり。
誰かの何かの役に立てれば嬉しいです。それではでは。
Additional Notes追記
Swift4に対応しました。
uruly/ MovieCreator: 【iOS】UIImageからmp4を生成する
Swift4に対応しておきました〜。 ワンクリックしただけですが!
コメントはありません。
現在コメントフォームは工事中です。