見出し画像

【個人開発】Canvaで作ったGIFがnoteでエラーになる問題。ブラウザだけで動く「最適化ツール - NoteGIF Optimizer」を自作して解決した話【コード配布】(インストール不要・HTML1つだけ)

noteの記事を書く際、テキストだけでは伝わりにくいニュアンスを伝えるために「GIF動画」を使いたいことはありませんか?
特に最近は、Googleの「Veo」や「Runway」のような動画生成AIで作成した数秒の動画を、記事のアイキャッチや解説に使いたいシーンが増えています。

私も先日、Veoで生成した8秒間の動画(MP4)をnoteに貼ろうとしました。
普段通りCanvaに取り込み、「GIFとしてダウンロード」を選択。意気揚々とnoteのエディタにドラッグしたのですが……。

「アップロードに失敗しました」

何度やってもエラー。画像サイズを小さくしても、時間を短くしても、なぜかうまくいきません。
この記事では、その「なぜ?」を解明し、解決するために自作した「note専用GIF最適化ツール」を共有します。

記事の後半では、エンジニアの方向けに「なぜHTMLファイル1つだけで動くのか?」という技術的な実装の裏側(Web Workerのハックなど)も詳細に解説します。

もくじ

なぜCanvaのGIFはnoteで失敗するのか?

幅: 640px(これで十分きれい)

結論から言うと、「解像度」と「フレームレート」がオーバースペックだからです。

Canvaなどの高機能ツールで動画をGIFとして書き出すと、デフォルトで「1920x1080(フルHD)」かつ「30fps(滑らかな動き)」で出力されることが多いです。

  • MP4の場合: 差分圧縮が効くため、フルHDでも数MBに収まります。

  • GIFの場合: パラパラ漫画のように全フレームを「一枚絵」として記録します。

1920x1080の画像が30枚/秒で8秒間続くと、ピクセルデータ量は膨大になります。
noteの画像アップロード上限は10MB(仕様により多少前後します)ですが、問題はファイルサイズだけではありません。「解像度(縦横のピクセル数)」が大きすぎると、noteのサーバー側で行われるリサイズ処理でメモリ不足やタイムアウトが発生し、エラーになるのです。

noteに最適なGIFの「黄金比」

noteの記事本文のカラム幅は、PC表示で約620pxです。つまり、それ以上の大きさのGIFを貼っても縮小表示されるだけで、サーバーに負荷をかけるだけです。

noteでエラーを出さずにきれいに表示するための条件は以下の通りです。

  1. 幅: 640px(これで十分きれい)

  2. フレームレート: 10fps(説明用ならこれで十分)

  3. ファイルサイズ: 10MB以下

これを毎回手動で調整するのは面倒です。そこで、この「黄金比」に自動変換するツールを自作しました。

自作ツール「NoteGIF Optimizer」

作成したのは、NoteGIF Optimizer というブラウザ用ツールです。

このツールの特徴

  • インストール不要: 配布するHTMLファイルをブラウザにドラッグ&ドロップするだけで起動します。

  • セキュリティ安心: 動画をサーバーにアップロードしません。すべての処理があなたのブラウザ内で完結します。

  • 自動最適化: どんな動画も、強制的に「幅640px / 10fps」に変換します。

  • トリミング機能: 動画の必要な部分だけを切り抜けます。

使い方

投稿までの流れ

使い方は極めてシンプルです。

  1. 後述するコードを保存した `noteGIF_optimizer.html` を開く。

  2. MP4ファイルを画面にドラッグ&ドロップ。

  3. スライダーで「使いたい範囲」を指定(初期状態では全範囲が選択されています)。

  4. 「GIFを作成」ボタンをクリック。

  5. 完成した画像をデスクトップに保存し、noteにアップロード。

これだけで、あれほどエラーが出ていた動画が一発で投稿できるようになります。


ソースコード(全コード)

以下のコードをコピーし、PCのメモ帳などに貼り付け、`noteGIF_optimizer.html` という名前で保存してください。
外部ライブラリ(gif.js)をCDNから読み込んでいるため、インターネットに繋がっている環境であればすぐに動作します。

【コードのコピーと、唯一の「使用条件」】
このツールは無料で配布しますが、今後のアップデートや新たな開発を続けるために「この記事に『スキ♡』を押すこと」を唯一の使用条件とさせてください。これは、どれくらいの方に必要とされているかを「計測」するための指標であり、私にとって次作を生み出すための「モチベーション(燃料)」になります。システムで縛ることはしませんが、この紳士協定にご合意いただける方のみ、以下のコードをコピーしてご活用ください!

※スキはnoteに登録していなくても押せます
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>NoteGIF Optimizer</title>
    <meta name="description" content="note記事用にMP4動画を最適なGIFに変換するツール。サーバー送信なし、ブラウザ完結。">
    
    <!-- 
      【技術解説: ライブラリの読み込み】
      GIFエンコードのコアライブラリとして 'gif.js' を使用します。
      通常は worker ファイルが別途必要ですが、本コードではJavaScript内で
      動的にWorkerを生成するため、HTMLファイル単体での動作を実現しています。
    -->
    <script src="/https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script>

    <style>
        /* --- デザイン定義 (Note Like UI) --- */
        :root { --main: #41C9B4; --bg: #F4F5F7; --text: #333; --card: #FFF; --danger: #E74C3C; }
        
        body { 
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 
            background: var(--bg); color: var(--text); 
            display: flex; justify-content: center; align-items: center; 
            min-height: 100vh; margin: 0; padding: 20px; box-sizing: border-box; 
        }
        
        .container { 
            width: 100%; max-width: 460px; background: var(--card); 
            border-radius: 24px; box-shadow: 0 15px 35px rgba(0,0,0,0.08); 
            padding: 30px; text-align: center; 
        }

        h1 { margin: 0; font-size: 22px; color: #222; }
        p.sub { font-size: 13px; color: #888; margin: 8px 0 25px; }

        /* UIコンポーネント: ドロップゾーン */
        #drop-zone { 
            border: 3px dashed #E0E0E0; border-radius: 16px; padding: 40px 20px; 
            cursor: pointer; transition: 0.2s; background: #FAFAFA; 
        }
        #drop-zone:hover, #drop-zone.dragover { 
            border-color: var(--main); background: #F0FFFD; transform: scale(1.01); 
        }
        .icon { font-size: 42px; display: block; margin-bottom: 10px; }
        
        /* プレビューエリア */
        video, img { 
            max-width: 100%; border-radius: 12px; margin-bottom: 15px; 
            box-shadow: 0 4px 12px rgba(0,0,0,0.1); 
        }
        #editor-view, #result-view { display: none; text-align: left; animation: fadeIn 0.3s; }
        #result-view { text-align: center; }
        @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }

        /* --- デュアルスライダー (CSS Hack) --- */
        /* 2つのrange inputを重ねて表示し、開始点と終了点を選択可能にする */
        .slider-wrapper { position: relative; height: 40px; margin-bottom: 15px; }
        .slider-track { position: absolute; top: 18px; left: 10px; right: 10px; height: 4px; background: #EEE; border-radius: 2px; }
        .slider-fill { position: absolute; top: 18px; height: 4px; background: var(--main); z-index: 1; left: 0%; width: 100%; }
        
        input[type=range] { 
            position: absolute; top: 0; left: 0; width: 100%; 
            -webkit-appearance: none; background: none; pointer-events: none; /* 重要: クリックを透過 */
            margin: 0; z-index: 2; 
        }
        input[type=range]::-webkit-slider-thumb { 
            pointer-events: auto; /* ツマミ部分だけ操作可能に */
            -webkit-appearance: none; width: 24px; height: 24px; border-radius: 50%; 
            background: #FFF; border: 2px solid var(--main); cursor: grab; 
            box-shadow: 0 2px 5px rgba(0,0,0,0.2); margin-top: -10px; position: relative; top: 10px; 
        }
        .time-labels { display: flex; justify-content: space-between; font-size: 12px; font-weight: bold; color: var(--main); padding: 0 5px; }

        /* 設定エリア */
        .settings-row { 
            display: flex; justify-content: space-between; align-items: center; 
            margin-bottom: 20px; font-size: 13px; background: #f9f9f9; 
            padding: 10px 15px; border-radius: 10px; 
        }
        .radio-group label { margin-right: 15px; cursor: pointer; display: inline-flex; align-items: center; }
        .est-size { font-weight: bold; color: #666; }
        .est-size.danger { color: var(--danger); } /* 容量オーバー警告 */
        
        .drag-hint { 
            background: #f0f0f0; color: #555; padding: 10px; 
            border-radius: 8px; font-size: 12px; margin-bottom: 15px; 
            border: 1px solid #ddd; 
        }

        /* ボタン類 */
        .btn { 
            display: block; width: 100%; padding: 14px 0; 
            background: var(--main); color: white; font-weight: bold; 
            text-decoration: none; border-radius: 30px; font-size: 15px; border: none; 
            cursor: pointer; transition: transform 0.1s; margin-bottom: 10px; 
        }
        .btn:active { transform: scale(0.98); }
        .btn:disabled { background: #CCC; cursor: not-allowed; }
        .btn-sub { 
            background: transparent; color: #999; border: none; font-size: 12px; 
            margin-top: 5px; cursor: pointer; text-decoration: underline; width: 100%; 
        }
        
        .progress-text { text-align: center; font-size: 12px; color: var(--main); font-weight: bold; margin-top: 10px; min-height: 18px; }
    </style>
</head>
<body>

<div class="container">
    <header>
        <h1>NoteGIF Optimizer</h1>
        <p class="sub">note専用 GIF最適化ツール</p>
    </header>

    <!-- STEP 1: 動画アップロード画面 -->
    <div id="drop-zone">
        <span class="icon">🎬</span>
        <div style="font-weight:bold; font-size:15px; margin-bottom:5px;">動画をドロップ</div>
        <div style="font-size:11px; color:#AAA;">またはクリックして選択 (MP4)</div>
    </div>
    <input type="file" id="file-input" hidden accept="video/mp4,video/mov,video/webm">

    <!-- STEP 2: 編集画面 (トリミング & 設定) -->
    <div id="editor-view">
        <video id="video-preview" playsinline muted></video>
        
        <!-- デュアルスライダー -->
        <div class="time-labels">
            <span id="label-start">0.0s</span>
            <span id="label-end">0.0s</span>
        </div>
        <div class="slider-wrapper">
            <div class="slider-track"></div>
            <div class="slider-fill" id="slider-fill"></div>
            <input type="range" id="range-start" min="0" max="100" step="0.1" value="0">
            <input type="range" id="range-end" min="0" max="100" step="0.1" value="100">
        </div>

        <div class="settings-row">
            <div class="radio-group">
                <label><input type="radio" name="fps" value="10" checked> 10fps (軽量)</label>
                <label><input type="radio" name="fps" value="15"> 15fps</label>
            </div>
            <div id="est-size" class="est-size">予想: -- MB</div>
        </div>

        <button id="convert-btn" class="btn">GIFを作成する</button>
        <div id="progress-text" class="progress-text"></div>
    </div>

    <!-- STEP 3: 結果画面 -->
    <div id="result-view">
        <div class="drag-hint">
            👇 <b>Tips:</b> 画像をデスクトップにドラッグ&ドロップすると保存できます。
        </div>
        <img id="result-img" alt="Generated GIF">
        <a id="dl-link" class="btn">💾 ファイルとして保存</a>
        <button id="retry-btn" class="btn-sub">別の動画を作成</button>
    </div>
</div>

<!-- 処理用の隠しキャンバス -->
<canvas id="hidden-canvas" style="display:none;"></canvas>

<script>
    /* --- 1. UI要素とステータス管理 --- */
    const ui = {
        drop: document.getElementById('drop-zone'),
        file: document.getElementById('file-input'),
        editor: document.getElementById('editor-view'),
        result: document.getElementById('result-view'),
        video: document.getElementById('video-preview'),
        rStart: document.getElementById('range-start'),
        rEnd: document.getElementById('range-end'),
        fill: document.getElementById('slider-fill'),
        lStart: document.getElementById('label-start'),
        lEnd: document.getElementById('label-end'),
        est: document.getElementById('est-size'),
        btn: document.getElementById('convert-btn'),
        prog: document.getElementById('progress-text'),
        img: document.getElementById('result-img'),
        dl: document.getElementById('dl-link'),
        canvas: document.getElementById('hidden-canvas')
    };
    let videoDuration = 0;

    /* --- 2. イベントリスナー --- */
    // ブラウザのデフォルトD&D挙動(ファイルを開く)を防止
    const prevent = e => { e.preventDefault(); e.stopPropagation(); };
    ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(e => window.addEventListener(e, prevent));

    ui.drop.ondragover = () => ui.drop.classList.add('dragover');
    ui.drop.ondragleave = () => ui.drop.classList.remove('dragover');
    ui.drop.ondrop = e => loadFile(e.dataTransfer.files[0]);
    ui.drop.onclick = () => ui.file.click();
    ui.file.onchange = e => loadFile(e.target.files[0]);
    document.getElementById('retry-btn').onclick = () => location.reload();

    /* --- 3. ファイル読み込み処理 --- */
    function loadFile(file) {
        if (!file || !file.type.startsWith('video/')) return alert('動画ファイル(MP4/MOV)を選択してください');
        
        // 動画をBlob URLとして読み込む
        ui.video.src = URL.createObjectURL(file);
        
        ui.video.onloadedmetadata = () => {
            videoDuration = ui.video.duration;
            
            // スライダーの範囲設定
            ui.rStart.max = ui.rEnd.max = videoDuration;
            
            // 初期設定: 全期間を選択(長すぎる場合は10秒でクリップするロジックを入れても良いが、今回は全選択)
            ui.rStart.value = 0;
            ui.rEnd.value = Math.min(videoDuration, 10); 
            
            updateSlider();
            
            ui.drop.style.display = 'none';
            ui.editor.style.display = 'block';
        };
    }

    /* --- 4. デュアルスライダー制御 & サイズ予測ロジック --- */
    function updateSlider() {
        let s = parseFloat(ui.rStart.value), e = parseFloat(ui.rEnd.value);
        
        // 開始と終了が逆転しないように制御
        if (s > e) [s, e] = [e, s];
        
        // 最低0.5秒の間隔を強制
        if (e - s < 0.5) s === parseFloat(ui.rStart.value) ? e = s + 0.5 : s = e - 0.5;

        // ビジュアル更新
        ui.rStart.value = s; ui.rEnd.value = e;
        ui.fill.style.left = (s / videoDuration * 100) + '%';
        ui.fill.style.width = ((e - s) / videoDuration * 100) + '%';
        ui.lStart.innerText = s.toFixed(1) + 's';
        ui.lEnd.innerText = e.toFixed(1) + 's';
        
        updateEst(s, e);
    }

    function updateEst(s, e) {
        // GIF容量予測: 640x360 * 圧縮係数(0.8) * FPS * 秒数
        // ※あくまで概算ですが、10MBオーバーの目安になります
        const fps = parseInt(document.querySelector('input[name=fps]:checked').value);
        const bytes = (640 * 360 * 0.8 * fps * (e - s)); 
        const mb = (bytes / 1024 / 1024).toFixed(1);
        
        ui.est.innerText = `予想: 約${mb} MB`;
        // 15MBを超えたら危険色に
        ui.est.className = parseFloat(mb) > 15 ? 'est-size danger' : 'est-size';
    }

    // スライダー操作時にプレビュー動画をシークさせる
    ui.rStart.oninput = () => { updateSlider(); ui.video.currentTime = ui.rStart.value; };
    ui.rEnd.oninput = () => { updateSlider(); ui.video.currentTime = ui.rEnd.value; };
    document.querySelectorAll('input[name=fps]').forEach(el => el.onchange = () => updateSlider());

    /* --- 5. GIF変換コアロジック (Web Worker Hack) --- */
    
    // 【技術ポイント】
    // gif.jsのworkerスクリプトをfetchしてBlob URL化し、単一ファイル動作を実現する
    async function getWorkerBlob() {
        const workerScriptUrl = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js';
        const res = await fetch(workerScriptUrl);
        const scriptText = await res.text();
        return URL.createObjectURL(new Blob([scriptText], {type: 'application/javascript'}));
    }

    ui.btn.onclick = async () => {
        ui.btn.disabled = true;
        ui.prog.innerText = "エンジン初期化中...";
        
        const start = parseFloat(ui.rStart.value);
        const end = parseFloat(ui.rEnd.value);
        const fps = parseInt(document.querySelector('input[name=fps]:checked').value);
        
        // note最適化サイズ: 幅640pxに固定
        const width = 640;
        const height = Math.floor(ui.video.videoHeight * (width / ui.video.videoWidth));
        
        ui.canvas.width = width; ui.canvas.height = height;
        
        // willReadFrequently: true でCanvas読み出しを高速化
        const ctx = ui.canvas.getContext('2d', { willReadFrequently: true });
        
        // Worker Blobの取得
        const workerUrl = await getWorkerBlob();
        
        // GIFエンコーダー初期化
        const gif = new GIF({ 
            workers: 2, 
            quality: 10, 
            width: width, 
            height: height, 
            workerScript: workerUrl // Blob URLを渡す
        });

        // フレーム抽出ループ
        const step = 1 / fps;
        let currentTime = start;
        
        while (currentTime < end) {
            ui.video.currentTime = currentTime;
            
            // 'seeked' イベントを待機してコマ飛びを防止
            await new Promise(r => {
                const h = () => { ui.video.removeEventListener('seeked', h); r(); };
                ui.video.addEventListener('seeked', h);
            });
            
            ctx.drawImage(ui.video, 0, 0, width, height);
            
            // フレーム追加 (copy: true でピクセルデータを複製)
            gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
            
            // 進捗表示
            const percent = Math.round((currentTime - start) / (end - start) * 100);
            ui.prog.innerText = `フレーム解析中: ${percent}%`;
            
            // UIスレッドをブロックしないための待機
            await new Promise(r => setTimeout(r, 0)); 
            
            currentTime += step;
        }

        ui.prog.innerText = "エンコード中... (数秒かかります)";
        
        // 完了時の処理
        gif.on('finished', blob => {
            URL.revokeObjectURL(workerUrl); // メモリ解放
            
            const url = URL.createObjectURL(blob);
            const filename = `note-optimized-${Date.now()}.gif`;

            // 結果表示
            ui.img.src = url;
            ui.dl.href = url;
            ui.dl.download = filename;

            // 【Chrome/Edge専用】Drag-Out保存機能の実装
            // 画像をデスクトップへドラッグするだけで保存できるようにする
            ui.img.dataset.downloadurl = ['image/gif', filename, url].join(':');
            ui.img.draggable = true;
            ui.img.ondragstart = e => {
                e.dataTransfer.setData('DownloadURL', e.target.dataset.downloadurl);
            };

            ui.editor.style.display = 'none';
            ui.result.style.display = 'block';
        });

        // レンダリング開始
        gif.render();
    };
</script>
</body>
</html>

【エンジニア向け】技術実装の裏側解説

ここからは、Web技術に詳しい方向けに、このツールが「なぜHTMLファイル1つだけで動作するのか」、そして「どのようにブラウザの制限を回避しているのか」を解説します。

1. Web Workerの「Blob URL化」ハック

GIF生成ライブラリの `gif.js` は、重いエンコード処理をバックグラウンドで行うために Web Worker を使用します。通常、Workerを起動するには `gif.worker.js` という別ファイルを指定する必要があります。

// 通常の実装
const gif = new GIF({ workerScript: 'js/gif.worker.js' });

しかし、これではHTML単体で配布できません。また、CDNのURL(クロスオリジン)を直接指定すると、ブラウザのセキュリティポリシー(CORS)によりWorkerの起動がブロックされます。

そこで、本ツールでは以下の手順でこれを回避しています。

  1. JavaScript内で `gif.worker.js` のソースコードを `fetch` でテキストとして取得。

  2. 取得したテキストから `Blob` オブジェクトを生成。

  3. `URL.createObjectURL(blob)` で、そのBlobを指す「ローカルなURL(blob:https://...)」を生成。

  4. そのURLをWorkerのパスとして渡す。

これにより、あたかもローカルにファイルが存在するかのように振る舞わせ、単一ファイルでの動作を実現しています。

2. Chrome独自の「Drag-Out」保存

生成されたGIF画像を、ブラウザからデスクトップへドラッグ&ドロップして保存する機能。これは標準のHTML5にはない機能ですが、ChromeやEdgeには独自の拡張仕様として実装されています。

e.dataTransfer.setData('DownloadURL', 'image/gif:filename.gif:blob_url');

`ondragstart` イベントでこのデータをセットすることで、OSのファイルシステムとの連携が可能になり、直感的な「ドラッグ保存」を実現しています。

3. Canvasの最適化

動画のフレームをキャプチャする際、`<canvas>` 要素を経由します。
この際、コンテキスト取得時に `{ willReadFrequently: true }` オプションを指定しています。

const ctx = ui.canvas.getContext('2d', { willReadFrequently: true });

これはブラウザに対し、「このキャンバスからは頻繁にデータを読み出す(`getImageData`などを行う)」と宣言するものです。これにより、ブラウザはGPUではなくCPU側のメモリにデータを保持するなどして最適化を行い、エンコード速度の向上に寄与しています。

最後に

結論

このツールは、「8秒の動画をnoteに貼りたい」という個人的な悩みから生まれました。
同じように「Canvaで作ったGIFがエラーになる」「変換サイトにアップロードしたくない」と悩んでいるクリエイターの助けになれば幸いです。

ぜひ、このコードを保存して使ってみてください。そして、快適なnote執筆ライフを!

音声解説

ここから先は

4,157字

¥ 100

Amazon Payで支払うと最大2%還元のチャンス! 9/30まで

この記事が気に入ったらチップで応援してみませんか?