[iOS][swift] アニメーションGIFを作る

2016.08.10

iOSではCGImageDestinationを使うことによって複数の画像を一つのGIFアニメにすることが出来ます。

イメージ図

今回はCGImageDestinationを使ってアニメーションGIFを作る方法について記載します。

検証環境

今回は下記環境で試しています。

Xcode 7.3.1
Swift 2.2
Deployment Target iOS9.0以上

アニメーションGIFを作る

概要

処理の流れは下記図のイメージです。

処理の流れのイメージ図

準備

GIFを作るための画像が必要です。ここでは、

private var images = [CGImage]()

に画像が入っているものとします。

また、下記importも必要です。

import ImageIO
import MobileCoreServices

1.CGImageDestinationの作成

CGImageDestinationの作成には、DataConsumer、MutableData、URLに書き出すものの3パターンあります。
この記事ではURLに書き出す、 CGImageDestinationCreateWithURL() を利用します。(扱いやすいため)

/* Create an image destination writing to `url'. The parameter `type'
 * specifies the type identifier of the resulting image file.  The
 * parameter `count' specifies number of images (not including thumbnails)
 * that the image file will contain. The `options' dictionary is reserved
 * for future use; currently, you should pass NULL for this parameter.
 * Note that if `url' already exists, it will be overwritten. */

@available(iOS 4.0, *)
public func CGImageDestinationCreateWithURL(url: CFURL, _ type: CFString, _ count: Int, _ options: CFDictionary?) -> CGImageDestination?

引数は下記の通りです。

url このURLに画像が書き出されます
type 画像タイプの定数を設定
count 画像の枚数
options 今後のための予約。現時点ではnilを渡す必要がある

コード

下記の例ではTemporaryに適当な名称のgifファイルのパスを作成しています。
CGImageDestinationCreateWithURLの画像タイプにはGIFの場合、kUTTypeGIF定数を入れます。

let url = NSURL(fileURLWithPath: NSTemporaryDirectory()).URLByAppendingPathComponent("\(NSUUID().UUIDString).gif")
guard let destination = CGImageDestinationCreateWithURL(url, kUTTypeGIF, images.count, nil) else {
    print("CGImageDestinationの作成に失敗")
    return
}

CGImageDestinationCreateWithURLの戻り値はOptional型なのでguardでチェックしました。

2.CGImageDestinationにプロパティをセットする

プロパティをセットするにはCGImageDestinationSetPropertiesを使用します。

/* Specify the dictionary `properties' of properties which apply to all
 * images in the image destination `idst'. */

@available(iOS 4.0, *)
public func CGImageDestinationSetProperties(idst: CGImageDestination, _ properties: CFDictionary?)

引数は下記の通りです。

idst CGImageDestinationを設定
properties プロパティを設定

プロパティを作成する

アニメーションGIFを作成するのに必要な情報は、

  • ループカウント (アニメーションの再生回数)
  • フレームレート (アニメーションの切り替わる速さ)

がありますが、SetPropertiesにはループカウントの方を設定します。

kCGImagePropertyGIFDictionaryをキーとして値にkCGImagePropertyGIFLoopCountをキーとしたDictionaryを設定します。kCGImagePropertyGIFLoopCountの値はアニメーションの再生回数になります。0を設定すると無限ループになります。
文字に書くと分かり難いですが、コードで書くと下記になります。

// ループカウント(0で無限ループ)
let properties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]]

コード

7行目でプロパティを定義し、8行目で設定しています。

let url = NSURL(fileURLWithPath: NSTemporaryDirectory()).URLByAppendingPathComponent("\(NSUUID().UUIDString).gif")
guard let destination = CGImageDestinationCreateWithURL(url, kUTTypeGIF, images.count, nil) else {
    print("CGImageDestinationの作成に失敗")
    return
}

let properties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]]
CGImageDestinationSetProperties(destination, properties)

3.CGImageDestinationに画像を追加する

画像をセットするにはCGImageDestinationAddImageを使用します。
また、追加する画像分呼び出す必要があります。

/* Set the next image in the image destination `idst' to be `image' with
 * optional properties specified in `properties'.  An error is logged if
 * more images are added than specified in the original count of the image
 * destination. */

@available(iOS 4.0, *)
public func CGImageDestinationAddImage(idst: CGImageDestination, _ image: CGImage, _ properties: CFDictionary?)

引数は下記の通りです。

idst CGImageDestinationを設定
image 画像を設定(CGImage)
properties プロパティを設定

プロパティを作成する

CGImageDestinationAddImageのプロパティにはフレームレートを設定します。

kCGImagePropertyGIFDictionaryをキーとして値にkCGImagePropertyGIFDelayTimeをキーとしたDictionaryを設定します。kCGImagePropertyGIFDelayTimeの値はフレームレート(秒数)になります。floatで設定します。
文字に書くと分かり難いですが、コードで書くと下記になります。

// フレームレート
let frameProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFDelayTime as String: 0.2]]

コード

ループで画像を取り出してCGImageDestinationAddImageに画像を入れています。
下記コードではフレームレートを一定にしていますが、個別で設定すると、だんだん早くしたり、特定の所でスローにしたりすることも出来ます。

let url = NSURL(fileURLWithPath: NSTemporaryDirectory()).URLByAppendingPathComponent("\(NSUUID().UUIDString).gif")
guard let destination = CGImageDestinationCreateWithURL(url, kUTTypeGIF, images.count, nil) else {
    print("CGImageDestinationの作成に失敗")
    return
}

let properties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]]
CGImageDestinationSetProperties(destination, properties)

let frameProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFDelayTime as String: 0.2]]
for image in images {
    CGImageDestinationAddImage(destination, image, frameProperties)
}

4.生成した画像を書き出す

最後に生成した画像を書き出す為にCGImageDestinationFinalizeを使用します。

/* Write everything to the destination data, url or consumer of the image
 * destination `idst'.  You must call this function or the image
 * destination will not be valid.  After this function is called, no
 * additional data will be written to the image destination.  Return true
 * if the image was successfully written; false otherwise. */

@available(iOS 4.0, *)
public func CGImageDestinationFinalize(idst: CGImageDestination) -> Bool

引数は下記の通りです。

idst CGImageDestinationを設定

戻り値は成功だったらtrue、失敗だったらfalseになります。

コード

CGImageDestinationFinalizeが成功したら(戻り値がtrueだったら)、urlに作成されたアニメーションGIFが生成されます。

let url = NSURL(fileURLWithPath: NSTemporaryDirectory()).URLByAppendingPathComponent("\(NSUUID().UUIDString).gif")
guard let destination = CGImageDestinationCreateWithURL(url, kUTTypeGIF, images.count, nil) else {
    print("CGImageDestinationの作成に失敗")
    return
}

let properties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]]
CGImageDestinationSetProperties(destination, properties)

let frameProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFDelayTime as String: 0.2]]
for image in images {
    CGImageDestinationAddImage(destination, image, frameProperties)
}

if CGImageDestinationFinalize(destination) {
    print("GIF生成が成功")
} else {
    print("GIF生成に失敗")
}

これでアニメーションGIFを作成することが出来ます。

さいごに

(コードが最適化されてませんが、)下記はカメラで写した映像をCoreImageのFilterをかけつつアニメーションGIFにするサンプルです。

Storyboard上にUIImageViewとUIButtonとUIProgressViewを用意します。

storyboardサンプル

ボタンを押したらGIF用の画像をためて、もう一度ボタンを押すか一定数たまったら生成するようにしています。

import UIKit
import AVFoundation
import ImageIO
import MobileCoreServices

class ViewController: UIViewController {

    // フレームレート
    private let frameRate = CMTimeMake(1, 5)
    // GIFを作る画像の枚数の上限
    private let maxCount = 25
    // GIF用の画像を入れておく
    private var images = [CGImage]()
    // GIF用のキャプチャを行うか
    private var isCapture = false
    // AVCaptureSession
    private var captureSession: AVCaptureSession?
    // Filter
    private let filter = CIFilter(name: "CIPhotoEffectChrome")

    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var startButton: UIButton!
    @IBOutlet weak var progressView: UIProgressView!

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

    override func viewWillAppear(animated: Bool) {
        super.viewWillAppear(animated)

        // カメラの使用準備
        initCamera()
    }

    override func viewDidDisappear(animated: Bool) {
        // カメラの停止とメモリ解放.
        guard let cpsSession = captureSession else {
            return
        }
        cpsSession.stopRunning()
        for output in cpsSession.outputs
        {
            cpsSession.removeOutput(output as! AVCaptureOutput)
        }
        for input in cpsSession.inputs
        {
            cpsSession.removeInput(input as! AVCaptureInput)
        }
        captureSession = nil
        resetComponent()
    }

    func initCamera() {
        //カメラからの入力を作成
        guard let device = backCamera() else {
            print("背面カメラが見つかりません")
            return
        }

        var deviceInput: AVCaptureDeviceInput!
        //入力データの取得
        do {
            deviceInput = try AVCaptureDeviceInput(device: device)
        } catch {
            return
        }

        //出力データの取得
        let videoDataOutput:AVCaptureVideoDataOutput = AVCaptureVideoDataOutput()

        //カラーチャンネルの設定
        videoDataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey : Int(kCVPixelFormatType_32BGRA)]

        //画像をキャプチャするキューを指定
        videoDataOutput.setSampleBufferDelegate(self, queue: dispatch_get_main_queue())

        //キューがブロックされているときに新しいフレームが来たら削除
        videoDataOutput.alwaysDiscardsLateVideoFrames = true

        //セッションの使用準備
        let session = AVCaptureSession()

        //Input
        if session.canAddInput(deviceInput)
        {
            session.addInput(deviceInput as AVCaptureDeviceInput)
        }
        //Output
        if(session.canAddOutput(videoDataOutput))
        {
            session.addOutput(videoDataOutput)
        }
        //解像度の指定
        session.sessionPreset = AVCaptureSessionPresetMedium

        session.startRunning()

        // GIF画像なのでフレームレートを調整する
        do {
            try device.lockForConfiguration()
            device.activeVideoMaxFrameDuration = frameRate
            device.activeVideoMinFrameDuration = frameRate
            device.unlockForConfiguration()
        } catch {
        }

        captureSession = session
    }

    /// 背面カメラを取得
    private func backCamera() -> AVCaptureDevice? {
        for device: AnyObject in AVCaptureDevice.devices()
        {
            if device.position == AVCaptureDevicePosition.Back
            {
                return device as? AVCaptureDevice
            }
        }
        return nil
    }

    @IBAction func didTapStartButton(sender: AnyObject) {
        isCapture = !isCapture
        if isCapture {
            // 開始
            startButton.setTitle("Stop", forState: .Normal)
            images.removeAll()
            progressView.progress = 0
        } else {
            // 終了
            startButton.enabled = false
            startButton.setTitle("Start", forState: .Normal)
            makeGifImage()
        }

    }

    private func resetComponent() {
        isCapture = false
        startButton.setTitle("Start", forState: .Normal)
        images.removeAll()
        progressView.progress = 0
    }

    private func makeGifImage() {
        // ループカウント(0で無限ループ)
        let fileProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]]
        // フレームレート
        let frameProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFDelayTime as String: CMTimeGetSeconds(frameRate)]]

        let url = NSURL(fileURLWithPath: NSTemporaryDirectory()).URLByAppendingPathComponent("\(NSUUID().UUIDString).gif")
        guard let destination = CGImageDestinationCreateWithURL(url, kUTTypeGIF, images.count, nil) else {
            print("CGImageDestinationの作成に失敗")
            return
        }
        CGImageDestinationSetProperties(destination, fileProperties)

        for image in images {
            CGImageDestinationAddImage(destination, image, frameProperties)
        }
        if CGImageDestinationFinalize(destination) {
            let controller = UIActivityViewController(activityItems: [url], applicationActivities: nil)
            presentViewController(controller, animated: true, completion: nil)
        } else {
            print("GIF生成に失敗")
        }

        startButton.enabled = true
        resetComponent()
    }
}

extension ViewController : AVCaptureVideoDataOutputSampleBufferDelegate {

    func captureOutput(captureOutput: AVCaptureOutput!, didOutputSampleBuffer sampleBuffer: CMSampleBuffer!, fromConnection connection: AVCaptureConnection!) {
        guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }
        let opaqueBuffer = Unmanaged<CVImageBuffer>.passUnretained(imageBuffer).toOpaque()
        let pixelBuffer = Unmanaged<CVPixelBuffer>.fromOpaque(opaqueBuffer).takeUnretainedValue()
        let ciImage = CIImage(CVPixelBuffer: pixelBuffer, options: nil)
        filter?.setValue(ciImage, forKeyPath: kCIInputImageKey)
        guard let outputImage = filter?.valueForKey(kCIOutputImageKey) as? CIImage else {
            print("Filter適用に失敗")
            return
        }
        // Filterを適用しない場合はこちら
        //let outputImage = CIImage(CVPixelBuffer: pixelBuffer, options: nil)

        let image = UIImage(CIImage: outputImage, scale: 1.0, orientation: .Right)
        imageView.image = image

        // GIF用のキャプチャリング中かどうか
        if isCapture {
            if images.count < maxCount {
                let context = CIContext(options: nil)
                let cgImage = context.createCGImage(outputImage, fromRect: outputImage.extent)
                images.append(cgImage)
                progressView.progress = Float(images.count) / Float(maxCount)
            }
            if images.count >= maxCount {
                progressView.progress = 1
                isCapture = false
                startButton.enabled = false
                makeGifImage()
            }
        }
    }
}