0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SVGAdvent Calendar 2024

Day 23

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

Posted at

はじめに

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

image.png

こんな人におすすめ

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

機能と使い方

1. 基本機能

image.png

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

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

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

3. 変換オプション

設定可能なパラメータ:

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

4. 使用手順

  1. ツールの起動
python svg_to_gif_converter.py
  1. SVGファイルの選択

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

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

    • 出力フォルダの指定
    • GIFファイル名の設定
  4. 変換実行

    • 「変換開始」ボタンをクリック
    • 進捗バーで状況を確認

5. 出力例

animation.gif

入力したSVGの例

<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ブラウザ

インストール手順

# 必要なライブラリのインストール
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で簡単に行えるツールを紹介しました。
実用的な使い方と設定例を中心に解説しましたが、いかがでしたでしょうか?

参考情報

実装

1. アーキテクチャ設計

  • MVCパターンの採用
    • 機能の分離と保守性の向上
    • 将来の機能追加を考慮

2. マルチスレッド処理

  • UI操作のブロックを防止
  • 変換処理の進捗表示

3. エラーハンドリング

  • ファイル存在チェック
  • ブラウザ操作のエラー処理
  • ユーザーフレンドリーなエラーメッセージ
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()

クラス図

シーケンス図

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?