TL;DR

  • XCUITestでiPhone・iPadシミュレータのスクリーンショットを日英両言語で自動撮影
  • PythonのPillowでグラデーション背景+デバイスフレーム+テキストオーバーレイのマーケティング画像を生成
  • xcrun simctl io recordVideoでデモ動画も録画
  • App Store Connect APIで自動アップロード
  • すべてをシェルスクリプト1本で実行可能

はじめに

iOSアプリのApp Storeスクリーンショットは、iPhone 6.7インチ、iPad 12.9インチの各サイズを日英2言語分用意すると、それだけで12枚以上の画像を作る必要があります。

アプリを更新するたびに手作業でスクリーンショットを撮り直し、FigmaやPhotoshopでマーケティング画像を作り、App Store Connectに1枚ずつアップロードするのは手間がかかります。

本記事では、撮影 → 画像生成 → アップロードの全工程をコマンド一発で実行できるパイプラインの構築方法を解説します。

全体構成

capture_screenshots.sh
├── Step 1: シミュレータの準備(起動 + テスト画像追加)
├── Step 2: XCUITestでスクリーンショット撮影(JA/EN × iPhone/iPad)
├── Step 3: sipsでApple規定サイズにリサイズ
├── Step 4: Pillow でマーケティング画像を生成
├── Step 5: xcrun simctl io でデモ動画を録画
└── Step 6: App Store Connect APIでアップロード

Step 1: XCUITestでスクリーンショットを撮影する

テストコードの設計

UIテスト用のテストクラスを作成します。ポイントは以下の3つです。

  1. テスト画像の自動読み込み: TEST_IMAGE_PATH環境変数で画像パスを渡し、PHPickerを経由せずに画像を直接ロードします
  2. 言語切り替え: xcodebuild -testLanguageで設定された言語を-AppleLanguagesとしてアプリに渡します
  3. レビューダイアログの抑制: 起動引数で不要なダイアログを抑制します
final class ScreenshotTests: XCTestCase {

    private var app: XCUIApplication!
    private let screenshotDir = ProcessInfo.processInfo.environment["SCREENSHOT_DIR"]
        ?? "/tmp/myapp_screenshots"

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        // オンボーディングをスキップ、レビューダイアログを抑制
        app.launchArguments += ["-hasCompletedOnboarding", "YES"]

        // テスト言語をアプリの言語設定に反映
        let preferredLang = Locale.preferredLanguages.first ?? "ja"
        let langCode = preferredLang.components(separatedBy: "-").first ?? "ja"
        app.launchArguments += ["-AppleLanguages", "(\(langCode))", "-AppleLocale", langCode]

        // テスト用画像パスを環境変数で渡す
        app.launchEnvironment["TEST_IMAGE_PATH"] = "/path/to/test_sample.jpg"

        try FileManager.default.createDirectory(
            atPath: screenshotDir,
            withIntermediateDirectories: true
        )
    }

    func testCaptureScreenshots() throws {
        app.launch()

        // 処理完了を待機
        let backButton = app.buttons["back_button"]
        XCTAssertTrue(backButton.waitForExistence(timeout: 300))
        sleep(2)

        // メイン画面のスクリーンショット
        saveScreenshot(name: "04_result")

        // 他の画面に遷移してスクリーンショットを撮る
        // ...

        backButton.tap()
        sleep(1)
        saveScreenshot(name: "01_camera")
    }

    private func saveScreenshot(name: String) {
        let screenshot = app.windows.firstMatch.screenshot()
        let attachment = XCTAttachment(screenshot: screenshot)
        attachment.name = name
        attachment.lifetime = .keepAlways
        add(attachment)

        let path = "\(screenshotDir)/\(name).png"
        try? screenshot.pngRepresentation.write(to: URL(fileURLWithPath: path))
    }
}

saveScreenshotメソッドは、XCTestのXCTAttachmentとしてテスト結果に添付すると同時に、指定ディレクトリにPNGファイルとして保存します。ファイル名のプレフィックス(01_, 04_等)は、後のマーケティング画像生成で優先順位を制御するために使います。

デモ動画用テストメソッド

App Storeのプレビュー動画用に、ユーザー操作を模したテストメソッドも用意します。

func testDemoVideoFlow() throws {
    // テスト画像の自動読み込みを無効化(カメラ画面を見せるため)
    app.launchEnvironment["TEST_IMAGE_PATH"] = ""
    app.launch()
    sleep(3)  // カメラ画面を表示

    // フォトライブラリから画像を選択
    let photoButton = app.buttons["photo_library_button"]
    guard photoButton.waitForExistence(timeout: 5) else { return }
    photoButton.tap()
    sleep(2)

    // PHPickerで最初の画像をタップ
    let firstImage = app.scrollViews.images.firstMatch
    if firstImage.waitForExistence(timeout: 5) {
        firstImage.tap()
    }

    // 結果画面を表示、スクロール、戻る...
}

このテストメソッドの実行中にxcrun simctl io recordVideoで画面を録画すれば、そのままApp Storeプレビュー動画として使えます。

Step 2: シェルスクリプトからUIテストを実行する

シミュレータの準備

#!/bin/bash
set -euo pipefail

IPHONE_SIM="iPhone 16 Pro Max"
IPAD_SIM="iPad Pro 13-inch (M4)"
SCREENSHOT_DIR="/tmp/myapp_screenshots"

# シミュレータのUDIDを取得
IPHONE_UDID=$(xcrun simctl list devices available \
    | grep "$IPHONE_SIM" | head -1 | grep -oE '[A-F0-9-]{36}')
IPAD_UDID=$(xcrun simctl list devices available \
    | grep "$IPAD_SIM" | head -1 | grep -oE '[A-F0-9-]{36}')

# シミュレータを起動してテスト画像を追加
xcrun simctl boot "$IPHONE_UDID" 2>/dev/null || true
sleep 3
xcrun simctl addmedia "$IPHONE_UDID" "path/to/test_sample.jpg"

xcrun simctl addmediaでシミュレータのフォトライブラリにテスト画像を追加しておくと、デモ動画撮影時にPHPickerから画像を選択する操作も自動化できます。

言語別のスクリーンショット撮影

capture_device() {
    local UDID="$1"
    local DEVICE_TYPE="$2"   # "iphone" or "ipad"
    local LANG="$3"          # "ja" or "en"
    local OUTPUT_DIR="$4"

    echo "Capturing $DEVICE_TYPE screenshots ($LANG)..."

    xcodebuild test \
        -project MyApp.xcodeproj \
        -scheme MyApp \
        -destination "platform=iOS Simulator,id=$UDID" \
        -only-testing:MyAppUITests/ScreenshotTests/testCaptureScreenshots \
        -testLanguage "$LANG" \
        -testRegion "$(echo "${LANG}" | tr '[:lower:]' '[:upper:]')" \
        2>&1 | tail -20

    mkdir -p "$OUTPUT_DIR"
    mv "$SCREENSHOT_DIR"/*.png "$OUTPUT_DIR/" 2>/dev/null || true
}

# JA/EN × iPhone/iPad = 4パターン撮影
capture_device "$IPHONE_UDID" "iphone" "ja" "$SCREENSHOT_DIR/ja/iphone"
capture_device "$IPAD_UDID"   "ipad"   "ja" "$SCREENSHOT_DIR/ja/ipad"
capture_device "$IPHONE_UDID" "iphone" "en" "$SCREENSHOT_DIR/en/iphone"
capture_device "$IPAD_UDID"   "ipad"   "en" "$SCREENSHOT_DIR/en/ipad"

-testLanguagexcodebuild testのオプションで、テストランナーのロケールを切り替えます。テストコード側でLocale.preferredLanguagesを参照して-AppleLanguagesに渡すことで、アプリのUI言語も連動して切り替わります。

Apple規定サイズへのリサイズ

App Storeでは厳密な画像サイズが要求されます。sipsコマンド(macOS標準)で正確にリサイズします。

resize_screenshots() {
    local INPUT_DIR="$1"
    local OUTPUT_DIR="$2"
    local TARGET_W="$3"
    local TARGET_H="$4"

    mkdir -p "$OUTPUT_DIR"
    for f in "$INPUT_DIR"/*.png; do
        [ -f "$f" ] || continue
        sips -z "$TARGET_H" "$TARGET_W" "$f" \
            --out "$OUTPUT_DIR/$(basename "$f")" 2>/dev/null
    done
}

# iPhone 6.7": 1290x2796、iPad 12.9": 2048x2732
resize_screenshots "$SCREENSHOT_DIR/ja/iphone" "$RESIZED_DIR/ja/iphone" 1290 2796
resize_screenshots "$SCREENSHOT_DIR/ja/ipad"   "$RESIZED_DIR/ja/ipad"   2048 2732

Step 3: PythonのPillowでマーケティング画像を生成する

素のスクリーンショットをそのままApp Storeに載せるのではなく、グラデーション背景+デバイスフレーム+キャッチコピーを合成した「マーケティング画像」を生成します。

テーマの定義

言語ごとにテーマ(背景色+テキスト)を定義します。

IPHONE_SIZE = (1290, 2796)  # iPhone 6.7"
IPAD_SIZE = (2048, 2732)    # iPad 12.9"

THEMES_JA = [
    {
        "bg_top": (230, 81, 0),       # オレンジ
        "bg_bottom": (153, 51, 0),
        "title": "AIが手書き文字を読み取る",
        "subtitle": "写真を撮るだけ、テキストに変換",
    },
    {
        "bg_top": (41, 98, 255),      # ブルー
        "bg_bottom": (0, 48, 135),
        "title": "高精度な文字認識",
        "subtitle": "最新のAIモデルを搭載",
    },
    {
        "bg_top": (123, 44, 191),     # パープル
        "bg_bottom": (66, 15, 120),
        "title": "完全オフラインで動作",
        "subtitle": "すべての処理をデバイス上で完結",
    },
]

THEMES_EN = [
    {
        "bg_top": (230, 81, 0),
        "bg_bottom": (153, 51, 0),
        "title": "AI-Powered Text Recognition",
        "subtitle": "Just snap a photo to get text",
    },
    # ...
]

グラデーション背景の生成

from PIL import Image, ImageDraw, ImageFont, ImageFilter

def create_gradient(size, color_top, color_bottom):
    """垂直グラデーション画像を生成する。"""
    img = Image.new("RGB", size)
    draw = ImageDraw.Draw(img)
    w, h = size
    for y in range(h):
        ratio = y / h
        r = int(color_top[0] + (color_bottom[0] - color_top[0]) * ratio)
        g = int(color_top[1] + (color_bottom[1] - color_top[1]) * ratio)
        b = int(color_top[2] + (color_bottom[2] - color_top[2]) * ratio)
        draw.line([(0, y), (w, y)], fill=(r, g, b))
    return img

デバイスフレームの付加

スクリーンショットに角丸とダークベゼルを追加して、デバイスの見た目にします。

def add_rounded_corners(img, radius):
    """画像に角丸を適用する。"""
    mask = Image.new("L", img.size, 0)
    draw = ImageDraw.Draw(mask)
    draw.rounded_rectangle([(0, 0), img.size], radius=radius, fill=255)
    result = img.copy()
    result.putalpha(mask)
    return result

def add_device_frame(screenshot, corner_radius, is_ipad=False):
    """デバイスフレーム(ベゼル)を追加する。"""
    bezel = int(corner_radius * 0.35)
    frame_radius = corner_radius + bezel

    frame_w = screenshot.width + bezel * 2
    frame_h = screenshot.height + bezel * 2

    frame = Image.new("RGBA", (frame_w, frame_h), (0, 0, 0, 0))
    frame_draw = ImageDraw.Draw(frame)

    # ダークベゼル
    frame_draw.rounded_rectangle(
        [(0, 0), (frame_w - 1, frame_h - 1)],
        radius=frame_radius, fill=(30, 30, 30, 255)
    )
    # インナーエッジのハイライト
    frame_draw.rounded_rectangle(
        [(bezel - 1, bezel - 1), (frame_w - bezel, frame_h - bezel)],
        radius=corner_radius + 1, fill=(50, 50, 50, 255)
    )
    # スクリーンショットをフレーム内に配置
    frame.paste(screenshot, (bezel, bezel), screenshot)
    return frame

マーケティング画像の組み立て

背景、テキスト、デバイスフレーム付きスクリーンショットを合成します。デバイスの下部が画面外にはみ出す「見切れレイアウト」にするのがポイントです。

def create_marketing_image(screenshot_path, theme, output_size, lang="ja"):
    w, h = output_size
    is_ipad = w / h > 0.6

    # デバイスタイプに応じたパラメータ
    if is_ipad:
        title_font_pct = 0.055
        sub_font_pct = 0.030
        max_scale_w_pct = 0.82
    else:
        title_font_pct = 0.065
        sub_font_pct = 0.035
        max_scale_w_pct = 0.88

    # 言語に応じたフォント選択
    if lang == "en":
        font_bold_path = "/System/Library/Fonts/Helvetica.ttc"
    else:
        font_bold_path = "/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc"

    # グラデーション背景
    bg = create_gradient(output_size, theme["bg_top"], theme["bg_bottom"])
    bg = bg.convert("RGBA")

    # フォント読み込み+テキスト描画位置の計算
    font_title = ImageFont.truetype(font_bold_path, int(w * title_font_pct))
    draw = ImageDraw.Draw(bg)
    title_bbox = draw.textbbox((0, 0), theme["title"], font=font_title)
    title_h = title_bbox[3] - title_bbox[1]
    title_y = int(h * 0.10)

    # スクリーンショットの拡大・配置(下部を見切れさせる)
    screenshot = Image.open(screenshot_path).convert("RGBA")
    ss_y = title_y + title_h + int(h * 0.05)
    bleed_fraction = 0.35  # 下部35%がはみ出す
    desired_visible_h = h - ss_y
    desired_total_h = desired_visible_h / (1.0 - bleed_fraction)
    scale_factor = desired_total_h / screenshot.height

    scale_w = min(int(screenshot.width * scale_factor), int(w * max_scale_w_pct))
    scale_h = int(screenshot.height * (scale_w / screenshot.width))
    screenshot = screenshot.resize((scale_w, scale_h), Image.LANCZOS)

    # 角丸 + フレーム付加
    corner_radius = int(scale_w * 0.05)
    screenshot = add_rounded_corners(screenshot, corner_radius)
    framed = add_device_frame(screenshot, corner_radius, is_ipad=is_ipad)

    # 中央配置
    ss_x = (w - framed.width) // 2
    bg.paste(framed, (ss_x, ss_y + int(h * 0.06)), framed)

    # テキストを上に描画
    title_w = title_bbox[2] - title_bbox[0]
    draw.text(((w - title_w) // 2, title_y), theme["title"],
              fill=(255, 255, 255), font=font_title)

    final = Image.new("RGB", output_size, (0, 0, 0))
    final.paste(bg, (0, 0), bg)
    return final

スクリーンショットの優先順位

どの画面のスクリーンショットを使うかを優先順位で制御します。ファイル名のプレフィックスでソートする仕組みです。

SCREENSHOT_PRIORITY = ["04_result", "05_translation", "06_settings", "02_crop", "03_confirm"]

def find_best_screenshots(input_dir, count=3):
    all_files = sorted([f for f in os.listdir(input_dir) if f.endswith(".png")])
    selected = []
    for prefix in SCREENSHOT_PRIORITY:
        for f in all_files:
            if f.startswith(prefix) and f not in selected:
                selected.append(f)
                break
        if len(selected) >= count:
            break
    return [os.path.join(input_dir, f) for f in selected]

Step 4: デモ動画を録画する

xcrun simctl io recordVideoでシミュレータの画面を録画しながらUIテストを実行すれば、デモ動画が自動で作成されます。

record_demo() {
    local UDID="$1"
    local DEVICE_TYPE="$2"
    local LANG="$3"
    local OUTPUT="videos/demo_${LANG}_${DEVICE_TYPE}.mp4"

    # バックグラウンドで録画開始
    xcrun simctl io "$UDID" recordVideo --codec h264 "$OUTPUT" &
    local RECORD_PID=$!
    sleep 2

    # デモ用テストを実行
    xcodebuild test \
        -project MyApp.xcodeproj \
        -scheme MyApp \
        -destination "platform=iOS Simulator,id=$UDID" \
        -only-testing:MyAppUITests/ScreenshotTests/testDemoVideoFlow \
        -testLanguage "$LANG" \
        2>&1 | tail -10

    # 録画停止
    kill -INT "$RECORD_PID" 2>/dev/null || true
    wait "$RECORD_PID" 2>/dev/null || true
}

record_demo "$IPHONE_UDID" "iphone" "ja"
record_demo "$IPHONE_UDID" "iphone" "en"

テストメソッド内のsleepの長さが動画の「間」になるので、見せたい画面では長めに待つとよいでしょう。

Step 5: App Store Connect APIでアップロードする

生成したマーケティング画像をAPIで自動アップロードします。JWT認証でApp Store Connect APIを利用します。

認証

import jwt
import time

KEY_ID = "YOUR_KEY_ID"
ISSUER_ID = "YOUR_ISSUER_ID"
KEY_PATH = "~/.private_keys/AuthKey_YOUR_KEY_ID.p8"

def generate_token():
    with open(os.path.expanduser(KEY_PATH), "r") as f:
        private_key = f.read()
    now = int(time.time())
    payload = {
        "iss": ISSUER_ID, "iat": now, "exp": now + 1200,
        "aud": "appstoreconnect-v1"
    }
    return jwt.encode(payload, private_key, algorithm="ES256",
                      headers={"kid": KEY_ID})

スクリーンショットのアップロード

App Store Connect APIのスクリーンショットアップロードは3ステップで行います。

import hashlib
import base64

def upload_screenshot(screenshot_set_id, filepath, filename):
    with open(filepath, "rb") as f:
        file_data = f.read()
    filesize = len(file_data)
    checksum = base64.b64encode(hashlib.md5(file_data).digest()).decode()

    # 1. アップロード枠を予約
    result = api_request("POST", "appScreenshots", {
        "data": {
            "type": "appScreenshots",
            "attributes": {"fileName": filename, "fileSize": filesize},
            "relationships": {
                "appScreenshotSet": {
                    "data": {"type": "appScreenshotSets", "id": screenshot_set_id}
                }
            }
        }
    })
    screenshot_id = result["data"]["id"]
    upload_ops = result["data"]["attributes"]["uploadOperations"]

    # 2. バイナリをアップロード(チャンクごと)
    for op in upload_ops:
        chunk = file_data[op["offset"]:op["offset"] + op["length"]]
        req = urllib.request.Request(op["url"], data=chunk, method=op["method"])
        for h in op["requestHeaders"]:
            req.add_header(h["name"], h["value"])
        urllib.request.urlopen(req)

    # 3. コミット(アップロード完了を通知)
    api_request("PATCH", f"appScreenshots/{screenshot_id}", {
        "data": {
            "type": "appScreenshots", "id": screenshot_id,
            "attributes": {"uploaded": True, "sourceFileChecksum": checksum}
        }
    })

言語別のアップロード

App Store Connectのローカライゼーションに合わせて、jaen-USで異なるスクリーンショットをアップロードします。

def main():
    app_id, version_id, version, state = get_app_and_version()
    locs = get_localizations(version_id)  # {"ja": "loc_id_1", "en-US": "loc_id_2"}

    for locale, loc_id in locs.items():
        lang = locale.split("-")[0]  # "en-US" -> "en"
        lang_dir = os.path.join(marketing_dir, lang)

        iphone_files = sorted([f for f in os.listdir(lang_dir)
                               if "iphone" in f and f.endswith(".png")])

        # 既存スクリーンショットを削除して新しいものをアップロード
        ss_set_id = delete_existing_screenshots(loc_id, "APP_IPHONE_67")
        if ss_set_id is None:
            ss_set_id = create_screenshot_set(loc_id, "APP_IPHONE_67")

        for f in iphone_files:
            upload_screenshot(ss_set_id, os.path.join(lang_dir, f), f)

--dry-runオプションを付けておくと、実際にアップロードせずに何が行われるかを確認できます。本番前の確認に便利です。

Step 6: 全工程を1本のスクリプトにまとめる

#!/bin/bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"

DO_UPLOAD=false
[[ "${1:-}" == "--upload" ]] && DO_UPLOAD=true

# 1. シミュレータ準備
xcrun simctl boot "$IPHONE_UDID" 2>/dev/null || true
xcrun simctl addmedia "$IPHONE_UDID" "$PROJECT_DIR/TestResources/sample.jpg"

# 2. JA/EN スクリーンショット撮影
for LANG in ja en; do
    capture_device "$IPHONE_UDID" "iphone" "$LANG" "$SCREENSHOT_DIR/$LANG/iphone"
    capture_device "$IPAD_UDID"   "ipad"   "$LANG" "$SCREENSHOT_DIR/$LANG/ipad"

    resize_screenshots "$SCREENSHOT_DIR/$LANG/iphone" "$RESIZED_DIR/$LANG/iphone" 1290 2796
    resize_screenshots "$SCREENSHOT_DIR/$LANG/ipad"   "$RESIZED_DIR/$LANG/ipad"   2048 2732
done

# 3. マーケティング画像生成
for LANG in ja en; do
    python3 "$SCRIPT_DIR/generate_marketing_screenshots.py" \
        --input-iphone "$RESIZED_DIR/$LANG/iphone" \
        --input-ipad "$RESIZED_DIR/$LANG/ipad" \
        --output "$MARKETING_DIR" \
        --lang "$LANG"
done

# 4. デモ動画録画
record_demo "$IPHONE_UDID" "iphone" "ja"
record_demo "$IPHONE_UDID" "iphone" "en"

# 5. アップロード(オプション)
if $DO_UPLOAD; then
    python3 "$SCRIPT_DIR/upload_screenshots.py" --dir "$MARKETING_DIR"
fi

# 6. 後片付け
xcrun simctl shutdown "$IPHONE_UDID" 2>/dev/null || true
xcrun simctl shutdown "$IPAD_UDID" 2>/dev/null || true

実行方法:

# スクリーンショット撮影 + マーケティング画像生成のみ
./scripts/capture_screenshots.sh

# アップロードまで含む
./scripts/capture_screenshots.sh --upload

実装のポイントとハマりどころ

1. iPhone/iPadでパラメータを分ける

iPhone 6.7インチ(1290x2796、アスペクト比 約0.46)とiPad 12.9インチ(2048x2732、アスペクト比 約0.75)では、フォントサイズやスクリーンショットの拡大率を変えないと見た目のバランスが崩れます。コード中ではw / hのアスペクト比で判定しています。

2. macOS標準フォントの利用

CI環境でも動くように、macOS標準のヒラギノ角ゴシック(日本語)とHelvetica(英語)を使っています。パスは以下の通りです。

FONT_BOLD = "/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc"
FONT_BOLD_EN = "/System/Library/Fonts/Helvetica.ttc"

3. PHPickerの自動操作

UIテストからPHPickerを操作するのは不安定になりがちです。スクリーンショット撮影ではTEST_IMAGE_PATH環境変数で画像を直接渡してPHPickerをバイパスし、デモ動画撮影のときだけPHPickerを使うようにしています。

4. XcodeGenとの連携

project.ymlでプロジェクトを管理している場合、テスト実行前にxcodegen generateを走らせて.xcodeprojを最新にしておく必要があります。

まとめ

この自動化パイプラインにより、アプリの更新時にコマンド1つで以下がすべて完了します。

  • iPhone・iPad × 日本語・英語のスクリーンショット撮影
  • グラデーション背景+デバイスフレーム付きマーケティング画像の生成
  • デモ動画の録画
  • App Store Connectへのアップロード

手作業だと1時間以上かかっていた作業が、スクリプト実行後に放置するだけで終わるようになりました。多言語対応しているアプリでは、言語数が増えるほど自動化の効果が出やすいです。

Pillowの画像生成はテーマカラーやレイアウトの調整もコードで完結するため、デザイナーに依頼しなくてもある程度の見た目のマーケティング画像が作れます。