うるおいらんど

【Swift 3】UIImageの配列から動画を生成する【AVAssetWriter】

追記があります。

どもも。

UIImageから動画生成がやっとある程度形になったので紹介していきます。

といっても詳しくは端折り、コードと行き詰まりポイント程度にしておきます。

なのでまずは全体コードから。

 

全体コード

MovieCreator.swift

import Foundation
import AVFoundation
import UIKit

class MovieCreator: NSObject {
    
    override init() {
        
        super.init()
    }
    
    func create(images:[UIImage],size:CGSize,time:Int) -> URL{
        
        //保存先のURL
        let url = NSURL(fileURLWithPath:NSTemporaryDirectory()).appendingPathComponent("\(NSUUID().uuidString).mp4")
        // AVAssetWriter
        guard let videoWriter = try? AVAssetWriter(outputURL: url!, fileType: AVFileTypeQuickTimeMovie) else {
            fatalError("AVAssetWriter error")
        }
        
        //画像サイズを変える
        let width = size.width
        let height = size.height
        
        // AVAssetWriterInput
        let outputSettings = [
            AVVideoCodecKey: AVVideoCodecH264,
            AVVideoWidthKey: width,
            AVVideoHeightKey: height
            ] as [String : Any]
        let writerInput = AVAssetWriterInput(mediaType: AVMediaTypeVideo, outputSettings: outputSettings as [String : AnyObject])
        videoWriter.add(writerInput)
        
        // AVAssetWriterInputPixelBufferAdaptor
        let 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
        
        // 現在のフレームカウント
        var frameCount = 0
        
        // 各画像の表示する時間
        let durationForEachImage = time
        
        // FPS
        let fps: __int32_t = 60
        
        // 全画像をbufferに埋め込む
        for image in images {
            
            if (!adaptor.assetWriterInput.isReadyForMoreMediaData) {
                break
            }
            
            // 動画の時間を生成(その画像の表示する時間/開始時点と表示時間を渡す)
            let frameTime: CMTime = CMTimeMake(Int64(__int32_t(frameCount) * __int32_t(durationForEachImage)), fps)
            //時間経過を確認(確認用)
            let second = CMTimeGetSeconds(frameTime)
            print(second)
            
            let resize = resizeImage(image: image, contentSize: size)
            // CGImageからBufferを生成
            buffer = self.pixelBufferFromCGImage(cgImage: resize.cgImage!)
            
            // 生成したBufferを追加
            if (!adaptor.append(buffer!, withPresentationTime: frameTime)) {
                // Error!
                print("adaptError")
                print(videoWriter.error!)
            }
            
            frameCount += 1
        }
        
        // 動画生成終了
        writerInput.markAsFinished()
        videoWriter.endSession(atSourceTime: CMTimeMake(Int64((__int32_t(frameCount)) *  __int32_t(durationForEachImage)), fps))
        videoWriter.finishWriting(completionHandler: {
            // Finish!
            print("movie created.")
        })
        
        return 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!
    }
    
    private func resizeImage(image:UIImage,contentSize:CGSize) -> UIImage{
        // リサイズ処理
        let origWidth  = Int(image.size.width)
        let origHeight = Int(image.size.height)
        var resizeWidth:Int = 0, resizeHeight:Int = 0
        if (origWidth < origHeight) {
            resizeWidth = Int(contentSize.width)
            resizeHeight = origHeight * resizeWidth / origWidth
        } else {
            resizeHeight = Int(contentSize.height)
            resizeWidth = origWidth * resizeHeight / origHeight
        }
        
        let resizeSize = CGSize(width:CGFloat(resizeWidth), height:CGFloat(resizeHeight))
        UIGraphicsBeginImageContext(resizeSize)
        
        image.draw(in: CGRect(x:0,y: 0,width: CGFloat(resizeWidth), height:CGFloat(resizeHeight)))
        
        let resizeImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    
        return resizeImage!
    }
    
}

 

呼び出したい場所(適当なViewController)

let movie = MovieCreator()
//16の倍数
let movieSize = CGSize(width:480,height:640)
// time / 60 秒表示する
let time = 1
//動画を生成
let movieURL = movie.create(imageData:imageArray,size:movieSize,time:time)

 

あとはつまずきポイントの説明だけしたいと思います。

動画の保存先URL

最初に動画をどこに書き出すかを指定しています。

        //保存先のURL
        let url = NSURL(fileURLWithPath:NSTemporaryDirectory()).appendingPathComponent("\(NSUUID().uuidString).mp4")
        // AVAssetWriter
        guard let videoWriter = try? AVAssetWriter(outputURL: url!, fileType: AVFileTypeQuickTimeMovie) else {
            fatalError("AVAssetWriter error")
        }

これがまた色々やったんですが、上手くできず・・・。

上記コードの大部分が「複数枚のUIImageから動画を生成したい」を参考にさせていただいているのですが、うまいこと解決できず、結局こんな感じになりました。

 

1フレームの長さ

1枚の写真をどれだけの時間表示するか、ということなのですが、なんか結構はまりました。

この辺です。

            // 動画の時間を生成(その画像の表示する時間/開始時点と表示時間を渡す)
            let frameTime: CMTime = CMTimeMake(Int64(__int32_t(frameCount) * __int32_t(durationForEachImage)), fps)
            //時間経過を確認(確認用)
            let second = CMTimeGetSeconds(frameTime)
            print(second)

 

このCMTimeは、「[Swift]CMTimeを使う」を参考にさせていただきなんとか解決。というか使い方をよくわかっていなかっただけでした。

CMTime(a,b)は a / b 秒を表しています。

durationForEachImage = 1

fps = 60とします。

1枚目の画像 0(frameCount) × 1  / 60 = 0 なので開始位置は0

2枚目の画像 1(frameCount) × 1 / 60 = 0.016 なので開始位置は0.016秒

3枚目の画像 2(frameCount) × 1 / 60 = 0.033 なので開始位置は 0.033秒

といった風になります。

この a / b を1 / 1とした場合は、1秒に1枚画像を表示します。

実際に時間がどのように変わっているのか見る場合はCMTimeGetSeconds() で確認できます。

 

動画のサイズ

今回一番ハマりました。まじでわけがわからんかった・・・

結論からいうと、画像の横幅は16の倍数でなければなりません。

それに気づかないとこんなことになります。

めっちゃずれてます。じゃみじゃみです。

横幅が16の倍数だとちゃんと表示されます。

これはこちらの「How do I export UIImage array as a movie in Swift 3?」のページのコードを見ていて気づきました。

 

意外と情報自体はあるのに、なかなか上手くいかなかった動画生成でした・・・。

 

(2017/9/5)配列からではなく一枚ずつ継ぎ足して動画を生成する方法を記事にしました。そっちのが詳しく説明してあります〜「【Swift 3】UIImageを1枚ずつ継ぎ足して動画を生成する【AVAssetWriter】

 

とりあえずは一番大きい山を越えたかなぁといった感じ。本当に今月中旬までには新しいアプリ出します〜。

ではでは

Additional Notes追記

Gistにあげました。

>(2017/9/5)配列からではなく一枚ずつ継ぎ足して動画を生成する方法を記事にしました。そっちのが詳しく説明してあります〜「【Swift 3】UIImageを1枚ずつ継ぎ足して動画を生成する【AVAssetWriter】

で一応少し違うやり方で作ったものをGithubにupしています。uruly / MovieCreator

のですが、一応こっちでもSwift4に対応したものを書いてみたのでついでにgistにあげておきました。

AVFileTypeQuickTimeMovieっていう指定がSwift4ではできなくなっているのでそこは.mp4にしておきました。

あとmovieの横幅は16の倍数と書いてますが、そもそも画像自体が16の倍数でなかったらぐにゃります。

Comments
T

16の倍数ハマってました。ありがとうございます!

tester

はじめまして。コメント失礼します。 resizeImage関数について教えてください。 ・なぜresizeImage関数が必要なのでしょうか。images引数で与えられたUIImageをそのまま使用するのではだめでしょうか。

かず

初めまして、コメントありがとうございます。

なぜresizeImage関数が必要なのでしょうか。images引数で与えられたUIImageをそのまま使用するのではだめでしょうか。

此方の回答としまして、本記事の動画のサイズにて書いています通り、画像の横幅が16の倍数のでないと、動画をうまく書き出すことができません。

結論からいうと、画像の横幅は16の倍数でなければなりません。

そのため、画像のリサイズ処理が必要になります。

動画のサイズが width:480, height:640 であり、用意した全ての画像も同様に width:480, height:640 であれば、リサイズ処理なしで images 引数で与えられた UIImage をそのまま使用しても何も問題にはなりません。 しかし、実際に利用する時にはそうなっている場合ってかなり少ないのではかなと思います。 例えば UIPickerController 等と組み合わせて利用する場合は、サイズの大きい写真を含んだり、画像サイズもバラバラになるので、その場合にもリサイズを行ってから動画にした方が良いでしょう。

使い所次第ですので、リサイズ処理なしでもダメですとは言い切れませんが、私はリサイズ処理をしてから動画にすることを推奨いたします。

Reo(管理人)