App Store のスクショと記事の画像を、スクリプトで作る
iPhone の集中アプリ「DODO」の App Store 用スクリーンショットを、デザインツールを開かずに作った。シミュレータで撮るところから、端末の枠にはめて見出しを付けるところまで、全部スクリプトにしてある。4 枚 × 4 言語(日本語・英語・ドイツ語・フランス語)の 16 枚が、コマンド1つで作り直せる。
ついでに、このブログのサムネイルも Python の小さなスクリプトで作るようにした。その2つの作り方を書く。

全体の流れ
scripts/app_store_screenshots.sh
├─ xcodegen generate + xcodebuild(シミュレータ向けにビルド)
├─ scripts/screenshots.sh … simctl で 4 言語 × 3 画面を撮る
├─ scripts/frame_screenshots.swift … AppKit で背景・見出し・端末の枠を合成
└─ (--upload なら)App Store Connect API で上げる
撮るのはシェル、仕上げは Swift のスクリプトに分けている。撮り直さずに、見出しの文言や配置だけを何度も作り直せるからだ。
1. simctl で撮る
起動引数で「撮りたい画面」を開く
アプリには、開発ビルドだけで効く起動引数をいくつか用意してある。これで、手でタップしなくても撮りたい状態を一発で作れる。
| 起動引数 | やること |
|---|---|
--demo-data | 4 週間ぶんの架空の記録を入れる |
--demo-inbox | AI から届いたやることを、サジェストに並べる |
--demo-title | 入力欄に例の文字を入れる(炎の出た Do ボタンを撮るため) |
--demo-webhook | 設定画面の Webhook の画面を、Slack の例で開く(保存はしない) |
--no-keyboard | キーボードを出さない |
--demo-reset | 前の言語で入れた記録を消す |
--screenshot | 通知の許可のダイアログを出さない |
--tab do|ketsui|timeline|settings | 開くタブ |
言語は -AppleLanguages (ja) と -AppleLocale ja を付けて起動すれば切り替わる。ステータスバーは simctl status_bar で 9:41・電波満タン・充電済みにそろえる。
xcrun simctl status_bar $U override --time "9:41" --dataNetwork wifi --wifiMode active --wifiBars 3 \
--cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100
# shot <言語> <ファイル名> <待つ秒数> <起動引数…>
shot() {
local lang=$1 name=$2 wait=$3; shift 3
xcrun simctl terminate $U $APP 2>/dev/null || true
xcrun simctl launch $U $APP --premium --screenshot -AppleLanguages "($lang)" -AppleLocale $lang "$@" >/dev/null
sleep $wait
xcrun simctl io $U screenshot "$OUT/$lang/$name.png" >/dev/null 2>&1
}
for lang in ja en de fr; do
shot $lang 02_input 5 --demo-reset --demo-data --demo-inbox --demo-title --tab do
shot $lang 03_pushin 4 --demo-reset --demo-data --demo-inbox --tab do --no-keyboard
shot $lang 04_webhook 4 --demo-webhook --tab settings
done
--premium で広告も消している。
ハマった所:通知の許可のダイアログが写る
最初に撮ったら、3 枚に「通知を送信します。よろしいですか?」のダイアログが写っていた。言語ごとにアプリを入れ直していたので、そのたびに通知の許可が消えて、起動のたびに聞かれていた。
⚠️ simctl privacy で許可を付けられるのは、カレンダーや位置情報などで、通知は含まれない。
結局、次のようにした。
- アプリは最初に1回だけ入れて、通知は手で一度「許可」しておく。
- 言語ごとの記録は、入れ直すのではなく
--demo-resetで消してから入れ直す。
2. AppKit で仕上げる
撮った素のスクショを、NSBitmapImageRep の上に描いていく。出力は 6.9 インチの 1320 × 2868。

文言は言語ごとに表で持つ
let copies: [String: Copy] = [
"ja": Copy(headline: "**ADHD**と**INFP**\n__のための__\n行動集中アプリ", sub: "",
slides: [("今やることだけに\n全集中!", "目標時間内の完了を目指そう!", nil),
("MCP でやることリストを\n取り込む!", "外部からやることリストを取り込める", "プレミアムで利用できます"),
("行動開始と終了で\nWebHook!", "Slack や Discord などに通知ができる!", nil)]),
"en": Copy(headline: "An action &\nfocus app __for__\n**ADHD** & **INFP**", ...),
...
]
1 枚目の見出しは、** で囲んだ所を太く青く、__ で囲んだ所を小さくする、小さな書式にした。「ADHD と INFP」を目立たせて、「のための」は小さくしてメリハリを付けている。
部品は小さな関数に分ける
gradient/glow:背景のグラデーションと、ぼんやりした光の円。text:中央ぞろえの文字。幅に入らなければ、文字を小さくしていく。richText:**と__を解釈する、左寄せの見出し。device:黒いベゼルと Dynamic Island を描いて、その中にスクショをはめる。save:App Store Connect はアルファ付きの PNG を受け付けないので、不透明な RGB にして書き出す。
func device(_ cg: CGContext, _ shot: NSImage, x: CGFloat, y: CGFloat, width: CGFloat) {
let bezel = width * 0.024
let screenW = width - bezel * 2
let screenH = screenW * H / W
let outer = CGRect(x: x, y: y, width: width, height: screenH + bezel * 2)
let screen = outer.insetBy(dx: bezel, dy: bezel)
// 影つきの黒い本体 → 画面を角丸で切り抜いてスクショを描く → Dynamic Island
...
}
端末は下にずらして大きく描き、下の端は画像からはみ出させている。全体が見えるより、画面が大きく見えるほうがスクショとして強い。
ハマった所:日本語が中国語のフォントで描かれる
NSFont.systemFont で描くと、英字は SF だけど、日本語は PingFang(中国語のフォント) で描かれていた。「直」や「画」の形が、日本語のものと微妙に違う。
フォントの「カスケードリスト」に、ヒラギノ角ゴを入れて解決した。英字は SF、日本語はヒラギノになる。
/// 英字は SF、日本語はヒラギノ角ゴ(指定しないと Mac は中国語のフォント PingFang で日本語を描くため)。
func uiFont(_ size: CGFloat, _ weight: NSFont.Weight) -> NSFont {
let w: Int = switch weight {
case .black: 9
case .heavy: 8
case .bold: 7
case .semibold: 6
case .medium: 5
default: 4
}
let base = NSFont.systemFont(ofSize: size, weight: weight)
let jp = NSFontDescriptor(name: "HiraginoSans-W\(w)", size: size)
return NSFont(descriptor: base.fontDescriptor.addingAttributes([.cascadeList: [jp]]), size: size) ?? base
}
ハマった所:絵の白い地が見出しを隠す
1 枚目は、真っ白な地の真ん中にアイコンの炎を大きく置いている。最初はアイコンの画像をそのまま使っていたので、炎を見出しに近づけると、アイコンの白い四角が見出しの下の端を隠していた。
一度は「絵を描いてから見出しをもう一度上に描く」でしのいだ。最終的には、アイコンから白い地を抜いた透過 PNG を作って、それを使うようにした。白い地は、角から塗りつぶすように「白に近い色がつながっている所」を探して、透明にしている。
3. App Store Connect に上げる
--upload を付けると、App Store Connect API で上げる。同じサイズの古い画像は置き換えるので、何度でも上げ直せる。API のキーは、環境変数で渡してリポジトリには入れない。
4. ブログのサムネイルは Python で
この記事のサムネイルも、スクリプトで作っている。こちらは Swift ではなく、Python の画像ライブラリ Pillow を使った。
python3 scripts/make_thumbnail.py app_store_screenshots_script \
--lines "App Store のスクショと" "*記事の画像を*" "スクリプトで作る" \
--tag "Swift / AppKit / simctl / Pillow" --art DodoMark.png
![]()
やっていることは単純だ。
- 1200 × 630 の水色のグラデーションを、1 行ずつ線を引いて作る。
- 右に、透過の絵(DODO の炎)を置く。
- 左に文字を並べる。
*で囲んだ行は大きく青くする。 - 文字が絵にかかるなら、かからない大きさまで縮める。
- 日本語はここでもヒラギノ角ゴ W8 を、ファイルのパスで直接指定する。
for line in a.lines:
strong = line.startswith('*') and line.endswith('*')
line = line.strip('*')
size = 64 if strong else 50
font = ImageFont.truetype(FONT, size)
while d.textlength(line, font=font) > text_right - 70 and size > 28: # 絵にかからない大きさまで縮める
size -= 2
font = ImageFont.truetype(FONT, size)
d.text((70, y), line, font=font, fill=ACCENT if strong else INK)
y += int(size * 1.35)
最初に作ったときは、題の長い行が炎に少しかかっていた。それで「かからない大きさまで縮める」を足した。
なぜスクリプトにするのか
- 画面を変えても、すぐ作り直せる。今回も、タブの名前やボタンのデザインを何度も変えた。そのたびにデザインツールで 16 枚を直すのは無理だった。
- 言語を増やしても手間が増えない。文言の表に1行足すだけ。
- 見た目がそろう。サムネイルも同じスクリプトなので、記事ごとにバラバラにならない。
デザインの細かい調整(炎の位置、見出しの大きさ)も、数字を変えて作り直すだけなので、AI に「もう少し上に」と頼みながら詰めていけた。