個人で開発している Web アプリ Belle AI では、ユーザーが選んだ写真をブラウザ側で base64 にして、JSON で API に送っています。スマホで撮った原画像は数 MB あり、base64 にすると文字列の長さは元のバイト数の約 4/3 倍になります。モバイル回線ではこのリクエストが重くなるので、送る前にブラウザ側で縮小しています。
この記事では、実際に使っている TypeScript のコードを紹介し、その挙動を手元のブラウザで動かして確かめた結果をまとめます。
先に結果
| 項目 | 結果 |
|---|---|
| 入力 | 3000×3002 の JPEG、6,511,067 バイト(Exif あり) |
| 出力 | 2047×2048 の JPEG、771,483 バイト(元の約 11.8%) |
| base64(data URL)の文字数 | 8,681,447 → 1,028,667 |
| 処理時間 | 99 ms(1 回だけ計測) |
| Exif | 入力にはあり、出力にはなし |
測定条件です。
- ブラウザは Chromium 148(Electron 42)です。Safari、Firefox、iOS・Android の実機では試していません
- 入力は NASA の Apollo 17 の写真です(Wikimedia Commons、パブリックドメイン)。自撮り写真ではないので、実際のサイズは写真の内容によって変わります
全体の流れ
コード
const MAX_EDGE = 2048;
const JPEG_QUALITY = 0.88;
/** すでに小さい JPEG は再エンコードせず、そのまま使う(二重圧縮による劣化を避ける) */
const SKIP_COMPRESS_BYTES = 1.2 * 1024 * 1024;
function readAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error ?? new Error("read_failed"));
reader.readAsDataURL(blob);
});
}
async function decode(
file: File
): Promise<{ source: CanvasImageSource; width: number; height: number; release: () => void }> {
if (typeof createImageBitmap === "function") {
// "from-image" で Exif の向きを反映してデコードする(縦撮りが横倒しにならない)
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
return { source: bitmap, width: bitmap.width, height: bitmap.height, release: () => bitmap.close() };
}
// createImageBitmap がない環境向け
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image();
el.onload = () => resolve(el);
el.onerror = () => reject(new Error("decode_failed"));
el.src = url;
});
return { source: img, width: img.naturalWidth, height: img.naturalHeight, release: () => URL.revokeObjectURL(url) };
} catch (err) {
URL.revokeObjectURL(url);
throw err;
}
}
/** そのままアップロードできる data URL を返す。失敗したとき、または効果がないときは元ファイルを返す */
export async function prepareImageForUpload(file: File): Promise<string> {
try {
const { source, width, height, release } = await decode(file);
try {
const scale = Math.min(1, MAX_EDGE / Math.max(width, height));
if (scale === 1 && file.type === "image/jpeg" && file.size <= SKIP_COMPRESS_BYTES) {
return await readAsDataUrl(file);
}
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
const ctx = canvas.getContext("2d");
if (!ctx) return await readAsDataUrl(file);
// 透過 PNG を JPEG にするとき、透明部分が黒くならないように白で塗る
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY)
);
if (!blob || (scale === 1 && blob.size >= file.size)) return await readAsDataUrl(file);
return await readAsDataUrl(blob);
} finally {
release();
}
} catch {
return readAsDataUrl(file);
}
}
このアプリでは、顔と肌の色が判別できれば十分なので、長辺 2048px・JPEG 品質 0.88 にしています。この値は用途に合わせた私の判断で、端末や回線ごとの調整はしていません。
動作確認
上のコードを、同じ処理のままブラウザの上で実行して確かめました。
1. デコードと Exif の向き
3000×1500 の JPEG の先頭に、Orientation=6(90 度回転して表示する)の Exif を手で差し込んだファイルを作りました。
// Orientation=6 の APP1(Exif)セグメントを、JPEG の SOI(FF D8)の直後に差し込む
const app1 = new Uint8Array([
0xFF, 0xE1, 0x00, 0x22, // APP1、長さ 34
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, // "Exif\0\0"
0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00, // TIFF ヘッダ(リトルエンディアン)
0x01, 0x00, // IFD のエントリ数 1
0x12, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, // Orientation = 6
0x00, 0x00, 0x00, 0x00, // 次の IFD なし
]);
const merged = new Uint8Array(jpeg.length + app1.length);
merged.set(jpeg.subarray(0, 2), 0);
merged.set(app1, 2);
merged.set(jpeg.subarray(2), 2 + app1.length);
このファイルを createImageBitmap に渡した結果です。
| 呼び出し | 幅×高さ |
|---|---|
| オプションなし | 1500×3000 |
imageOrientation: "from-image" |
1500×3000 |
imageOrientation: "none" |
1500×3000 |
imageOrientation: "flipY" |
1500×3000 |
<img> の naturalWidth / naturalHeight
|
1500×3000 |
全部、Exif を反映した向き(縦長)でした。MDN には none は Exif を無視すると書かれていますが、この Chromium 148 では none でも Exif が反映されました。HTML 仕様には「以前あった none は from-image に改名された。将来、別の意味で none を戻す」という注記があります。この経緯があるので、コードでは "from-image" を明示しています。
この Exif 付きファイルを prepareImageForUpload に通すと、出力は 1024×2048 になりました。元の左上に置いた白いマーカーは右上に移動しています。ピクセル (10, 10) は青系の (74, 144, 216)、右上端の近くは白 (255, 255, 255) でした。回転は画素に反映済みです。
createImageBitmap がない環境向けの Image を使う経路は、試していません。
2. 縮小と JPEG 化
scale = min(1, 2048 / 長辺) を、3000×3002 に当てはめると約 0.682 で、幅は Math.round で 2047、高さは 2048 になります。縦横比は完全には保たれず、幅の丸め分だけずれます。
透過 PNG は白で塗る
3000×1500 の透過 PNG(円を 1 つ描いたもの、108,202 バイト)で、左上のピクセルを読みました。
| 白で塗る | 左上のピクセル(RGB) |
|---|---|
| 塗る(コードどおり) | (255, 255, 255) |
| 塗らない | (0, 0, 0) |
塗らないと、透明な部分は JPEG で黒になります。
Exif は出力に残らない
ファイルの先頭 64KB に Exif\0\0 が含まれるかを調べました。入力の JPEG にはあり、canvas から toBlob した出力にはありませんでした。撮影日時や位置情報などのメタデータは、再エンコードで送られなくなります。ただし、この実験で確認したのは Exif ヘッダの有無だけです。
3. 何もしない条件と、元ファイルに戻す条件
- 縮小が不要で、JPEG で、1.2MB 以下のとき、元ファイルをそのまま使います。800×400・3,304 バイトの JPEG で試すと、戻り値は元ファイルの data URL と完全に一致しました
- 縮小なしで再エンコードしたのに元より大きくなったとき、デコードや描画で例外が出たときも、元ファイルに戻します。この 2 つの分岐は、コードを読んだだけで、実行して確かめていません
4. アップロードのタイムアウトとリトライ
const UPLOAD_MAX_ATTEMPTS = 3;
const UPLOAD_TIMEOUT_MS = 45_000;
/**
* 画像をアップロードする。モバイル回線ではタイムアウトや切断が起きやすいので、
* タイムアウトと最大 3 回のリトライ(待ち時間は 1 秒、2 秒)を付けている。
* リトライするのはネットワークエラー、HTTP 5xx、業務コード 500 のみ。
* 4xx やそれ以外の業務エラーは再送せず、そのまま呼び出し側に返す。
* (抜粋・簡略化。URL と送信フィールドの一部は省略)
*/
export async function uploadImage(base64Data: string): Promise<ApiResponse<{ url: string }>> {
let lastError: unknown = null;
for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
try {
const res = await fetch(`${API_BASE}/upload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageBase64: base64Data }),
signal: controller.signal,
});
if (res.status >= 500 && attempt < UPLOAD_MAX_ATTEMPTS) {
lastError = new Error(`HTTP ${res.status}`);
} else {
const json = (await res.json()) as ApiResponse<{ url: string }>;
if (json.code === 500 && attempt < UPLOAD_MAX_ATTEMPTS) {
lastError = new Error(json.message || "upload_server_error");
} else {
return json;
}
}
} catch (err) {
lastError = err;
if (attempt === UPLOAD_MAX_ATTEMPTS) throw err;
} finally {
clearTimeout(timer);
}
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
}
throw lastError instanceof Error ? lastError : new Error("upload_failed");
}
fetch を差し替えた状態で、同じロジックを動かした結果です。タイムアウトだけ、テストのため 45 秒から 300ms に縮めています。
| シナリオ | 試行回数 | 結果 |
|---|---|---|
| HTTP 503、503、200 | 3 回(0 秒、約 1.0 秒、約 3.0 秒) | 3 回目で成功 |
| HTTP 400 + JSON | 1 回 | 再送せず、その JSON を返す |
| ネットワークエラー ×3 | 3 回(0 秒、約 1.0 秒、約 3.0 秒) | 最後のエラー(TypeError)を throw |
| HTTP 200 + 業務コード 500、次は成功 | 2 回 | 1 秒待って再送し、成功 |
| 応答なし(テスト用 300ms でタイムアウト)、次は成功 | 2 回(0 秒、約 1.3 秒) | abort の後に 1 秒待って再送し、成功 |
HTTP 503 ×3(本文は {}) |
3 回 | 3 回目は throw せず、本文の JSON を返す |
最後の行は注意が必要です。最後の試行が 5xx のとき、この実装は例外を投げず、レスポンス本文をそのまま返します(本文が JSON でなければ res.json() が例外になります)。呼び出し側で uploadRes.code !== 200 を確認しているので、このアプリでは失敗として扱われます。
限界と注意
- テストは Chromium 148 だけです。Safari、Firefox、iOS・Android の実機、HEIC は試していません
- 測定に使った画像は自撮りではなく、公有領域の風景写真 1 枚です。サイズの削減率は、写真の内容で変わります
- JPEG の再エンコードなので、画質は落ちます。品質 0.88 と長辺 2048px は固定値です
- リトライは同じ画像の再送です。サーバー側で重複を除いているかは、クライアントからは分かりません
- 処理時間は 1 回の計測で、端末ごとの比較はしていません
おわりに
写真を縮小してから base64 で送る処理を、コードと実測値で整理しました。ブラウザ側の画像処理の参考になれば幸いです。この処理は、写真から AI でパーソナルカラーなどを診断する Web アプリ Belle AI の試用ページで使っています。
この記事の下書きには AI を使っています。記載した数値と挙動は、手元のブラウザでコードを動かして確認したものです。