10/3

2026

App Store のスクショと記事の画像を、スクリプトで作る — simctl で撮って AppKit で仕上げる

#App Store#スクリーンショット#Swift#AppKit#simctl#Python#Pillow#iOSApp Store のスクショと記事の画像を、スクリプトで作る — simctl で撮って AppKit で仕上げる

App Store のスクショと記事の画像を、スクリプトで作る

iPhone の集中アプリ「DODO」の App Store 用スクリーンショットを、デザインツールを開かずに作った。シミュレータで撮るところから、端末の枠にはめて見出しを付けるところまで、全部スクリプトにしてある。4 枚 × 4 言語(日本語・英語・ドイツ語・フランス語)の 16 枚が、コマンド1つで作り直せる。

ついでに、このブログのサムネイルも Python の小さなスクリプトで作るようにした。その2つの作り方を書く。

できあがった App Store のスクリーンショット(日本語)


全体の流れ

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-data4 週間ぶんの架空の記録を入れる
--demo-inboxAI から届いたやることを、サジェストに並べる
--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

Cloud Functions の記事のサムネイルも同じスクリプトの形で作った

やっていることは単純だ。

  • 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 に「もう少し上に」と頼みながら詰めていけた。