[JavaScript] 高精度GIF圧縮&リサイザーの実装 Gifsicle WASMで80%軽量化を実現

[JavaScript] 高精度GIF圧縮&リサイザーの実装 Gifsicle WASMで80%軽量化を実現

はじめに

ブログやWebメディア(noteなど)にアニメーションGIFを投稿する際、ファイルサイズ制限(10MB以下推奨など)に頭を悩ませるケースは少なくありません。

今回、サーバーへ画像を一切送信せず、ブラウザのローカル処理(JavaScript / WebAssembly)のみで巨大なGIFファイルを超高画質なまま大幅カット(37.4MB ➔ 7.6MB)できるWebツールを開発しました。

本記事では、純粋なJavaScript実装で遭遇した課題(横線ノイズやサイズ膨張)の背景と、それを克服するためにWebAssembly版「Gifsicle」を組み込んだ実装アプローチについて詳しく解説します。

[JavaScript] 高精度GIF圧縮&リサイザーの実装 Gifsicle WASMで80%軽量化を実現

WEBページ

https://lain-lab.com/tool/GifResize/index.html

1. 開発の背景と直面した課題

当初は、ブラウザ標準の ImageDecoder API や Canvas、独自実装の JavaScript エンコーダーを用いてリサイズとLZW圧縮を行っていました。しかし、以下の深刻な問題が発生しました。

① ディザリングとLossy処理の干渉による「横線ノイズ」

色数を落とす際のディザリング(点描処理)と、容量を削減するLossy処理が干渉し、出力画像にCRTモニターのような激しい横線(走査線ノイズ)が入ってしまう現象が発生しました。

② フレーム差分最適化の不在による「ファイルサイズの膨張」

単純に全フレームをキャンバスから切り出して結合するだけの処理では、背景などの動かない部分も毎回全画面書き出すことになります。その結果、元画像より解像度を下げたにもかかわらず、14MBといった巨大なファイルサイズのまま下がらない問題が発生しました。


2. 解決策:WebAssembly (Gifsicle) の採用

これらの課題を一挙に解決したのが、CLIツールやプロ仕様のオンライン圧縮サービス(FreeConvert等)の裏側でも採用されているC言語製の最高峰GIF最適化エンジン Gifsicle です。

これを WebAssembly (WASM) に移植したライブラリ gifsicle-wasm-browser を用いることで、ブラウザ上でネイティブと同等の高度な圧縮アルゴリズムを実行可能にしました。

Gifsicle WASM がもたらすメリット

  • 最高峰のフレーム差分最適化 (-O3): 前後のコマで変化のない領域(背景など)を自動で透明化し、差分のみを書き出すことで容量を極限まで削削ります。
  • 破綻しない Lossy 圧縮 (--lossy=30): 人間の目には知覚できないレベルで LZ77/LZW ハッシュパターンを均一化し、画像崩れを起こさずにファイルサイズを1/3〜1/5へと劇的にカットします。
  • 完全ローカル完結の安全性: WebAssembly としてブラウザ内部で完結するため、第三者のサーバーに画像をアップロードする必要がなく、プライバシーとセキュリティが100%保護されます。

3. ユーザー体験(UX)を高める工夫

Gifsicle ような C/C++ 由来の処理を WebAssembly で動かす場合、数十MBに及ぶ巨大なGIFの処理には数秒〜数分程度の計算時間を要します。

処理中に画面が静止してしまうとユーザーが「フリーズした」「エラーが起きた」と勘違いしてしまうため、以下の UI/UX アプローチを取り入れました。

リアルタイム・プログレスバー&進捗ステータス

  • グラデーション背景のアニメーション: 処理が裏で正常に進行していることを視覚的に伝えます。
  • 秒数カウントタイマー: 経過時間を毎秒更新し、フリーズと区別できるようにしました。
  • ステージ別状況メッセージ: 経過時間に応じて「フレーム展開中…」「カラーパレット最適化中…」とメッセージを更新し、心理的負担を軽減させます。

4. 全実装コード (index.html)

単一の HTML ファイルとしてそのまま動作する実装コードです。CDN 経由で ES Module 版の WASM エンジンを読み込んでいます。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>プロ仕様 GIF爆速&高画質コンプレッサー (Gifsicle WASM)</title>
    <style>
        :root {
            --primary: #2cb696;
            --bg: #f7f9f9;
            --card: #ffffff;
            --text: #333333;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background-color: var(--bg);
            color: var(--text);
            display: flex;
            justify-content: center;
            padding: 40px 20px;
            margin: 0;
        }

        .container {
            background: var(--card);
            padding: 30px;
            border-radius: 12px;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
            width: 100%;
            max-width: 500px;
        }

        h2 {
            margin-top: 0;
            text-align: center;
            font-size: 1.4rem;
        }

        .drop-zone {
            border: 2px dashed #ccc;
            border-radius: 8px;
            padding: 25px;
            text-align: center;
            cursor: pointer;
            transition: 0.2s;
            margin-bottom: 20px;
            background: #fafafa;
        }

        .drop-zone:hover, .drop-zone.dragover {
            border-color: var(--primary);
            background: #eefbf7;
        }

        .form-group {
            margin-bottom: 15px;
        }

        label {
            display: block;
            font-size: 0.85rem;
            font-weight: bold;
            margin-bottom: 5px;
        }

        input[type="number"], select {
            width: 100%;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 6px;
            box-sizing: border-box;
        }

        button {
            width: 100%;
            background: var(--primary);
            color: white;
            border: none;
            padding: 12px;
            border-radius: 6px;
            font-size: 1rem;
            font-weight: bold;
            cursor: pointer;
            transition: 0.2s;
        }

        button:disabled {
            background: #ccc;
            cursor: not-allowed;
        }

        /* プログレスバー演出 */
        .progress-box {
            margin-top: 20px;
            text-align: center;
            display: none;
            background: #f0fdf9;
            padding: 15px;
            border-radius: 8px;
            border: 1px solid #c6f6d5;
        }

        .progress-status {
            font-size: 0.9rem;
            font-weight: bold;
            color: #2b6cb0;
            margin-bottom: 8px;
        }

        .progress-timer {
            font-size: 0.8rem;
            color: #718096;
            margin-top: 6px;
        }

        .progress-bar-bg {
            width: 100%;
            height: 12px;
            background-color: #e2e8f0;
            border-radius: 6px;
            overflow: hidden;
        }

        .progress-bar-fill {
            width: 100%;
            height: 100%;
            background: linear-gradient(90deg, #2cb696 0%, #68d391 50%, #2cb696 100%);
            background-size: 200% 100%;
            animation: moveGradient 1.5s infinite linear;
            border-radius: 6px;
        }

        @keyframes moveGradient {
            0% { background-position: 200% 0; }
            100% { background-position: -200% 0; }
        }

        .result {
            margin-top: 25px;
            text-align: center;
            display: none;
            border-top: 1px solid #eee;
            padding-top: 20px;
        }

        .result img {
            max-width: 100%;
            border-radius: 6px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }

        .download-btn {
            display: inline-block;
            margin-top: 15px;
            background: #333;
            color: #fff;
            text-decoration: none;
            padding: 10px 20px;
            border-radius: 6px;
            font-weight: bold;
        }
    </style>
</head>
<body>

<div class="container">
    <h2>Gifsicle プロ仕様 GIF圧縮&リサイザー</h2>

    <div class="drop-zone" id="dropZone">
        GIFファイルをドラッグ&ドロップ<br>
        <span style="font-size: 0.8rem; color: #888;">またはクリックして選択</span>
        <input type="file" id="fileInput" accept="image/gif" style="display: none;">
    </div>

    <div class="form-group">
        <label for="targetWidth">最大横幅 (px):</label>
        <input type="number" id="targetWidth" value="640">
    </div>

    <div class="form-group">
        <label for="lossyLevel">圧縮レベル (Lossy):</label>
        <select id="lossyLevel">
            <option value="30" selected>標準軽量化 (Lossy 30 / おすすめ・画質維持)</option>
            <option value="60">強力軽量化 (Lossy 60 / 容量最優先)</option>
            <option value="0">完全無劣化 (Lossy 0 / 差分最適化のみ)</option>
        </select>
    </div>

    <div class="form-group">
        <label for="maxColors">最大色数:</label>
        <select id="maxColors">
            <option value="256" selected>256色 (最高画質)</option>
            <option value="128">128色 (さらに軽量化)</option>
        </select>
    </div>

    <button id="processBtn" disabled>プロエンジンで処理実行</button>

    <div class="progress-box" id="progressBox">
        <div class="progress-status" id="progressStatus">⚡ Gifsicle WASM エンジン起動中...</div>
        <div class="progress-bar-bg">
            <div class="progress-bar-fill"></div>
        </div>
        <div class="progress-timer" id="progressTimer">経過時間: 0 秒</div>
    </div>

    <div class="result" id="resultArea">
        <h3>🎉 変換完了!</h3>
        <p id="sizeInfo" style="font-size: 0.85rem; color: #555;"></p>
        <img id="outputImage" src="" alt="変換後のGIF">
        <br>
        <a id="downloadBtn" class="download-btn" href="#" download="compressed.gif">画像をダウンロード</a>
    </div>
</div>

<script type="module">
    import gifsicle from '[https://cdn.jsdelivr.net/npm/gifsicle-wasm-browser/dist/gifsical.min.js](https://cdn.jsdelivr.net/npm/gifsicle-wasm-browser/dist/gifsical.min.js)';

    const dropZone = document.getElementById('dropZone');
    const fileInput = document.getElementById('fileInput');
    const processBtn = document.getElementById('processBtn');
    const targetWidthInput = document.getElementById('targetWidth');
    const lossyLevelSelect = document.getElementById('lossyLevel');
    const maxColorsSelect = document.getElementById('maxColors');

    const progressBox = document.getElementById('progressBox');
    const progressStatus = document.getElementById('progressStatus');
    const progressTimer = document.getElementById('progressTimer');

    const resultArea = document.getElementById('resultArea');
    const outputImage = document.getElementById('outputImage');
    const downloadBtn = document.getElementById('downloadBtn');
    const sizeInfo = document.getElementById('sizeInfo');

    let selectedFile = null;
    let timerInterval = null;
    let secondsElapsed = 0;

    dropZone.addEventListener('click', () => fileInput.click());
    dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
    dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
    dropZone.addEventListener('drop', (e) => {
        e.preventDefault();
        dropZone.classList.remove('dragover');
        if (e.dataTransfer.files.length > 0) handleFileSelect(e.dataTransfer.files[0]);
    });

    fileInput.addEventListener('change', (e) => {
        if (e.target.files.length > 0) handleFileSelect(e.target.files[0]);
    });

    function handleFileSelect(file) {
        if (file.type !== 'image/gif') {
            alert('GIFファイルを選択してください。');
            return;
        }
        selectedFile = file;
        dropZone.innerHTML = `選択中: <b>${file.name}</b><br>(${(file.size / 1024 / 1024).toFixed(2)} MB)`;
        processBtn.disabled = false;
        resultArea.style.display = 'none';
    }

    processBtn.addEventListener('click', async () => {
        if (!selectedFile) return;

        processBtn.disabled = true;
        resultArea.style.display = 'none';
        progressBox.style.display = 'block';

        secondsElapsed = 0;
        progressTimer.innerText = `経過時間: 0 秒`;
        clearInterval(timerInterval);

        // 進捗状況を可視化するタイマー処理
        timerInterval = setInterval(() => {
            secondsElapsed++;
            progressTimer.innerText = `経過時間: ${secondsElapsed} 秒 (処理中...)`;

            if (secondsElapsed === 3) {
                progressStatus.innerText = "🌀 GIFフレーム展開&リサイズ処理中...";
            } else if (secondsElapsed === 10) {
                progressStatus.innerText = "🎨 透明化&カラーパレット最適化中...";
            } else if (secondsElapsed === 25) {
                progressStatus.innerText = "📦 Lossy差分圧縮を適用中...";
            } else if (secondsElapsed === 60) {
                progressStatus.innerText = "☕ 最終ビルド処理を行っています...";
            }
        }, 1000);

        try {
            const width = parseInt(targetWidthInput.value, 10) || 640;
            const lossy = parseInt(lossyLevelSelect.value, 10);
            const colors = parseInt(maxColorsSelect.value, 10);

            // Gifsicleコマンド引数の構築
            let commandStr = `-O3 --resize-fit-width ${width} --colors ${colors}`;
            if (lossy > 0) {
                commandStr += ` --lossy=${lossy}`;
            }
            commandStr += ` input.gif -o /out/output.gif`;

            // イベントループに描画機会を与えるためのわずかな待機
            await new Promise(resolve => setTimeout(resolve, 100));

            // WebAssembly上でGifsicleを実行
            const result = await gifsicle.run({
                input: [{ file: selectedFile, name: "input.gif" }],
                command: [commandStr]
            });

            clearInterval(timerInterval);

            if (!result || result.length === 0) {
                throw new Error("変換処理に失敗しました。");
            }

            const outputBlob = result[0];
            const url = URL.createObjectURL(outputBlob);

            outputImage.src = url;
            downloadBtn.href = url;
            downloadBtn.download = `compressed_${width}px_${selectedFile.name}`;

            const origMB = (selectedFile.size / 1024 / 1024).toFixed(2);
            const newMB = (outputBlob.size / 1024 / 1024).toFixed(2);
            sizeInfo.innerText = `元のサイズ: ${origMB} MB ➔ 変換後: ${newMB} MB (${secondsElapsed}秒で完了)`;

            progressBox.style.display = 'none';
            resultArea.style.display = 'block';
            processBtn.disabled = false;

        } catch (err) {
            clearInterval(timerInterval);
            console.error(err);
            alert(`エラーが発生しました: ${err.message}`);
            progressBox.style.display = 'none';
            processBtn.disabled = false;
        }
    });
</script>

</body>
</html>