matplotlibでのgifアニメーション作成が容量大きすぎて失敗したので対処

## はじめに
- matplotlibでgifアニメーションを作成できるが、長すぎるgif（下記のコードの`end_frame`が大きすぎる場合）を作ろうとするとプロセスがkillされるので困った
    ```python:app.py
    import numpy as np
    import matplotlib.pyplot as plt
    from matplotlib.animation import FuncAnimation

    def create_gif(start_frame, end_frame, part_number, base_name, fps, gif_format, plot_interval):
        fig, ax = plt.subplots(figsize=(8, 8))
        ax.set_xlim(-200, 200)
        ax.set_ylim(-200, 200)
        ax.set_aspect('equal')
        ax.axis('off')
    
        line, = ax.plot([], [], 'r--', linewidth=2)
    
        a = 1  # Control the distance between turns
    
        def animate(frame):
            t = np.arange(0, start_frame + frame + 1) * plot_interval
            x = a * t * np.cos(t)
            y = a * t * np.sin(t)
            
            line.set_data(x, y)
            
            ax.set_xlim(min(x) - 10, max(x) + 10)
            ax.set_ylim(min(y) - 10, max(y) + 10)
    
            return line,
    
        frames = end_frame - start_frame
        anim = FuncAnimation(fig, animate, frames=frames, interval=20, blit=True)
        gif_name = gif_format.format(base_name=base_name, part_number=part_number)
        anim.save(gif_name, writer='pillow', fps=fps)
        plt.close(fig)

    start_frame = 0
    end_frame = 2000
    base_name = "archimedes_spiral"
    fps = 30
    gif_format = "{base_name}_part_{part_number:03d}.gif"
    plot_interval = 0.1
    create_gif(start_frame, end_frame, 0, base_name, fps, gif_format, plot_interval)

    ```
    ```bash
    $ python app.py 
    Killed
    ```
- manimの利用も検討したが、仕様がまだ変わりそうなので今回は見送り

## 解決策
- 下記の流れでgifを作成するようにした
    1. アニメーションを分割してmp4として保存
    1. ffmpegでmp4を結合
    1. ffmpegでmp4をgifに変換
- 変更後のコードは下記の通り

    ```python:app.py
    import numpy as np
    import matplotlib.pyplot as plt
    from matplotlib.animation import FuncAnimation, FFMpegWriter
    import subprocess
    import os
    
    def create_mp4(start_frame, end_frame, part_number, base_name, fps, plot_interval):
        fig, ax = plt.subplots(figsize=(8, 8))
        ax.set_xlim(-200, 200)
        ax.set_ylim(-200, 200)
        ax.set_aspect('equal')
        ax.axis('off')
    
        line, = ax.plot([], [], 'r--', linewidth=2)
    
        a = 1  # Control the distance between turns
    
        def animate(frame):
            t = np.arange(0, start_frame + frame + 1) * plot_interval
            x = a * t * np.cos(t)
            y = a * t * np.sin(t)
    
            line.set_data(x, y)
    
            ax.set_xlim(min(x) - 10, max(x) + 10)
            ax.set_ylim(min(y) - 10, max(y) + 10)
    
            return line,
    
        frames = end_frame - start_frame
        anim = FuncAnimation(fig, animate, frames=frames, interval=20, blit=True)
    
        # Save as MP4 using FFMpegWriter
        mp4_name = f"{base_name}_part_{part_number}.mp4"
        writer = FFMpegWriter(fps=fps)
        anim.save(mp4_name, writer=writer)
        
        plt.close(fig)
    
    def create_all_partial(total_frames, frames_per_part, base_name, fps, gif_format, plot_interval):
        for i in range(0, total_frames, frames_per_part):
            start_frame = i
            end_frame = min(i + frames_per_part, total_frames)
            create_mp4(start_frame=start_frame, 
                       end_frame=end_frame, 
                       part_number=i // frames_per_part + 1, 
                       base_name=base_name, 
                       fps=fps, 
                       plot_interval=plot_interval)
    
    def combine_mp4s_and_convert_to_gif(input_prefix, output_filename, max_size_kb=20000, target_width=None, target_height=None, frame_rate=10):
        # Get list of files matching the prefix
        matching_files = [f for f in os.listdir() if f.startswith(input_prefix) and f.endswith('.mp4')]
        
        if not matching_files:
            print(f"No MP4 files found with prefix '{input_prefix}'")
            return
    
        # Sort the files to ensure correct order
        matching_files.sort()
    
        # Create a temporary file with the list of input MP4 files
        with open('input_files.txt', 'w') as f:
            for mp4 in matching_files:
                f.write(f"file '{mp4}'\n")
    
        # Use FFmpeg to concatenate the MP4 files
        combined_mp4 = 'combined.mp4'
        ffmpeg_command = [
            'ffmpeg',
            '-f', 'concat',
            '-safe', '0',
            '-i', 'input_files.txt',
            '-c', 'copy',
            combined_mp4
        ]
    
        try:
            subprocess.run(ffmpeg_command, check=True)
            print(f"Combined MP4 saved as {combined_mp4}")
    
            # Generate a palette for better quality and smaller size
            palette_file = 'palette.png'
            palette_command = [
                'ffmpeg', '-i', combined_mp4,
                '-vf', f'fps={frame_rate},scale={target_width}:{target_height}:flags=lanczos,palettegen',
                palette_file
            ]
            
            subprocess.run(palette_command, check=True)
    
            # Prepare GIF conversion command using the generated palette
            gif_command = [
                'ffmpeg', '-i', combined_mp4,
                '-i', palette_file,
                '-lavfi', f'fps={frame_rate},scale={target_width}:{target_height}:flags=lanczos[x];[x][1:v]paletteuse',
                '-y', output_filename
            ]
    
            subprocess.run(gif_command, check=True)
    
            # Check file size and adjust if necessary (e.g., by reducing frame rate or dimensions)
            while os.path.getsize(output_filename) > max_size_kb * 1024:
                print(f"GIF size exceeds {max_size_kb} KB. Adjusting...")
                frame_rate = max(1, frame_rate - 1)  # Reduce frame rate by 1 fps
                
                gif_command = [
                    'ffmpeg', '-i', combined_mp4,
                    '-i', palette_file,
                    '-lavfi', f'fps={frame_rate},scale={target_width}:{target_height}:flags=lanczos[x];[x][1:v]paletteuse',
                    '-y', output_filename
                ]
                
                subprocess.run(gif_command, check=True)
    
            print(f"Combined GIF saved as {output_filename}")
            
        except subprocess.CalledProcessError as e:
            print(f"Error combining MP4s or converting to GIF: {e}")
    
        # Clean up temporary files
        os.remove('input_files.txt')
        os.remove(combined_mp4)
        os.remove(palette_file)

    
    def clean_up_mp4(base_name):
        for file in os.listdir():
            if file.startswith(base_name) and file.endswith('.mp4'):
                os.remove(file)

    start_frame = 0
    end_frame = 2000
    base_name = "archimedes_spiral"
    
    fps = 30
    gif_format = "{base_name}_part_{part_number:03d}.gif"
    plot_interval = 0.1

    total_frames = end_frame - start_frame
    frames_per_part = 500
    create_all_partial(total_frames, frames_per_part, base_name, fps, gif_format, plot_interval)
    combine_mp4s_and_convert_to_gif(
        base_name + "_", 
        output_name + ".gif", 
        max_size_kb=10000, 
        target_width=320, 
        target_height=320, frame_rate=10)
    clean_up_mp4(base_name)
    ```
- 実行のためのDockerfileはこちら
    ```Dockerfile:Dockerfile
    # Use an official Python runtime as a parent image
    FROM python:3.9-slim
    
    # Set the working directory in the container
    WORKDIR /app
    
    # Copy the current directory contents into the container at /app
    COPY . /app
    
    # Install FFmpeg and any needed packages specified in requirements.txt
    RUN apt-get update && apt-get install -y ffmpeg && \
        pip install --no-cache-dir matplotlib numpy pillow
    ```
    
## 出力されるgif
![archimedes_spiral_final.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3982238/055d05e6-e643-b198-b73a-b14f6b864e91.gif)
(表示に時間がかかるかも)