ある朝、App Store Connect を開いたら、5年運用している壁紙アプリの英語版スクリーンショットだけが、半年前の古い UI のままになっていることに気づきました。日本語版だけは新しい iOS 26 のデザインに差し替えていたのですが、英語版に手が回らないまま、いつの間にかリリースから 4 バージョン進んでしまっていたのです。Apple Search Ads でその英語版に出稿していたので、CTR が前月比で 18% ほど落ちているのも当然でした。
複数のアプリを個人で並行運用していると、ほぼ確実に起きるドリフトだと思います。コードのほうは CI が壊れれば気づけますが、ストアの掲載物は「古いまま」でも何も鳴りません。Antigravity の Background Agent を運用監視に使い始めた流れで、この多言語スクリーンショットの更新も任せてみようと決めてから 5 週間。今回の記事は、その間に何を任せて、何を任せなかったかの所感です。
なぜ「Background Agent に丸ごと」ではなく「下処理だけ」にしたか
最初の週に、つい欲が出て「スクリーンショットの選定から多言語化、Connect への差し替えまで全部任せる」というジョブを書いたのですが、これは早々にやめました。理由は単純で、デザインの判断(どのカットを 1 枚目にするか、コピーの語感を英語と中国語でどこまで揃えるか)は、まだエージェントに丸投げできる成熟度ではないと感じたためです。
判断の質が落ちる領域と、単に手数が多いだけの領域。この 2 つは混ざりやすいのですが、混ぜたまま自動化すると、後者の速さで前者の失敗が量産されます。
個人開発では、レビューする人が自分しかいません。私自身、事故を見つける役も自分だと考えると、エージェントには「速く終わらせること」よりも「私が確認すべき箇所を減らさないこと」を期待するようになりました。
5 週間運用してみて落ち着いた境界線は、こうなりました。
- 下処理(端末別の解像度書き出し、テキスト差し込みのレイヤー位置調整、各言語の文字数オーバー検出)は Background Agent
- 1 枚目のキービジュアル選定、コピーの語感、最終承認は人間
この役割分担にしてから、私が触る時間は週末の 90 分くらいに圧縮できました。どこで線を引くかという話は、AIに任せた、そして後悔した — 個人開発者が気づいたエージェント委任の境界線にも書いていますが、今回はその線が「レンダリング前」に落ちた事例です。
動かしているジョブの骨格
Background Agent に渡している定義は、概ね次の形に落ち着いています。説明のため要点だけ抜き出していますが、実物もこれに近い構成です。
# antigravity/agents/appstore-screenshots-weekly.yaml
name: appstore-screenshots-weekly
schedule: "0 4 * * SUN" # 日曜 04:00 JST
inputs:
apps:
- { bundle_id: jp.dolice.wallpaper.zen, locales: [ja, en, zh-Hans, zh-Hant, ko, es-MX, fr, de] }
- { bundle_id: jp.dolice.wallpaper.dark, locales: [ja, en, zh-Hans, zh-Hant, ko, es-MX, fr, de] }
- { bundle_id: jp.dolice.wallpaper.mind, locales: [ja, en, zh-Hans, zh-Hant, ko] }
- { bundle_id: jp.dolice.wallpaper.kid, locales: [ja, en, zh-Hans, ko] }
tools:
- fs.read_master_psd
- text.translate # 私が用意した用語集を参照
- validate.caption_width # 第3週に追加(後述)
- image.render_screenshot # 端末枠込みで PNG 書き出し
- asc.list_localizations
- notify.slack
steps:
- id: detect_master_diff
run: fs.read_master_psd($app.master) -> compare(prev_snapshot)
- id: skip_if_no_change
when: detect_master_diff.changed == false
run: notify.slack("$app: no master change, skipping")
- id: regenerate_per_locale
foreach: $app.locales
run: |
text.translate(source=$app.master.copy, target=$locale, glossary=$app.glossary)
-> validate.caption_width(locale=$locale, devices=$app.devices)
-> image.render_screenshot(device=[iPhone-6.9, iPhone-6.5, iPad-13])
- id: report
run: notify.slack(diff_summary + warnings)要は「マスター PSD が変わった週だけ動く」「最終的に Connect へ流すかは Slack でレビューしてから人間が判断する」という構成です。asc.list_localizations だけは差し替え前のロケール一覧を取得するために置いていて、書き戻し(asc.upload_screenshot)は外してあります。書き戻しを自動化しなかった理由は次の章に書きます。
自動アップロードを外した3つの理由
最初の週は書き戻しまで自動化していました。しかし第 2 週で、英語のキャッチコピーの語尾が中途半端なまま反映され、レビュー前のメタデータ画面で気づくという小さな事故が起きました。そこから書き戻しを止めて、次の 3 つを理由として整理しました。
- 語感は最終的に人間の責任が残る領域。1 行のコピーの切れ味は、翻訳の正しさとは別の軸で決まります。用語集で担保できるのは正しさまでで、そこから先はまだ任せられる段階にないと感じています
- Connect への書き戻しは取り消しのコストが高い。スクリーンショットを上書きしたあとで前のバージョンに戻すには手動アップロードが必要で、夜間ジョブの失敗を朝に直すには重い作業になる
- Apple Search Ads とのタイミング調整。新しいスクリーンショットに切り替えるタイミングで、対応するキーワード入札も微調整したいときがあり、両方を人間がレビューしながら 1 セットで切り替えるほうがアトリビューションが追いやすい
5週間でぶつかった具体的な壁
数字を 3 つだけ書いておきます。アプリ 4 本 × ロケール最大 8 = 32 セットを 5 週間で 5 回まわしました。延べ 160 セットの生成のうち、そのまま採用できたのは 121 セットで、人間の手直しが入ったのが 39 セットです。手直しの内訳はおおむね次の比率でした。
| 手直しの内容 | 件数 | 自動化で吸収できたか |
|---|---|---|
| 中国語簡体・繁体の文字数オーバー(縦長デバイスで折り返しが汚くなる) | 18 | できた(第3週にバリデーション追加) |
| 韓国語のフォント高さがズレてキービジュアルにかぶる | 9 | 部分的(検知はできるが判断は手作業) |
| スペイン語の語尾調整(メキシコ向け / スペイン向けの口語差) | 8 | できなかった |
| 日本語マスターの行間がレンダラに反映されない | 4 | できた(マスター側の設定ミス) |
件数の多い順に潰していったのですが、いちばん効いたのは 1 行目でした。次の章にその中身を書きます。
第3週に足した文字幅バリデーション
「中国語の文字数オーバー」は、文字数で判定しようとすると必ず外します。同じ 12 文字でも簡体字と英字では描画幅が倍ほど違いますし、同じロケールでも端末フレームの幅が変われば結果が変わるためです。そこで、翻訳の直後・レンダリングの前に、実際のフォントで描画幅を測るステップを挟みました。
# antigravity/tools/validate_caption_width.py
from PIL import ImageFont
# 端末フレーム内でコピーを収めたい実効幅(px)
# マスター PSD のテキストボックス幅を実測した値
FRAME_TEXT_WIDTH = {
"iPhone-6.9": 1080,
"iPhone-6.5": 1010,
"iPad-13": 1520,
}
# レンダラが実際に使うフォントと同じものを指定する(ここがずれると測定の意味がない)
LOCALE_FONT = {
"ja": ("NotoSansJP-Bold.otf", 64),
"zh-Hans": ("NotoSansSC-Bold.otf", 64),
"zh-Hant": ("NotoSansTC-Bold.otf", 64),
"ko": ("NotoSansKR-Bold.otf", 60),
"en": ("Inter-Bold.ttf", 64),
"es-MX": ("Inter-Bold.ttf", 64),
"fr": ("Inter-Bold.ttf", 64),
"de": ("Inter-Bold.ttf", 64),
}
# レンダラと実機のヒンティング差を吸収するための余白。
# 0.98 だと実機で 3 件すり抜けたため 0.94 に下げた
TOLERANCE = 0.94
def measure_width(text: str, locale: str) -> float:
"""指定ロケールのフォントで描画したときの実幅を px で返す"""
font_name, size = LOCALE_FONT[locale]
font = ImageFont.truetype(font_name, size)
left, _top, right, _bottom = font.getbbox(text)
return right - left
def validate_caption(text: str, locale: str, devices: list[str]) -> list[str]:
"""収まらない端末を警告文字列のリストで返す。空リストなら合格"""
warnings = []
for device in devices:
limit = FRAME_TEXT_WIDTH[device] * TOLERANCE
width = measure_width(text, locale)
if width > limit:
over = width / limit
warnings.append(
f"{locale}/{device}: {width:.0f}px > {limit:.0f}px ({over:.0%})"
)
return warnings
if __name__ == "__main__":
devices = ["iPhone-6.9", "iPhone-6.5", "iPad-13"]
print(validate_caption("每天换一张,让桌面安静下来", "zh-Hans", devices))
# => ['zh-Hans/iPhone-6.5: 1043px > 949px (110%)']なぜ文字数ではなく getbbox の実幅で測るかというと、判定したいのは「何文字か」ではなく「フレームからはみ出すか」だからです。当たり前のようですが、最初は文字数の上限をロケールごとに決める方式で書いていて、それが 18 件を取りこぼした原因でした。
TOLERANCE = 0.94 は経験値です。オフラインのレンダラで測った幅と、実機のスクリーンショット上での見え方には数 % のずれが出ます。最初は 0.98 で運用しましたが、それでも 3 件が実機で窮屈に見えたため 0.94 まで下げました。ここを厳しくすると翻訳のやり直しが増えるので、通す基準ではなく「人間に見せる基準」として扱っています。
このステップを入れた第 3 週以降の 2 週間で、中国語の文字数オーバーによる手直しは 18 件から 2 件に減りました。残った 2 件は、警告は出ていたのに私が「これくらいなら」と通してしまったもので、つまり検知側の取りこぼしではありませんでした。
韓国語のベースラインずれだけ手検査に残した理由
9 件あった韓国語の事故は、性質が違いました。文字がはみ出すのではなく、上下方向の位置がずれてキービジュアルにかぶるというものです。原因はフォントのメトリクスにあります。
from PIL import ImageFont
for name, size in [("NotoSansKR-Bold.otf", 60), ("Inter-Bold.ttf", 64)]:
font = ImageFont.truetype(name, size)
ascent, descent = font.getmetrics() # em ボックス上の上端・下端
top = font.getbbox("Ag한국어")[1] # 実際に描き始める y 座標
print(f"{name}: ascent={ascent} descent={descent} bbox_top={top}")
# NotoSansKR-Bold.otf: ascent=78 descent=21 bbox_top=13
# Inter-Bold.ttf: ascent=79 descent=21 bbox_top=17ascent はほぼ同じなのに bbox_top が 4px 違います。レンダラは em ボックスを基準に上下中央揃えをするため、視覚的な重心は言語ごとに上下へ振れます。1 行なら誤差ですが、2 行に折り返した瞬間に効いてきて、下の行がキービジュアルの輪郭に触れます。
検知だけなら bbox_top の差分に閾値を引けば書けます。実際に書いてもみました。ただ、閾値を超えたときに正解が「文字を上げる」なのか「キービジュアルを下げる」なのか「そもそも折り返させない」なのかは、その 1 枚の絵を見ないと決まりません。自動で寄せると、収まってはいるが窮屈な絵ができあがります。そこで、警告は出すが修正はしない、というルールにしました。
自動化の議論では「検知できる=自動化できる」と扱われがちですが、この 9 件は検知と修正の間に判断が挟まる典型でした。
レポーティングを Slack に絞ったことの効用
notify.slack 1 つに絞ったのは、通知先を増やすと「あとで見る」が積み上がるからです。週に 1 回しか動かないジョブの通知は、埋もれた時点で存在しないのと同じになります。
Background Agent には「やったことを長文で書かない、変更があった項目だけ箇条書きで送る」という指示を入れています。差分が空のときも「変更なし」とだけ送らせているのは、無音と正常を区別できなくするためです。通知が来ないことが「異常なし」なのか「ジョブが起動していない」なのか判別できない状態は、静かに壊れます。この観点はAntigravity サブエージェントの「動いているのに何もしていない」を捕まえる Observability 3層設計で詳しく扱っています。
レポートのフォーマットも何回か直しました。最終的に落ち着いたのは、こういう形です。
[Sun 04:18 JST] appstore-screenshots-weekly
- zen: ja=OK en=⚠️ コピー語尾 zh-Hans=⚠️ 字数 zh-Hant=OK ko=OK es-MX=OK fr=OK de=OK
- dark: ja=OK en=OK zh-Hans=OK zh-Hant=OK ko=⚠️ フォント高 es-MX=OK fr=OK de=OK
- mind: no master change, skipped
- kid: ja=OK en=OK zh-Hans=OK ko=OK
to_review_in_drive: 5 PNGs1 行 1 アプリ、ロケールを横に並べる形に落ち着いたのは、⚠️ の位置が縦に揃うと「どの言語が慢性的に弱いか」が数週間で見えてくるためです。実際、この形にして 2 週目で「zh-Hans と ko だけが常連」だと分かり、バリデーションを足す判断につながりました。
5週間を経ての結論
書き戻しまで自動化した第 1 週は速かったのですが事故が増え、書き戻しを外した第 2 週以降は、私の作業時間を 90 分に圧縮しつつ事故が消えました。第 3 週に文字幅バリデーションを足したことで、手直しは 39 件ペースから大きく下がりました。
振り返ると、効いた順序は「任せる範囲を狭める」→「狭めた範囲の中で検査を厚くする」でした。逆順に進めていたら、たぶん今も事故の後始末をしています。夜間にジョブを回す運用そのものの設計はAntigravity を個人開発の「夜のもう1人」として迎え入れる運用モデルに整理しています。
同じ仕組みを試すなら、まず validate_caption_width.py に相当する 30 行だけを、いま手作業でやっているワークフローの途中に差し込んでみるのがおすすめです。エージェントを組む前に「何を検査したいのか」が言語化できていないと、任せる範囲を決めようがないためです。私の場合、その 30 行を書いた時点で、この記事の境界線はほぼ決まっていました。
次に手をつけたいのは、Apple Search Ads のキーワード入札と、スクリーンショット差し替えのタイミングを 1 つの差分ビューにまとめる仕組みです。運用が進んだら、また所感を書こうと思います。