【個人開発】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で失敗するのか?

結論から言うと、「解像度」と「フレームレート」がオーバースペックだからです。
Canvaなどの高機能ツールで動画をGIFとして書き出すと、デフォルトで「1920x1080(フルHD)」かつ「30fps(滑らかな動き)」で出力されることが多いです。
MP4の場合: 差分圧縮が効くため、フルHDでも数MBに収まります。
GIFの場合: パラパラ漫画のように全フレームを「一枚絵」として記録します。
1920x1080の画像が30枚/秒で8秒間続くと、ピクセルデータ量は膨大になります。
noteの画像アップロード上限は10MB(仕様により多少前後します)ですが、問題はファイルサイズだけではありません。「解像度(縦横のピクセル数)」が大きすぎると、noteのサーバー側で行われるリサイズ処理でメモリ不足やタイムアウトが発生し、エラーになるのです。
noteに最適なGIFの「黄金比」
noteの記事本文のカラム幅は、PC表示で約620pxです。つまり、それ以上の大きさのGIFを貼っても縮小表示されるだけで、サーバーに負荷をかけるだけです。
noteでエラーを出さずにきれいに表示するための条件は以下の通りです。
幅: 640px(これで十分きれい)
フレームレート: 10fps(説明用ならこれで十分)
ファイルサイズ: 10MB以下
これを毎回手動で調整するのは面倒です。そこで、この「黄金比」に自動変換するツールを自作しました。
自作ツール「NoteGIF Optimizer」
作成したのは、NoteGIF Optimizer というブラウザ用ツールです。
このツールの特徴
インストール不要: 配布するHTMLファイルをブラウザにドラッグ&ドロップするだけで起動します。
セキュリティ安心: 動画をサーバーにアップロードしません。すべての処理があなたのブラウザ内で完結します。
自動最適化: どんな動画も、強制的に「幅640px / 10fps」に変換します。
トリミング機能: 動画の必要な部分だけを切り抜けます。
使い方

使い方は極めてシンプルです。
後述するコードを保存した `noteGIF_optimizer.html` を開く。
MP4ファイルを画面にドラッグ&ドロップ。
スライダーで「使いたい範囲」を指定(初期状態では全範囲が選択されています)。
「GIFを作成」ボタンをクリック。
完成した画像をデスクトップに保存し、noteにアップロード。
これだけで、あれほどエラーが出ていた動画が一発で投稿できるようになります。
ソースコード(全コード)
以下のコードをコピーし、PCのメモ帳などに貼り付け、`noteGIF_optimizer.html` という名前で保存してください。
外部ライブラリ(gif.js)をCDNから読み込んでいるため、インターネットに繋がっている環境であればすぐに動作します。
【コードのコピーと、唯一の「使用条件」】
このツールは無料で配布しますが、今後のアップデートや新たな開発を続けるために「この記事に『スキ♡』を押すこと」を唯一の使用条件とさせてください。これは、どれくらいの方に必要とされているかを「計測」するための指標であり、私にとって次作を生み出すための「モチベーション(燃料)」になります。システムで縛ることはしませんが、この紳士協定にご合意いただける方のみ、以下のコードをコピーしてご活用ください!
<!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の起動がブロックされます。
そこで、本ツールでは以下の手順でこれを回避しています。
JavaScript内で `gif.worker.js` のソースコードを `fetch` でテキストとして取得。
取得したテキストから `Blob` オブジェクトを生成。
`URL.createObjectURL(blob)` で、そのBlobを指す「ローカルなURL(blob:https://...)」を生成。
その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執筆ライフを!
音声解説
ここから先は
¥ 100
この記事が気に入ったらチップで応援してみませんか?
