ホームページ開発ツール>Xojo / Real Studio Trial and Error・Cocoa/CarbonのDeclareでアニメGIFキャプチャーする

 Xojo / Real Studio Trial and Error

Cocoa/CarbonのDeclareでアニメGIFキャプチャーする

目次
 はじめに

 以下は、Xojo Cocoaビルドについての話題です。

 動きのあるキャプチャーをアニメーションGIFで実現できないか、調べてみました。

 なお検証には、Xojo 2017 Release 1.1を用いています。(Mac mini 2018 + macOS 10.14.6 Mojave)


 方針

 以前、こちらの記事でMP4動画による画面キャプチャーを試みましたが、他にアニメーションGIFを使う方法も考えられます。
 アニメーションGIFは256インデックスカラーという制約はあるものの、ポピュラーで実績もあります。
 ただし動画とは異なり、キャプチャーのステップとアニメーションGIF作成ステップが別々になる点を考慮する必要があります。

 まずはキャプチャーですが、APIは以前の記事で試行したものを使います。
 連続して実行するためにタイマーを使いますが、使い勝手を考えてコードで実装します。
 終了は、ここでは終了時間(回数)を指定して自動停止するようにしますが、別途停止メソッドを実装してもいいでしょう。

 次に作成の方ですが、一般的な一枚絵のGIF(以下、静止画GIF)であればCocoaのAPIを使って簡単にできます。
// BitmapImageRepからGIF形式のData生成(dictはGIF用オプション。BitmapImageRepはNSImageから変換)
Declare Function representationUsingType Lib "Cocoa" Selector "representationUsingType:properties:" (receiver As Ptr, type As Integer, prop As Ptr) As Ptr
Dim gifData As Ptr = representationUsingType(bitmapImageRep, 2, dict)  // 2 = NSGIFFileType
 なので、アニメーションGIFも同様の手法で書き出せるかと思ったのですが、そうはすんなりいきませんでした。
 検索して見つかったのは、Core Graphics、即ちCarbon系のメソッド群を使うものでした。

 参考サイト(1):複数画像からGIFをつくる · GitHub
 参考サイト(2):objective c - GIF Image generated on iOS10 no longer loops forever on browser - Stack Overflow
 参考サイト(3):cocoa - Saving CGImageRef to a png file? - Stack Overflow
 参考サイト(4):Swift:CGImage ←→ NSImage ←→ CIImage【変換Extension】 - Qiita

 Core Graphicsはデータがオブジェクトではなく構造体、とのことなのでキャスト、それも__bridgeとか、自分にとってあまり目にしないものが使われています。
 ですが、結論から言うと、キャストについては何もしなくてよさそうでした。

 また、これは実装して分かったことですが、キャプチャー時のフルカラーから256カラーに減色した時、静止画GIFではスムースに見えるものが、アニメーション化すると色のバランスが崩れる場合があります。(注:常に、という訳ではありません。)
 いくつか試した結果、kCGImagePropertyGIFHasGlobalColorMapをfalseにすることで、回避できました。
S Shot1 S Shot2
元になった静止画GIF(の一枚) 出来上がったアニメーションGIF
 他に、注意点としては、
 以上を踏まえ、(残りの)仕様は以下の通りとしました。

 Xojoでの実装
【ソースコードのコピー&ペーストについて】
ソースコード(グレー背景部分の全文)をコピーし、指定のウィンドウ/クラスにペーストすると、(新規作成して名前等を個別にコピー&ペーストしなくても)復元されます。
ただし、この方法は、メソッドでは問題ないようですが、イベント/アクション/プロパティでは不安定?なので、ペーストできない場合は、各項目のカッコ内を適用して下さい。
  1. Xojoで新規プロジェクトを作成
  2. Window1に、Label(Name:Label1)、PushButton(Name:PushButton1)、Timer(Name:Timer1)を置く
    (注:LabelとTimerは。アニメーションの効果をわかり易くするためのもの。)
  3. 以下をPushButton1にペースト(できなければ、Sub - Endの間をActionイベントに記述)
    Sub Action() Handles Action
      // テスト用
      Timer1.Mode=2
      
      // 引数は、対象ウィンドウ
      Capture(self)
    End Sub
    
  4. 以下をTimer1にペースト(できなければ、Sub - Endの間をActionイベントに記述)
    Sub Action() Handles Action
      Label1.Text=Str(Val(Label1.Text)+1)
    End Sub
    
  5. 新規モジュールを作成(名前は、ここでは「GIFCapture」とした。)
  6. 以下をGIFCaptureにペースト
    Public Sub Capture(win As Window)
      // 初期化
      pWin=win   // 対象ウィンドウ
      pMax=5     // キャプチャー回数
      pTbarH=22  // タイトル/ツールバーを含みたい場合の加算分
      
      // テンポラリフォルダ内に一時フォルダを作成
      Dim dt As New Date
      Dim fname As String
      fname=Format(dt.Year,"0000")+Format(dt.Month,"00")+Format(dt.Day,"00") _
      +Format(dt.Hour,"00")+Format(dt.Minute,"00")+Format(dt.Second,"00")  // 現在日時の取得(フォルダ名を固有化するために用いる)
      pTempFI=SpecialFolder.Temporary.Child(fname)  // テンポラリフォルダ内にフォルダを取得
      pTempFI.CreateAsFolder  // フォルダの生成
      
      // タイマーを初期化して、キャプチャー開始
      InitTimer(500)  // 500 = 0.5秒
    End Sub
    
  7. 以下をGIFCaptureにペースト
    Private Function DeleteEntireFolder(theFolder as FolderItem, continueIfErrors as Boolean = false) as Integer
      // Returns an error code if it fails, or zero if the folder was deleted successfully
      
      dim returnCode, lastErr, itemCount as integer
      dim files(), dirs() as FolderItem
      
      if theFolder = nil or not theFolder.Exists() then
        return 0
      end if
      
      // Collect the folder‘s contents first.
      // This is faster than collecting them in reverse order and deleting them right away!
      itemCount = theFolder.Count
      for i as integer = 1 to itemCount
        dim f as FolderItem
        f = theFolder.TrueItem( i )
        if f <> nil then
          if f.Directory then
            dirs.Append f
          else
            files.Append f
          end if
        end if
      next
      
      // Now delete the files
      for each f as FolderItem in files
        f.Delete
        lastErr = f.LastErrorCode   // Check if an error occurred
        if lastErr <> 0 then
          if continueIfErrors then
            if returnCode = 0 then returnCode = lastErr
          else
            // Return the error code if any. This will cancel the deletion.
            return lastErr
          end if
        end if
      next
      
      redim files(-1) // free the memory used by the files array before we enter recursion
      
      // Now delete the directories
      for each f as FolderItem in dirs
        lastErr = DeleteEntireFolder( f, continueIfErrors )
        if lastErr <> 0 then
          if continueIfErrors then
            if returnCode = 0 then returnCode = lastErr
          else
            // Return the error code if any. This will cancel the deletion.
            return lastErr
          end if
        end if
      next
      
      if returnCode = 0 then
        // We‘re done without error, so the folder should be empty and we can delete it.
        theFolder.Delete
        returnCode = theFolder.LastErrorCode
      end if
      
      return returnCode
    End Function
    
    注)Xojo言語リファレンスのサンプルを使わせて頂きました。(FolderItem.Removeの項)
  8. 以下をGIFCaptureにペースト
    Private Sub InitTimer(ival As Integer)
      // タイマー生成
      pTimer = New Timer
      pTimer.Period = ival
      pTimer.Mode = Timer.ModeMultiple  // この時点で動作開始
      AddHandler pTimer.Action, AddressOf TimerRun
    End Sub
    
    注)Xojo言語リファレンスのサンプルを使わせて頂きました。(AddHandlerの項)
  9. 以下をGIFCaptureにペースト
    Private Sub MakeCapture()
      // 文字列を指定してクラスオブジェクトを取得する。最初に一回宣言しておけばよい。
      Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
      
      // サイズのセット 
      Dim rect As CGRect
      rect = CGRectMake(pWin.Left, pWin.Top-pTbarH, pWin.Width, pWin.Height+pTbarH)  // pTbarH = タイトル/ツールバーを含みたい場合の加算分
      
      // ウィンドウナンバーの取得
      Declare Function windowNumber Lib "Cocoa" Selector "windowNumber" (receiver As Integer) As Integer
      Dim window_id As Integer = windowNumber(pWin.Handle)
      
      // ウィンドウのキャプチャー(Bitwise.ShiftLeft(1,3) = kCGWindowListOptionIncludingWindow (1 << 3)、0 = kCGWindowImageDefault)
      Declare Function CGWindowListCreateImage Lib "Carbon" (rect As CGRect, winopt As Integer, winid As Integer, imgopt As Integer) As Ptr
      Dim cgimage As Ptr = CGWindowListCreateImage(rect, Bitwise.ShiftLeft(1,3), window_id, 0)
      
      // NSBitmapImageRep初期化
      Dim bitmapImageRep As Ptr = NSClassFromString("NSBitmapImageRep")
      Declare Function alloc Lib "Cocoa" Selector "alloc" (receiver As Ptr) As Ptr
      bitmapImageRep = alloc(bitmapImageRep)
      Declare Function initWithCGImage Lib "Cocoa" Selector "initWithCGImage:" (receiver As Ptr, img As Ptr) As Ptr
      bitmapImageRep = initWithCGImage(bitmapImageRep, cgimage)
      
      // falseをNSNumber形式に変換
      Dim numb As Ptr = NSClassFromString("NSNumber")
      Declare Function numberWithBool Lib "Cocoa" Selector "numberWithBool:" (receiver As Ptr, path As Boolean) As Ptr
      numb = numberWithBool(numb, false)
      
      // GIF用オプションのセット
      Dim dict As Ptr = NSClassFromString("NSDictionary")  // クラスメソッドなので、まずNSDictionaryクラスを取得
      Declare Function dictionaryWithObject Lib "Cocoa" Selector "dictionaryWithObject:forKey:" (receiver As Ptr, objt As Ptr, key As CFStringRef) As Ptr
      dict = dictionaryWithObject(dict, numb, "NSImageDitherTransparency")
      
      // GIF出力(NSGIFFileType = 2)
      Declare Function representationUsingType Lib "Cocoa" Selector "representationUsingType:properties:" (receiver As Ptr, type As Integer, prop As Ptr) As Ptr
      Dim gifData As Ptr = representationUsingType(bitmapImageRep, 2, dict)
      
      // clean up
      Declare Sub release Lib "Cocoa" Selector "release" (receiver As Ptr)
      release(bitmapImageRep)
      
      // ファイル出力
      Declare Function writeToFile Lib "Cocoa" Selector "writeToFile:atomically:" (receiver As Ptr, path As CFStringRef, atm As Boolean) As Boolean
      Dim ret As Boolean = writeToFile(gifData, pTempFI.Child(Str(pCnt)+".gif").NativePath, true)
      
      // clean up
      Declare Sub CFRelease lib "Carbon" (obj as Ptr)
      CFRelease(cgimage)
    End Sub
    
  10. 以下をGIFCaptureにペースト
    Private Sub PutAnimeGIF()
      // 文字列を指定してクラスオブジェクトを取得する。最初に一回宣言しておけばよい。
      Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
      
      // 出力ファイルを指定してImageDestinationを初期化
      Dim fo As FolderItem = SpecialFolder.Desktop.Child("test.gif")  // デスクトップに作成
      Dim url As Ptr = NSClassFromString("NSURL")  // クラスメソッドなので、まずNSURLクラスを取得
      Declare Function fileURLWithPath Lib "Cocoa" Selector "fileURLWithPath:" (receiver As Ptr, path As CFStringRef) As Ptr
      url = fileURLWithPath(url, fo.NativePath)  // 出力ファイルパス
      Declare Function CGImageDestinationCreateWithURL lib "Carbon" (url as Ptr, typ As CFStringRef, cnt As Integer, optn As Ptr) as Ptr
      Dim dest As Ptr = CGImageDestinationCreateWithURL(url, "com.compuserve.gif", pMax, nil)
      
      // ファイルプロパティを生成(ループカウントのみ設定)
      Dim num1 As Ptr = NSClassFromString("NSNumber")  // クラスメソッドなので、まずNSNumberクラスを取得
      Declare Function numberWithInteger Lib "Cocoa" Selector "numberWithInteger:" (receiver As Ptr, val As Integer) As Ptr
      num1 = numberWithInteger(num1, 0)  // 0 = 無限ループ
      Dim dict1 As Ptr = NSClassFromString("NSDictionary")  // クラスメソッドなので、まずNSDictionaryクラスを取得
      Declare Function dictionaryWithObject Lib "Cocoa" Selector "dictionaryWithObject:forKey:" (receiver As Ptr, obj As Ptr, key As CFStringRef) As Ptr
      dict1 = dictionaryWithObject(dict1, num1, "LoopCount")
      Dim gifProp As Ptr = NSClassFromString("NSDictionary")  // クラスメソッドなので、まずNSDictionaryクラスを取得
      gifProp = dictionaryWithObject(gifProp, dict1, "{GIF}")
      
      // ファイルプロパティをImageDestinationにセット
      Declare Sub CGImageDestinationSetProperties lib "Carbon" (obj as Ptr, prop As Ptr)
      CGImageDestinationSetProperties(dest, gifProp)
      
      Declare Function alloc Lib "Cocoa" Selector "alloc" (receiver As Ptr) As Ptr
      Declare Function initWithContentsOfFile Lib "Cocoa" Selector "initWithContentsOfFile:" (receiver As Ptr, path As CFStringRef) As Ptr
      Declare Function TIFFRepresentation Lib "Cocoa" Selector "TIFFRepresentation" (receiver As Ptr) As Ptr
      Declare Function CGImageSourceCreateWithData lib "Carbon" (data as Ptr, optn As Ptr) as Ptr
      Declare Function CGImageSourceCreateImageAtIndex lib "Carbon" (data as Ptr, cnt As Integer, optn As Ptr) as Ptr
      Declare Function dictionary Lib "Cocoa" Selector "dictionary" (receiver As Ptr) As Ptr
      Declare Function numberWithFloat Lib "Cocoa" Selector "numberWithFloat:" (receiver As Ptr, val As Single) As Ptr
      Declare Function numberWithBool Lib "Cocoa" Selector "numberWithBool:" (receiver As Ptr, path As Boolean) As Ptr
      Declare Sub setObject Lib "Cocoa" Selector "setObject:forKey:" (receiver As Ptr, obj As Ptr, key As CFStringRef)
      Declare Sub CGImageDestinationAddImage lib "Carbon" (obj as Ptr, img As Ptr, prop As Ptr)
      Declare Sub release Lib "Cocoa" Selector "release" (receiver As Ptr)
      
      // 画像数(=フレーム数)のループ
      Dim dict2, num2, num3, frameP, data, imgSrc, cgImage As Ptr
      for i As Integer = 1 to pMax
        
        // ファイルからNSImage形式で画像を取得
        Dim image As Ptr = NSClassFromString("NSImage")
        image = alloc(image)
        image = initWithContentsOfFile(image, pTempFI.Child(Str(i)+".gif").NativePath)
        
        // NSImage形式からCGImage形式に変換
        data = TIFFRepresentation(image)  // return NSData*
        imgSrc = CGImageSourceCreateWithData(data, nil)
        cgImage = CGImageSourceCreateImageAtIndex(imgSrc, 0, nil)
        
        // フレームプロパティを生成
        dict2 = NSClassFromString("NSMutableDictionary")  // 複数個設定するので、まず空のNSMutableDictionary生成
        dict2 = dictionary(dict2)
        num2 = NSClassFromString("NSNumber")  // クラスメソッドなので、まずNSNumberクラスを取得
        num2 = numberWithFloat(num2, 0.5)  // 0.5 = 0.5秒
        setObject(dict2, num2, "DelayTime")  // 表示時間
        num3 = NSClassFromString("NSNumber")  // クラスメソッドなので、まずNSNumberクラスを取得
        num3 = numberWithBool(num3, false)
        setObject(dict2, num3, "HasGlobalColorMap")  // Globalカラーマップを使わない(Localを使う)
        frameP = NSClassFromString("NSDictionary")  // クラスメソッドなので、まずNSDictionaryクラスを取得
        frameP = dictionaryWithObject(frameP, dict2, "{GIF}")
        
        // フレーム(画像とプロパティ)をImageDestinationに追加
        CGImageDestinationAddImage(dest, cgImage, frameP)
        
        // clean up
        release(image)
        
      next
      
      // ImageDestination終了処理
      Declare Sub CGImageDestinationFinalize lib "Carbon" (obj as Ptr)
      CGImageDestinationFinalize(dest)
      
      // clean up
      Declare Sub CFRelease lib "Carbon" (obj as Ptr)
      CFRelease(dest)
    End Sub
    
  11. 以下をGIFCaptureにペースト
    Private Sub TimerRun(sender As Timer)
      // キャプチャー
      MakeCapture()
      
      // 回数カウント
      pCnt=pCnt+1
      
      // 回数の上限を超えたら
      if pCnt>pMax then
        pTimer.Mode=0  // タイマーを止める
        PutAnimeGIF()  // 合体とファイル出力
        Dim ret As Integer = DeleteEntireFolder(pTempFI)  // 一時フォルダを削除
      end if
    End Sub
    
  12. 以下をGIFCaptureにペースト(できなければプロパティに、名前:pCnt、データ型:Integer、標準値:1、を追加)
    Private Property pCnt as Integer = 1
    
  13. 以下をGIFCaptureにペースト(できなければプロパティに、名前:pMax、データ型:Integer、を追加)
    Private Property pMax as Integer
    
  14. 以下をGIFCaptureにペースト(できなければプロパティに、名前:pTbarH、データ型:Integer、を追加)
    Private Property pTbarH as Integer
    
  15. 以下をGIFCaptureにペースト(できなければプロパティに、名前:pTempFI、データ型:FolderItem、を追加)
    Private Property pTempFI as FolderItem
    
  16. 以下をGIFCaptureにペースト(できなければプロパティに、名前:pTimer、データ型:Timer、を追加)
    Private Property pTimer as Timer
    
  17. 以下をGIFCaptureにペースト(できなければプロパティに、名前:pWin、データ型:Window、を追加)
    Private Property pWin as Window
    
  18. 他に、CGRectMake(メソッド)、CGRect(構造体)が必要ですが、macoslibからコピーさせて頂きました。(上記BurstCaptureまたは別途モジュールを用意してコピーする。)
    注)macoslibではCGRect(のメンバーの型であるCGPoint、CGSize)のメンバーの型にSingleが割り当てられているが、64bitにも対応したい場合は、CGFloatに書き換える。
 実行してみたところ、動きのあるキャプチャーをアニメーションGIFで実現できることを確認しました。
S Shot3


 おわりに

 アプリケーションに組み込む場合は、ボタンではなくメニュー項目に仕込む等した方が、使い勝手がいいと思われます。

 当初は、アニメーションGIFの方がMP4動画よりファイルサイズが小さくなるのかと思っていたのですが、そうでもないようです。
 静止画レベルでは圧縮していますが、アニメーションレベルでの圧縮等も考えた方がいいかもしれません。


 お世話になったサイト

 貴重な情報をご提供頂いている皆様に、お礼申し上げます。(以下、順不同)

 参考サイト(1):複数画像からGIFをつくる · GitHub
 参考サイト(2):objective c - GIF Image generated on iOS10 no longer loops forever on browser - Stack Overflow
 参考サイト(3):cocoa - Saving CGImageRef to a png file? - Stack Overflow
 参考サイト(4):Swift:CGImage ←→ NSImage ←→ CIImage【変換Extension】 - Qiita


 更新履歴

 2020.01.27 新規作成


[Home]  [MacSoft]  [Donation]  [History]  [Privacy Policy]  [Affiliate Policy]