この記事で作るもの
MP4 などの動画を選ぶと、サーバーに送らずにブラウザの中だけで GIF を作る処理です。
筆者が運営している無料の Web ツール集の 1 本として公開していて、実際に動くものは 動画を GIF に変換(アオタカツール) で試せます。
処理は次の 4 段です。
-
<video>を指定の時刻へ動かし、移り終わるのを待つ - そのコマを
<canvas>に描いて、画素(RGBA)を取り出す - 画素を Web Worker に渡し、GIF に詰める
- GIF は 1 コマ 256 色までなので、Worker の中で 256 色に減らしてから詰める
画面の操作を止めないために重い処理(3 と 4)を Worker に逃がし、GIF の書き出しは gifenc(MIT)を使いました。
減色(4)だけは gifenc のものを使わず自作しています。
その理由と、作ってみて分かった減色のコツを後半に書きます。
環境は次のとおりです。
- ブラウザ: Windows の Chrome と iPhone の Safari で動作を確かめた
- gifenc 1.0.3
- テストと計測: Node.js 24.19.0(
node --test)
コードは Claude Code に書かせ、筆者が手元で動かしてテストと計測をしました。
記事中のコードは、公開しているツールのコードから要点を抜き出したものです。
全体の流れ
画面側(メインスレッド)と Worker のあいだは、次のメッセージだけでやり取りします。
| 向き | type | 中身 |
|---|---|---|
| 画面 → Worker | start |
幅・高さ・1 コマの表示時間・コマ数 |
| 画面 → Worker | frame |
1 コマ分の RGBA の ArrayBuffer
|
| 画面 → Worker | finish |
なし |
| Worker → 画面 | progress |
詰め終わったコマ数 |
| Worker → 画面 | done |
GIF のバイト列 |
| Worker → 画面 | error |
誤りの文 |
画面側は、コマを 1 枚取り出すたびに Worker へ送ります。
全部のコマを画面側で溜めてから送る形にしなかったのは、溜めるとそのぶんメモリを使うからです(480×270 のコマは 1 枚で約 52 万バイトあります)。
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
worker.postMessage({ type: "start", width, height, delayMs: Math.round(1000 / fps), total: times.length });
for (const t of times) {
await seekTo(video, t);
ctx.drawImage(video, 0, 0, width, height);
const { data } = ctx.getImageData(0, 0, width, height);
// 2 つめの引数で ArrayBuffer の持ち主を Worker に移す(コピーしない)
worker.postMessage({ type: "frame", buffer: data.buffer }, [data.buffer]);
}
worker.postMessage({ type: "finish" });
getImageData を何度も呼ぶので、getContext に willReadFrequently: true を渡しています。
postMessage の 2 つめの引数に data.buffer を入れると、バッファはコピーされずに Worker へ移ります。
移したあとの data は画面側では空になるので、次のコマでは getImageData で新しく取り直します。
1. video を指定の時刻へ動かす
コマを取り出すには、video.currentTime に時刻を入れて、seeked イベントを待ちます。
seeked が来る前に drawImage すると、前の位置のコマが描かれることがあります。
export const SEEK_TIMEOUT_MS = 5000;
export function seekTo(video, time, timeoutMs = SEEK_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const off = () => {
clearTimeout(timer);
video.removeEventListener("seeked", onSeeked);
video.removeEventListener("error", onError);
};
const onSeeked = () => { off(); resolve(); };
const onError = () => { off(); reject(new Error("動画の読み込みで誤りが起きました")); };
const timer = setTimeout(() => {
off();
const error = new Error(`動画の位置を ${timeoutMs} ミリ秒以内に動かせませんでした`);
error.name = "SeekTimeoutError";
reject(error);
}, timeoutMs);
video.addEventListener("seeked", onSeeked);
video.addEventListener("error", onError);
video.currentTime = time;
});
}
制限時間を付けたのは、seeked が来なかったときに await が終わらず、画面が「作成中」のまま戻らなくなるからです。
コードレビューでこの穴を指摘されて、5 秒で失敗にする形に直しました。
失敗したときは error.name を見て、「動画の位置が動きませんでした」と画面に出し分けています。
2. コマの時刻と範囲の計算
取り出すコマの時刻は、始まりから 1 / fps 秒ずつ進めて作ります。
export function frameTimes(start, end, fps) {
const count = Math.max(1, Math.floor((end - start) * fps + 1e-9));
return Array.from({ length: count }, (_, i) => start + i / fps);
}
ここで 1e-9 を足しているのは、浮動小数点の誤差でコマが 1 枚減るのを防ぐためです。
たとえば 1.1 秒から 1.4 秒までを 10 fps で取り出すなら 3 コマのはずですが、(1.4 - 1.1) * 10 は 2.9999999999999982 になり、切り捨てると 2 になります。
同じ種類の罠がもう 1 つありました。
再生中の位置を入力欄に「0.1 秒単位」で入れるとき、toFixed(1) は四捨五入なので、長さ 2.96 秒の動画で「3.0」になり、終わりが動画の長さを超えます。
そこで切り捨てにし、10 倍したときに誤差で整数の少し下に落ちても 1 つ下の値にならないよう、小さな余裕を足してから切り捨てています。
export function floorTenth(seconds) {
return (Math.floor(seconds * 10 + 1e-6) / 10).toFixed(1);
}
公開しているツールでは、範囲は 15 秒まで、コマは 150 枚まで、横幅は 240・320・480 px、1 秒のコマ数は 5・10・15 から選ぶ形にしています。
3. Web Worker で GIF に詰める
Worker は、コマを受け取るたびに 256 色に減らして GIF に書き、終わったらバイト列を返します。
import { createGif } from "./encode.js";
let gif = null;
let width = 0, height = 0, delayMs = 100, total = 0, done = 0;
self.onmessage = (event) => {
const msg = event.data;
try {
if (msg.type === "start") {
gif = createGif();
({ width, height, delayMs, total } = msg);
done = 0;
} else if (msg.type === "frame") {
gif.addFrame(new Uint8ClampedArray(msg.buffer), width, height, delayMs);
done += 1;
self.postMessage({ type: "progress", done, total });
} else if (msg.type === "finish") {
const bytes = gif.finish();
gif = null;
self.postMessage({ type: "done", bytes }, [bytes.buffer]);
}
} catch (error) {
self.postMessage({ type: "error", message: String(error) });
}
};
import { GIFEncoder, applyPalette } from "gifenc/dist/gifenc.esm.js";
import { quantize } from "./quantize.js";
export function createGif() {
const gif = GIFEncoder();
return {
addFrame(rgba, width, height, delayMs) {
const palette = quantize(rgba, 256); // 自作の減色
const index = applyPalette(rgba, palette); // 各画素をパレットの番号にする(gifenc)
gif.writeFrame(index, width, height, { palette, delay: delayMs });
},
finish() {
gif.finish();
return gif.bytes();
},
};
}
パレットはコマごとに作っています。
場面が変わる動画でも、各コマの色に合ったパレットになるからです。
gifenc の取り込みでつまずいた点
import ... from "gifenc" と書くと、Node のテストとブラウザ(Vite で束ねたもの)で読まれるファイルが変わり、どちらか片方で動きませんでした。
- Node は CommonJS 版(
dist/gifenc.js)を読み、名前付きの取り込み({ GIFEncoder })が失敗する - Vite は ESM 版(
dist/gifenc.esm.js)を読み、既定の取り込みはGIFEncoderの関数そのものになる
そこで、ESM 版のファイルを直に名前付きで取り込む形(gifenc/dist/gifenc.esm.js)にしました。
Node 24 は、拡張子が .js でも中身が ESM の書き方なら ESM として読むので、テストとブラウザで同じファイルを使えます。
4. 256 色への減色(メディアンカット)
GIF は 1 コマ 256 色までなので、フルカラーのコマから 256 色のパレットを選ぶ必要があります。
ここでは、色の空間を箱に入れて割っていくメディアンカットを使いました。
最初は全部の色を 1 つの箱に入れ、箱を割る操作を 256 個になるまで繰り返し、最後に各箱の色の平均をパレットの 1 色にします。
画素を色の「ビン」にまとめる
480×270 のコマは約 13 万画素あります。
画素を 1 つずつ並べ替えながら箱を割ると重いので、先に赤・緑・青を各 5 ビット(32 段階)に丸めた「ビン」に数えます。
ビンは最大 32,768 個で、並べ替えるのは使われているビンだけになります。
const count = new Uint32Array(1 << 15);
const sumR = new Float64Array(1 << 15);
const sumG = new Float64Array(1 << 15);
const sumB = new Float64Array(1 << 15);
for (let i = 0; i < rgba.length / 4; i++) {
const r = rgba[i * 4], g = rgba[i * 4 + 1], b = rgba[i * 4 + 2];
const bin = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
count[bin]++;
sumR[bin] += r; sumG[bin] += g; sumB[bin] += b;
}
ビンごとに元の 8 ビットの色の合計も持っておき、最後のパレットの色は丸める前の色の平均にします。
単色の画像なら、元の色そのものがパレットに入ります。
どの箱を、どこで割るか
教科書どおりのメディアンカットは、「画素数の最も多い箱」を「最も幅の広い軸の、画素数の中央」で割ります。
この形だと、夜空の小さな星や窓の明かりのような、数は少ないが目立つ色が消えやすくなります(下の比較の表)。
画素数で箱を選ぶと、数の少ない星の色は暗い空の色と同じ箱に入ったまま割られず、平均の色に埋もれるからです。
そこで、2 か所を変えました。
- 割る箱は「画素数 × その箱の色の幅」が最も大きいものにする。数の少ない色でも、色の幅が広い箱は割られる
- 割る位置は、画素数の中央ではなく、最も広い軸の色の幅の真ん中にする。数の多い色に引っ張られずに、離れた色が別の箱に分かれる
// 割れる(ビンが 2 つ以上ある)箱のうち、「画素数 × 色の幅」が最も大きい箱を選ぶ
let target = -1, bestScore = -1;
for (let i = 0; i < boxes.length; i++) {
const box = boxes[i];
if (box.end - box.start < 2) continue;
const score = box.pixels * (box.range ??= widestRange(bins, box.start, box.end));
if (score > bestScore) { bestScore = score; target = i; }
}
// 最も広い軸で並べ、その軸の色の幅の真ん中で 2 つに割る
const axis = widestAxis(bins, box.start, box.end);
bins.subarray(box.start, box.end).sort((a, b) => channelOf(a, axis) - channelOf(b, axis) || a - b);
const mid = (channelOf(bins[box.start], axis) + channelOf(bins[box.end - 1], axis)) / 2;
let acc = 0, cut = box.start;
while (channelOf(bins[cut], axis) <= mid) acc += count[bins[cut++]];
割れる箱はビンを 2 つ以上持ち、最も広い軸の幅は 1 以上あるので、真ん中より下と上にビンが 1 つずつは必ずあります。
そのため cut が箱の端に張り付いて空の箱ができることはありません。
2 つのやり方の比べ方と結果
2 つのやり方を同じ絵で比べました。
使ったのは、夜空に小さな星が散る絵と、昼の空と山の絵を、480×270 に縮めたものです。
誤差は、元の画素の色と、割り当てたパレットの色の距離(RGB の 3 次元のユークリッド距離)の平均です。
「明るい画素」は輝度(0.299R + 0.587G + 0.114B)が 200 以上の画素で、夜の絵では星の部分の 78 画素がこれに当たります。
| 絵 | やり方 | 全体の誤差 | 明るい画素の誤差 | 1 コマの時間 |
|---|---|---|---|---|
| 夜空 | 教科書どおり(画素を直接並べ替え) | 3.65 | 25.00 | 193 ms |
| 夜空 | 今回のやり方(ビン、幅の真ん中で割る) | 4.97 | 3.53 | 4.9 ms |
| 昼の空 | 教科書どおり | 3.13 | 2.83 | 199 ms |
| 昼の空 | 今回のやり方 | 3.05 | 3.00 | 3.5 ms |
夜空の絵では、星の色の誤差が 25.00 から 3.53 に下がりました。
その代わり、全体の誤差は 3.65 から 4.97 に増えています。
暗い空のなだらかな色の違いに割り当てていたパレットの一部を、星の色に回したためです。
GIF にしたとき目に付くのは、空のわずかな色の段差より星が消えることのほうだと判断し、こちらを採りました。
昼の空を描いた絵のように明るい色が広く占める絵では、2 つのやり方の差はほとんどありません。
時間の差は、ビンにまとめて並べ替える数を減らしたことだけでは説明できません。
教科書どおりのほうは画素を 1 つずつ配列に入れて並べ替える素朴な実装で、型付き配列を使うビンの版とは書き方も違うからです。
表の時間は、Node.js 24.19.0 で各 1 回だけ測った値です。
ビンにまとめることの限界
色を 5 ビットに丸めてから割るので、丸めた段階で同じビンに入った色は区別できません。
なだらかなグラデーションだけの絵では、使われるビンが 256 個より少なくなり、パレットが 256 色に届かないことがあります(暗い青だけの合成画像では 27 色になりました)。
こういう絵では、色の段差(バンディング)が目立つことがあります。
ディザリング(誤差拡散)で段差を散らすのは、今後の改善の候補です。
まとめ
-
seekedを待ってからdrawImageし、待つ側には制限時間を付ける - 画素は
postMessageの第 2 引数で Worker に移し、コピーを避ける - 時刻の計算は、浮動小数点の誤差でコマが減ったり範囲を超えたりしないよう、小さな余裕を足す
- メディアンカットは、割る箱を「画素数 × 色の幅」で選び、色の幅の真ん中で割ると、少数の目立つ色が残る
この記事のコードで動いているツールは 動画を GIF に変換(アオタカツール) です。
動画はブラウザの外へ送らず、保存もしません。