SVGアニメーションをGIFに変換するGUIツールを作ってみた

## はじめに
SVGアニメーションをGIFに変換するGUIツールを作成しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/e47522e1-8098-f457-b4d1-9c377249ddd2.png)

## こんな人におすすめ

- ドキュメントやスライドにアニメーションを埋め込みたい開発者
- SVGアニメーションを幅広い環境で共有したい設計者
- CSSアニメーションをGIF形式に変換したいWebデザイナー

## 機能と使い方

### 1. 基本機能

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/fd3e10ce-d4d9-dbdf-1a54-4b7e1a62ca80.png)

- SVGファイルの選択と読み込み
- フレーム数とアニメーション速度の調整
- 出力フォルダとファイル名の設定
- 変換進捗のリアルタイム表示
- エラー発生時の親切な通知

### 2. 対応するSVGアニメーション

- CSSアニメーション（@keyframes）
- CSS変数を使用したアニメーション
- SMIL形式のアニメーション

### 3. 変換オプション

設定可能なパラメータ：
- フレーム数：2-30フレーム
  - 少ないフレーム数：ファイルサイズ削減
  - 多いフレーム数：滑らかなアニメーション
- フレーム間隔：50-500ミリ秒
  - 短い間隔：速いアニメーション
  - 長い間隔：ゆっくりとした動き

### 4. 使用手順

1. ツールの起動
```bash
python svg_to_gif_converter.py
```

2. SVGファイルの選択
   - 「参照」ボタンをクリック
   - アニメーション付きのSVGファイルを選択

3. 変換設定
   - フレーム数の調整
     - 推奨：10-15フレーム
     - 複雑なアニメーション：20フレーム以上
   - フレーム間隔の設定
     - 推奨：100ms
     - 細かい動き：50-80ms
     - ゆっくりとした動き：200-300ms

4. 出力設定
   - 出力フォルダの指定
   - GIFファイル名の設定

5. 変換実行
   - 「変換開始」ボタンをクリック
   - 進捗バーで状況を確認


### 5. 出力例
![animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/877ff219-7fe9-f96b-b3a9-977a9573e62b.gif)

入力したSVGの例
```xml
<svg width="200" height="200" viewBox="-100 -100 200 200" xmlns="http://www.w3.org/2000/svg">
  <g id="character">
    <!-- 本体(緑色) -->
    <ellipse cx="0" cy="0" rx="50" ry="45" fill="#4CAF50">
      <animateTransform attributeName="transform" type="scale" values="1;1.05;1" dur="2s" repeatCount="indefinite" />
    </ellipse>

    <!-- 顔部分(クリーム色の緑部分) -->
    <ellipse cx="0" cy="0" rx="30" ry="25" fill="#FDF1D2"></ellipse>

    <!-- 左葉 -->
    <path d="M -20 -32 C -30 -50, -10 -60, -15 -40 C -18 -35, -18 -35, -20 -32 Z" fill="#4CAF50">
      <animateTransform attributeName="transform" type="rotate" values="0;10;-10;0" dur="2s" repeatCount="indefinite" origin="-20 -32" />
    </path>

    <!-- 左葉内装 -->
    <path d="M -20 -34 C -27 -47, -13 -52, -16 -40 C -18 -37, -18 -37, -20 -34 Z" fill="#FFD83D"></path>

    <!-- 右葉 -->
    <path d="M 20 -32 C 30 -50, 10 -60, 15 -40 C 18 -35, 18 -35, 20 -32 Z" fill="#4CAF50">
      <animateTransform attributeName="transform" type="rotate" values="0;-10;10;0" dur="2s" repeatCount="indefinite" origin="20 -32" />
    </path>

    <!-- 右葉内装 -->
    <path d="M 20 -34 C 27 -47, 13 -52, 16 -40 C 18 -37, 18 -37, 20 -34 Z" fill="#FFD83D"></path>

    <!-- 左目 -->
    <path id="left-eye" d="M -20 -5 L -10 -5" stroke="#000" stroke-width="2" fill="none">
      <animate 
        attributeName="d" 
        values="
          M -20 -5 A 5 5 0 1 1 -10 -5;
          M -18 -8 L -12 -2 L -18 4;
          M -20 -8 Q -15 -3 -10 -8;
          M -20 -5 L -10 -5;
          M -20 -5 A 5 5 0 1 1 -10 -5"
        dur="4s"
        repeatCount="indefinite"
        keyTimes="0;0.25;0.5;0.75;1" />
    </path>

    <!-- 右目 -->
    <path id="right-eye" d="M 10 -5 L 20 -5" stroke="#000" stroke-width="2" fill="none">
      <animate 
        attributeName="d" 
        values="
          M 10 -5 A 5 5 0 1 1 20 -5;
          M 18 -8 L 12 -2 L 18 4;
          M 10 -8 Q 15 -3 20 -8;
          M 10 -5 L 20 -5;
          M 10 -5 A 5 5 0 1 1 20 -5"
        dur="4s"
        repeatCount="indefinite"
        keyTimes="0;0.25;0.5;0.75;1" />
    </path>

    <!-- 口 -->
    <path id="mouth" d="M -5 5 Q 0 10 5 5" stroke="#000" stroke-width="1" fill="none">
      <animate 
        attributeName="d"
        values="
          M -5 5 Q 0 10 5 5;
          M -5 5 Q 0 0 5 5;
          M -5 5 Q 0 12 5 5;
          M -5 7 Q 0 3 5 7;
          M -5 5 Q 0 10 5 5"
        dur="4s"
        repeatCount="indefinite"
        keyTimes="0;0.25;0.5;0.75;1" />
    </path>

    <!-- 頬 -->
    <circle cx="-15" cy="5" r="5" fill="#FFC0CB">
      <animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
    </circle>
    <circle cx="15" cy="5" r="5" fill="#FFC0CB">
      <animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
    </circle>

    <!-- しっぽ -->
    <path d="M 35 30 C 45 40, 60 35, 50 20 C 45 10, 40 20, 35 30 Z" fill="#4CAF50">
      <animateTransform attributeName="transform" type="rotate" values="0;15;-15;0" dur="2s" repeatCount="indefinite" origin="35 30" />
    </path>
  </g>
</svg>

```


## 動作環境と準備

### 必要な環境
- Python 3.8以上
- Chrome/Chromiumブラウザ

### インストール手順
```bash
# 必要なライブラリのインストール
pip install pillow selenium

# ChromeDriverのインストール
# Windowsの場合
choco install chromedriver

# macOSの場合
brew install chromedriver

# Linuxの場合
apt-get install chromium-chromedriver
```

## よくある使用例と設定

### 1. ドキュメント用の軽量GIF作成
- フレーム数：8-10
- フレーム間隔：150ms
- 目的：ファイルサイズを抑えつつ、基本的な動きを表現

### 2. プレゼンテーション用の滑らかなアニメーション
- フレーム数：20-25
- フレーム間隔：80ms
- 目的：より滑らかで見やすいアニメーション

### 3. Web用の最適化されたGIF
- フレーム数：12-15
- フレーム間隔：100ms
- 目的：表示品質とファイルサイズのバランス

## 制限事項と注意点

1. 対応していないアニメーション
   - JavaScriptによるアニメーション
   - インタラクティブなアニメーション

2. ファイルサイズの考慮
   - フレーム数が多いとファイルサイズが大きくなる
   - 必要最小限のフレーム数を推奨

3. 変換時の注意点
   - 複雑なアニメーションは変換に時間がかかる
   - メモリ使用量が一時的に増加

## まとめ

SVGアニメーションのGIF変換を、GUIで簡単に行えるツールを紹介しました。
実用的な使い方と設定例を中心に解説しましたが、いかがでしたでしょうか？

## 参考情報
- [SVGアニメーションの基本](https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial/Animations)
- [Selenium Document](https://selenium-python.readthedocs.io/)

## 実装

### 1. アーキテクチャ設計
- MVCパターンの採用
  - 機能の分離と保守性の向上
  - 将来の機能追加を考慮

### 2. マルチスレッド処理
- UI操作のブロックを防止
- 変換処理の進捗表示

### 3. エラーハンドリング
- ファイル存在チェック
- ブラウザ操作のエラー処理
- ユーザーフレンドリーなエラーメッセージ

```python
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image
import os
from selenium import webdriver
from dataclasses import dataclass
from typing import List, Optional
import time
import threading
from abc import ABC, abstractmethod

# Model: データとビジネスロジックを管理
@dataclass
class ConversionSettings:
    svg_file: str
    output_dir: str
    gif_output: str
    frame_count: int
    duration: int

class ConversionModel:
    def __init__(self):
        self.settings: Optional[ConversionSettings] = None
        self._observers: List[IConversionObserver] = []
        self.is_converting = False
    
    def add_observer(self, observer: 'IConversionObserver'):
        self._observers.append(observer)
    
    def notify_progress(self, progress: int, message: str):
        for observer in self._observers:
            observer.on_progress_update(progress, message)
    
    def convert_svg_to_gif(self, settings: ConversionSettings):
        self.settings = settings
        self.is_converting = True
        
        try:
            # 出力ディレクトリの作成
            if not os.path.exists(settings.output_dir):
                os.makedirs(settings.output_dir)

            # ブラウザセットアップ
            options = webdriver.ChromeOptions()
            options.add_argument("--headless")
            options.add_argument("--disable-gpu")
            options.add_argument("--window-size=800x800")
            driver = webdriver.Chrome(options=options)

            # SVGを表示
            driver.get(f"file://{os.path.abspath(settings.svg_file)}")
            time.sleep(1)

            frames = []
            # フレームキャプチャ
            for i in range(settings.frame_count):
                frame_file = os.path.join(settings.output_dir, f"frame_{i}.png")
                
                # JavaScriptでアニメーションを進める
                driver.execute_script(f"document.documentElement.style.setProperty('--frame', {i / settings.frame_count});")
                time.sleep(0.1)
                
                # スクリーンショット保存
                driver.save_screenshot(frame_file)
                frames.append(frame_file)
                
                progress = int((i + 1) / settings.frame_count * 50)
                self.notify_progress(progress, f"フレーム {i+1}/{settings.frame_count} を生成中...")

            driver.quit()

            # GIFに結合
            self.notify_progress(75, "フレームをGIFに結合中...")
            images = [Image.open(frame) for frame in frames]
            images[0].save(
                settings.gif_output,
                save_all=True,
                append_images=images[1:],
                duration=settings.duration,
                loop=0
            )

            self.notify_progress(100, "変換完了!")
            
        except Exception as e:
            self.notify_progress(-1, f"エラーが発生しました: {str(e)}")
        finally:
            self.is_converting = False

# Observer Interface
class IConversionObserver(ABC):
    @abstractmethod
    def on_progress_update(self, progress: int, message: str):
        pass

# Controller: ユーザー入力の処理とModelの更新を管理
class ConversionController:
    def __init__(self, model: ConversionModel, view: 'ConversionView'):
        self.model = model
        self.view = view
        
    def start_conversion(self, settings: ConversionSettings):
        if self.model.is_converting:
            messagebox.showwarning("警告", "変換処理が既に実行中です")
            return
            
        # 別スレッドで変換処理を実行
        thread = threading.Thread(
            target=self.model.convert_svg_to_gif,
            args=(settings,)
        )
        thread.daemon = True
        thread.start()

# View: UIの表示と更新を管理
class ConversionView(tk.Tk, IConversionObserver):
    def __init__(self):
        super().__init__()

        self.title("SVG to GIF Converter")
        self.geometry("600x400")
        self.model = ConversionModel()
        self.controller = ConversionController(self.model, self)
        self.model.add_observer(self)
        
        self._create_widgets()
        self._setup_layout()
        
    def _create_widgets(self):
        # 入力ファイル選択
        self.file_frame = ttk.LabelFrame(self, text="入力/出力設定", padding=10)
        self.svg_path = tk.StringVar()
        self.output_path = tk.StringVar(value="output_frames")
        self.gif_path = tk.StringVar(value="animation.gif")
        
        ttk.Label(self.file_frame, text="SVGファイル:").grid(row=0, column=0, sticky="w")
        ttk.Entry(self.file_frame, textvariable=self.svg_path, width=50).grid(row=0, column=1, padx=5)
        ttk.Button(self.file_frame, text="参照", command=self._browse_svg).grid(row=0, column=2)
        
        ttk.Label(self.file_frame, text="出力フォルダ:").grid(row=1, column=0, sticky="w")
        ttk.Entry(self.file_frame, textvariable=self.output_path).grid(row=1, column=1, padx=5)
        
        ttk.Label(self.file_frame, text="GIFファイル名:").grid(row=2, column=0, sticky="w")
        ttk.Entry(self.file_frame, textvariable=self.gif_path).grid(row=2, column=1, padx=5)
        
        # パラメータ設定
        self.param_frame = ttk.LabelFrame(self, text="変換設定", padding=10)
        self.frame_count = tk.IntVar(value=10)
        self.duration = tk.IntVar(value=100)
        
        ttk.Label(self.param_frame, text="フレーム数:").grid(row=0, column=0, sticky="w")
        ttk.Scale(self.param_frame, from_=2, to=30, variable=self.frame_count, orient="horizontal").grid(row=0, column=1, sticky="ew")
        ttk.Label(self.param_frame, textvariable=self.frame_count).grid(row=0, column=2, padx=5)
        
        ttk.Label(self.param_frame, text="フレーム間隔(ms):").grid(row=1, column=0, sticky="w")
        ttk.Scale(self.param_frame, from_=50, to=500, variable=self.duration, orient="horizontal").grid(row=1, column=1, sticky="ew")
        ttk.Label(self.param_frame, textvariable=self.duration).grid(row=1, column=2, padx=5)
        
        # 変換ボタンとプログレスバー
        self.control_frame = ttk.Frame(self, padding=10)
        self.convert_btn = ttk.Button(self.control_frame, text="変換開始", command=self._start_conversion)
        self.progress = ttk.Progressbar(self.control_frame, length=400, mode='determinate')
        self.status_label = ttk.Label(self.control_frame, text="準備完了")
        
    def _setup_layout(self):
        self.file_frame.pack(fill="x", padx=10, pady=5)
        self.param_frame.pack(fill="x", padx=10, pady=5)
        self.control_frame.pack(fill="x", padx=10, pady=5)
        
        self.convert_btn.pack(pady=5)
        self.progress.pack(pady=5)
        self.status_label.pack(pady=5)
        
    def _browse_svg(self):
        filename = filedialog.askopenfilename(
            filetypes=[("SVG files", "*.svg"), ("HTML files", "*.html"), ("All files", "*.*")]
        )
        if filename:
            self.svg_path.set(filename)
            
    def _start_conversion(self):
        if not os.path.exists(self.svg_path.get()):
            messagebox.showerror("エラー", "SVGファイルが見つかりません")
            return
            
        settings = ConversionSettings(
            svg_file=self.svg_path.get(),
            output_dir=self.output_path.get(),
            gif_output=self.gif_path.get(),
            frame_count=self.frame_count.get(),
            duration=self.duration.get()
        )
        
        self.controller.start_conversion(settings)
        
    def on_progress_update(self, progress: int, message: str):
        if progress >= 0:
            self.progress['value'] = progress
        self.status_label['text'] = message
        
        if progress == 100:
            messagebox.showinfo("完了", "GIFの生成が完了しました！")
        elif progress == -1:
            messagebox.showerror("エラー", message)

def main():
    app = ConversionView()
    app.mainloop()

if __name__ == "__main__":
    main()
```

## クラス図 

```mermaid
classDiagram
    class ConversionSettings {
        +str svg_file
        +str output_dir
        +str gif_output
        +int frame_count
        +int duration
    }

    class IConversionObserver {
        <<interface>>
        +on_progress_update(progress: int, message: str)*
    }

    class ConversionModel {
        -List~IConversionObserver~ _observers
        -bool is_converting
        -ConversionSettings settings
        +add_observer(observer: IConversionObserver)
        +notify_progress(progress: int, message: str)
        +convert_svg_to_gif(settings: ConversionSettings)
    }

    class ConversionController {
        -ConversionModel model
        -ConversionView view
        +start_conversion(settings: ConversionSettings)
    }

    class ConversionView {
        -ConversionModel model
        -ConversionController controller
        -tk.StringVar svg_path
        -tk.StringVar output_path
        -tk.StringVar gif_path
        -tk.IntVar frame_count
        -tk.IntVar duration
        -ttk.Progressbar progress
        -ttk.Label status_label
        +_create_widgets()
        +_setup_layout()
        +_browse_svg()
        +_start_conversion()
        +on_progress_update(progress: int, message: str)
    }

    IConversionObserver <|.. ConversionView : implements
    ConversionModel "1" -- "0..*" IConversionObserver : observers
    ConversionController "1" -- "1" ConversionModel : uses
    ConversionController "1" -- "1" ConversionView : uses
    ConversionView "1" -- "1" ConversionModel : uses
    ConversionModel "1" -- "1" ConversionSettings : uses
```

## シーケンス図

```mermaid
sequenceDiagram
    actor User
    participant View as ConversionView
    participant Controller as ConversionController
    participant Model as ConversionModel
    participant Browser as Selenium Browser
    participant FileSystem as File System

    %% SVGファイル選択シーケンス
    User->>View: SVGファイル選択
    View->>FileSystem: ファイルダイアログを開く
    FileSystem-->>View: ファイルパス返却
    View->>View: パス情報を更新

    %% パラメータ設定シーケンス
    User->>View: フレーム数設定
    User->>View: フレーム間隔設定
    User->>View: 出力設定

    %% 変換開始シーケンス
    User->>View: 変換開始ボタンクリック
    View->>Controller: start_conversion(settings)
    Controller->>Model: convert_svg_to_gif(settings)
    
    %% 変換処理シーケンス
    activate Model
    Model->>Browser: ブラウザ初期化
    Browser-->>Model: ブラウザ準備完了
    
    loop フレームごとの処理
        Model->>Browser: フレーム状態更新
        Browser-->>Model: フレーム更新完了
        Model->>FileSystem: フレーム画像保存
        Model->>View: 進捗更新通知
        View->>User: プログレスバー更新
    end

    Model->>FileSystem: GIF生成処理
    FileSystem-->>Model: GIF生成完了
    Model->>View: 完了通知
    deactivate Model
    
    View->>User: 完了メッセージ表示

    %% エラーハンドリング
    alt エラー発生時
        Model->>View: エラー通知
        View->>User: エラーメッセージ表示
    end
```