アーティスト・クリエイターの廣川政樹です。累計5,000万ダウンロードの壁紙アプリを個人で運営していますが、いちばん地味に効いてくるコストは「端末解像度が毎年少しずつ増えること」でした。iPhone Air と 17 Pro が加わった更新のとき、配信側のログを見て手が止まりました。同じ1枚の原画を、横幅が 1080px の端末にも 1320px の端末にも、まったく同じ巨大な PNG のまま配っていたのです。
壁紙は1枚あたりの画素数が大きく、しかもユーザーは何十枚もスワイプして眺めます。原画をそのまま返す設計だと、表示には使われない余白の画素まで毎回転送することになります。端末が3〜4種類だった頃は誤差でしたが、対応解像度が二桁に乗った瞬間、転送量とサムネイル表示の体感速度がじわじわ悪化していました。この記事は、その配信層を解像度バケット方式へ組み直し、面倒な変換と検証を Antigravity のエージェントに任せるまでの運用記録です。
端末解像度がばらけると、配信は静かに破綻する
最初に正直に書いておきますと、破綻は派手なクラッシュとして現れません。レビューに「重い」と書かれるわけでもなく、Crashlytics にも出ません。代わりに、サムネイル一覧のスクロールがほんの少しカクつき、AdMob のインタースティシャルが表示される前のロード時間が延び、低速回線のユーザーが2枚目で離脱します。数字としては、壁紙詳細画面の平均表示時間が 0.9 秒から 1.4 秒へ伸びていました。
原因を分解すると単純でした。私のアプリは原画を 2160px 幅で持っていて、それを全端末に配っていました。横 1080px の端末では、受け取った画素のちょうど半分を捨てて表示していたことになります。捨てる画素のために、毎回およそ倍のバイト数を転送していたわけです。端末の種類が増えるほど「ちょうど合う1枚」と「実際に配っている1枚」の差は広がり、平均すると無駄が積み上がっていきます。
宮大工だった祖父は、寸法の合わない材をそのまま使うことをしませんでした。少し削れば収まる、ではなく、その場所のために木取りをするのが当たり前という人でした。画像配信もそれと同じで、端末ごとに「その画面のために用意した1枚」を返すべきだと、ログを眺めながら改めて思いました。問題は、手作業で全解像度ぶんのバリアントを作るのは現実的ではないという一点でした。
1枚の原画を全解像度へ配るのをやめる — バケット設計
端末ごとに専用の画像を持つと言っても、世の中の解像度を1px刻みで用意するのは無駄です。実機の横幅は 1080 / 1170 / 1206 / 1290 / 1320 のように離散的に分布していて、しかも壁紙は数px の差なら拡大縮小しても破綻しません。そこで、横幅を8段の「バケット」へ丸めることにしました。
// resolution-buckets.ts
// 端末の論理幅(px)を、配信する画像幅のバケットへ丸める。
// 上下の端末を1つのバケットに寄せ、変種数を抑えるのが狙い。
export const BUCKETS = [ 828 , 1080 , 1170 , 1242 , 1290 , 1320 , 1440 , 1620 ] as const ;
export function pickBucket ( deviceWidthPx : number ) : number {
// 端末幅以上で最も近いバケットを選ぶ(縮小はしても拡大はしない)。
for ( const w of BUCKETS ) {
if (w >= deviceWidthPx) return w;
}
return BUCKETS [ BUCKETS . length - 1 ];
}
ここで一つ判断があります。端末幅より「大きい側」の最も近いバケットを選ぶようにしました。小さい画像を引き伸ばすと壁紙はぼやけますが、少し大きい画像を縮小する分にはきれいに収まるからです。原画は依然 2160px で1枚だけ持ち、そこから各バケットの WebP と AVIF を派生させます。原本が1つなので、新しい端末が出てバケットを1段足したくなっても、再生成すれば追従できます。
バケットを8段に決めた根拠は、手元の DAU 上位 99% の端末をカバーするのに必要な刻みを実機分布から逆算したからです。9段目を足しても恩恵を受けるユーザーは 0.4% ほどで、変種が1段増えるとストレージと生成時間が無視できない比率で増えます。私は「上位99%を8段で」を基準線に置きました。
WebP / AVIF を併用したバリアント生成の実際
生成は Node の sharp で行っています。AVIF は同画質で WebP よりさらに小さくなりますが、生成が重く、ごく一部の古い端末では表示できません。そこで両方を出力し、配信側で端末の対応状況に応じて選ぶ構成にしました。
// generate-variants.mjs
import sharp from "sharp" ;
import { BUCKETS } from "./resolution-buckets.js" ;
// 1枚の原画(2160px幅)から、全バケットの WebP と AVIF を派生させる。
export async function buildVariants ( srcPath , outDir , id ) {
const src = sharp (srcPath);
const meta = await src. metadata ();
const aspect = meta.height / meta.width; // 縦長壁紙のアスペクト比を保持
const results = [];
for ( const w of BUCKETS ) {
const h = Math. round (w * aspect);
const base = src. clone (). resize (w, h, { fit: "cover" });
const webp = `${ outDir }/${ id }_${ w }.webp` ;
const avif = `${ outDir }/${ id }_${ w }.avif` ;
// WebP は q=80、AVIF は q=50 で見た目がほぼ等価になる(壁紙の実測値)。
await base. clone (). webp ({ quality: 80 , effort: 4 }). toFile (webp);
await base. clone (). avif ({ quality: 50 , effort: 4 }). toFile (avif);
results. push ({ width: w, webp, avif });
}
return results;
}
実測では、2160px の元 PNG が平均 3.1MB だったのに対し、1080 バケットの WebP は 280KB 前後、AVIF は 180KB 前後に収まりました。多くのユーザーが触れる中位バケットで、原画比およそ十数分の一です。AVIF を q=50 まで攻められたのは壁紙という題材ゆえで、文字や UI スクリーンショットならここまで圧縮すると破綻します。題材に合わせて品質パラメータを決めるのが肝心だと感じています。
注意点として、effort を上げると AVIF はさらに縮みますが、生成時間が跳ね上がります。6本のアプリで数千枚を一括変換する都合上、私は effort: 4 を上限にしました。ここは「縮め切る」ことより「夜間バッチが朝までに終わる」ことを優先した判断です。
エッジで端末に最適な1枚だけを返す
バリアントを用意しても、クライアントが正しい1枚を選べなければ意味がありません。以前のクライアントは固定 URL の原画を取りに来ていました。これをエッジで受け止め、端末幅と Accept ヘッダを見て最適なファイルへ内部リダイレクトする構成へ変えました。
変更前は、こうしてどの端末にも同じ原画を返していました。
// Before: 全端末に同一の原画を返す
export default {
async fetch ( req , env ) {
const id = new URL (req.url).pathname. split ( "/" ). pop ();
return env. ASSETS . fetch ( `https://assets/${ id }_original.png` );
} ,
} ;
変更後は、クエリで渡された端末幅をバケットへ丸め、Accept に image/avif があれば AVIF を、なければ WebP を返します。クライアントは画面幅を1つ付けるだけで済み、どのファイルが最適かを知る必要がありません。
// After: 端末幅と Accept から最適な1枚を選んで返す
import { pickBucket } from "./resolution-buckets.js" ;
export default {
async fetch ( req , env ) {
const url = new URL (req.url);
const id = url.pathname. split ( "/" ). pop ();
const dw = Number (url.searchParams. get ( "w" )) || 1080 ;
const bucket = pickBucket (dw);
const accept = req.headers. get ( "Accept" ) || "" ;
const ext = accept. includes ( "image/avif" ) ? "avif" : "webp" ;
const res = await env. ASSETS . fetch ( `https://assets/${ id }_${ bucket }.${ ext }` );
// バケット選択はキャッシュキーに含めるため Vary を明示する。
const out = new Response (res.body, res);
out.headers. set ( "Vary" , "Accept" );
out.headers. set ( "Cache-Control" , "public, max-age=31536000, immutable" );
return out;
} ,
} ;
Vary: Accept を付け忘れると、AVIF 対応端末向けに返した応答が非対応端末へキャッシュ配信され、画像が表示されない端末が混じります。これは実際に検証中にやらかしました。エッジキャッシュは強力ですが、出し分けの軸を明示しないと静かに事故ります。immutable を効かせられるのは、ファイル名に幅と拡張子を含めて内容とURLを1対1にしてあるからです。
変換と検証を Antigravity エージェントへ任せる境界線
ここまでの仕組みは一度組めば動きますが、運用では「新しい壁紙を追加する」「新端末に合わせてバケットを足す」が毎週のように発生します。原画を置いたら全バリアントを生成し、サイズと見た目を確認し、R2 へ上げ、配信マップを更新する——この定型作業を Antigravity のエージェントに渡しました。
私が任せたのは、原画の検出、buildVariants の実行、生成物のメタデータ収集、そして後述する検証ゲートの実行までです。逆に任せていないのは、新しい品質パラメータの決定と、検証で弾かれたバリアントの最終判断です。エージェントには「迷ったら止めて、判断材料だけ並べる」ように指示してあります。AdMob の収益に直結する画面ですから、見た目が崩れた壁紙を無言で本番へ流すリスクは負えません。
エージェントへの依頼は、おおむね次のような構造のプロンプトにしています。
役割: 壁紙バリアント生成オペレーター
入力: _incoming/ に置かれた原画(2160px幅 PNG)
手順:
1. 原画を1枚ずつ buildVariants で全8バケット × WebP/AVIF に変換
2. validate-variants.mjs を実行し、合否を JSON で集計
3. 合格分のみ R2 の wallpapers/ へ put、配信マップ delivery-map.json を更新
出力: 1通の Markdown レポート(合格枚数 / 要確認 / 所要時間)
制約:
- 検証で1件でも fail があれば R2 への put を保留し、人間の確認を待つ
- 品質パラメータ(q値・effort)は変更しない。変更が必要なら提案だけ書く
このプロンプト設計で大事なのは、エージェントの裁量を「作業」に限り、「基準を動かすこと」を渡さない点です。基準まで任せると、ある朝こっそり品質が下がっていた、という事故が起きます。境界線を最初に文章で固定しておくと、エージェントの出力が安定しました。
生成物を信用しないための検証ゲート
エージェントが生成したバリアントは、必ず機械の検証を通してから R2 へ上げます。壁紙でいちばん怖いのはアスペクト比の崩れで、fit: "cover" の指定ミスや原画の取り違えが起きると、人物の顔が切れたり、左右が反転したような構図破綻が起きます。これは目視では数千枚をさばけないので、ルール化しました。
// validate-variants.mjs
import sharp from "sharp" ;
// 各バリアントが本番配信に耐えるかを機械判定する。
export async function validate ( variant , expectAspect ) {
const fails = [];
const meta = await sharp (variant.path). metadata ();
// 1) アスペクト比: 原画から ±1% を超えてずれたら不合格
const aspect = meta.height / meta.width;
if (Math. abs (aspect - expectAspect) / expectAspect > 0.01 ) {
fails. push ( `aspect ${ aspect . toFixed ( 3 ) } != ${ expectAspect . toFixed ( 3 ) }` );
}
// 2) ファイルサイズ: 想定上限を超える(=圧縮失敗)を弾く
const cap = variant.width <= 1080 ? 400_000 : 900_000 ; // bytes
if (meta.size > cap) fails. push ( `size ${ meta . size } > ${ cap }` );
// 3) 真っ黒・真っ白: 変換事故の典型。平均輝度の極端値を弾く
const stats = await sharp (variant.path). stats ();
const mean = stats.channels[ 0 ].mean;
if (mean < 4 || mean > 251 ) fails. push ( `mean luminance ${ mean . toFixed ( 1 ) }` );
return { ok: fails. length === 0 , fails };
}
この3つのルールは、すべて過去に一度ずつ実際にやらかした事故から逆算しました。アスペクト比のずれは原画の差し替えミスで、ファイルサイズ超過は AVIF の effort を下げすぎた回で、平均輝度の異常は変換途中で破損した1枚を掴んだ回です。検証ゲートは「起きうる事故」ではなく「起きた事故」を1件ずつ条文にしていくのが、結局いちばん効きます。最初から完璧なルールを書こうとせず、痛い目を見るたびに1行足す運用にしています。
エージェントはこの validate を全バリアントに走らせ、1件でも ok: false があればその壁紙群の公開を保留し、レポートに理由を並べます。私は朝にそのレポートだけを見て、保留分を承認するか原画を直すかを決めます。判断に要する時間は壁紙100枚あたり数分まで縮みました。
ストレージと転送量をどう見積もったか
バリアント方式は転送量を減らす代わりに、ストレージの保有量を増やします。原画1枚に対して 8バケット × 2フォーマット = 16ファイルが増えるからです。ここを見積もらずに始めると、R2 の保管料で足元をすくわれます。
手元の数字で言いますと、壁紙1枚あたりのバリアント合計は平均で約 4.8MB でした。6アプリ合計でおよそ 9,000 枚を持っているので、保管量はおよそ 43GB です。R2 の保管料は転送料が無料という性質があるため、月額の保管コストは数ドル規模に収まりました。一方で転送量は、原画を配っていた頃と比べて約42%削減できました。中位バケットへ寄せた効果と、AVIF 対応端末で更に縮んだ効果の合算です。
この「保管は増えるが転送は減る」のトレードを、個人開発の規模で割に合うと判断したのは、壁紙アプリが転送主体のワークロードだからです。1ユーザーが何十枚もダウンロードするため、転送1回あたりを軽くする投資が効きやすいのです。逆に、画像をほとんど閲覧されないアプリなら、ここまでやる価値は薄いと考えています。自分のワークロードがどちらに寄っているかを先に測るのが、遠回りに見えて近道でした。
クライアントは画面幅を1つ渡すだけにする
配信側を賢くした分、クライアントは徹底的に薄くしました。端末がやることは、論理ピクセル幅を1つ計算して URL のクエリに付けるだけです。iOS なら UIScreen.main.bounds.width に scale を掛けた物理ピクセル幅を、Android なら DisplayMetrics の widthPixels を渡します。
// iOS: 物理ピクセル幅を1つ付けるだけ。どのファイルが最適かは知らなくてよい。
let scale = UIScreen.main.scale // 2.0 や 3.0
let widthPx = Int (UIScreen.main.bounds.width * scale)
let url = "https://api.dolice.asia/wp/ \( id ) ?w= \( widthPx ) "
ここで一度ハマったのが、論理幅と物理幅の取り違えです。最初は bounds.width(論理幅、iPhone なら 393 など)をそのまま渡してしまい、配信側が常に最小バケットを返していました。画面にはぼやけた壁紙が並び、原因にたどり着くまで半日かかりました。scale を掛け忘れただけの、よくある凡ミスです。クライアントを薄くすると、こうした取り違えが起きてもエッジ側のログだけで再現できるので、原因の切り分けは早くなりました。
Android では端末密度の分割(density split)と組み合わさるため、APK に同梱する画像とサーバー配信の壁紙を混同しないよう、サーバー経由の壁紙には必ずこの w クエリを通す規約にしています。アプリ内蔵リソースは端末密度で OS が選び、ダウンロード壁紙はエッジで幅から選ぶ——配信の経路を2つに分けて考えると、頭の中が整理できました。
原画を差し替えたときに、古い1枚を確実に捨てる
immutable を効かせている以上、同じ URL のまま中身を差し替えることはできません。これは利点でもあり、落とし穴でもあります。壁紙の原画を修正して再生成したのに、エッジと端末が古いバリアントを握り続け、修正が反映されない、という事態が起きます。
解決策はシンプルで、ファイル名にコンテンツのハッシュを1つ織り込み、原画が変われば URL ごと変えるようにしました。
// 原画の内容ハッシュ先頭8桁をファイル名に混ぜ、差し替え=URL変更にする。
import { createHash } from "node:crypto" ;
function variantName ( id , width , ext , srcBuffer ) {
const h = createHash ( "sha256" ). update (srcBuffer). digest ( "hex" ). slice ( 0 , 8 );
return `${ id }_${ width }_${ h }.${ ext }` ;
}
配信マップ delivery-map.json は、壁紙 ID から「現在有効なハッシュ」を引けるようにしておきます。クライアントは ID で配信マップを取りに行き、返ってきたハッシュ付き URL で実体を取得します。原画を直して再生成するとハッシュが変わり、配信マップが新しい URL を指すので、古いバリアントは自然に参照されなくなります。エッジキャッシュを手で消す作業から解放されたのは、運用負荷の面で想像以上に大きかったです。
この仕組みを入れる前は、壁紙を1枚直すたびにキャッシュパージの手順を踏んでいました。個人開発では、その「毎回の小さな手順」がいちばん続かない部分です。URL を内容に紐づけてしまえば、差し替えは再生成して配信マップを更新するだけになり、手順が1つ消えます。私はこの「手順を消す」方向の改善を、機能を足す改善より優先するようにしています。
失敗から決めた、運用の3つのルール
最後に、この配信層を半年ほど回して固まったルールを残します。どれも一度つまずいた末に明文化したものです。
1つ目は、原画は常に1枚だけを正とし、バリアントは派生物として扱うことです。バリアントを直接手で差し替え始めると、原画と配信物の整合が崩れ、どれが本物か分からなくなります。修正は必ず原画に対して行い、再生成で配信物を作り直します。
2つ目は、配信マップとファイルの整合をエージェントの最後の仕事にすることです。delivery-map.json に載っているのに R2 に無い、あるいはその逆、という不整合は実際に表示崩れを起こします。エージェントには put 完了後にマップとストレージの突き合わせまでやらせ、差分があれば公開を止めるようにしました。
3つ目は、品質パラメータの変更だけは人間が握り続けることです。q値や effort を動かすと、何千枚の見た目が一斉に変わります。ここを自動化の対象に含めると、ある日サイト全体の壁紙が少し眠くなっていた、という静かな劣化を招きます。作業はエージェントへ、基準は手元へ。この線引きが、個人開発で AI に運用を任せるときの私なりの安全装置になっています。
同じように画像主体のアプリを個人で回している方の、配信層を見直すきっかけになれば嬉しいです。